From 7dedaa0344a7c29523d3e5add541f7a6275b4043 Mon Sep 17 00:00:00 2001 From: Andreas Martens Date: Mon, 30 Jul 2018 14:42:02 +0100 Subject: [PATCH 001/929] add comments to the OVS service --- daemon/core/services/sdn.py | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/daemon/core/services/sdn.py b/daemon/core/services/sdn.py index 9d8a26ba..44a40f48 100644 --- a/daemon/core/services/sdn.py +++ b/daemon/core/services/sdn.py @@ -51,10 +51,15 @@ class OvsService(SdnService): cfg = "#!/bin/sh\n" cfg += "# auto-generated by OvsService (OvsService.py)\n" - cfg += "/etc/init.d/openvswitch-switch start < /dev/null\n" - cfg += "ovs-vsctl add-br ovsbr0\n" - cfg += "ifconfig ovsbr0 up\n" + cfg += "## First make sure that the ovs services are up and running\n" + cfg += "/etc/init.d/openvswitch-switch start < /dev/null\n\n" + cfg += "## create the switch itself, set the fail mode to secure, \n" + cfg += "## this stops it from routing traffic without defined flows.\n" + cfg += "## remove the -- and everything after if you want it to act as a regular switch\n" + cfg += "ovs-vsctl add-br ovsbr0 -- set Bridge ovsbr0 fail-mode=secure\n" + cfg += "\n## Now add all our interfaces as ports to the switch\n" + portnum = 1 for ifc in node.netifs(): if hasattr(ifc, 'control') and ifc.control is True: continue @@ -62,9 +67,10 @@ class OvsService(SdnService): ifnum = ifnumstr[0] # create virtual interfaces + cfg += "## Create a veth pair to send the data to\n" cfg += "ip link add rtr%s type veth peer name sw%s\n" % (ifnum, ifnum) - cfg += "ifconfig rtr%s up\n" % ifnum - cfg += "ifconfig sw%s up\n" % ifnum +# cfg += "ifconfig rtr%s up\n" % ifnum +# cfg += "ifconfig sw%s up\n" % ifnum # remove ip address of eths because quagga/zebra will assign same IPs to rtr interfaces # or assign them manually to rtr interfaces if zebra is not running @@ -81,17 +87,31 @@ class OvsService(SdnService): raise ValueError("invalid address: %s" % ifcaddr) # add interfaces to bridge - cfg += "ovs-vsctl add-port ovsbr0 eth%s\n" % ifnum - cfg += "ovs-vsctl add-port ovsbr0 sw%s\n" % ifnum + # Make port numbers explicit so they're easier to follow in reading the script + cfg += "## Add the CORE interface to the switch\n" + cfg += "ovs-vsctl add-port ovsbr0 eth%s -- set Interface eth%s ofport_request=%d\n" % (ifnum, ifnum, portnum) + cfg += "## And then add its sibling veth interface\n" + cfg += "ovs-vsctl add-port ovsbr0 sw%s -- set Interface sw%s ofport_request=%d\n" % (ifnum, ifnum, portnum+1) + cfg += "## start them up so we can send/receive data\n" + cfg += "ovs-ofctl mod-port ovsbr0 eth%s up\n" % ifnum + cfg += "ovs-ofctl mod-port ovsbr0 sw%s up\n" % ifnum + cfg += "## Bring up the lower part of the veth pair\n" + cfg += "ip link set dev rtr%s up\n" % ifnum + portnum += 2 # Add rule for default controller if there is one local (even if the controller is not local, it finds it) + cfg += "\n## We assume there will be an SDN controller on the other end of this, \n" + cfg += "## but it will still function if there's not\n" cfg += "ovs-vsctl set-controller ovsbr0 tcp:127.0.0.1:6633\n" + cfg += "\n## Now to create some default flows, \n" + cfg += "## if the above controller will be present then you probably want to delete them\n" # Setup default flows portnum = 1 for ifc in node.netifs(): if hasattr(ifc, 'control') and ifc.control is True: continue + cfg += "## Take the data from the CORE interface and put it on the veth and vice versa\n" cfg += "ovs-ofctl add-flow ovsbr0 priority=1000,in_port=%d,action=output:%d\n" % (portnum, portnum + 1) cfg += "ovs-ofctl add-flow ovsbr0 priority=1000,in_port=%d,action=output:%d\n" % (portnum + 1, portnum) portnum += 2 From 939921812358ab4502b17df847494977ce30c6ae Mon Sep 17 00:00:00 2001 From: Jeff Ahrenholz Date: Wed, 10 Oct 2018 09:58:18 -0700 Subject: [PATCH 002/929] enable OSPFv2 fast convergence, and fix router-id for IPv6-only nodes --- daemon/core/services/quagga.py | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/daemon/core/services/quagga.py b/daemon/core/services/quagga.py index de25ecda..a7b85ed8 100644 --- a/daemon/core/services/quagga.py +++ b/daemon/core/services/quagga.py @@ -242,7 +242,7 @@ class QuaggaService(CoreService): if a.find(".") >= 0: return a.split('/')[0] # raise ValueError, "no IPv4 address found for router ID" - return "0.0.0.0" + return "0.0.0.%d" % node.objid @staticmethod def rj45check(ifc): @@ -329,22 +329,18 @@ class Ospfv2(QuaggaService): return cfg @classmethod - def generatequaggaifcconfig(cls, node, ifc): - return cls.mtucheck(ifc) - # cfg = cls.mtucheck(ifc) + def generatequaggaifcconfig(cls, node, ifc): + cfg = cls.mtucheck(ifc) # external RJ45 connections will use default OSPF timers - # if cls.rj45check(ifc): - # return cfg - # cfg += cls.ptpcheck(ifc) - - # return cfg + """\ - - -# ip ospf hello-interval 2 -# ip ospf dead-interval 6 -# ip ospf retransmit-interval 5 -# """ + if cls.rj45check(ifc): + return cfg + cfg += cls.ptpcheck(ifc) + return cfg + """\ + ip ospf hello-interval 2 + ip ospf dead-interval 6 + ip ospf retransmit-interval 5 +""" class Ospfv3(QuaggaService): """ From 1fc5c92039349435ebce107ba7235667af3dd4ba Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 23 Apr 2020 13:57:07 -0700 Subject: [PATCH 003/929] initial work towards trying to model loss between wlan nodes using tc filters --- daemon/core/emulator/session.py | 5 ++ daemon/core/gui/interface.py | 1 + daemon/core/location/mobility.py | 57 +++++++++++-------- daemon/core/nodes/network.py | 98 +++++++++++++++++++++++++++----- 4 files changed, 123 insertions(+), 38 deletions(-) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 7d1d3228..edef8a0d 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -1493,6 +1493,11 @@ class Session: # initialize distributed tunnels self.distributed.start() + # initialize wlan loss + for node in self.nodes.values(): + if isinstance(node, WlanNode): + node.initialize_loss() + # instantiate will be invoked again upon emane configure if self.emane.startup() == self.emane.NOT_READY: return [] diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 359dba8e..b2e037c9 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -59,6 +59,7 @@ class InterfaceManager: def next_mac(self) -> str: mac = str(self.current_mac) + logging.info("mac(%s) value(%s)", self.current_mac, self.current_mac.value) value = self.current_mac.value + 1 self.current_mac = EUI(value) self.current_mac.dialect = netaddr.mac_unix_expanded diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index 05a6ac3e..ddbbdce8 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -363,14 +363,12 @@ class BasicRangeModel(WirelessModel): :return: nothing """ x, y, z = netif.node.position.get() - self._netifslock.acquire() - self._netifs[netif] = (x, y, z) - if x is None or y is None: - self._netifslock.release() - return - for netif2 in self._netifs: - self.calclink(netif, netif2) - self._netifslock.release() + with self._netifslock: + self._netifs[netif] = (x, y, z) + if x is None or y is None: + return + for netif2 in self._netifs: + self.calclink(netif, netif2) position_callback = set_position @@ -407,35 +405,43 @@ class BasicRangeModel(WirelessModel): :param netif2: interface two :return: nothing """ - if netif == netif2: + if not self.range: return + if netif == netif2: + return try: x, y, z = self._netifs[netif] x2, y2, z2 = self._netifs[netif2] - if x2 is None or y2 is None: return - d = self.calcdistance((x, y, z), (x2, y2, z2)) - # ordering is important, to keep the wlan._linked dict organized + # calculate loss + start_point = self.range / 2 + start_value = max(d - start_point, 0) + loss = max(start_value / start_point, 0.001) + loss = min(loss, 1.0) * 100 + self.wlan.change_loss(netif, netif2, loss) + self.wlan.change_loss(netif2, netif, loss) + + # # ordering is important, to keep the wlan._linked dict organized a = min(netif, netif2) b = max(netif, netif2) - with self.wlan._linked_lock: linked = self.wlan.linked(a, b) - if d > self.range: if linked: logging.debug("was linked, unlinking") self.wlan.unlink(a, b) - self.sendlinkmsg(a, b, unlink=True) + self.sendlinkmsg(MessageFlags.DELETE, a, b) else: if not linked: logging.debug("was not linked, linking") self.wlan.link(a, b) - self.sendlinkmsg(a, b) + self.sendlinkmsg(MessageFlags.ADD, a, b, loss=loss) + else: + self.sendlinkmsg(MessageFlags.NONE, a, b, loss=loss) except KeyError: logging.exception("error getting interfaces during calclinkS") @@ -472,7 +478,6 @@ class BasicRangeModel(WirelessModel): self.delay = self._get_config(self.delay, config, "delay") self.loss = self._get_config(self.loss, config, "error") self.jitter = self._get_config(self.jitter, config, "jitter") - self.setlinkparams() def create_link_data( self, @@ -499,22 +504,26 @@ class BasicRangeModel(WirelessModel): ) def sendlinkmsg( - self, netif: CoreInterface, netif2: CoreInterface, unlink: bool = False + self, + message_type: MessageFlags, + netif: CoreInterface, + netif2: CoreInterface, + loss: float = None, ) -> None: """ Send a wireless link/unlink API message to the GUI. + :param message_type: type of link message to send :param netif: interface one :param netif2: interface two - :param unlink: unlink or not + :param loss: link loss value :return: nothing """ - if unlink: - message_type = MessageFlags.DELETE - else: - message_type = MessageFlags.ADD - + label = None + if loss is not None: + label = f"{loss:.2f}%" link_data = self.create_link_data(netif, netif2, message_type) + link_data.label = label self.session.broadcast_link(link_data) def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index f2c16bd0..4eafd8b0 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -288,7 +288,6 @@ class CoreNetwork(CoreNetworkBase): self.has_ebtables_chain = False if start: self.startup() - ebq.startupdateloop(self) def host_cmd( self, @@ -335,16 +334,8 @@ class CoreNetwork(CoreNetworkBase): if not self.up: return - ebq.stopupdateloop(self) - try: self.net_client.delete_bridge(self.brname) - if self.has_ebtables_chain: - cmds = [ - f"{EBTABLES_BIN} -D FORWARD --logical-in {self.brname} -j {self.brname}", - f"{EBTABLES_BIN} -X {self.brname}", - ] - ebtablescmds(self.host_cmd, cmds) except CoreCommandError: logging.exception("error during shutdown") @@ -421,8 +412,6 @@ class CoreNetwork(CoreNetworkBase): return self._linked[netif1][netif2] = False - ebq.ebchange(self) - def link(self, netif1: CoreInterface, netif2: CoreInterface) -> None: """ Link two interfaces together, resulting in adding or removing @@ -437,8 +426,6 @@ class CoreNetwork(CoreNetworkBase): return self._linked[netif1][netif2] = True - ebq.ebchange(self) - def linkconfig( self, netif: CoreInterface, @@ -1052,6 +1039,8 @@ class WlanNode(CoreNetwork): # wireless and mobility models (BasicRangeModel, Ns2WaypointMobility) self.model = None self.mobility = None + self.loss_maps = {} + self.bands = None def startup(self) -> None: """ @@ -1061,7 +1050,88 @@ class WlanNode(CoreNetwork): """ super().startup() self.net_client.disable_mac_learning(self.brname) - ebq.ebchange(self) + + def initialize_loss(self) -> None: + logging.info("initializing loss") + + # get band count, must be at least 3 + self.bands = len(self.netifs()) + if self.bands < 3: + self.bands = 3 + logging.info("wlan qdisc prio with bands: %s", self.bands) + + # initialize loss rules + for netif in self.netifs(): + node = netif.node + name = netif.localname + logging.info("connected nodes: %s - %s", node.name, name) + + # setup root handler for wlan interface + self.host_cmd( + f"tc qdisc add dev {name} root handle 1: " f"prio bands {self.bands}" + ) + + # setup filter rules and qdisc for each other node + index = 1 + for other_netif in self.netifs(): + if netif == other_netif: + continue + other_name = other_netif.localname + logging.info("setup rules from %s to %s", name, other_name) + + # initialize filter rules for all other nodes and catch all + mac = other_netif.hwaddr + qdisc_index = self._qdisc_index(index) + logging.info( + "setup filter to %s for src mac(%s) index(%s) qdisc(%s)", + other_name, + mac, + index, + qdisc_index, + ) + + # save loss map + loss_map = self.loss_maps.setdefault(name, {}) + loss_map[other_name] = index + self.host_cmd( + f"tc filter add dev {name} protocol all parent 1: " + f"prio 1 u32 match eth src {mac} flowid 1:{index}" + ) + self.host_cmd( + f"tc qdisc add dev {name} parent 1:{index} " + f"handle {qdisc_index}: netem loss 0.1%" + ) + index += 1 + + # setup catch all + self.host_cmd( + f"tc filter add dev {name} protocol all parent 1: " + f"prio 0 u32 match u32 0 0 flowid 1:{index}" + ) + qdisc_index = self._qdisc_index(index) + self.host_cmd( + f"tc qdisc add dev {name} parent 1:{index} handle {qdisc_index}: sfq" + ) + + import pprint + + pretty_map = pprint.pformat(self.loss_maps, indent=4, compact=False) + logging.info("wlan loss map:\n%s", pretty_map) + + def _qdisc_index(self, index: int) -> int: + return self.bands + index + + def change_loss( + self, netif: CoreInterface, netif2: CoreInterface, loss: float + ) -> None: + name = netif.localname + other_name = netif2.localname + index = self.loss_maps[name][other_name] + qdisc_index = self._qdisc_index(index) + self.host_cmd( + f"tc qdisc change dev {name} parent 1:{index}" + f" handle {qdisc_index}: netem loss {loss}%" + ) def attach(self, netif: CoreInterface) -> None: """ From 914cca589a7e760f206b0d59c770b6f1eeb701b8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 27 Apr 2020 12:07:13 -0700 Subject: [PATCH 004/929] updates to leverage htb as the means for managing loss between more nodes, added in updates to rate configurations and delay/jitter settings --- daemon/core/emulator/session.py | 5 -- daemon/core/nodes/network.py | 89 ++++++++++++++++++++------------- 2 files changed, 53 insertions(+), 41 deletions(-) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index edef8a0d..7d1d3228 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -1493,11 +1493,6 @@ class Session: # initialize distributed tunnels self.distributed.start() - # initialize wlan loss - for node in self.nodes.values(): - if isinstance(node, WlanNode): - node.initialize_loss() - # instantiate will be invoked again upon emane configure if self.emane.startup() == self.emane.NOT_READY: return [] diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 4eafd8b0..6ed62a36 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -1037,6 +1037,7 @@ class WlanNode(CoreNetwork): """ super().__init__(session, _id, name, start, server, policy) # wireless and mobility models (BasicRangeModel, Ns2WaypointMobility) + self.initialized = False self.model = None self.mobility = None self.loss_maps = {} @@ -1051,14 +1052,12 @@ class WlanNode(CoreNetwork): super().startup() self.net_client.disable_mac_learning(self.brname) - def initialize_loss(self) -> None: - logging.info("initializing loss") + def _initialize_tc(self) -> None: + logging.info("setting wlan configuration: %s", self.model.bw) - # get band count, must be at least 3 + # initial settings self.bands = len(self.netifs()) - if self.bands < 3: - self.bands = 3 - logging.info("wlan qdisc prio with bands: %s", self.bands) + self.loss_maps.clear() # initialize loss rules for netif in self.netifs(): @@ -1068,58 +1067,71 @@ class WlanNode(CoreNetwork): # setup root handler for wlan interface self.host_cmd( - f"tc qdisc add dev {name} root handle 1: " f"prio bands {self.bands}" + f"tc qdisc add dev {name} root handle 1: htb default {self.bands}" ) # setup filter rules and qdisc for each other node - index = 1 + index = 2 for other_netif in self.netifs(): if netif == other_netif: continue other_name = other_netif.localname - logging.info("setup rules from %s to %s", name, other_name) - - # initialize filter rules for all other nodes and catch all mac = other_netif.hwaddr - qdisc_index = self._qdisc_index(index) logging.info( - "setup filter to %s for src mac(%s) index(%s) qdisc(%s)", - other_name, + "tc filter from(%s) to(%s) for src mac(%s) index(%s)", + node.name, + other_netif.node.name, mac, index, - qdisc_index, ) - # save loss map loss_map = self.loss_maps.setdefault(name, {}) loss_map[other_name] = index - self.host_cmd( - f"tc filter add dev {name} protocol all parent 1: " - f"prio 1 u32 match eth src {mac} flowid 1:{index}" - ) - self.host_cmd( - f"tc qdisc add dev {name} parent 1:{index} " - f"handle {qdisc_index}: netem loss 0.1%" - ) + self._tc_filter(name, index, mac) index += 1 # setup catch all - self.host_cmd( - f"tc filter add dev {name} protocol all parent 1: " - f"prio 0 u32 match u32 0 0 flowid 1:{index}" - ) - qdisc_index = self._qdisc_index(index) - self.host_cmd( - f"tc qdisc add dev {name} parent 1:{index} handle {qdisc_index}: sfq" - ) + loss_map = self.loss_maps.setdefault(name, {}) + loss_map["catch"] = index + self._tc_catch_all(name, index) import pprint pretty_map = pprint.pformat(self.loss_maps, indent=4, compact=False) logging.info("wlan loss map:\n%s", pretty_map) - def _qdisc_index(self, index: int) -> int: - return self.bands + index + def _tc_update_rate(self): + for name, loss_map in self.loss_maps.items(): + for index in loss_map.values(): + self.host_cmd( + f"tc class change dev {name} parent 1: classid 1:{index} " + f"htb rate {self.model.bw}" + ) + + def _tc_catch_all(self, name: str, index: int) -> None: + self.host_cmd( + f"tc class add dev {name} parent 1: classid 1:{index} " + f"htb rate {self.model.bw}" + ) + self.host_cmd( + f"tc filter add dev {name} protocol all parent 1: " + f"prio 0 u32 match u32 0 0 flowid 1:{index}" + ) + self.host_cmd(f"tc qdisc add dev {name} parent 1:{index} handle {index}: sfq") + + def _tc_filter(self, name: str, index: int, mac: str) -> None: + self.host_cmd( + f"tc class add dev {name} parent 1: classid 1:{index} " + f"htb rate {self.model.bw}" + ) + self.host_cmd( + f"tc filter add dev {name} protocol all parent 1: " + f"prio 1 u32 match eth src {mac} flowid 1:{index}" + ) + self.host_cmd( + f"tc qdisc add dev {name} parent 1:{index} " + f"handle {index}: netem loss 0.01%" + ) def change_loss( self, netif: CoreInterface, netif2: CoreInterface, loss: float @@ -1127,10 +1139,10 @@ class WlanNode(CoreNetwork): name = netif.localname other_name = netif2.localname index = self.loss_maps[name][other_name] - qdisc_index = self._qdisc_index(index) self.host_cmd( f"tc qdisc change dev {name} parent 1:{index}" - f" handle {qdisc_index}: netem loss {loss}%" + f" handle {index}: netem loss {loss}%" + f" delay {self.model.delay}us {self.model.jitter}us 25%" ) def attach(self, netif: CoreInterface) -> None: @@ -1176,6 +1188,11 @@ class WlanNode(CoreNetwork): "node(%s) updating model(%s): %s", self.id, self.model.name, config ) self.model.update_config(config) + if not self.initialized: + self.initialized = True + self._initialize_tc() + else: + self._tc_update_rate() for netif in self.netifs(): netif.setposition() From 4037da49c28de14ccbddf2e83f7825cb8dfeeca4 Mon Sep 17 00:00:00 2001 From: Huy Pham <42948410+hpham@users.noreply.github.com> Date: Thu, 30 Apr 2020 12:48:51 -0700 Subject: [PATCH 005/929] Fix issue: node's services won't save when clearing all the services and add default services back to the node. Set core node's services to default services (instead of leaving it empty) when a new node is created. --- daemon/core/gui/coreclient.py | 4 ++++ daemon/core/gui/dialogs/nodeservice.py | 19 ++++++++++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index b6b13c58..4940d7ad 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -815,6 +815,10 @@ class CoreClient: if NodeUtils.is_custom(node_type, model): services = NodeUtils.get_custom_node_services(self.app.guiconfig, model) node.services[:] = services + else: + services = self.default_services.get(model, None) + if services: + node.services[:] = services logging.info( "add node(%s) to session(%s), coordinates(%s, %s)", node.name, diff --git a/daemon/core/gui/dialogs/nodeservice.py b/daemon/core/gui/dialogs/nodeservice.py index 4927fece..e8f67220 100644 --- a/daemon/core/gui/dialogs/nodeservice.py +++ b/daemon/core/gui/dialogs/nodeservice.py @@ -153,18 +153,19 @@ class NodeServiceDialog(Dialog): ) def click_save(self): - # if node is custom type or current services are not the default services then - # set core node services and add node to modified services node set + core_node = self.canvas_node.core_node + # custom type node or CORE node with custom services if ( - self.canvas_node.core_node.model not in self.app.core.default_services - or self.current_services - != self.app.core.default_services[self.canvas_node.core_node.model] + core_node.model not in self.app.core.default_services + or self.current_services != self.app.core.default_services[core_node.model] ): - self.canvas_node.core_node.services[:] = self.current_services - self.app.core.modified_service_nodes.add(self.canvas_node.core_node.id) + core_node.services[:] = self.current_services + self.app.core.modified_service_nodes.add(core_node.id) + # custom services CORE node but modified back to having default services + # or just CORE nodes that don't get any change else: - if len(self.canvas_node.core_node.services) > 0: - self.canvas_node.core_node.services[:] = [] + core_node.services[:] = self.current_services + self.app.core.modified_service_nodes.discard(core_node.id) self.destroy() def click_cancel(self): From d945e7c41e20593d551e3883bcf411c08c15178b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 30 Apr 2020 12:57:05 -0700 Subject: [PATCH 006/929] formatted sdn.py after recent merge --- daemon/core/services/sdn.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/daemon/core/services/sdn.py b/daemon/core/services/sdn.py index 9a810b3d..ab46f551 100644 --- a/daemon/core/services/sdn.py +++ b/daemon/core/services/sdn.py @@ -77,9 +77,15 @@ class OvsService(SdnService): # add interfaces to bridge # Make port numbers explicit so they're easier to follow in reading the script cfg += "## Add the CORE interface to the switch\n" - cfg += "ovs-vsctl add-port ovsbr0 eth%s -- set Interface eth%s ofport_request=%d\n" % (ifnum, ifnum, portnum) + cfg += ( + "ovs-vsctl add-port ovsbr0 eth%s -- set Interface eth%s ofport_request=%d\n" + % (ifnum, ifnum, portnum) + ) cfg += "## And then add its sibling veth interface\n" - cfg += "ovs-vsctl add-port ovsbr0 sw%s -- set Interface sw%s ofport_request=%d\n" % (ifnum, ifnum, portnum+1) + cfg += ( + "ovs-vsctl add-port ovsbr0 sw%s -- set Interface sw%s ofport_request=%d\n" + % (ifnum, ifnum, portnum + 1) + ) cfg += "## start them up so we can send/receive data\n" cfg += "ovs-ofctl mod-port ovsbr0 eth%s up\n" % ifnum cfg += "ovs-ofctl mod-port ovsbr0 sw%s up\n" % ifnum @@ -100,8 +106,14 @@ class OvsService(SdnService): if hasattr(ifc, "control") and ifc.control is True: continue cfg += "## Take the data from the CORE interface and put it on the veth and vice versa\n" - cfg += "ovs-ofctl add-flow ovsbr0 priority=1000,in_port=%d,action=output:%d\n" % (portnum, portnum + 1) - cfg += "ovs-ofctl add-flow ovsbr0 priority=1000,in_port=%d,action=output:%d\n" % (portnum + 1, portnum) + cfg += ( + "ovs-ofctl add-flow ovsbr0 priority=1000,in_port=%d,action=output:%d\n" + % (portnum, portnum + 1) + ) + cfg += ( + "ovs-ofctl add-flow ovsbr0 priority=1000,in_port=%d,action=output:%d\n" + % (portnum + 1, portnum) + ) portnum += 2 return cfg From 7e0ead0766679be891bd73c6170fd4f19effed23 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 30 Apr 2020 13:23:00 -0700 Subject: [PATCH 007/929] fixed formatting for quagga fast convergence merge --- daemon/core/services/quagga.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/daemon/core/services/quagga.py b/daemon/core/services/quagga.py index ed0d6dbd..331e23da 100644 --- a/daemon/core/services/quagga.py +++ b/daemon/core/services/quagga.py @@ -347,17 +347,21 @@ class Ospfv2(QuaggaService): return cfg @classmethod - def generatequaggaifcconfig(cls, node, ifc): + def generatequaggaifcconfig(cls, node, ifc): cfg = cls.mtucheck(ifc) # external RJ45 connections will use default OSPF timers if cls.rj45check(ifc): return cfg cfg += cls.ptpcheck(ifc) - return cfg + """\ + return ( + cfg + + """\ ip ospf hello-interval 2 ip ospf dead-interval 6 ip ospf retransmit-interval 5 """ + ) + class Ospfv3(QuaggaService): """ From 580641f5d96168aaffd7e77c5ef5a16ceef20973 Mon Sep 17 00:00:00 2001 From: Huy Pham <42948410+hpham@users.noreply.github.com> Date: Thu, 30 Apr 2020 13:47:45 -0700 Subject: [PATCH 008/929] remove CoreClient.modified_service_node. When a new CORE node is created, assign default services right away (instead of leaving it empty), therefore no more confusion whether [] means empty service or means CORE node with default services --- daemon/core/gui/coreclient.py | 7 +--- daemon/core/gui/dialogs/nodeservice.py | 49 +++----------------------- daemon/core/gui/graph/graph.py | 5 --- 3 files changed, 6 insertions(+), 55 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 4940d7ad..f3ec6612 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -85,7 +85,6 @@ class CoreClient: self.handling_events = None self.xml_dir = None self.xml_file = None - self.modified_service_nodes = set() @property def client(self): @@ -112,7 +111,6 @@ class CoreClient: self.links.clear() self.hooks.clear() self.emane_config = None - self.modified_service_nodes.clear() for mobility_player in self.mobility_players.values(): mobility_player.handle_close() self.mobility_players.clear() @@ -815,6 +813,7 @@ class CoreClient: if NodeUtils.is_custom(node_type, model): services = NodeUtils.get_custom_node_services(self.app.guiconfig, model) node.services[:] = services + # assign default services to CORE node else: services = self.default_services.get(model, None) if services: @@ -840,7 +839,6 @@ class CoreClient: logging.error("unknown node: %s", node_id) continue del self.canvas_nodes[node_id] - self.modified_service_nodes.discard(node_id) for edge in canvas_node.edges: if edge in edges: continue @@ -1056,9 +1054,6 @@ class CoreClient: ) return dict(config) - def service_been_modified(self, node_id: int) -> bool: - return node_id in self.modified_service_nodes - def execute_script(self, script): response = self.client.execute_script(script) logging.info("execute python script %s", response) diff --git a/daemon/core/gui/dialogs/nodeservice.py b/daemon/core/gui/dialogs/nodeservice.py index e8f67220..3e716627 100644 --- a/daemon/core/gui/dialogs/nodeservice.py +++ b/daemon/core/gui/dialogs/nodeservice.py @@ -3,11 +3,10 @@ core node services """ import tkinter as tk from tkinter import messagebox, ttk -from typing import TYPE_CHECKING, Any, Set +from typing import TYPE_CHECKING, Any from core.gui.dialogs.dialog import Dialog from core.gui.dialogs.serviceconfig import ServiceConfigDialog -from core.gui.nodeutils import NodeUtils from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CheckboxList, ListboxScroll @@ -17,13 +16,7 @@ if TYPE_CHECKING: class NodeServiceDialog(Dialog): - def __init__( - self, - master: Any, - app: "Application", - canvas_node: "CanvasNode", - services: Set[str] = None, - ): + def __init__(self, master: Any, app: "Application", canvas_node: "CanvasNode"): title = f"{canvas_node.core_node.name} Services" super().__init__(master, app, title, modal=True) self.app = app @@ -32,24 +25,7 @@ class NodeServiceDialog(Dialog): self.groups = None self.services = None self.current = None - if services is None: - services = canvas_node.core_node.services - model = canvas_node.core_node.model - if len(services) == 0: - # not custom node type and node's services haven't been modified before - if not NodeUtils.is_custom( - canvas_node.core_node.type, canvas_node.core_node.model - ) and not self.app.core.service_been_modified(self.node_id): - services = set(self.app.core.default_services[model]) - # services of default type nodes were modified to be empty - elif canvas_node.core_node.id in self.app.core.modified_service_nodes: - services = set() - else: - services = set( - NodeUtils.get_custom_node_services(self.app.guiconfig, model) - ) - else: - services = set(services) + services = set(canvas_node.core_node.services) self.current_services = services self.draw() @@ -103,7 +79,7 @@ class NodeServiceDialog(Dialog): button.grid(row=0, column=1, sticky="ew", padx=PADX) button = ttk.Button(frame, text="Remove", command=self.click_remove) button.grid(row=0, column=2, sticky="ew", padx=PADX) - button = ttk.Button(frame, text="Cancel", command=self.click_cancel) + button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=3, sticky="ew") # trigger group change @@ -154,22 +130,7 @@ class NodeServiceDialog(Dialog): def click_save(self): core_node = self.canvas_node.core_node - # custom type node or CORE node with custom services - if ( - core_node.model not in self.app.core.default_services - or self.current_services != self.app.core.default_services[core_node.model] - ): - core_node.services[:] = self.current_services - self.app.core.modified_service_nodes.add(core_node.id) - # custom services CORE node but modified back to having default services - # or just CORE nodes that don't get any change - else: - core_node.services[:] = self.current_services - self.app.core.modified_service_nodes.discard(core_node.id) - self.destroy() - - def click_cancel(self): - self.current_services = None + core_node.services[:] = self.current_services self.destroy() def click_remove(self): diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 40a941b1..fb0f39e0 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -936,11 +936,6 @@ class CanvasGraph(tk.Canvas): node.service_file_configs = deepcopy(canvas_node.service_file_configs) node.config_service_configs = deepcopy(canvas_node.config_service_configs) - # add new node to modified_service_nodes set if that set contains the - # to_copy node - if self.core.service_been_modified(core_node.id): - self.core.modified_service_nodes.add(copy.id) - copy_map[canvas_node.id] = node.id self.core.canvas_nodes[copy.id] = node self.nodes[node.id] = node From 9a42368221e68abf271ef9d4733b9410abcc27cc Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 13:39:27 -0700 Subject: [PATCH 009/929] initial changes to mimic prior address creation --- daemon/core/gui/coreclient.py | 8 +++- daemon/core/gui/interface.py | 87 +++++++++++++++++++++++------------ daemon/core/gui/nodeutils.py | 7 ++- 3 files changed, 70 insertions(+), 32 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index b6b13c58..523c8bb8 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -830,6 +830,7 @@ class CoreClient: such as link, configurations, interfaces """ edges = set() + removed_links = [] for canvas_node in canvas_nodes: node_id = canvas_node.core_node.id if node_id not in self.canvas_nodes: @@ -841,11 +842,14 @@ class CoreClient: if edge in edges: continue edges.add(edge) - self.links.pop(edge.token, None) + edge = self.links.pop(edge.token, None) + if edge is not None: + removed_links.append(edge.link) + self.interfaces_manager.removed(removed_links) def create_interface(self, canvas_node: CanvasNode) -> core_pb2.Interface: node = canvas_node.core_node - ip4, ip6 = self.interfaces_manager.get_ips(node.id) + ip4, ip6 = self.interfaces_manager.get_ips(node) ip4_mask = self.interfaces_manager.ip4_mask ip6_mask = self.interfaces_manager.ip6_mask interface_id = len(canvas_node.interfaces) diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 359dba8e..3310da90 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -1,6 +1,5 @@ import logging -import random -from typing import TYPE_CHECKING, Set, Union +from typing import TYPE_CHECKING, List, Set, Tuple, Union import netaddr from netaddr import EUI, IPNetwork @@ -14,20 +13,20 @@ if TYPE_CHECKING: from core.gui.graph.node import CanvasNode -def random_mac(): - return ("{:02x}" * 6).format(*[random.randrange(256) for _ in range(6)]) - - class Subnets: def __init__(self, ip4: IPNetwork, ip6: IPNetwork) -> None: self.ip4 = ip4 self.ip6 = ip6 + self.used_indexes = set() def __eq__(self, other: "Subnets") -> bool: - return (self.ip4, self.ip6) == (other.ip4, other.ip6) + return self.key() == other.key() def __hash__(self) -> int: - return hash((self.ip4, self.ip6)) + return hash(self.key()) + + def key(self) -> Tuple[IPNetwork, IPNetwork]: + return self.ip4, self.ip6 def next(self) -> "Subnets": return Subnets(self.ip4.next(), self.ip6.next()) @@ -47,6 +46,7 @@ class InterfaceManager: self.mac = EUI(mac) self.current_mac = None self.current_subnets = None + self.used_subnets = {} def update_ips(self, ip4: str, ip6: str) -> None: self.reset() @@ -65,37 +65,66 @@ class InterfaceManager: return mac def next_subnets(self) -> Subnets: - # define currently used subnets - used_subnets = set() - for edge in self.app.core.links.values(): - link = edge.link - subnets = None - if link.HasField("interface_one"): - subnets = self.get_subnets(link.interface_one) - if link.HasField("interface_two"): - subnets = self.get_subnets(link.interface_two) - if subnets: - used_subnets.add(subnets) - - # find next available subnets - subnets = Subnets(self.ip4_subnets, self.ip6_subnets) - while subnets in used_subnets: + subnets = self.current_subnets + if subnets is None: + subnets = Subnets(self.ip4_subnets, self.ip6_subnets) + while subnets.key() in self.used_subnets: subnets = subnets.next() + self.used_subnets[subnets.key()] = subnets return subnets def reset(self): self.current_subnets = None + self.used_subnets.clear() - def get_ips(self, node_id: int) -> [str, str]: - ip4 = self.current_subnets.ip4[node_id] - ip6 = self.current_subnets.ip6[node_id] + def removed(self, links: List["core_pb2.Link"]): + # get remaining subnets + remaining_subnets = set() + + for link in links: + if link.HasField("interface_one"): + subnets = self.get_subnets(link.interface_one) + if subnets not in remaining_subnets: + self.used_subnets.pop(subnets.key(), None) + if link.HasField("interface_two"): + subnets = self.get_subnets(link.interface_two) + if subnets not in remaining_subnets: + self.used_subnets.pop(subnets.key(), None) + + def initialize_links(self, links: List["core_pb2.Link"]): + for link in links: + if link.HasField("interface_one"): + subnets = self.get_subnets(link.interface_one) + if subnets.key() not in self.used_subnets: + self.used_subnets[subnets.key()] = subnets + if link.HasField("interface_two"): + subnets = self.get_subnets(link.interface_two) + if subnets.key() not in self.used_subnets: + self.used_subnets[subnets.key()] = subnets + + def next_index(self, node: "core_pb2.Node") -> int: + if NodeUtils.is_router_node(node): + index = 1 + else: + index = 20 + while True: + if index not in self.current_subnets.used_indexes: + self.current_subnets.used_indexes.add(index) + break + index += 1 + return index + + def get_ips(self, node: "core_pb2.Node") -> [str, str]: + index = self.next_index(node) + ip4 = self.current_subnets.ip4[index] + ip6 = self.current_subnets.ip6[index] return str(ip4), str(ip6) - @classmethod - def get_subnets(cls, interface: "core_pb2.Interface") -> Subnets: + def get_subnets(self, interface: "core_pb2.Interface") -> Subnets: ip4_subnet = IPNetwork(f"{interface.ip4}/{interface.ip4mask}").cidr ip6_subnet = IPNetwork(f"{interface.ip6}/{interface.ip6mask}").cidr - return Subnets(ip4_subnet, ip6_subnet) + subnets = Subnets(ip4_subnet, ip6_subnet) + return self.used_subnets.get(subnets.key(), subnets) def determine_subnets( self, canvas_src_node: "CanvasNode", canvas_dst_node: "CanvasNode" diff --git a/daemon/core/gui/nodeutils.py b/daemon/core/gui/nodeutils.py index 7ccb7ca3..24c01f06 100644 --- a/daemon/core/gui/nodeutils.py +++ b/daemon/core/gui/nodeutils.py @@ -1,7 +1,7 @@ import logging from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union -from core.api.grpc.core_pb2 import NodeType +from core.api.grpc.core_pb2 import Node, NodeType from core.gui.images import ImageEnum, Images, TypeToImage if TYPE_CHECKING: @@ -64,8 +64,13 @@ class NodeUtils: RJ45_NODES = {NodeType.RJ45} IGNORE_NODES = {NodeType.CONTROL_NET, NodeType.PEER_TO_PEER} NODE_MODELS = {"router", "host", "PC", "mdr", "prouter"} + ROUTER_NODES = {"router", "mdr"} ANTENNA_ICON = None + @classmethod + def is_router_node(cls, node: Node) -> bool: + return cls.is_model_node(node.type) and node.model in cls.ROUTER_NODES + @classmethod def is_ignore_node(cls, node_type: NodeType) -> bool: return node_type in cls.IGNORE_NODES From 4a7abe71e406786e6b673164acb7dd545a578146 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 13:42:15 -0700 Subject: [PATCH 010/929] removed unwanted grpc client stream log --- daemon/core/api/grpc/client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 871e75e7..76e20426 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -493,7 +493,6 @@ class CoreGrpcClient: """ request = core_pb2.EventsRequest(session_id=session_id, events=events) stream = self.stub.Events(request) - logging.info("STREAM TYPE: %s", type(stream)) start_streamer(stream, handler) return stream From f7281459ed73dbd4101635128d0405a075c38712 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 14:13:44 -0700 Subject: [PATCH 011/929] pygui changes to avoid deleting session and open xml race conditions, fix to reset canvas view options when creating a new session --- daemon/core/emulator/coreemu.py | 1 - daemon/core/gui/coreclient.py | 2 +- daemon/core/gui/graph/graph.py | 9 +++++++++ daemon/core/gui/menubar.py | 8 ++------ 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/daemon/core/emulator/coreemu.py b/daemon/core/emulator/coreemu.py index 6c6f1418..90f75427 100644 --- a/daemon/core/emulator/coreemu.py +++ b/daemon/core/emulator/coreemu.py @@ -128,5 +128,4 @@ class CoreEmu: result = True else: logging.error("session to delete did not exist: %s", _id) - return result diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index f3ec6612..79347baa 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -614,7 +614,7 @@ class CoreClient: Open core xml """ try: - response = self.client.open_xml(file_path) + response = self._client.open_xml(file_path) logging.info("open xml file %s, response: %s", file_path, response) self.join_session(response.session_id) except grpc.RpcError as e: diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index fb0f39e0..7c241bf7 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -133,6 +133,15 @@ class CanvasGraph(tk.Canvas): # hide context self.hide_context() + # reset view options to default state + self.show_node_labels.set(True) + self.show_link_labels.set(True) + self.show_grid.set(True) + self.show_annotations.set(True) + self.show_interface_names.set(False) + self.show_ip4s.set(True) + self.show_ip6s.set(True) + # delete any existing drawn items for tag in tags.COMPONENT_TAGS: self.delete(tag) diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index fafe6b5c..b5ae9ac7 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -387,12 +387,8 @@ class Menubar(tk.Menu): if self.core.is_runtime(): result = messagebox.askyesnocancel("Exit", "Stop the running session?") if result: - callback = None - if quit_app: - callback = self.app.quit - task = BackgroundTask(self.app, self.core.delete_session, callback) - task.start() - elif quit_app: + self.core.delete_session() + if quit_app: self.app.quit() def click_new(self) -> None: From 2e9968c306bd36a27ed669c74a8d50ba7b611063 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 15:35:53 -0700 Subject: [PATCH 012/929] pygui further changes to mimic old gui behavior, parsing link data when joining and removing link data when they are removed --- daemon/core/gui/coreclient.py | 3 ++ daemon/core/gui/interface.py | 68 +++++++++++++++++++++++++---------- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index ef50ace2..8c05a30f 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -283,6 +283,9 @@ class CoreClient: response = self.client.get_emane_config(self.session_id) self.emane_config = response.config + # update interface manager + self.interfaces_manager.joined(session.links) + # draw session self.app.canvas.reset_and_redraw(session) diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 3310da90..8e2f4aa6 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -1,5 +1,5 @@ import logging -from typing import TYPE_CHECKING, List, Set, Tuple, Union +from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple import netaddr from netaddr import EUI, IPNetwork @@ -13,13 +13,22 @@ if TYPE_CHECKING: from core.gui.graph.node import CanvasNode +def get_index(interface: "core_pb2.Interface") -> int: + net = netaddr.IPNetwork(f"{interface.ip4}/{interface.ip4mask}") + ip_value = net.value + cidr_value = net.cidr.value + return ip_value - cidr_value + + class Subnets: def __init__(self, ip4: IPNetwork, ip6: IPNetwork) -> None: self.ip4 = ip4 self.ip6 = ip6 self.used_indexes = set() - def __eq__(self, other: "Subnets") -> bool: + def __eq__(self, other: Any) -> bool: + if not isinstance(other, Subnets): + return False return self.key() == other.key() def __hash__(self) -> int: @@ -73,34 +82,55 @@ class InterfaceManager: self.used_subnets[subnets.key()] = subnets return subnets - def reset(self): + def reset(self) -> None: self.current_subnets = None self.used_subnets.clear() - def removed(self, links: List["core_pb2.Link"]): + def removed(self, links: List["core_pb2.Link"]) -> None: # get remaining subnets remaining_subnets = set() - - for link in links: + for edge in self.app.core.links.values(): + link = edge.link if link.HasField("interface_one"): subnets = self.get_subnets(link.interface_one) - if subnets not in remaining_subnets: - self.used_subnets.pop(subnets.key(), None) + remaining_subnets.add(subnets) if link.HasField("interface_two"): subnets = self.get_subnets(link.interface_two) - if subnets not in remaining_subnets: - self.used_subnets.pop(subnets.key(), None) + remaining_subnets.add(subnets) - def initialize_links(self, links: List["core_pb2.Link"]): + # remove all subnets from used subnets when no longer present + # or remove used indexes from subnet + interfaces = [] for link in links: if link.HasField("interface_one"): - subnets = self.get_subnets(link.interface_one) - if subnets.key() not in self.used_subnets: - self.used_subnets[subnets.key()] = subnets + interfaces.append(link.interface_one) if link.HasField("interface_two"): - subnets = self.get_subnets(link.interface_two) - if subnets.key() not in self.used_subnets: - self.used_subnets[subnets.key()] = subnets + interfaces.append(link.interface_two) + for interface in interfaces: + subnets = self.get_subnets(interface) + if subnets not in remaining_subnets: + if self.current_subnets == subnets: + self.current_subnets = None + self.used_subnets.pop(subnets.key(), None) + else: + index = get_index(interface) + subnets.used_indexes.discard(index) + + def joined(self, links: List["core_pb2.Link"]) -> None: + interfaces = [] + for link in links: + if link.HasField("interface_one"): + interfaces.append(link.interface_one) + if link.HasField("interface_two"): + interfaces.append(link.interface_two) + + # add to used subnets and mark used indexes + for interface in interfaces: + subnets = self.get_subnets(interface) + index = get_index(interface) + subnets.used_indexes.add(index) + if subnets.key() not in self.used_subnets: + self.used_subnets[subnets.key()] = subnets def next_index(self, node: "core_pb2.Node") -> int: if NodeUtils.is_router_node(node): @@ -128,7 +158,7 @@ class InterfaceManager: def determine_subnets( self, canvas_src_node: "CanvasNode", canvas_dst_node: "CanvasNode" - ): + ) -> None: src_node = canvas_src_node.core_node dst_node = canvas_dst_node.core_node is_src_container = NodeUtils.is_container_node(src_node.type) @@ -152,7 +182,7 @@ class InterfaceManager: def find_subnets( self, canvas_node: "CanvasNode", visited: Set[int] = None - ) -> Union[IPNetwork, None]: + ) -> Optional[IPNetwork]: logging.info("finding subnet for node: %s", canvas_node.core_node.name) canvas = self.app.canvas subnets = None From 4ae5936bdc01cbc79f5d992425a4bc59500a1e70 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 17:28:28 -0700 Subject: [PATCH 013/929] pygui raise copied nodes above copied edges --- daemon/core/gui/graph/graph.py | 1 + 1 file changed, 1 insertion(+) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 7c241bf7..9e60f131 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -998,6 +998,7 @@ class CanvasGraph(tk.Canvas): width=self.itemcget(edge.id, "width"), fill=self.itemcget(edge.id, "fill"), ) + self.tag_raise(tags.NODE) def scale_graph(self): for nid, canvas_node in self.nodes.items(): From 686026d9f2ce02a9aeae195def64d13e272d35c3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 17:40:53 -0700 Subject: [PATCH 014/929] improved netaddr mac dialect usage to leverage constructor parameter --- daemon/core/api/tlv/coreapi.py | 3 +-- daemon/core/gui/dialogs/nodeconfig.py | 3 +-- daemon/core/gui/interface.py | 6 ++---- daemon/core/utils.py | 6 ++---- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/daemon/core/api/tlv/coreapi.py b/daemon/core/api/tlv/coreapi.py index b8021b9f..df60e374 100644 --- a/daemon/core/api/tlv/coreapi.py +++ b/daemon/core/api/tlv/coreapi.py @@ -350,8 +350,7 @@ class CoreTlvDataMacAddr(CoreTlvDataObj): """ # only use 48 bits value = binascii.hexlify(value[2:]).decode() - mac = netaddr.EUI(value) - mac.dialect = netaddr.mac_unix + mac = netaddr.EUI(value, dialect=netaddr.mac_unix_expanded) return str(mac) diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index 9d10a083..3048b15c 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -354,8 +354,7 @@ class NodeConfigDialog(Dialog): error = True break elif not auto_mac: - mac = netaddr.EUI(mac) - mac.dialect = netaddr.mac_unix_expanded + mac = netaddr.EUI(mac, dialect=netaddr.mac_unix_expanded) interface.mac = str(mac) # redraw diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 8e2f4aa6..6f5f5fff 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -52,7 +52,7 @@ class InterfaceManager: self.ip4_subnets = IPNetwork(f"{ip4}/{self.ip4_mask}") self.ip6_subnets = IPNetwork(f"{ip6}/{self.ip6_mask}") mac = self.app.guiconfig.get("mac", appconfig.DEFAULT_MAC) - self.mac = EUI(mac) + self.mac = EUI(mac, dialect=netaddr.mac_unix_expanded) self.current_mac = None self.current_subnets = None self.used_subnets = {} @@ -64,13 +64,11 @@ class InterfaceManager: def reset_mac(self) -> None: self.current_mac = self.mac - self.current_mac.dialect = netaddr.mac_unix_expanded def next_mac(self) -> str: mac = str(self.current_mac) value = self.current_mac.value + 1 - self.current_mac = EUI(value) - self.current_mac.dialect = netaddr.mac_unix_expanded + self.current_mac = EUI(value, dialect=netaddr.mac_unix_expanded) return mac def next_subnets(self) -> Subnets: diff --git a/daemon/core/utils.py b/daemon/core/utils.py index 0eb9fef1..f1f74dbe 100644 --- a/daemon/core/utils.py +++ b/daemon/core/utils.py @@ -437,8 +437,7 @@ def random_mac() -> str: """ value = random.randint(0, 0xFFFFFF) value |= 0x00163E << 24 - mac = netaddr.EUI(value) - mac.dialect = netaddr.mac_unix_expanded + mac = netaddr.EUI(value, dialect=netaddr.mac_unix_expanded) return str(mac) @@ -450,8 +449,7 @@ def validate_mac(value: str) -> str: :return: unix formatted mac """ try: - mac = netaddr.EUI(value) - mac.dialect = netaddr.mac_unix_expanded + mac = netaddr.EUI(value, dialect=netaddr.mac_unix_expanded) return str(mac) except netaddr.AddrFormatError as e: raise CoreError(f"invalid mac address {value}: {e}") From 0ee679d978f6c106a9741f9f5b87046ae1d14890 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 18:05:54 -0700 Subject: [PATCH 015/929] pygui changes to disable most widgets related to configuring a node during runtime --- daemon/core/gui/dialogs/nodeconfig.py | 29 ++++++++++++++++----------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index 3048b15c..6af82746 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -122,6 +122,10 @@ class NodeConfigDialog(Dialog): self.top.columnconfigure(0, weight=1) row = 0 + # field states + state = tk.DISABLED if self.app.core.is_runtime() else tk.NORMAL + combo_state = tk.DISABLED if self.app.core.is_runtime() else "readonly" + # field frame frame = ttk.Frame(self.top) frame.grid(sticky="ew") @@ -148,6 +152,7 @@ class NodeConfigDialog(Dialog): textvariable=self.name, validate="key", validatecommand=(self.app.validation.name, "%P"), + state=state, ) entry.bind( "", lambda event: self.app.validation.focus_out(event, "noname") @@ -163,7 +168,7 @@ class NodeConfigDialog(Dialog): frame, textvariable=self.type, values=list(NodeUtils.NODE_MODELS), - state="readonly", + state=combo_state, ) combobox.grid(row=row, column=1, sticky="ew") row += 1 @@ -172,7 +177,7 @@ class NodeConfigDialog(Dialog): if NodeUtils.is_image_node(self.node.type): label = ttk.Label(frame, text="Image") label.grid(row=row, column=0, sticky="ew", padx=PADX, pady=PADY) - entry = ttk.Entry(frame, textvariable=self.container_image) + entry = ttk.Entry(frame, textvariable=self.container_image, state=state) entry.grid(row=row, column=1, sticky="ew") row += 1 @@ -185,7 +190,7 @@ class NodeConfigDialog(Dialog): servers = ["localhost"] servers.extend(list(sorted(self.app.core.servers.keys()))) combobox = ttk.Combobox( - frame, textvariable=self.server, values=servers, state="readonly" + frame, textvariable=self.server, values=servers, state=combo_state ) combobox.grid(row=row, column=1, sticky="ew") row += 1 @@ -194,6 +199,7 @@ class NodeConfigDialog(Dialog): response = self.app.core.client.get_interfaces() logging.debug("host machine available interfaces: %s", response) interfaces = ListboxScroll(frame) + interfaces.listbox.config(state=state) interfaces.grid( row=row, column=0, columnspan=2, sticky="ew", padx=PADX, pady=PADY ) @@ -213,7 +219,7 @@ class NodeConfigDialog(Dialog): notebook = ttk.Notebook(self.top) notebook.grid(sticky="nsew", pady=PADY) self.top.rowconfigure(notebook.grid_info()["row"], weight=1) - + state = tk.DISABLED if self.app.core.is_runtime() else tk.NORMAL for interface in self.canvas_node.interfaces: logging.info("interface: %s", interface) tab = ttk.Frame(notebook, padding=FRAME_PAD) @@ -237,16 +243,15 @@ class NodeConfigDialog(Dialog): label = ttk.Label(tab, text="MAC") label.grid(row=row, column=0, padx=PADX, pady=PADY) auto_set = not interface.mac - if auto_set: - state = tk.DISABLED - else: - state = tk.NORMAL + mac_state = tk.DISABLED if auto_set else tk.NORMAL is_auto = tk.BooleanVar(value=auto_set) - checkbutton = ttk.Checkbutton(tab, text="Auto?", variable=is_auto) + checkbutton = ttk.Checkbutton( + tab, text="Auto?", variable=is_auto, state=state + ) checkbutton.var = is_auto checkbutton.grid(row=row, column=1, padx=PADX) mac = tk.StringVar(value=interface.mac) - entry = ttk.Entry(tab, textvariable=mac, state=state) + entry = ttk.Entry(tab, textvariable=mac, state=mac_state) entry.grid(row=row, column=2, sticky="ew") func = partial(mac_auto, is_auto, entry, mac) checkbutton.config(command=func) @@ -258,7 +263,7 @@ class NodeConfigDialog(Dialog): if interface.ip4: ip4_net = f"{interface.ip4}/{interface.ip4mask}" ip4 = tk.StringVar(value=ip4_net) - entry = ttk.Entry(tab, textvariable=ip4) + entry = ttk.Entry(tab, textvariable=ip4, state=state) entry.grid(row=row, column=1, columnspan=2, sticky="ew") row += 1 @@ -268,7 +273,7 @@ class NodeConfigDialog(Dialog): if interface.ip6: ip6_net = f"{interface.ip6}/{interface.ip6mask}" ip6 = tk.StringVar(value=ip6_net) - entry = ttk.Entry(tab, textvariable=ip6) + entry = ttk.Entry(tab, textvariable=ip6, state=state) entry.grid(row=row, column=1, columnspan=2, sticky="ew") self.interfaces[interface.id] = InterfaceData(is_auto, mac, ip4, ip6) From ea4271d7cb23a7972f2f2530dd359d133bc2525f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 19:15:53 -0700 Subject: [PATCH 016/929] changed defaultroute service to behave similarly as before and use the first interface for a default .1 address --- .../configservices/utilservices/services.py | 21 +++++++----------- daemon/core/services/utility.py | 22 +++++++------------ 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/daemon/core/configservices/utilservices/services.py b/daemon/core/configservices/utilservices/services.py index 75b5c745..8ddf1cc7 100644 --- a/daemon/core/configservices/utilservices/services.py +++ b/daemon/core/configservices/utilservices/services.py @@ -4,7 +4,6 @@ import netaddr from core import utils from core.configservice.base import ConfigService, ConfigServiceMode -from core.nodes.base import CoreNode GROUP_NAME = "Utility" @@ -26,18 +25,14 @@ class DefaultRouteService(ConfigService): def data(self) -> Dict[str, Any]: # only add default routes for linked routing nodes routes = [] - for other_node in self.node.session.nodes.values(): - if not isinstance(other_node, CoreNode): - continue - if other_node.type not in ["router", "mdr"]: - continue - commonnets = self.node.commonnets(other_node) - if commonnets: - _, _, router_eth = commonnets[0] - for x in router_eth.addrlist: - addr, prefix = x.split("/") - routes.append(addr) - break + netifs = self.node.netifs(sort=True) + if netifs: + netif = netifs[0] + for x in netif.addrlist: + net = netaddr.IPNetwork(x).cidr + if net.size > 1: + router = net[1] + routes.append(str(router)) return dict(routes=routes) diff --git a/daemon/core/services/utility.py b/daemon/core/services/utility.py index 028c2c0b..8a6e828b 100644 --- a/daemon/core/services/utility.py +++ b/daemon/core/services/utility.py @@ -7,7 +7,6 @@ import netaddr from core import constants, utils from core.errors import CoreCommandError -from core.nodes.base import CoreNode from core.services.coreservices import CoreService, ServiceMode @@ -77,20 +76,15 @@ class DefaultRouteService(UtilService): @classmethod def generate_config(cls, node, filename): - # only add default routes for linked routing nodes routes = [] - for other_node in node.session.nodes.values(): - if not isinstance(other_node, CoreNode): - continue - if other_node.type not in ["router", "mdr"]: - continue - commonnets = node.commonnets(other_node) - if commonnets: - _, _, router_eth = commonnets[0] - for x in router_eth.addrlist: - addr, prefix = x.split("/") - routes.append(addr) - break + netifs = node.netifs(sort=True) + if netifs: + netif = netifs[0] + for x in netif.addrlist: + net = netaddr.IPNetwork(x).cidr + if net.size > 1: + router = net[1] + routes.append(str(router)) cfg = "#!/bin/sh\n" cfg += "# auto-generated by DefaultRoute service (utility.py)\n" for route in routes: From 9d1f5cfcc6f6ff3927eb0bfb7793bc94e51ddb19 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 19:56:57 -0700 Subject: [PATCH 017/929] pygui most dialogs are modal, default dialogs to modal --- daemon/core/gui/dialogs/about.py | 2 +- daemon/core/gui/dialogs/alerts.py | 4 ++-- daemon/core/gui/dialogs/canvassizeandscale.py | 2 +- daemon/core/gui/dialogs/canvaswallpaper.py | 2 +- daemon/core/gui/dialogs/colorpicker.py | 2 +- daemon/core/gui/dialogs/configserviceconfig.py | 2 +- daemon/core/gui/dialogs/copyserviceconfig.py | 4 ++-- daemon/core/gui/dialogs/customnodes.py | 4 ++-- daemon/core/gui/dialogs/dialog.py | 2 +- daemon/core/gui/dialogs/emaneconfig.py | 9 +++------ daemon/core/gui/dialogs/executepython.py | 2 +- daemon/core/gui/dialogs/find.py | 2 +- daemon/core/gui/dialogs/hooks.py | 4 ++-- daemon/core/gui/dialogs/ipdialog.py | 2 +- daemon/core/gui/dialogs/linkconfig.py | 2 +- daemon/core/gui/dialogs/macdialog.py | 2 +- daemon/core/gui/dialogs/mobilityconfig.py | 5 +---- daemon/core/gui/dialogs/nodeconfig.py | 4 +--- daemon/core/gui/dialogs/nodeconfigservice.py | 2 +- daemon/core/gui/dialogs/nodeservice.py | 2 +- daemon/core/gui/dialogs/observers.py | 2 +- daemon/core/gui/dialogs/preferences.py | 2 +- daemon/core/gui/dialogs/runtool.py | 2 +- daemon/core/gui/dialogs/servers.py | 2 +- daemon/core/gui/dialogs/serviceconfig.py | 2 +- daemon/core/gui/dialogs/sessionoptions.py | 2 +- daemon/core/gui/dialogs/sessions.py | 2 +- daemon/core/gui/dialogs/shapemod.py | 2 +- daemon/core/gui/dialogs/throughput.py | 2 +- daemon/core/gui/dialogs/wlanconfig.py | 2 +- daemon/core/gui/errors.py | 2 +- 31 files changed, 37 insertions(+), 45 deletions(-) diff --git a/daemon/core/gui/dialogs/about.py b/daemon/core/gui/dialogs/about.py index bf498bb8..5402b1ab 100644 --- a/daemon/core/gui/dialogs/about.py +++ b/daemon/core/gui/dialogs/about.py @@ -36,7 +36,7 @@ THE POSSIBILITY OF SUCH DAMAGE.\ class AboutDialog(Dialog): def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "About CORE", modal=True) + super().__init__(master, app, "About CORE") self.draw() def draw(self): diff --git a/daemon/core/gui/dialogs/alerts.py b/daemon/core/gui/dialogs/alerts.py index 6c07f214..b425a30c 100644 --- a/daemon/core/gui/dialogs/alerts.py +++ b/daemon/core/gui/dialogs/alerts.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: class AlertsDialog(Dialog): def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Alerts", modal=True) + super().__init__(master, app, "Alerts") self.app = app self.tree = None self.codetext = None @@ -125,7 +125,7 @@ class AlertsDialog(Dialog): class DaemonLog(Dialog): def __init__(self, master: tk.Widget, app: "Application"): - super().__init__(master, app, "core-daemon log", modal=True) + super().__init__(master, app, "core-daemon log") self.columnconfigure(0, weight=1) self.path = tk.StringVar(value="/var/log/core-daemon.log") self.draw() diff --git a/daemon/core/gui/dialogs/canvassizeandscale.py b/daemon/core/gui/dialogs/canvassizeandscale.py index f04b991c..9543d8c6 100644 --- a/daemon/core/gui/dialogs/canvassizeandscale.py +++ b/daemon/core/gui/dialogs/canvassizeandscale.py @@ -19,7 +19,7 @@ class SizeAndScaleDialog(Dialog): """ create an instance for size and scale object """ - super().__init__(master, app, "Canvas Size and Scale", modal=True) + super().__init__(master, app, "Canvas Size and Scale") self.canvas = self.app.canvas self.validation = app.validation self.section_font = font.Font(weight="bold") diff --git a/daemon/core/gui/dialogs/canvaswallpaper.py b/daemon/core/gui/dialogs/canvaswallpaper.py index fe3fbd79..3b32572e 100644 --- a/daemon/core/gui/dialogs/canvaswallpaper.py +++ b/daemon/core/gui/dialogs/canvaswallpaper.py @@ -21,7 +21,7 @@ class CanvasWallpaperDialog(Dialog): """ create an instance of CanvasWallpaper object """ - super().__init__(master, app, "Canvas Background", modal=True) + super().__init__(master, app, "Canvas Background") self.canvas = self.app.canvas self.scale_option = tk.IntVar(value=self.canvas.scale_option.get()) self.adjust_to_dim = tk.BooleanVar(value=self.canvas.adjust_to_dim.get()) diff --git a/daemon/core/gui/dialogs/colorpicker.py b/daemon/core/gui/dialogs/colorpicker.py index b5a7d924..742e64f2 100644 --- a/daemon/core/gui/dialogs/colorpicker.py +++ b/daemon/core/gui/dialogs/colorpicker.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: class ColorPickerDialog(Dialog): def __init__(self, master: Any, app: "Application", initcolor: str = "#000000"): - super().__init__(master, app, "color picker", modal=True) + super().__init__(master, app, "color picker") self.red_entry = None self.blue_entry = None self.green_entry = None diff --git a/daemon/core/gui/dialogs/configserviceconfig.py b/daemon/core/gui/dialogs/configserviceconfig.py index 2239034e..45ea3a76 100644 --- a/daemon/core/gui/dialogs/configserviceconfig.py +++ b/daemon/core/gui/dialogs/configserviceconfig.py @@ -29,7 +29,7 @@ class ConfigServiceConfigDialog(Dialog): node_id: int, ): title = f"{service_name} Config Service" - super().__init__(master, app, title, modal=True) + super().__init__(master, app, title) self.master = master self.app = app self.core = app.core diff --git a/daemon/core/gui/dialogs/copyserviceconfig.py b/daemon/core/gui/dialogs/copyserviceconfig.py index c1b4376c..87c86fd3 100644 --- a/daemon/core/gui/dialogs/copyserviceconfig.py +++ b/daemon/core/gui/dialogs/copyserviceconfig.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: class CopyServiceConfigDialog(Dialog): def __init__(self, master: Any, app: "Application", node_id: int): - super().__init__(master, app, f"Copy services to node {node_id}", modal=True) + super().__init__(master, app, f"Copy services to node {node_id}") self.parent = master self.app = app self.node_id = node_id @@ -177,7 +177,7 @@ class ViewConfigDialog(Dialog): data: str, filename: str = None, ): - super().__init__(master, app, f"n{node_id} config data", modal=True) + super().__init__(master, app, f"n{node_id} config data") self.data = data self.service_data = None self.filepath = tk.StringVar(value=f"/tmp/services.tmp-n{node_id}-{filename}") diff --git a/daemon/core/gui/dialogs/customnodes.py b/daemon/core/gui/dialogs/customnodes.py index 2cad2fef..e154483a 100644 --- a/daemon/core/gui/dialogs/customnodes.py +++ b/daemon/core/gui/dialogs/customnodes.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: class ServicesSelectDialog(Dialog): def __init__(self, master: Any, app: "Application", current_services: Set[str]): - super().__init__(master, app, "Node Services", modal=True) + super().__init__(master, app, "Node Services") self.groups = None self.services = None self.current = None @@ -101,7 +101,7 @@ class ServicesSelectDialog(Dialog): class CustomNodesDialog(Dialog): def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Custom Nodes", modal=True) + super().__init__(master, app, "Custom Nodes") self.edit_button = None self.delete_button = None self.nodes_list = None diff --git a/daemon/core/gui/dialogs/dialog.py b/daemon/core/gui/dialogs/dialog.py index 00532793..32708450 100644 --- a/daemon/core/gui/dialogs/dialog.py +++ b/daemon/core/gui/dialogs/dialog.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: class Dialog(tk.Toplevel): def __init__( - self, master: tk.Widget, app: "Application", title: str, modal: bool = False + self, master: tk.Widget, app: "Application", title: str, modal: bool = True ): super().__init__(master) self.withdraw() diff --git a/daemon/core/gui/dialogs/emaneconfig.py b/daemon/core/gui/dialogs/emaneconfig.py index f200cd6e..a7835751 100644 --- a/daemon/core/gui/dialogs/emaneconfig.py +++ b/daemon/core/gui/dialogs/emaneconfig.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: class GlobalEmaneDialog(Dialog): def __init__(self, master: Any, app: "Application"): - super().__init__(master, app, "EMANE Configuration", modal=True) + super().__init__(master, app, "EMANE Configuration") self.config_frame = None self.draw() @@ -60,10 +60,7 @@ class EmaneModelDialog(Dialog): interface: int = None, ): super().__init__( - master, - app, - f"{canvas_node.core_node.name} {model} Configuration", - modal=True, + master, app, f"{canvas_node.core_node.name} {model} Configuration" ) self.canvas_node = canvas_node self.node = canvas_node.core_node @@ -117,7 +114,7 @@ class EmaneConfigDialog(Dialog): self, master: "Application", app: "Application", canvas_node: "CanvasNode" ): super().__init__( - master, app, f"{canvas_node.core_node.name} EMANE Configuration", modal=True + master, app, f"{canvas_node.core_node.name} EMANE Configuration" ) self.app = app self.canvas_node = canvas_node diff --git a/daemon/core/gui/dialogs/executepython.py b/daemon/core/gui/dialogs/executepython.py index 9adf4f93..e0a1a40d 100644 --- a/daemon/core/gui/dialogs/executepython.py +++ b/daemon/core/gui/dialogs/executepython.py @@ -9,7 +9,7 @@ from core.gui.themes import FRAME_PAD, PADX class ExecutePythonDialog(Dialog): def __init__(self, master, app): - super().__init__(master, app, "Execute Python Script", modal=True) + super().__init__(master, app, "Execute Python Script") self.app = app self.with_options = tk.IntVar(value=0) self.options = tk.StringVar(value="") diff --git a/daemon/core/gui/dialogs/find.py b/daemon/core/gui/dialogs/find.py index 4d7bc6bd..74543aa3 100644 --- a/daemon/core/gui/dialogs/find.py +++ b/daemon/core/gui/dialogs/find.py @@ -8,7 +8,7 @@ from core.gui.themes import FRAME_PAD, PADX, PADY class FindDialog(Dialog): def __init__(self, master, app) -> None: - super().__init__(master, app, "Find", modal=True) + super().__init__(master, app, "Find", modal=False) self.find_text = tk.StringVar(value="") self.tree = None diff --git a/daemon/core/gui/dialogs/hooks.py b/daemon/core/gui/dialogs/hooks.py index ad8ad533..f9da431a 100644 --- a/daemon/core/gui/dialogs/hooks.py +++ b/daemon/core/gui/dialogs/hooks.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: class HookDialog(Dialog): def __init__(self, master: Any, app: "Application"): - super().__init__(master, app, "Hook", modal=True) + super().__init__(master, app, "Hook") self.name = tk.StringVar() self.codetext = None self.hook = core_pb2.Hook() @@ -89,7 +89,7 @@ class HookDialog(Dialog): class HooksDialog(Dialog): def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Hooks", modal=True) + super().__init__(master, app, "Hooks") self.listbox = None self.edit_button = None self.delete_button = None diff --git a/daemon/core/gui/dialogs/ipdialog.py b/daemon/core/gui/dialogs/ipdialog.py index 58c06fb2..72e0d73a 100644 --- a/daemon/core/gui/dialogs/ipdialog.py +++ b/daemon/core/gui/dialogs/ipdialog.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: class IpConfigDialog(Dialog): def __init__(self, master: "Application", app: "Application") -> None: - super().__init__(master, app, "IP Configuration", modal=True) + super().__init__(master, app, "IP Configuration") ip_config = self.app.guiconfig.setdefault("ips") self.ip4 = ip_config.setdefault("ip4", appconfig.DEFAULT_IP4) self.ip6 = ip_config.setdefault("ip6", appconfig.DEFAULT_IP6) diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index f809059a..5a93d3fa 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -33,7 +33,7 @@ def get_float(var: tk.StringVar) -> Union[float, None]: class LinkConfigurationDialog(Dialog): def __init__(self, master: "CanvasGraph", app: "Application", edge: "CanvasEdge"): - super().__init__(master, app, "Link Configuration", modal=True) + super().__init__(master, app, "Link Configuration") self.app = app self.edge = edge self.is_symmetric = edge.link.options.unidirectional is False diff --git a/daemon/core/gui/dialogs/macdialog.py b/daemon/core/gui/dialogs/macdialog.py index fa5d81ad..558c3c29 100644 --- a/daemon/core/gui/dialogs/macdialog.py +++ b/daemon/core/gui/dialogs/macdialog.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: class MacConfigDialog(Dialog): def __init__(self, master: "Application", app: "Application") -> None: - super().__init__(master, app, "MAC Configuration", modal=True) + super().__init__(master, app, "MAC Configuration") mac = self.app.guiconfig.get("mac", appconfig.DEFAULT_MAC) self.mac_var = tk.StringVar(value=mac) self.draw() diff --git a/daemon/core/gui/dialogs/mobilityconfig.py b/daemon/core/gui/dialogs/mobilityconfig.py index 61cbfc14..2222e06f 100644 --- a/daemon/core/gui/dialogs/mobilityconfig.py +++ b/daemon/core/gui/dialogs/mobilityconfig.py @@ -21,10 +21,7 @@ class MobilityConfigDialog(Dialog): self, master: "Application", app: "Application", canvas_node: "CanvasNode" ): super().__init__( - master, - app, - f"{canvas_node.core_node.name} Mobility Configuration", - modal=True, + master, app, f"{canvas_node.core_node.name} Mobility Configuration" ) self.canvas_node = canvas_node self.node = canvas_node.core_node diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index 6af82746..ff21f886 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -100,9 +100,7 @@ class NodeConfigDialog(Dialog): """ create an instance of node configuration """ - super().__init__( - master, app, f"{canvas_node.core_node.name} Configuration", modal=True - ) + super().__init__(master, app, f"{canvas_node.core_node.name} Configuration") self.canvas_node = canvas_node self.node = canvas_node.core_node self.image = canvas_node.image diff --git a/daemon/core/gui/dialogs/nodeconfigservice.py b/daemon/core/gui/dialogs/nodeconfigservice.py index c86d8887..f593526f 100644 --- a/daemon/core/gui/dialogs/nodeconfigservice.py +++ b/daemon/core/gui/dialogs/nodeconfigservice.py @@ -25,7 +25,7 @@ class NodeConfigServiceDialog(Dialog): services: Set[str] = None, ): title = f"{canvas_node.core_node.name} Config Services" - super().__init__(master, app, title, modal=True) + super().__init__(master, app, title) self.app = app self.canvas_node = canvas_node self.node_id = canvas_node.core_node.id diff --git a/daemon/core/gui/dialogs/nodeservice.py b/daemon/core/gui/dialogs/nodeservice.py index 3e716627..08abd308 100644 --- a/daemon/core/gui/dialogs/nodeservice.py +++ b/daemon/core/gui/dialogs/nodeservice.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: class NodeServiceDialog(Dialog): def __init__(self, master: Any, app: "Application", canvas_node: "CanvasNode"): title = f"{canvas_node.core_node.name} Services" - super().__init__(master, app, title, modal=True) + super().__init__(master, app, title) self.app = app self.canvas_node = canvas_node self.node_id = canvas_node.core_node.id diff --git a/daemon/core/gui/dialogs/observers.py b/daemon/core/gui/dialogs/observers.py index 51e9fe88..1282789e 100644 --- a/daemon/core/gui/dialogs/observers.py +++ b/daemon/core/gui/dialogs/observers.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: class ObserverDialog(Dialog): def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Observer Widgets", modal=True) + super().__init__(master, app, "Observer Widgets") self.observers = None self.save_button = None self.delete_button = None diff --git a/daemon/core/gui/dialogs/preferences.py b/daemon/core/gui/dialogs/preferences.py index 0df71e7f..9c6ba5b9 100644 --- a/daemon/core/gui/dialogs/preferences.py +++ b/daemon/core/gui/dialogs/preferences.py @@ -17,7 +17,7 @@ SCALE_INTERVAL = 0.01 class PreferencesDialog(Dialog): def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Preferences", modal=True) + super().__init__(master, app, "Preferences") self.gui_scale = tk.DoubleVar(value=self.app.app_scale) preferences = self.app.guiconfig["preferences"] self.editor = tk.StringVar(value=preferences["editor"]) diff --git a/daemon/core/gui/dialogs/runtool.py b/daemon/core/gui/dialogs/runtool.py index 21a2f44b..c3e3dec9 100644 --- a/daemon/core/gui/dialogs/runtool.py +++ b/daemon/core/gui/dialogs/runtool.py @@ -9,7 +9,7 @@ from core.gui.widgets import CodeText, ListboxScroll class RunToolDialog(Dialog): def __init__(self, master, app) -> None: - super().__init__(master, app, "Run Tool", modal=True) + super().__init__(master, app, "Run Tool") self.cmd = tk.StringVar(value="ps ax") self.app = app self.result = None diff --git a/daemon/core/gui/dialogs/servers.py b/daemon/core/gui/dialogs/servers.py index c57e97d3..26a76835 100644 --- a/daemon/core/gui/dialogs/servers.py +++ b/daemon/core/gui/dialogs/servers.py @@ -17,7 +17,7 @@ DEFAULT_PORT = 50051 class ServersDialog(Dialog): def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "CORE Servers", modal=True) + super().__init__(master, app, "CORE Servers") self.name = tk.StringVar(value=DEFAULT_NAME) self.address = tk.StringVar(value=DEFAULT_ADDRESS) self.port = tk.IntVar(value=DEFAULT_PORT) diff --git a/daemon/core/gui/dialogs/serviceconfig.py b/daemon/core/gui/dialogs/serviceconfig.py index 33d38323..8fc85394 100644 --- a/daemon/core/gui/dialogs/serviceconfig.py +++ b/daemon/core/gui/dialogs/serviceconfig.py @@ -29,7 +29,7 @@ class ServiceConfigDialog(Dialog): node_id: int, ): title = f"{service_name} Service" - super().__init__(master, app, title, modal=True) + super().__init__(master, app, title) self.master = master self.app = app self.core = app.core diff --git a/daemon/core/gui/dialogs/sessionoptions.py b/daemon/core/gui/dialogs/sessionoptions.py index a3f738a7..c042eef4 100644 --- a/daemon/core/gui/dialogs/sessionoptions.py +++ b/daemon/core/gui/dialogs/sessionoptions.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: class SessionOptionsDialog(Dialog): def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Session Options", modal=True) + super().__init__(master, app, "Session Options") self.config_frame = None self.has_error = False self.config = self.get_config() diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index 3671b308..288f8b4b 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -20,7 +20,7 @@ class SessionsDialog(Dialog): def __init__( self, master: "Application", app: "Application", is_start_app: bool = False ) -> None: - super().__init__(master, app, "Sessions", modal=True) + super().__init__(master, app, "Sessions") self.is_start_app = is_start_app self.selected_session = None self.selected_id = None diff --git a/daemon/core/gui/dialogs/shapemod.py b/daemon/core/gui/dialogs/shapemod.py index f47cb7b3..9efb9fa3 100644 --- a/daemon/core/gui/dialogs/shapemod.py +++ b/daemon/core/gui/dialogs/shapemod.py @@ -25,7 +25,7 @@ class ShapeDialog(Dialog): title = "Add Shape" else: title = "Add Text" - super().__init__(master, app, title, modal=True) + super().__init__(master, app, title) self.canvas = app.canvas self.fill = None self.border = None diff --git a/daemon/core/gui/dialogs/throughput.py b/daemon/core/gui/dialogs/throughput.py index 96aa3bc5..5c6b1d28 100644 --- a/daemon/core/gui/dialogs/throughput.py +++ b/daemon/core/gui/dialogs/throughput.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: class ThroughputDialog(Dialog): def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Throughput Config", modal=False) + super().__init__(master, app, "Throughput Config") self.app = app self.canvas = app.canvas self.show_throughput = tk.IntVar(value=1) diff --git a/daemon/core/gui/dialogs/wlanconfig.py b/daemon/core/gui/dialogs/wlanconfig.py index a777c7d4..d5d0c673 100644 --- a/daemon/core/gui/dialogs/wlanconfig.py +++ b/daemon/core/gui/dialogs/wlanconfig.py @@ -21,7 +21,7 @@ class WlanConfigDialog(Dialog): self, master: "Application", app: "Application", canvas_node: "CanvasNode" ): super().__init__( - master, app, f"{canvas_node.core_node.name} Wlan Configuration", modal=True + master, app, f"{canvas_node.core_node.name} Wlan Configuration" ) self.canvas_node = canvas_node self.node = canvas_node.core_node diff --git a/daemon/core/gui/errors.py b/daemon/core/gui/errors.py index 1f9353d8..782a795d 100644 --- a/daemon/core/gui/errors.py +++ b/daemon/core/gui/errors.py @@ -14,7 +14,7 @@ if TYPE_CHECKING: class ErrorDialog(Dialog): def __init__(self, master, app: "Application", title: str, details: str) -> None: - super().__init__(master, app, "CORE Exception", modal=True) + super().__init__(master, app, "CORE Exception") self.title = title self.details = details self.error_message = None From d158fc99c6fc20ad3019bdad1623bed57b7fc319 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 19:59:41 -0700 Subject: [PATCH 018/929] pygui small cleanup to layout of find dialog --- daemon/core/gui/dialogs/find.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/daemon/core/gui/dialogs/find.py b/daemon/core/gui/dialogs/find.py index 74543aa3..2541c74e 100644 --- a/daemon/core/gui/dialogs/find.py +++ b/daemon/core/gui/dialogs/find.py @@ -9,7 +9,6 @@ from core.gui.themes import FRAME_PAD, PADX, PADY class FindDialog(Dialog): def __init__(self, master, app) -> None: super().__init__(master, app, "Find", modal=False) - self.find_text = tk.StringVar(value="") self.tree = None self.draw() @@ -18,13 +17,11 @@ class FindDialog(Dialog): def draw(self) -> None: self.top.columnconfigure(0, weight=1) - self.top.rowconfigure(0, weight=1) - self.top.rowconfigure(1, weight=5) - self.top.rowconfigure(2, weight=1) + self.top.rowconfigure(1, weight=1) # Find node frame frame = ttk.Frame(self.top, padding=FRAME_PAD) - frame.grid(sticky="nsew") + frame.grid(sticky="ew", pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Find:") label.grid() @@ -35,7 +32,7 @@ class FindDialog(Dialog): frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) frame.rowconfigure(0, weight=1) - frame.grid(sticky="nsew", padx=PADX, pady=PADY) + frame.grid(sticky="nsew", pady=PADY) self.tree = ttk.Treeview( frame, columns=("nodeid", "name", "location", "detail"), @@ -54,26 +51,23 @@ class FindDialog(Dialog): self.tree.heading("location", text="Location") self.tree.column("detail", stretch=tk.YES, anchor="center") self.tree.heading("detail", text="Detail") - self.tree.bind("<>", self.click_select) - yscrollbar = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview) yscrollbar.grid(row=0, column=1, sticky="ns") self.tree.configure(yscrollcommand=yscrollbar.set) - xscrollbar = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview) xscrollbar.grid(row=1, sticky="ew") self.tree.configure(xscrollcommand=xscrollbar.set) # button frame frame = ttk.Frame(self.top) - frame.grid(sticky="nsew") + frame.grid(sticky="ew") frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) button = ttk.Button(frame, text="Find", command=self.find_node) button.grid(row=0, column=0, sticky="ew", padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.close_dialog) - button.grid(row=0, column=1, sticky="ew", padx=PADX) + button.grid(row=0, column=1, sticky="ew") def clear_treeview_items(self) -> None: """ From 5a8984de106d613e64d03b23069579c9abd2048b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 23:36:33 -0700 Subject: [PATCH 019/929] pygui some delete node/link cleanup and added unlink option to node context menu for an easier unlinking --- daemon/core/gui/coreclient.py | 24 +++++++++--------------- daemon/core/gui/graph/graph.py | 29 ++++++++++++++++++++++------- daemon/core/gui/graph/node.py | 18 ++++++++++++++++++ 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 8c05a30f..fc4fc64f 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -6,7 +6,7 @@ import logging import os from pathlib import Path from tkinter import messagebox -from typing import TYPE_CHECKING, Dict, List +from typing import TYPE_CHECKING, Dict, Iterable, List import grpc @@ -830,27 +830,21 @@ class CoreClient: ) return node - def delete_graph_nodes(self, canvas_nodes: List[core_pb2.Node]): + def deleted_graph_nodes(self, canvas_nodes: List[core_pb2.Node]): """ remove the nodes selected by the user and anything related to that node such as link, configurations, interfaces """ - edges = set() - removed_links = [] for canvas_node in canvas_nodes: node_id = canvas_node.core_node.id - if node_id not in self.canvas_nodes: - logging.error("unknown node: %s", node_id) - continue del self.canvas_nodes[node_id] - for edge in canvas_node.edges: - if edge in edges: - continue - edges.add(edge) - edge = self.links.pop(edge.token, None) - if edge is not None: - removed_links.append(edge.link) - self.interfaces_manager.removed(removed_links) + + def deleted_graph_edges(self, edges: Iterable[CanvasEdge]) -> None: + links = [] + for edge in edges: + del self.links[edge.token] + links.append(edge.link) + self.interfaces_manager.removed(links) def create_interface(self, canvas_node: CanvasNode) -> core_pb2.Interface: node = canvas_node.core_node diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 9e60f131..22d21b51 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -517,15 +517,13 @@ class CanvasGraph(tk.Canvas): canvas_node.delete() nodes.append(canvas_node) is_wireless = NodeUtils.is_wireless_node(canvas_node.core_node.type) - # delete related edges for edge in canvas_node.edges: if edge in edges: continue edges.add(edge) - self.edges.pop(edge.token, None) + del self.edges[edge.token] edge.delete() - # update node connected to edge being deleted other_id = edge.src other_interface = edge.src_interface @@ -534,10 +532,8 @@ class CanvasGraph(tk.Canvas): other_interface = edge.dst_interface other_node = self.nodes[other_id] other_node.edges.remove(edge) - try: + if other_interface in other_node.interfaces: other_node.interfaces.remove(other_interface) - except ValueError: - pass if is_wireless: other_node.delete_antenna() @@ -547,7 +543,26 @@ class CanvasGraph(tk.Canvas): shape.delete() self.selection.clear() - self.core.delete_graph_nodes(nodes) + self.core.deleted_graph_nodes(nodes) + self.core.deleted_graph_edges(edges) + + def delete_edge(self, edge: CanvasEdge): + edge.delete() + del self.edges[edge.token] + src_node = self.nodes[edge.src] + src_node.edges.discard(edge) + if edge.src_interface in src_node.interfaces: + src_node.interfaces.remove(edge.src_interface) + dst_node = self.nodes[edge.dst] + dst_node.edges.discard(edge) + if edge.dst_interface in dst_node.interfaces: + dst_node.interfaces.remove(edge.dst_interface) + src_wireless = NodeUtils.is_wireless_node(src_node.core_node.type) + if src_wireless: + dst_node.delete_antenna() + dst_wireless = NodeUtils.is_wireless_node(dst_node.core_node.type) + if dst_wireless: + src_node.delete_antenna() def zoom(self, event: tk.Event, factor: float = None): if not factor: diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 90896284..5b3aed10 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -1,3 +1,4 @@ +import functools import logging import tkinter as tk from typing import TYPE_CHECKING @@ -15,6 +16,7 @@ from core.gui.dialogs.nodeservice import NodeServiceDialog from core.gui.dialogs.wlanconfig import WlanConfigDialog from core.gui.errors import show_grpc_error from core.gui.graph import tags +from core.gui.graph.edges import CanvasEdge from core.gui.graph.tooltip import CanvasTooltip from core.gui.images import ImageEnum, Images from core.gui.nodeutils import ANTENNA_SIZE, NodeUtils @@ -230,6 +232,18 @@ class CanvasNode: label="Link To Selected", command=self.wireless_link_selected ) context.add_command(label="Select Members", state=tk.DISABLED) + unlink_menu = tk.Menu(context) + for edge in self.edges: + other_id = edge.src + if self.id == other_id: + other_id = edge.dst + other_node = self.canvas.nodes[other_id] + func_unlink = functools.partial(self.click_unlink, edge) + unlink_menu.add_command( + label=other_node.core_node.name, command=func_unlink + ) + themes.style_menu(unlink_menu) + context.add_cascade(label="Unlink", menu=unlink_menu) edit_menu = tk.Menu(context) themes.style_menu(edit_menu) edit_menu.add_command(label="Cut", command=self.click_cut) @@ -242,6 +256,10 @@ class CanvasNode: self.canvas_copy() self.canvas_delete() + def click_unlink(self, edge: CanvasEdge) -> None: + self.canvas.delete_edge(edge) + self.app.core.deleted_graph_edges([edge]) + def canvas_delete(self) -> None: self.canvas.clear_selection() self.canvas.selection[self.id] = self From 491f2a8e9342b6abd049ee1809001aec4e122604 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 1 May 2020 23:47:37 -0700 Subject: [PATCH 020/929] pygui enabled delete on link context menu, removed split/merge for now, set edge labels to bold to stand out until better solution --- daemon/core/gui/app.py | 4 +++- daemon/core/gui/graph/edges.py | 9 ++++----- daemon/core/gui/graph/graph.py | 1 + daemon/core/gui/graph/node.py | 1 - 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 0f40a594..13d10dd0 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -50,7 +50,9 @@ class Application(tk.Frame): text_scale = self.app_scale if self.app_scale < 1 else math.sqrt(self.app_scale) themes.scale_fonts(self.fonts_size, self.app_scale) self.icon_text_font = font.Font(family="TkIconFont", size=int(12 * text_scale)) - self.edge_font = font.Font(family="TkDefaultFont", size=int(8 * text_scale)) + self.edge_font = font.Font( + family="TkDefaultFont", size=int(8 * text_scale), weight=font.BOLD + ) def setup_theme(self): themes.load(self.style) diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index b70fe6b2..8aa63de4 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -377,15 +377,14 @@ class CanvasEdge(Edge): context = tk.Menu(self.canvas) themes.style_menu(context) context.add_command(label="Configure", command=self.configure) - context.add_command(label="Delete") - context.add_command(label="Split") - context.add_command(label="Merge") + context.add_command(label="Delete", command=self.click_delete) if self.canvas.app.core.is_runtime(): context.entryconfigure(1, state="disabled") - context.entryconfigure(2, state="disabled") - context.entryconfigure(3, state="disabled") context.post(event.x_root, event.y_root) + def click_delete(self): + self.canvas.delete_edge(self) + def configure(self) -> None: dialog = LinkConfigurationDialog(self.canvas, self.canvas.app, self) dialog.show() diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 22d21b51..74040e64 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -563,6 +563,7 @@ class CanvasGraph(tk.Canvas): dst_wireless = NodeUtils.is_wireless_node(dst_node.core_node.type) if dst_wireless: src_node.delete_antenna() + self.core.deleted_graph_edges([edge]) def zoom(self, event: tk.Event, factor: float = None): if not factor: diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 5b3aed10..cee0e822 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -258,7 +258,6 @@ class CanvasNode: def click_unlink(self, edge: CanvasEdge) -> None: self.canvas.delete_edge(edge) - self.app.core.deleted_graph_edges([edge]) def canvas_delete(self) -> None: self.canvas.clear_selection() From 65466909d33c1077bb36d14caa82181f145c9d03 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 2 May 2020 08:41:10 -0700 Subject: [PATCH 021/929] pygui improved edge context by properly using tk_popup --- daemon/core/gui/graph/edges.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index 8aa63de4..17809dcb 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -268,9 +268,16 @@ class CanvasEdge(Edge): self.throughput = None self.draw(src_pos, dst_pos) self.set_binding() + self.context = tk.Menu(self.canvas) + self.create_context() + + def create_context(self): + themes.style_menu(self.context) + self.context.add_command(label="Configure", command=self.click_configure) + self.context.add_command(label="Delete", command=self.click_delete) def set_binding(self) -> None: - self.canvas.tag_bind(self.id, "", self.create_context) + self.canvas.tag_bind(self.id, "", self.show_context) def set_link(self, link) -> None: self.link = link @@ -373,18 +380,14 @@ class CanvasEdge(Edge): self.middle_label = None self.canvas.itemconfig(self.id, fill=self.color, width=self.scaled_width()) - def create_context(self, event: tk.Event) -> None: - context = tk.Menu(self.canvas) - themes.style_menu(context) - context.add_command(label="Configure", command=self.configure) - context.add_command(label="Delete", command=self.click_delete) - if self.canvas.app.core.is_runtime(): - context.entryconfigure(1, state="disabled") - context.post(event.x_root, event.y_root) + def show_context(self, event: tk.Event) -> None: + state = tk.DISABLED if self.canvas.core.is_runtime() else tk.NORMAL + self.context.entryconfigure(1, state=state) + self.context.tk_popup(event.x_root, event.y_root) def click_delete(self): self.canvas.delete_edge(self) - def configure(self) -> None: + def click_configure(self) -> None: dialog = LinkConfigurationDialog(self.canvas, self.canvas.app, self) dialog.show() From ac2d60dad6121313452aaf325ecb53104bb705a3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 2 May 2020 09:20:36 -0700 Subject: [PATCH 022/929] pygui improved node context to properly use tk_popup, avoiding bandage code to compensate for other issues --- daemon/core/gui/graph/graph.py | 85 +++++++++++----------------------- daemon/core/gui/graph/node.py | 69 ++++++++++++++------------- daemon/core/gui/toolbar.py | 2 - 3 files changed, 62 insertions(+), 94 deletions(-) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 74040e64..60df2440 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -58,7 +58,6 @@ class CanvasGraph(tk.Canvas): self.select_box = None self.selected = None self.node_draw = None - self.context = None self.nodes = {} self.edges = {} self.shapes = {} @@ -130,9 +129,6 @@ class CanvasGraph(tk.Canvas): client. :param session: session to draw """ - # hide context - self.hide_context() - # reset view options to default state self.show_node_labels.set(True) self.show_link_labels.set(True) @@ -166,7 +162,6 @@ class CanvasGraph(tk.Canvas): self.bind("", self.click_press) self.bind("", self.click_release) self.bind("", self.click_motion) - self.bind("", self.click_context) self.bind("", self.press_delete) self.bind("", self.ctrl_click) self.bind("", self.double_click) @@ -176,11 +171,6 @@ class CanvasGraph(tk.Canvas): self.bind("", lambda e: self.scan_mark(e.x, e.y)) self.bind("", lambda e: self.scan_dragto(e.x, e.y, gain=1)) - def hide_context(self, event=None): - if self.context: - self.context.unpost() - self.context = None - def get_actual_coords(self, x: float, y: float) -> [float, float]: actual_x = (x - self.offset[0]) / self.ratio actual_y = (y - self.offset[1]) / self.ratio @@ -396,41 +386,35 @@ class CanvasGraph(tk.Canvas): x, y = self.canvas_xy(event) if not self.inside_canvas(x, y): return - - if self.context: - self.hide_context() + if self.mode == GraphMode.ANNOTATION: + self.focus_set() + if self.shape_drawing: + shape = self.shapes[self.selected] + shape.shape_complete(x, y) + self.shape_drawing = False + elif self.mode == GraphMode.SELECT: + self.focus_set() + if self.select_box: + x0, y0, x1, y1 = self.coords(self.select_box.id) + inside = [ + x + for x in self.find_enclosed(x0, y0, x1, y1) + if "node" in self.gettags(x) or "shape" in self.gettags(x) + ] + for i in inside: + self.select_object(i, True) + self.select_box.disappear() + self.select_box = None else: - if self.mode == GraphMode.ANNOTATION: - self.focus_set() - if self.shape_drawing: - shape = self.shapes[self.selected] - shape.shape_complete(x, y) - self.shape_drawing = False - elif self.mode == GraphMode.SELECT: - self.focus_set() - if self.select_box: - x0, y0, x1, y1 = self.coords(self.select_box.id) - inside = [ - x - for x in self.find_enclosed(x0, y0, x1, y1) - if "node" in self.gettags(x) or "shape" in self.gettags(x) - ] - for i in inside: - self.select_object(i, True) - self.select_box.disappear() - self.select_box = None - else: - self.focus_set() - self.selected = self.get_selected(event) - logging.debug( - f"click release selected({self.selected}) mode({self.mode})" - ) - if self.mode == GraphMode.EDGE: - self.handle_edge_release(event) - elif self.mode == GraphMode.NODE: - self.add_node(x, y) - elif self.mode == GraphMode.PICKNODE: - self.mode = GraphMode.NODE + self.focus_set() + self.selected = self.get_selected(event) + logging.debug(f"click release selected({self.selected}) mode({self.mode})") + if self.mode == GraphMode.EDGE: + self.handle_edge_release(event) + elif self.mode == GraphMode.NODE: + self.add_node(x, y) + elif self.mode == GraphMode.PICKNODE: + self.mode = GraphMode.NODE self.selected = None def handle_edge_release(self, _event: tk.Event): @@ -717,19 +701,6 @@ class CanvasGraph(tk.Canvas): if self.select_box and self.mode == GraphMode.SELECT: self.select_box.shape_motion(x, y) - def click_context(self, event: tk.Event): - logging.info("context: %s", self.context) - if not self.context: - selected = self.get_selected(event) - canvas_node = self.nodes.get(selected) - if canvas_node: - logging.debug("node context: %s", selected) - self.context = canvas_node.create_context() - self.context.bind("", self.hide_context) - self.context.post(event.x_root, event.y_root) - else: - self.hide_context() - def press_delete(self, _event: tk.Event): """ delete selected nodes and any data that relates to it diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index cee0e822..5bc92db0 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -68,11 +68,14 @@ class CanvasNode: self.service_file_configs = {} self.config_service_configs = {} self.setup_bindings() + self.context = tk.Menu(self.canvas) + themes.style_menu(self.context) def setup_bindings(self): self.canvas.tag_bind(self.id, "", self.double_click) self.canvas.tag_bind(self.id, "", self.on_enter) self.canvas.tag_bind(self.id, "", self.on_leave) + self.canvas.tag_bind(self.id, "", self.show_context) def delete(self): logging.debug("Delete canvas node for %s", self.core_node) @@ -188,51 +191,55 @@ class CanvasNode: else: self.show_config() - def create_context(self) -> tk.Menu: + def show_context(self, event: tk.Event) -> None: + # clear existing menu + self.context.delete(0, tk.END) is_wlan = self.core_node.type == NodeType.WIRELESS_LAN is_emane = self.core_node.type == NodeType.EMANE - context = tk.Menu(self.canvas) - themes.style_menu(context) if self.app.core.is_runtime(): - context.add_command(label="Configure", command=self.show_config) + self.context.add_command(label="Configure", command=self.show_config) if NodeUtils.is_container_node(self.core_node.type): - context.add_command(label="Services", state=tk.DISABLED) - context.add_command(label="Config Services", state=tk.DISABLED) + self.context.add_command(label="Services", state=tk.DISABLED) + self.context.add_command(label="Config Services", state=tk.DISABLED) if is_wlan: - context.add_command(label="WLAN Config", command=self.show_wlan_config) + self.context.add_command( + label="WLAN Config", command=self.show_wlan_config + ) if is_wlan and self.core_node.id in self.app.core.mobility_players: - context.add_command( + self.context.add_command( label="Mobility Player", command=self.show_mobility_player ) - context.add_command(label="Select Adjacent", state=tk.DISABLED) + self.context.add_command(label="Select Adjacent", state=tk.DISABLED) if NodeUtils.is_container_node(self.core_node.type): - context.add_command(label="Shell Window", state=tk.DISABLED) - context.add_command(label="Tcpdump", state=tk.DISABLED) - context.add_command(label="Tshark", state=tk.DISABLED) - context.add_command(label="Wireshark", state=tk.DISABLED) - context.add_command(label="View Log", state=tk.DISABLED) + self.context.add_command(label="Shell Window", state=tk.DISABLED) + self.context.add_command(label="Tcpdump", state=tk.DISABLED) + self.context.add_command(label="Tshark", state=tk.DISABLED) + self.context.add_command(label="Wireshark", state=tk.DISABLED) + self.context.add_command(label="View Log", state=tk.DISABLED) else: - context.add_command(label="Configure", command=self.show_config) + self.context.add_command(label="Configure", command=self.show_config) if NodeUtils.is_container_node(self.core_node.type): - context.add_command(label="Services", command=self.show_services) - context.add_command( + self.context.add_command(label="Services", command=self.show_services) + self.context.add_command( label="Config Services", command=self.show_config_services ) if is_emane: - context.add_command( + self.context.add_command( label="EMANE Config", command=self.show_emane_config ) if is_wlan: - context.add_command(label="WLAN Config", command=self.show_wlan_config) - context.add_command( + self.context.add_command( + label="WLAN Config", command=self.show_wlan_config + ) + self.context.add_command( label="Mobility Config", command=self.show_mobility_config ) if NodeUtils.is_wireless_node(self.core_node.type): - context.add_command( + self.context.add_command( label="Link To Selected", command=self.wireless_link_selected ) - context.add_command(label="Select Members", state=tk.DISABLED) - unlink_menu = tk.Menu(context) + self.context.add_command(label="Select Members", state=tk.DISABLED) + unlink_menu = tk.Menu(self.context) for edge in self.edges: other_id = edge.src if self.id == other_id: @@ -243,14 +250,14 @@ class CanvasNode: label=other_node.core_node.name, command=func_unlink ) themes.style_menu(unlink_menu) - context.add_cascade(label="Unlink", menu=unlink_menu) - edit_menu = tk.Menu(context) + self.context.add_cascade(label="Unlink", menu=unlink_menu) + edit_menu = tk.Menu(self.context) themes.style_menu(edit_menu) edit_menu.add_command(label="Cut", command=self.click_cut) edit_menu.add_command(label="Copy", command=self.canvas_copy) edit_menu.add_command(label="Delete", command=self.canvas_delete) - context.add_cascade(label="Edit", menu=edit_menu) - return context + self.context.add_cascade(label="Edit", menu=edit_menu) + self.context.tk_popup(event.x_root, event.y_root) def click_cut(self) -> None: self.canvas_copy() @@ -270,39 +277,32 @@ class CanvasNode: self.canvas.copy() def show_config(self): - self.canvas.context = None dialog = NodeConfigDialog(self.app, self.app, self) dialog.show() def show_wlan_config(self): - self.canvas.context = None dialog = WlanConfigDialog(self.app, self.app, self) if not dialog.has_error: dialog.show() def show_mobility_config(self): - self.canvas.context = None dialog = MobilityConfigDialog(self.app, self.app, self) if not dialog.has_error: dialog.show() def show_mobility_player(self): - self.canvas.context = None mobility_player = self.app.core.mobility_players[self.core_node.id] mobility_player.show() def show_emane_config(self): - self.canvas.context = None dialog = EmaneConfigDialog(self.app, self.app, self) dialog.show() def show_services(self): - self.canvas.context = None dialog = NodeServiceDialog(self.app.master, self.app, self) dialog.show() def show_config_services(self): - self.canvas.context = None dialog = NodeConfigServiceDialog(self.app.master, self.app, self) dialog.show() @@ -324,7 +324,6 @@ class CanvasNode: return result def wireless_link_selected(self): - self.canvas.context = None for canvas_nid in [ x for x in self.canvas.selection if "node" in self.canvas.gettags(x) ]: diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 11e2896a..2304f010 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -263,7 +263,6 @@ class Toolbar(ttk.Frame): Start session handler redraw buttons, send node and link messages to grpc server. """ - self.app.canvas.hide_context() self.app.menubar.change_menubar_item_state(is_runtime=True) self.app.statusbar.progress_bar.start(5) self.app.canvas.mode = GraphMode.SELECT @@ -453,7 +452,6 @@ class Toolbar(ttk.Frame): redraw buttons on the toolbar, send node and link messages to grpc server """ logging.info("Click stop button") - self.app.canvas.hide_context() self.app.menubar.change_menubar_item_state(is_runtime=False) self.app.statusbar.progress_bar.start(5) self.time = time.perf_counter() From be70c5383ef67aaadd337807828b9152f8f06f79 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 2 May 2020 09:23:06 -0700 Subject: [PATCH 023/929] pygui removed manage members context from wireless node --- daemon/core/gui/graph/node.py | 1 - 1 file changed, 1 deletion(-) diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 5bc92db0..54f2e9fb 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -238,7 +238,6 @@ class CanvasNode: self.context.add_command( label="Link To Selected", command=self.wireless_link_selected ) - self.context.add_command(label="Select Members", state=tk.DISABLED) unlink_menu = tk.Menu(self.context) for edge in self.edges: other_id = edge.src From b858e66c499fca51b024dbb12bdfd2a5fc71629f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 2 May 2020 23:51:42 -0700 Subject: [PATCH 024/929] pygui updated main app frame to use grid layout like everything else --- daemon/core/gui/app.py | 37 +++++++++++++------- daemon/core/gui/dialogs/nodeconfigservice.py | 4 +-- daemon/core/gui/dialogs/nodeservice.py | 6 ++-- daemon/core/gui/graph/graph.py | 33 +++++++++-------- daemon/core/gui/graph/node.py | 4 +-- daemon/core/gui/statusbar.py | 2 +- daemon/core/gui/themes.py | 9 +++-- daemon/core/gui/toolbar.py | 3 +- 8 files changed, 60 insertions(+), 38 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 13d10dd0..7781cc0d 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -16,7 +16,7 @@ WIDTH = 1000 HEIGHT = 800 -class Application(tk.Frame): +class Application(ttk.Frame): def __init__(self, proxy: bool): super().__init__(master=None) # load node icons @@ -25,6 +25,7 @@ class Application(tk.Frame): # widgets self.menubar = None self.toolbar = None + self.right_frame = None self.canvas = None self.statusbar = None self.validation = None @@ -66,8 +67,8 @@ class Application(tk.Frame): self.master.protocol("WM_DELETE_WINDOW", self.on_closing) image = Images.get(ImageEnum.CORE, 16) self.master.tk.call("wm", "iconphoto", self.master._w, image) - self.pack(fill=tk.BOTH, expand=True) self.validation = InputValidation(self) + self.master.option_add("*tearOff", tk.FALSE) def center(self): screen_width = self.master.winfo_screenwidth() @@ -79,9 +80,17 @@ class Application(tk.Frame): ) def draw(self): - self.master.option_add("*tearOff", tk.FALSE) + self.master.rowconfigure(0, weight=1) + self.master.columnconfigure(0, weight=1) + self.rowconfigure(0, weight=1) + self.columnconfigure(1, weight=1) + self.grid(sticky="nsew") self.toolbar = Toolbar(self, self) - self.toolbar.pack(side=tk.LEFT, fill=tk.Y, ipadx=2, ipady=2) + self.toolbar.grid(sticky="ns") + self.right_frame = ttk.Frame(self) + self.right_frame.columnconfigure(0, weight=1) + self.right_frame.rowconfigure(0, weight=1) + self.right_frame.grid(row=0, column=1, sticky="nsew") self.draw_canvas() self.draw_status() self.menubar = Menubar(self.master, self) @@ -89,20 +98,24 @@ class Application(tk.Frame): def draw_canvas(self): width = self.guiconfig["preferences"]["width"] height = self.guiconfig["preferences"]["height"] - self.canvas = CanvasGraph(self, self.core, width, height) - self.canvas.pack(fill=tk.BOTH, expand=True) + canvas_frame = ttk.Frame(self.right_frame) + canvas_frame.rowconfigure(0, weight=1) + canvas_frame.columnconfigure(0, weight=1) + canvas_frame.grid(sticky="nsew", pady=1) + self.canvas = CanvasGraph(canvas_frame, self, self.core, width, height) + self.canvas.grid(sticky="nsew") + scroll_y = ttk.Scrollbar(canvas_frame, command=self.canvas.yview) + scroll_y.grid(row=0, column=1, sticky="ns") scroll_x = ttk.Scrollbar( - self.canvas, orient=tk.HORIZONTAL, command=self.canvas.xview + canvas_frame, orient=tk.HORIZONTAL, command=self.canvas.xview ) - scroll_x.pack(side=tk.BOTTOM, fill=tk.X) - scroll_y = ttk.Scrollbar(self.canvas, command=self.canvas.yview) - scroll_y.pack(side=tk.RIGHT, fill=tk.Y) + scroll_x.grid(row=1, column=0, sticky="ew") self.canvas.configure(xscrollcommand=scroll_x.set) self.canvas.configure(yscrollcommand=scroll_y.set) def draw_status(self): - self.statusbar = StatusBar(master=self, app=self) - self.statusbar.pack(side=tk.BOTTOM, fill=tk.X) + self.statusbar = StatusBar(self.right_frame, self) + self.statusbar.grid(sticky="ew") def on_closing(self): self.menubar.prompt_save_running_session(True) diff --git a/daemon/core/gui/dialogs/nodeconfigservice.py b/daemon/core/gui/dialogs/nodeconfigservice.py index f593526f..0e5ba7bb 100644 --- a/daemon/core/gui/dialogs/nodeconfigservice.py +++ b/daemon/core/gui/dialogs/nodeconfigservice.py @@ -4,7 +4,7 @@ core node services import logging import tkinter as tk from tkinter import messagebox, ttk -from typing import TYPE_CHECKING, Any, Set +from typing import TYPE_CHECKING, Set from core.gui.dialogs.configserviceconfig import ConfigServiceConfigDialog from core.gui.dialogs.dialog import Dialog @@ -19,7 +19,7 @@ if TYPE_CHECKING: class NodeConfigServiceDialog(Dialog): def __init__( self, - master: Any, + master: tk.Widget, app: "Application", canvas_node: "CanvasNode", services: Set[str] = None, diff --git a/daemon/core/gui/dialogs/nodeservice.py b/daemon/core/gui/dialogs/nodeservice.py index 08abd308..6641bf56 100644 --- a/daemon/core/gui/dialogs/nodeservice.py +++ b/daemon/core/gui/dialogs/nodeservice.py @@ -3,7 +3,7 @@ core node services """ import tkinter as tk from tkinter import messagebox, ttk -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from core.gui.dialogs.dialog import Dialog from core.gui.dialogs.serviceconfig import ServiceConfigDialog @@ -16,7 +16,9 @@ if TYPE_CHECKING: class NodeServiceDialog(Dialog): - def __init__(self, master: Any, app: "Application", canvas_node: "CanvasNode"): + def __init__( + self, master: tk.Widget, app: "Application", canvas_node: "CanvasNode" + ): title = f"{canvas_node.core_node.name} Services" super().__init__(master, app, title) self.app = app diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 60df2440..9f0e7bce 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -47,10 +47,15 @@ class ShowVar(BooleanVar): class CanvasGraph(tk.Canvas): def __init__( - self, master: "Application", core: "CoreClient", width: int, height: int + self, + master: tk.Widget, + app: "Application", + core: "CoreClient", + width: int, + height: int, ): super().__init__(master, highlightthickness=0, background="#cccccc") - self.app = master + self.app = app self.core = core self.mode = GraphMode.SELECT self.annotation_type = None @@ -67,7 +72,7 @@ class CanvasGraph(tk.Canvas): self.wireless_network = {} self.drawing_edge = None - self.grid = None + self.rect = None self.shape_drawing = False self.default_dimensions = (width, height) self.current_dimensions = self.default_dimensions @@ -107,12 +112,12 @@ class CanvasGraph(tk.Canvas): self.draw_grid() def draw_canvas(self, dimensions: Tuple[int, int] = None): - if self.grid is not None: - self.delete(self.grid) + if self.rect is not None: + self.delete(self.rect) if not dimensions: dimensions = self.default_dimensions self.current_dimensions = dimensions - self.grid = self.create_rectangle( + self.rect = self.create_rectangle( 0, 0, *dimensions, @@ -182,7 +187,7 @@ class CanvasGraph(tk.Canvas): return scaled_x, scaled_y def inside_canvas(self, x: float, y: float) -> [bool, bool]: - x1, y1, x2, y2 = self.bbox(self.grid) + x1, y1, x2, y2 = self.bbox(self.rect) valid_x = x1 <= x <= x2 valid_y = y1 <= y <= y2 return valid_x and valid_y @@ -219,7 +224,7 @@ class CanvasGraph(tk.Canvas): for i in range(0, height, 27): self.create_line(0, i, width, i, dash=(2, 4), tags=tags.GRIDLINE) self.tag_lower(tags.GRIDLINE) - self.tag_lower(self.grid) + self.tag_lower(self.rect) def add_wireless_edge( self, src: CanvasNode, dst: CanvasNode, link: core_pb2.Link @@ -293,7 +298,7 @@ class CanvasGraph(tk.Canvas): ) x = core_node.position.x y = core_node.position.y - node = CanvasNode(self.master, x, y, core_node, image) + node = CanvasNode(self.app, x, y, core_node, image) self.nodes[node.id] = node self.core.canvas_nodes[core_node.id] = node @@ -732,7 +737,7 @@ class CanvasGraph(tk.Canvas): self.node_draw.image = Images.get_custom( self.node_draw.image_file, int(ICON_SIZE * self.app.app_scale) ) - node = CanvasNode(self.master, x, y, core_node, self.node_draw.image) + node = CanvasNode(self.app, x, y, core_node, self.node_draw.image) self.core.canvas_nodes[core_node.id] = node self.nodes[node.id] = node return node @@ -741,7 +746,7 @@ class CanvasGraph(tk.Canvas): """ retrieve canvas width and height in pixels """ - x0, y0, x1, y1 = self.coords(self.grid) + x0, y0, x1, y1 = self.coords(self.rect) canvas_w = abs(x0 - x1) canvas_h = abs(y0 - y1) return canvas_w, canvas_h @@ -756,7 +761,7 @@ class CanvasGraph(tk.Canvas): self, image: ImageTk.PhotoImage, x: float = None, y: float = None ): if x is None and y is None: - x1, y1, x2, y2 = self.bbox(self.grid) + x1, y1, x2, y2 = self.bbox(self.rect) x = (x1 + x2) / 2 y = (y1 + y2) / 2 self.wallpaper_id = self.create_image((x, y), image=image, tags=tags.WALLPAPER) @@ -778,7 +783,7 @@ class CanvasGraph(tk.Canvas): image = ImageTk.PhotoImage(cropped) # draw on canvas - x1, y1, _, _ = self.bbox(self.grid) + x1, y1, _, _ = self.bbox(self.rect) x = (cropx / 2) + x1 y = (cropy / 2) + y1 self.draw_wallpaper(image, x, y) @@ -920,7 +925,7 @@ class CanvasGraph(tk.Canvas): copy = self.core.create_node( actual_x, actual_y, core_node.type, core_node.model ) - node = CanvasNode(self.master, scaled_x, scaled_y, copy, canvas_node.image) + node = CanvasNode(self.app, scaled_x, scaled_y, copy, canvas_node.image) # copy configurations and services node.core_node.services[:] = canvas_node.core_node.services diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 54f2e9fb..2ec0dbf8 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -298,11 +298,11 @@ class CanvasNode: dialog.show() def show_services(self): - dialog = NodeServiceDialog(self.app.master, self.app, self) + dialog = NodeServiceDialog(self.app, self.app, self) dialog.show() def show_config_services(self): - dialog = NodeConfigServiceDialog(self.app.master, self.app, self) + dialog = NodeConfigServiceDialog(self.app, self.app, self) dialog.show() def has_emane_link(self, interface_id: int) -> core_pb2.Node: diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py index 7524e318..cd630b82 100644 --- a/daemon/core/gui/statusbar.py +++ b/daemon/core/gui/statusbar.py @@ -13,7 +13,7 @@ if TYPE_CHECKING: class StatusBar(ttk.Frame): - def __init__(self, master: "Application", app: "Application", **kwargs): + def __init__(self, master: tk.Widget, app: "Application", **kwargs): super().__init__(master, **kwargs) self.app = app self.status = None diff --git a/daemon/core/gui/themes.py b/daemon/core/gui/themes.py index e9c5cba3..141a7a5c 100644 --- a/daemon/core/gui/themes.py +++ b/daemon/core/gui/themes.py @@ -181,21 +181,24 @@ def theme_change(event: tk.Event): Styles.green_alert, background="green", padding=0, - relief=tk.NONE, + relief=tk.RIDGE, + borderwidth=1, font="TkDefaultFont", ) style.configure( Styles.yellow_alert, background="yellow", padding=0, - relief=tk.NONE, + relief=tk.RIDGE, + borderwidth=1, font="TkDefaultFont", ) style.configure( Styles.red_alert, background="red", padding=0, - relief=tk.NONE, + relief=tk.RIDGE, + borderwidth=1, font="TkDefaultFont", ) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 2304f010..144fdd1f 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -41,13 +41,12 @@ class Toolbar(ttk.Frame): Core toolbar class """ - def __init__(self, master: "Application", app: "Application", **kwargs): + def __init__(self, master: tk.Widget, app: "Application", **kwargs): """ Create a CoreToolbar instance """ super().__init__(master, **kwargs) self.app = app - self.master = app.master self.time = None # design buttons From 835675480b1c91bd5a252bee99127e03a01a0391 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 2 May 2020 23:57:27 -0700 Subject: [PATCH 025/929] pygui removed unimplemented runtime node context options and moved find node to tools menu --- daemon/core/gui/graph/node.py | 10 ---------- daemon/core/gui/menubar.py | 5 +++-- 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 2ec0dbf8..758dbd26 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -198,9 +198,6 @@ class CanvasNode: is_emane = self.core_node.type == NodeType.EMANE if self.app.core.is_runtime(): self.context.add_command(label="Configure", command=self.show_config) - if NodeUtils.is_container_node(self.core_node.type): - self.context.add_command(label="Services", state=tk.DISABLED) - self.context.add_command(label="Config Services", state=tk.DISABLED) if is_wlan: self.context.add_command( label="WLAN Config", command=self.show_wlan_config @@ -209,13 +206,6 @@ class CanvasNode: self.context.add_command( label="Mobility Player", command=self.show_mobility_player ) - self.context.add_command(label="Select Adjacent", state=tk.DISABLED) - if NodeUtils.is_container_node(self.core_node.type): - self.context.add_command(label="Shell Window", state=tk.DISABLED) - self.context.add_command(label="Tcpdump", state=tk.DISABLED) - self.context.add_command(label="Tshark", state=tk.DISABLED) - self.context.add_command(label="Wireshark", state=tk.DISABLED) - self.context.add_command(label="View Log", state=tk.DISABLED) else: self.context.add_command(label="Configure", command=self.show_config) if NodeUtils.is_container_node(self.core_node.type): diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index b5ae9ac7..fa203c03 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -115,8 +115,8 @@ class Menubar(tk.Menu): Create edit menu """ menu = tk.Menu(self) - menu.add_command(label="Find", accelerator="Ctrl+F", command=self.click_find) menu.add_command(label="Preferences", command=self.click_preferences) + menu.add_separator() menu.add_command(label="Undo", accelerator="Ctrl+Z", state=tk.DISABLED) menu.add_command(label="Redo", accelerator="Ctrl+Y", state=tk.DISABLED) menu.add_separator() @@ -127,7 +127,6 @@ class Menubar(tk.Menu): label="Delete", accelerator="Ctrl+D", command=self.click_delete ) self.add_cascade(label="Edit", menu=menu) - self.app.master.bind_all("", self.click_find) self.app.master.bind_all("", self.click_cut) self.app.master.bind_all("", self.click_copy) self.app.master.bind_all("", self.click_paste) @@ -190,6 +189,8 @@ class Menubar(tk.Menu): Create tools menu """ menu = tk.Menu(self) + menu.add_command(label="Find", accelerator="Ctrl+F", command=self.click_find) + self.app.master.bind_all("", self.click_find) menu.add_command(label="Auto Grid", command=self.click_autogrid) menu.add_command(label="IP Addresses", command=self.click_ip_config) menu.add_command(label="MAC Addresses", command=self.click_mac_config) From 0999fabb1417461616fc89d8d59e5aa04126d9ef Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 3 May 2020 10:41:36 -0700 Subject: [PATCH 026/929] pygui revamped progress bar functionality into app task calls to simplify and commonize the functionality, handle and display task exceptions --- daemon/core/api/grpc/server.py | 1 - daemon/core/gui/app.py | 22 +++++++++- daemon/core/gui/dialogs/sessions.py | 10 ++--- daemon/core/gui/errors.py | 13 +++--- daemon/core/gui/menubar.py | 7 ++- daemon/core/gui/statusbar.py | 18 +++----- daemon/core/gui/task.py | 67 +++++++++++++---------------- daemon/core/gui/toolbar.py | 31 +++++-------- 8 files changed, 82 insertions(+), 87 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index ca5eb0ad..b6a298db 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -297,7 +297,6 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): for service_exception in boot_exception.args: exceptions.append(str(service_exception)) return core_pb2.StartSessionResponse(result=False, exceptions=exceptions) - return core_pb2.StartSessionResponse(result=True) def StopSession( diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 7781cc0d..e797f1de 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -1,6 +1,8 @@ import math +import time import tkinter as tk from tkinter import font, ttk +from tkinter.ttk import Progressbar from core.gui import appconfig, themes from core.gui.coreclient import CoreClient @@ -9,6 +11,7 @@ from core.gui.images import ImageEnum, Images from core.gui.menubar import Menubar from core.gui.nodeutils import NodeUtils from core.gui.statusbar import StatusBar +from core.gui.task import ProgressTask from core.gui.toolbar import Toolbar from core.gui.validation import InputValidation @@ -29,6 +32,8 @@ class Application(ttk.Frame): self.canvas = None self.statusbar = None self.validation = None + self.progress = None + self.time = None # fonts self.fonts_size = None @@ -93,6 +98,7 @@ class Application(ttk.Frame): self.right_frame.grid(row=0, column=1, sticky="nsew") self.draw_canvas() self.draw_status() + self.progress = Progressbar(self.right_frame, mode="indeterminate") self.menubar = Menubar(self.master, self) def draw_canvas(self): @@ -117,6 +123,21 @@ class Application(ttk.Frame): self.statusbar = StatusBar(self.right_frame, self) self.statusbar.grid(sticky="ew") + def progress_task(self, task: ProgressTask) -> None: + self.progress.grid(sticky="ew") + self.progress.start() + self.time = time.perf_counter() + task.app = self + task.start() + + def progress_task_complete(self) -> None: + self.progress.stop() + self.progress.grid_forget() + total = time.perf_counter() - self.time + self.time = None + message = f"Task ran for {total:.3f} seconds" + self.statusbar.set_status(message) + def on_closing(self): self.menubar.prompt_save_running_session(True) @@ -124,7 +145,6 @@ class Application(ttk.Frame): appconfig.save(self.guiconfig) def joined_session_update(self): - self.statusbar.progress_bar.stop() if self.core.is_runtime(): self.toolbar.set_runtime() else: diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index 288f8b4b..79a153f7 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -9,7 +9,7 @@ from core.api.grpc import core_pb2 from core.gui.dialogs.dialog import Dialog from core.gui.errors import show_grpc_error from core.gui.images import ImageEnum, Images -from core.gui.task import BackgroundTask +from core.gui.task import ProgressTask from core.gui.themes import PADX, PADY if TYPE_CHECKING: @@ -183,12 +183,11 @@ class SessionsDialog(Dialog): self.join_session(self.selected_session) def join_session(self, session_id: int) -> None: + self.destroy() if self.app.core.xml_file: self.app.core.xml_file = None - self.app.statusbar.progress_bar.start(5) - task = BackgroundTask(self.app, self.app.core.join_session, args=(session_id,)) - task.start() - self.destroy() + task = ProgressTask(self.app.core.join_session, args=(session_id,)) + self.app.progress_task(task) def double_click_join(self, _event: tk.Event) -> None: item = self.tree.selection() @@ -201,7 +200,6 @@ class SessionsDialog(Dialog): if not self.selected_session: return logging.debug("delete session: %s", self.selected_session) - # self.app.core.delete_session(self.selected_id, self.top) self.tree.delete(self.selected_id) self.app.core.delete_session(self.selected_session) if self.selected_session == self.app.core.session_id: diff --git a/daemon/core/gui/errors.py b/daemon/core/gui/errors.py index 782a795d..b11e684b 100644 --- a/daemon/core/gui/errors.py +++ b/daemon/core/gui/errors.py @@ -43,7 +43,12 @@ class ErrorDialog(Dialog): button.grid(sticky="ew") -def show_grpc_error(e: grpc.RpcError, master, app: "Application"): +def show_exception(app: "Application", title: str, exception: Exception) -> None: + dialog = ErrorDialog(app, app, title, str(exception)) + dialog.show() + + +def show_grpc_error(e: grpc.RpcError, master, app: "Application") -> None: title = [x.capitalize() for x in e.code().name.lower().split("_")] title = " ".join(title) title = f"GRPC {title}" @@ -51,8 +56,6 @@ def show_grpc_error(e: grpc.RpcError, master, app: "Application"): dialog.show() -def show_grpc_response_exceptions(class_name, exceptions, master, app: "Application"): - title = f"Exceptions from {class_name}" - detail = "\n".join([str(x) for x in exceptions]) - dialog = ErrorDialog(master, app, title, detail) +def show_error(app: "Application", title: str, message: str) -> None: + dialog = ErrorDialog(app, app, title, message) dialog.show() diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index fa203c03..a77913c3 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -22,7 +22,7 @@ from core.gui.dialogs.sessionoptions import SessionOptionsDialog from core.gui.dialogs.sessions import SessionsDialog from core.gui.dialogs.throughput import ThroughputDialog from core.gui.nodeutils import ICON_SIZE -from core.gui.task import BackgroundTask +from core.gui.task import ProgressTask if TYPE_CHECKING: from core.gui.app import Application @@ -340,9 +340,8 @@ class Menubar(tk.Menu): self.core.xml_file = filename self.core.xml_dir = str(os.path.dirname(filename)) self.prompt_save_running_session() - self.app.statusbar.progress_bar.start(5) - task = BackgroundTask(self.app, self.core.open_xml, args=(filename,)) - task.start() + task = ProgressTask(self.core.open_xml, args=(filename,)) + self.app.progress_task(task) def execute_python(self): dialog = ExecutePythonDialog(self.app, self.app) diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py index cd630b82..1f882b08 100644 --- a/daemon/core/gui/statusbar.py +++ b/daemon/core/gui/statusbar.py @@ -18,7 +18,6 @@ class StatusBar(ttk.Frame): self.app = app self.status = None self.statusvar = tk.StringVar() - self.progress_bar = None self.zoom = None self.cpu_usage = None self.memory = None @@ -28,19 +27,14 @@ class StatusBar(ttk.Frame): self.draw() def draw(self): - self.columnconfigure(0, weight=1) - self.columnconfigure(1, weight=5) + self.columnconfigure(0, weight=7) + self.columnconfigure(1, weight=1) self.columnconfigure(2, weight=1) self.columnconfigure(3, weight=1) - self.columnconfigure(4, weight=1) frame = ttk.Frame(self, borderwidth=1, relief=tk.RIDGE) frame.grid(row=0, column=0, sticky="ew") frame.columnconfigure(0, weight=1) - self.progress_bar = ttk.Progressbar( - frame, orient="horizontal", mode="indeterminate" - ) - self.progress_bar.grid(sticky="ew") self.status = ttk.Label( self, @@ -49,7 +43,7 @@ class StatusBar(ttk.Frame): borderwidth=1, relief=tk.RIDGE, ) - self.status.grid(row=0, column=1, sticky="ew") + self.status.grid(row=0, column=0, sticky="ew") self.zoom = ttk.Label( self, @@ -58,17 +52,17 @@ class StatusBar(ttk.Frame): borderwidth=1, relief=tk.RIDGE, ) - self.zoom.grid(row=0, column=2, sticky="ew") + self.zoom.grid(row=0, column=1, sticky="ew") self.cpu_usage = ttk.Label( self, text="CPU TBD", anchor=tk.CENTER, borderwidth=1, relief=tk.RIDGE ) - self.cpu_usage.grid(row=0, column=3, sticky="ew") + self.cpu_usage.grid(row=0, column=2, sticky="ew") self.alerts_button = ttk.Button( self, text="Alerts", command=self.click_alerts, style=Styles.green_alert ) - self.alerts_button.grid(row=0, column=4, sticky="ew") + self.alerts_button.grid(row=0, column=3, sticky="ew") def click_alerts(self): dialog = AlertsDialog(self.app, self.app) diff --git a/daemon/core/gui/task.py b/daemon/core/gui/task.py index bee69be3..c88a0151 100644 --- a/daemon/core/gui/task.py +++ b/daemon/core/gui/task.py @@ -1,46 +1,39 @@ import logging import threading -from typing import Any, Callable +from typing import Any, Callable, Tuple -from core.gui.errors import show_grpc_response_exceptions +from core.gui.errors import show_exception -class BackgroundTask: - def __init__(self, master: Any, task: Callable, callback: Callable = None, args=()): - self.master = master - self.args = args +class ProgressTask: + def __init__( + self, task: Callable, callback: Callable = None, args: Tuple[Any] = None + ): + self.app = None self.task = task self.callback = callback - self.thread = None + self.args = args + if self.args is None: + self.args = () - def start(self): - logging.info("starting task") - self.thread = threading.Thread(target=self.run, daemon=True) - self.thread.start() + def start(self) -> None: + thread = threading.Thread(target=self.run, daemon=True) + thread.start() - def run(self): - result = self.task(*self.args) - logging.info("task completed") - # if start session fails, a response with Result: False and a list of - # exceptions is returned - if not getattr(result, "result", True): - if len(getattr(result, "exceptions", [])) > 0: - self.master.after( - 0, - show_grpc_response_exceptions, - *( - result.__class__.__name__, - result.exceptions, - self.master, - self.master, - ) - ) - if self.callback: - if result is None: - args = () - elif isinstance(result, (list, tuple)): - args = result - else: - args = (result,) - logging.info("calling callback: %s", args) - self.master.after(0, self.callback, *args) + def run(self) -> None: + logging.info("running task") + try: + values = self.task(*self.args) + if values is None: + values = () + elif values and not isinstance(values, tuple): + values = (values,) + if self.callback: + logging.info("calling callback") + self.app.after(0, self.callback, *values) + except Exception as e: + logging.exception("progress task exception") + args = (self.app, "Task Error", e) + self.app.after(0, show_exception, *args) + finally: + self.app.after(0, self.app.progress_task_complete) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 144fdd1f..374a4eaf 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -1,5 +1,4 @@ import logging -import time import tkinter as tk from enum import Enum from functools import partial @@ -10,11 +9,12 @@ from core.api.grpc import core_pb2 from core.gui.dialogs.customnodes import CustomNodesDialog from core.gui.dialogs.marker import MarkerDialog from core.gui.dialogs.runtool import RunToolDialog +from core.gui.errors import show_error from core.gui.graph.enums import GraphMode from core.gui.graph.shapeutils import ShapeType, is_marker from core.gui.images import ImageEnum, Images from core.gui.nodeutils import NodeDraw, NodeUtils -from core.gui.task import BackgroundTask +from core.gui.task import ProgressTask from core.gui.themes import Styles from core.gui.tooltip import Tooltip @@ -47,7 +47,6 @@ class Toolbar(ttk.Frame): """ super().__init__(master, **kwargs) self.app = app - self.time = None # design buttons self.play_button = None @@ -263,22 +262,18 @@ class Toolbar(ttk.Frame): server. """ self.app.menubar.change_menubar_item_state(is_runtime=True) - self.app.statusbar.progress_bar.start(5) self.app.canvas.mode = GraphMode.SELECT - self.time = time.perf_counter() - task = BackgroundTask(self, self.app.core.start_session, self.start_callback) - task.start() + task = ProgressTask(self.app.core.start_session, self.start_callback) + self.app.progress_task(task) def start_callback(self, response: core_pb2.StartSessionResponse): - self.app.statusbar.progress_bar.stop() - total = time.perf_counter() - self.time - message = f"Start ran for {total:.3f} seconds" - self.app.statusbar.set_status(message) - self.time = None if response.result: self.set_runtime() self.app.core.set_metadata() self.app.core.show_mobility_players() + else: + message = "\n".join(response.exceptions) + show_error(self.app, "Start Session Error", message) def set_runtime(self): self.runtime_frame.tkraise() @@ -450,19 +445,13 @@ class Toolbar(ttk.Frame): """ redraw buttons on the toolbar, send node and link messages to grpc server """ - logging.info("Click stop button") + logging.info("clicked stop button") self.app.menubar.change_menubar_item_state(is_runtime=False) - self.app.statusbar.progress_bar.start(5) - self.time = time.perf_counter() - task = BackgroundTask(self, self.app.core.stop_session, self.stop_callback) - task.start() + task = ProgressTask(self.app.core.stop_session, self.stop_callback) + self.app.progress_task(task) def stop_callback(self, response: core_pb2.StopSessionResponse): - self.app.statusbar.progress_bar.stop() self.set_design() - total = time.perf_counter() - self.time - message = f"Stopped in {total:.3f} seconds" - self.app.statusbar.set_status(message) self.app.canvas.stopped_session() def update_annotation( From 1dd45f442434b6ac769a6b0494d552c3bde623fa Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 3 May 2020 12:42:56 -0700 Subject: [PATCH 027/929] pygui cleaned up error display by creating top level app methods for displaying exceptions and errors, logging exceptions, and making sure they work for background tasks --- daemon/core/gui/app.py | 16 ++++++++++ daemon/core/gui/coreclient.py | 31 +++++++++---------- .../core/gui/dialogs/configserviceconfig.py | 30 +++++++----------- daemon/core/gui/dialogs/emaneconfig.py | 3 +- .../core/gui/{errors.py => dialogs/error.py} | 20 ------------ daemon/core/gui/dialogs/mobilityconfig.py | 3 +- daemon/core/gui/dialogs/mobilityplayer.py | 7 ++--- daemon/core/gui/dialogs/serviceconfig.py | 5 ++- daemon/core/gui/dialogs/sessionoptions.py | 5 ++- daemon/core/gui/dialogs/sessions.py | 3 +- daemon/core/gui/dialogs/wlanconfig.py | 5 ++- daemon/core/gui/graph/node.py | 3 +- daemon/core/gui/task.py | 5 +-- daemon/core/gui/toolbar.py | 3 +- 14 files changed, 57 insertions(+), 82 deletions(-) rename daemon/core/gui/{errors.py => dialogs/error.py} (70%) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index e797f1de..0f37b370 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -1,11 +1,15 @@ +import logging import math import time import tkinter as tk from tkinter import font, ttk from tkinter.ttk import Progressbar +import grpc + from core.gui import appconfig, themes from core.gui.coreclient import CoreClient +from core.gui.dialogs.error import ErrorDialog from core.gui.graph.graph import CanvasGraph from core.gui.images import ImageEnum, Images from core.gui.menubar import Menubar @@ -138,6 +142,18 @@ class Application(ttk.Frame): message = f"Task ran for {total:.3f} seconds" self.statusbar.set_status(message) + def show_grpc_exception(self, title: str, e: grpc.RpcError) -> None: + logging.exception("app grpc exception", exc_info=e) + message = e.details() + self.show_error(title, message) + + def show_exception(self, title: str, e: Exception) -> None: + logging.exception("app exception", exc_info=e) + self.show_error(title, str(e)) + + def show_error(self, title: str, message: str) -> None: + self.after(0, lambda: ErrorDialog(self, self, title, message).show()) + def on_closing(self): self.menubar.prompt_save_running_session(True) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index fc4fc64f..86329591 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -16,9 +16,9 @@ from core.api.grpc.mobility_pb2 import MobilityConfig from core.api.grpc.services_pb2 import NodeServiceData, ServiceConfig, ServiceFileConfig from core.api.grpc.wlan_pb2 import WlanConfig from core.gui import appconfig +from core.gui.dialogs.error import ErrorDialog from core.gui.dialogs.mobilityplayer import MobilityPlayer from core.gui.dialogs.sessions import SessionsDialog -from core.gui.errors import show_grpc_error from core.gui.graph import tags from core.gui.graph.edges import CanvasEdge from core.gui.graph.node import CanvasNode @@ -343,7 +343,7 @@ class CoreClient: response = self.client.get_session_metadata(self.session_id) self.parse_metadata(response.config) except grpc.RpcError as e: - self.app.after(0, show_grpc_error, e, self.app, self.app) + self.app.show_grpc_exception("Join Session Error", e) # update ui to represent current state self.app.after(0, self.app.joined_session_update) @@ -426,21 +426,16 @@ class CoreClient: ) self.join_session(response.session_id, query_location=False) except grpc.RpcError as e: - self.app.after(0, show_grpc_error, e, self.app, self.app) + self.app.show_grpc_exception("New Session Error", e) - def delete_session(self, session_id: int = None, parent_frame=None): + def delete_session(self, session_id: int = None): if session_id is None: session_id = self.session_id try: response = self.client.delete_session(session_id) logging.info("deleted session(%s), Result: %s", session_id, response) except grpc.RpcError as e: - # use the right master widget so the error dialog displays - # right on top of it - master = self.app - if parent_frame: - master = parent_frame - self.app.after(0, show_grpc_error, e, master, self.app) + self.app.show_grpc_exception("Delete Session Error", e) def setup(self): """ @@ -472,7 +467,9 @@ class CoreClient: dialog = SessionsDialog(self.app, self.app, True) dialog.show() except grpc.RpcError as e: - show_grpc_error(e, self.app, self.app) + logging.exception("core setup error") + dialog = ErrorDialog(self.app, self.app, "Setup Error", e.details()) + dialog.show() self.app.close() def edit_node(self, core_node: core_pb2.Node): @@ -481,7 +478,7 @@ class CoreClient: self.session_id, core_node.id, core_node.position, source=GUI_SOURCE ) except grpc.RpcError as e: - self.app.after(0, show_grpc_error, e, self.app, self.app) + self.app.show_grpc_exception("Edit Node Error", e) def start_session(self) -> core_pb2.StartSessionResponse: self.interfaces_manager.reset_mac() @@ -532,7 +529,7 @@ class CoreClient: if response.result: self.set_metadata() except grpc.RpcError as e: - self.app.after(0, show_grpc_error, e, self.app, self.app) + self.app.show_grpc_exception("Start Session Error", e) return response def stop_session(self, session_id: int = None) -> core_pb2.StartSessionResponse: @@ -543,7 +540,7 @@ class CoreClient: response = self.client.stop_session(session_id) logging.info("stopped session(%s), result: %s", session_id, response) except grpc.RpcError as e: - self.app.after(0, show_grpc_error, e, self.app, self.app) + self.app.show_grpc_exception("Stop Session Error", e) return response def show_mobility_players(self): @@ -597,7 +594,7 @@ class CoreClient: logging.info("launching terminal %s", cmd) os.system(cmd) except grpc.RpcError as e: - self.app.after(0, show_grpc_error, e, self.app, self.app) + self.app.show_grpc_exception("Node Terminal Error", e) def save_xml(self, file_path: str): """ @@ -610,7 +607,7 @@ class CoreClient: response = self.client.save_xml(self.session_id, file_path) logging.info("saved xml file %s, result: %s", file_path, response) except grpc.RpcError as e: - self.app.after(0, show_grpc_error, e, self.app, self.app) + self.app.show_grpc_exception("Save XML Error", e) def open_xml(self, file_path: str): """ @@ -621,7 +618,7 @@ class CoreClient: logging.info("open xml file %s, response: %s", file_path, response) self.join_session(response.session_id) except grpc.RpcError as e: - self.app.after(0, show_grpc_error, e, self.app, self.app) + self.app.show_grpc_exception("Open XML Error", e) def get_node_service(self, node_id: int, service_name: str) -> NodeServiceData: response = self.client.get_node_service(self.session_id, node_id, service_name) diff --git a/daemon/core/gui/dialogs/configserviceconfig.py b/daemon/core/gui/dialogs/configserviceconfig.py index 45ea3a76..aaf67869 100644 --- a/daemon/core/gui/dialogs/configserviceconfig.py +++ b/daemon/core/gui/dialogs/configserviceconfig.py @@ -10,7 +10,6 @@ import grpc from core.api.grpc.services_pb2 import ServiceValidationMode from core.gui.dialogs.dialog import Dialog -from core.gui.errors import show_grpc_error from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CodeText, ConfigFrame, ListboxScroll @@ -116,8 +115,8 @@ class ConfigServiceConfigDialog(Dialog): self.modified_files.add(file) self.temp_service_files[file] = data except grpc.RpcError as e: + self.app.show_grpc_exception("Get Config Service Error", e) self.has_error = True - show_grpc_error(e, self.app, self.app) def draw(self): self.top.columnconfigure(0, weight=1) @@ -323,22 +322,17 @@ class ConfigServiceConfigDialog(Dialog): self.destroy() return - try: - service_config = self.canvas_node.config_service_configs.setdefault( - self.service_name, {} - ) - if self.config_frame: - self.config_frame.parse_config() - service_config["config"] = { - x.name: x.value for x in self.config.values() - } - templates_config = service_config.setdefault("templates", {}) - for file in self.modified_files: - templates_config[file] = self.temp_service_files[file] - all_current = current_listbox.get(0, tk.END) - current_listbox.itemconfig(all_current.index(self.service_name), bg="green") - except grpc.RpcError as e: - show_grpc_error(e, self.top, self.app) + service_config = self.canvas_node.config_service_configs.setdefault( + self.service_name, {} + ) + if self.config_frame: + self.config_frame.parse_config() + service_config["config"] = {x.name: x.value for x in self.config.values()} + templates_config = service_config.setdefault("templates", {}) + for file in self.modified_files: + templates_config[file] = self.temp_service_files[file] + all_current = current_listbox.get(0, tk.END) + current_listbox.itemconfig(all_current.index(self.service_name), bg="green") self.destroy() def handle_template_changed(self, event: tk.Event): diff --git a/daemon/core/gui/dialogs/emaneconfig.py b/daemon/core/gui/dialogs/emaneconfig.py index a7835751..8914dc2c 100644 --- a/daemon/core/gui/dialogs/emaneconfig.py +++ b/daemon/core/gui/dialogs/emaneconfig.py @@ -9,7 +9,6 @@ from typing import TYPE_CHECKING, Any import grpc from core.gui.dialogs.dialog import Dialog -from core.gui.errors import show_grpc_error from core.gui.images import ImageEnum, Images from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame @@ -78,7 +77,7 @@ class EmaneModelDialog(Dialog): ) self.draw() except grpc.RpcError as e: - show_grpc_error(e, self.app, self.app) + self.app.show_grpc_exception("Get EMANE Config Error", e) self.has_error = True self.destroy() diff --git a/daemon/core/gui/errors.py b/daemon/core/gui/dialogs/error.py similarity index 70% rename from daemon/core/gui/errors.py rename to daemon/core/gui/dialogs/error.py index b11e684b..3703b533 100644 --- a/daemon/core/gui/errors.py +++ b/daemon/core/gui/dialogs/error.py @@ -1,8 +1,6 @@ from tkinter import ttk from typing import TYPE_CHECKING -import grpc - from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum, Images from core.gui.themes import FRAME_PAD, PADX, PADY @@ -41,21 +39,3 @@ class ErrorDialog(Dialog): button = ttk.Button(self.top, text="Close", command=lambda: self.destroy()) button.grid(sticky="ew") - - -def show_exception(app: "Application", title: str, exception: Exception) -> None: - dialog = ErrorDialog(app, app, title, str(exception)) - dialog.show() - - -def show_grpc_error(e: grpc.RpcError, master, app: "Application") -> None: - title = [x.capitalize() for x in e.code().name.lower().split("_")] - title = " ".join(title) - title = f"GRPC {title}" - dialog = ErrorDialog(master, app, title, e.details()) - dialog.show() - - -def show_error(app: "Application", title: str, message: str) -> None: - dialog = ErrorDialog(app, app, title, message) - dialog.show() diff --git a/daemon/core/gui/dialogs/mobilityconfig.py b/daemon/core/gui/dialogs/mobilityconfig.py index 2222e06f..b4a6a163 100644 --- a/daemon/core/gui/dialogs/mobilityconfig.py +++ b/daemon/core/gui/dialogs/mobilityconfig.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING import grpc from core.gui.dialogs.dialog import Dialog -from core.gui.errors import show_grpc_error from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame @@ -33,8 +32,8 @@ class MobilityConfigDialog(Dialog): self.config = self.app.core.get_mobility_config(self.node.id) self.draw() except grpc.RpcError as e: + self.app.show_grpc_exception("Get Mobility Config Error", e) self.has_error = True - show_grpc_error(e, self.app, self.app) self.destroy() def draw(self): diff --git a/daemon/core/gui/dialogs/mobilityplayer.py b/daemon/core/gui/dialogs/mobilityplayer.py index 6b7b7869..e822aa4b 100644 --- a/daemon/core/gui/dialogs/mobilityplayer.py +++ b/daemon/core/gui/dialogs/mobilityplayer.py @@ -6,7 +6,6 @@ import grpc from core.api.grpc.mobility_pb2 import MobilityAction from core.gui.dialogs.dialog import Dialog -from core.gui.errors import show_grpc_error from core.gui.images import ImageEnum, Images from core.gui.themes import PADX, PADY @@ -154,7 +153,7 @@ class MobilityPlayerDialog(Dialog): session_id, self.node.id, MobilityAction.START ) except grpc.RpcError as e: - show_grpc_error(e, self.top, self.app) + self.app.show_grpc_exception("Mobility Error", e) def click_pause(self): self.set_pause() @@ -164,7 +163,7 @@ class MobilityPlayerDialog(Dialog): session_id, self.node.id, MobilityAction.PAUSE ) except grpc.RpcError as e: - show_grpc_error(e, self.top, self.app) + self.app.show_grpc_exception("Mobility Error", e) def click_stop(self): self.set_stop() @@ -174,4 +173,4 @@ class MobilityPlayerDialog(Dialog): session_id, self.node.id, MobilityAction.STOP ) except grpc.RpcError as e: - show_grpc_error(e, self.top, self.app) + self.app.show_grpc_exception("Mobility Error", e) diff --git a/daemon/core/gui/dialogs/serviceconfig.py b/daemon/core/gui/dialogs/serviceconfig.py index 8fc85394..79e8871f 100644 --- a/daemon/core/gui/dialogs/serviceconfig.py +++ b/daemon/core/gui/dialogs/serviceconfig.py @@ -9,7 +9,6 @@ import grpc from core.api.grpc.services_pb2 import ServiceValidationMode from core.gui.dialogs.copyserviceconfig import CopyServiceConfigDialog from core.gui.dialogs.dialog import Dialog -from core.gui.errors import show_grpc_error from core.gui.images import ImageEnum, Images from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CodeText, ListboxScroll @@ -119,8 +118,8 @@ class ServiceConfigDialog(Dialog): for file, data in file_configs.items(): self.temp_service_files[file] = data except grpc.RpcError as e: + self.app.show_grpc_exception("Get Node Service Error", e) self.has_error = True - show_grpc_error(e, self.master, self.app) def draw(self): self.top.columnconfigure(0, weight=1) @@ -484,7 +483,7 @@ class ServiceConfigDialog(Dialog): ) self.current_service_color("green") except grpc.RpcError as e: - show_grpc_error(e, self.top, self.app) + self.app.show_grpc_exception("Save Service Config Error", e) self.destroy() def display_service_file_data(self, event: tk.Event): diff --git a/daemon/core/gui/dialogs/sessionoptions.py b/daemon/core/gui/dialogs/sessionoptions.py index c042eef4..b2c5ede5 100644 --- a/daemon/core/gui/dialogs/sessionoptions.py +++ b/daemon/core/gui/dialogs/sessionoptions.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING import grpc from core.gui.dialogs.dialog import Dialog -from core.gui.errors import show_grpc_error from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame @@ -28,8 +27,8 @@ class SessionOptionsDialog(Dialog): response = self.app.core.client.get_session_options(session_id) return response.config except grpc.RpcError as e: + self.app.show_grpc_exception("Get Session Options Error", e) self.has_error = True - show_grpc_error(e, self.app, self.app) self.destroy() def draw(self): @@ -56,5 +55,5 @@ class SessionOptionsDialog(Dialog): response = self.app.core.client.set_session_options(session_id, config) logging.info("saved session config: %s", response) except grpc.RpcError as e: - show_grpc_error(e, self.top, self.app) + self.app.show_grpc_exception("Set Session Options Error", e) self.destroy() diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index 79a153f7..251f74b0 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -7,7 +7,6 @@ import grpc from core.api.grpc import core_pb2 from core.gui.dialogs.dialog import Dialog -from core.gui.errors import show_grpc_error from core.gui.images import ImageEnum, Images from core.gui.task import ProgressTask from core.gui.themes import PADX, PADY @@ -37,7 +36,7 @@ class SessionsDialog(Dialog): logging.info("sessions: %s", response) return response.sessions except grpc.RpcError as e: - show_grpc_error(e, self.app, self.app) + self.app.show_grpc_exception("Get Sessions Error", e) self.destroy() def draw(self) -> None: diff --git a/daemon/core/gui/dialogs/wlanconfig.py b/daemon/core/gui/dialogs/wlanconfig.py index d5d0c673..5096cd0f 100644 --- a/daemon/core/gui/dialogs/wlanconfig.py +++ b/daemon/core/gui/dialogs/wlanconfig.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING import grpc from core.gui.dialogs.dialog import Dialog -from core.gui.errors import show_grpc_error from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame @@ -21,7 +20,7 @@ class WlanConfigDialog(Dialog): self, master: "Application", app: "Application", canvas_node: "CanvasNode" ): super().__init__( - master, app, f"{canvas_node.core_node.name} Wlan Configuration" + master, app, f"{canvas_node.core_node.name} WLAN Configuration" ) self.canvas_node = canvas_node self.node = canvas_node.core_node @@ -38,7 +37,7 @@ class WlanConfigDialog(Dialog): self.init_draw_range() self.draw() except grpc.RpcError as e: - show_grpc_error(e, self.app, self.app) + self.app.show_grpc_exception("WLAN Config Error", e) self.has_error = True self.destroy() diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 758dbd26..c90be311 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -14,7 +14,6 @@ from core.gui.dialogs.nodeconfig import NodeConfigDialog from core.gui.dialogs.nodeconfigservice import NodeConfigServiceDialog from core.gui.dialogs.nodeservice import NodeServiceDialog from core.gui.dialogs.wlanconfig import WlanConfigDialog -from core.gui.errors import show_grpc_error from core.gui.graph import tags from core.gui.graph.edges import CanvasEdge from core.gui.graph.tooltip import CanvasTooltip @@ -180,7 +179,7 @@ class CanvasNode: output = self.app.core.run(self.core_node.id) self.tooltip.text.set(output) except grpc.RpcError as e: - show_grpc_error(e, self.app, self.app) + self.app.show_grpc_exception("Observer Error", e) def on_leave(self, event: tk.Event): self.tooltip.on_leave(event) diff --git a/daemon/core/gui/task.py b/daemon/core/gui/task.py index c88a0151..05855945 100644 --- a/daemon/core/gui/task.py +++ b/daemon/core/gui/task.py @@ -2,8 +2,6 @@ import logging import threading from typing import Any, Callable, Tuple -from core.gui.errors import show_exception - class ProgressTask: def __init__( @@ -33,7 +31,6 @@ class ProgressTask: self.app.after(0, self.callback, *values) except Exception as e: logging.exception("progress task exception") - args = (self.app, "Task Error", e) - self.app.after(0, show_exception, *args) + self.app.show_exception("Task Error", e) finally: self.app.after(0, self.app.progress_task_complete) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 374a4eaf..c735707a 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -9,7 +9,6 @@ from core.api.grpc import core_pb2 from core.gui.dialogs.customnodes import CustomNodesDialog from core.gui.dialogs.marker import MarkerDialog from core.gui.dialogs.runtool import RunToolDialog -from core.gui.errors import show_error from core.gui.graph.enums import GraphMode from core.gui.graph.shapeutils import ShapeType, is_marker from core.gui.images import ImageEnum, Images @@ -273,7 +272,7 @@ class Toolbar(ttk.Frame): self.app.core.show_mobility_players() else: message = "\n".join(response.exceptions) - show_error(self.app, "Start Session Error", message) + self.app.show_error("Start Session Error", message) def set_runtime(self): self.runtime_frame.tkraise() From 4ec6ef25fe6859ca9a3aa476b0c3ea23cfe6673b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 3 May 2020 21:47:58 -0700 Subject: [PATCH 028/929] pygui updated progress tasks to be self contained and leverage a title value to display runtime with more context to user --- daemon/core/gui/app.py | 18 ----------------- daemon/core/gui/dialogs/sessions.py | 6 ++++-- daemon/core/gui/menubar.py | 4 ++-- daemon/core/gui/task.py | 30 +++++++++++++++++++++++++---- daemon/core/gui/toolbar.py | 12 ++++++++---- 5 files changed, 40 insertions(+), 30 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 0f37b370..0d54e627 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -1,6 +1,5 @@ import logging import math -import time import tkinter as tk from tkinter import font, ttk from tkinter.ttk import Progressbar @@ -15,7 +14,6 @@ from core.gui.images import ImageEnum, Images from core.gui.menubar import Menubar from core.gui.nodeutils import NodeUtils from core.gui.statusbar import StatusBar -from core.gui.task import ProgressTask from core.gui.toolbar import Toolbar from core.gui.validation import InputValidation @@ -37,7 +35,6 @@ class Application(ttk.Frame): self.statusbar = None self.validation = None self.progress = None - self.time = None # fonts self.fonts_size = None @@ -127,21 +124,6 @@ class Application(ttk.Frame): self.statusbar = StatusBar(self.right_frame, self) self.statusbar.grid(sticky="ew") - def progress_task(self, task: ProgressTask) -> None: - self.progress.grid(sticky="ew") - self.progress.start() - self.time = time.perf_counter() - task.app = self - task.start() - - def progress_task_complete(self) -> None: - self.progress.stop() - self.progress.grid_forget() - total = time.perf_counter() - self.time - self.time = None - message = f"Task ran for {total:.3f} seconds" - self.statusbar.set_status(message) - def show_grpc_exception(self, title: str, e: grpc.RpcError) -> None: logging.exception("app grpc exception", exc_info=e) message = e.details() diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index 251f74b0..f7c4722a 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -185,8 +185,10 @@ class SessionsDialog(Dialog): self.destroy() if self.app.core.xml_file: self.app.core.xml_file = None - task = ProgressTask(self.app.core.join_session, args=(session_id,)) - self.app.progress_task(task) + task = ProgressTask( + self.app, "Join", self.app.core.join_session, args=(session_id,) + ) + task.start() def double_click_join(self, _event: tk.Event) -> None: item = self.tree.selection() diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index a77913c3..9c6dd7a9 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -340,8 +340,8 @@ class Menubar(tk.Menu): self.core.xml_file = filename self.core.xml_dir = str(os.path.dirname(filename)) self.prompt_save_running_session() - task = ProgressTask(self.core.open_xml, args=(filename,)) - self.app.progress_task(task) + task = ProgressTask(self.app, "Open XML", self.core.open_xml, args=(filename,)) + task.start() def execute_python(self): dialog = ExecutePythonDialog(self.app, self.app) diff --git a/daemon/core/gui/task.py b/daemon/core/gui/task.py index 05855945..2f055a90 100644 --- a/daemon/core/gui/task.py +++ b/daemon/core/gui/task.py @@ -1,20 +1,34 @@ import logging import threading -from typing import Any, Callable, Tuple +import time +from typing import TYPE_CHECKING, Any, Callable, Tuple + +if TYPE_CHECKING: + from core.gui.app import Application class ProgressTask: def __init__( - self, task: Callable, callback: Callable = None, args: Tuple[Any] = None + self, + app: "Application", + title: str, + task: Callable, + callback: Callable = None, + args: Tuple[Any] = None, ): - self.app = None + self.app = app + self.title = title self.task = task self.callback = callback self.args = args if self.args is None: self.args = () + self.time = None def start(self) -> None: + self.app.progress.grid(sticky="ew") + self.app.progress.start() + self.time = time.perf_counter() thread = threading.Thread(target=self.run, daemon=True) thread.start() @@ -33,4 +47,12 @@ class ProgressTask: logging.exception("progress task exception") self.app.show_exception("Task Error", e) finally: - self.app.after(0, self.app.progress_task_complete) + self.app.after(0, self.complete) + + def complete(self): + self.app.progress.stop() + self.app.progress.grid_forget() + total = time.perf_counter() - self.time + self.time = None + message = f"{self.title} ran for {total:.3f} seconds" + self.app.statusbar.set_status(message) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index c735707a..ee37df32 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -262,8 +262,10 @@ class Toolbar(ttk.Frame): """ self.app.menubar.change_menubar_item_state(is_runtime=True) self.app.canvas.mode = GraphMode.SELECT - task = ProgressTask(self.app.core.start_session, self.start_callback) - self.app.progress_task(task) + task = ProgressTask( + self.app, "Start", self.app.core.start_session, self.start_callback + ) + task.start() def start_callback(self, response: core_pb2.StartSessionResponse): if response.result: @@ -446,8 +448,10 @@ class Toolbar(ttk.Frame): """ logging.info("clicked stop button") self.app.menubar.change_menubar_item_state(is_runtime=False) - task = ProgressTask(self.app.core.stop_session, self.stop_callback) - self.app.progress_task(task) + task = ProgressTask( + self.app, "Stop", self.app.core.stop_session, self.stop_callback + ) + task.start() def stop_callback(self, response: core_pb2.StopSessionResponse): self.set_design() From 0e082421285cb7425d0f0baa09d4d6b4adff5908 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 3 May 2020 21:55:34 -0700 Subject: [PATCH 029/929] pygui close mobility players when stopping session --- daemon/core/gui/coreclient.py | 7 +++++-- daemon/core/gui/dialogs/mobilityplayer.py | 4 ++-- daemon/core/gui/toolbar.py | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 86329591..64255903 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -111,13 +111,16 @@ class CoreClient: self.links.clear() self.hooks.clear() self.emane_config = None - for mobility_player in self.mobility_players.values(): - mobility_player.handle_close() + self.close_mobility_players() self.mobility_players.clear() # clear streams self.cancel_throughputs() self.cancel_events() + def close_mobility_players(self): + for mobility_player in self.mobility_players.values(): + mobility_player.close() + def set_observer(self, value: str): self.observer = value diff --git a/daemon/core/gui/dialogs/mobilityplayer.py b/daemon/core/gui/dialogs/mobilityplayer.py index e822aa4b..1cd62684 100644 --- a/daemon/core/gui/dialogs/mobilityplayer.py +++ b/daemon/core/gui/dialogs/mobilityplayer.py @@ -37,7 +37,7 @@ class MobilityPlayer: self.dialog = MobilityPlayerDialog( self.master, self.app, self.canvas_node, self.config ) - self.dialog.protocol("WM_DELETE_WINDOW", self.handle_close) + self.dialog.protocol("WM_DELETE_WINDOW", self.close) if self.state == MobilityAction.START: self.set_play() elif self.state == MobilityAction.PAUSE: @@ -46,7 +46,7 @@ class MobilityPlayer: self.set_stop() self.dialog.show() - def handle_close(self): + def close(self): if self.dialog: self.dialog.destroy() self.dialog = None diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index ee37df32..49a4ab0c 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -448,6 +448,7 @@ class Toolbar(ttk.Frame): """ logging.info("clicked stop button") self.app.menubar.change_menubar_item_state(is_runtime=False) + self.app.core.close_mobility_players() task = ProgressTask( self.app, "Stop", self.app.core.stop_session, self.stop_callback ) From 828254dccddedc73e7131f0891cedabc5645b017 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 3 May 2020 22:01:21 -0700 Subject: [PATCH 030/929] pygui switched netstat socket observer to use ss instead --- daemon/core/gui/menubar.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index 9c6dd7a9..456ad7e8 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -31,9 +31,9 @@ MAX_FILES = 3 OBSERVERS = { "List Processes": "ps", "Show Interfaces": "ip address", - "IPV4 Routes": "ip -4 ro", - "IPV6 Routes": "ip -6 ro", - "Listening Sockets": "netstat -tuwnl", + "IPV4 Routes": "ip -4 route", + "IPV6 Routes": "ip -6 route", + "Listening Sockets": "ss -tuwnl", "IPv4 MFC Entries": "ip -4 mroute show", "IPv6 MFC Entries": "ip -6 mroute show", "Firewall Rules": "iptables -L", From 185c6736b3ba14e39db04c9088f41244e9640563 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 3 May 2020 22:47:46 -0700 Subject: [PATCH 031/929] pygui moved custom nodes dialog to menubar and small layout cleanup --- daemon/core/gui/dialogs/customnodes.py | 4 ++-- daemon/core/gui/menubar.py | 6 ++++++ daemon/core/gui/toolbar.py | 15 --------------- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/daemon/core/gui/dialogs/customnodes.py b/daemon/core/gui/dialogs/customnodes.py index e154483a..947e6312 100644 --- a/daemon/core/gui/dialogs/customnodes.py +++ b/daemon/core/gui/dialogs/customnodes.py @@ -137,11 +137,11 @@ class CustomNodesDialog(Dialog): frame.grid(row=0, column=2, sticky="nsew") frame.columnconfigure(0, weight=1) entry = ttk.Entry(frame, textvariable=self.name) - entry.grid(sticky="ew") + entry.grid(sticky="ew", pady=PADY) self.image_button = ttk.Button( frame, text="Icon", compound=tk.LEFT, command=self.click_icon ) - self.image_button.grid(sticky="ew") + self.image_button.grid(sticky="ew", pady=PADY) button = ttk.Button(frame, text="Services", command=self.click_services) button.grid(sticky="ew") diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index 456ad7e8..0d2f8e4e 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -10,6 +10,7 @@ from core.gui.appconfig import XMLS_PATH from core.gui.dialogs.about import AboutDialog from core.gui.dialogs.canvassizeandscale import SizeAndScaleDialog from core.gui.dialogs.canvaswallpaper import CanvasWallpaperDialog +from core.gui.dialogs.customnodes import CustomNodesDialog from core.gui.dialogs.executepython import ExecutePythonDialog from core.gui.dialogs.find import FindDialog from core.gui.dialogs.hooks import HooksDialog @@ -116,6 +117,7 @@ class Menubar(tk.Menu): """ menu = tk.Menu(self) menu.add_command(label="Preferences", command=self.click_preferences) + menu.add_command(label="Custom Nodes", command=self.click_custom_nodes) menu.add_separator() menu.add_command(label="Undo", accelerator="Ctrl+Z", state=tk.DISABLED) menu.add_command(label="Redo", accelerator="Ctrl+Y", state=tk.DISABLED) @@ -496,3 +498,7 @@ class Menubar(tk.Menu): def click_ip_config(self) -> None: dialog = IpConfigDialog(self.app, self.app) dialog.show() + + def click_custom_nodes(self) -> None: + dialog = CustomNodesDialog(self.app, self.app) + dialog.show() diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 49a4ab0c..0989affd 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -6,7 +6,6 @@ from tkinter import ttk from typing import TYPE_CHECKING, Callable from core.api.grpc import core_pb2 -from core.gui.dialogs.customnodes import CustomNodesDialog from core.gui.dialogs.marker import MarkerDialog from core.gui.dialogs.runtool import RunToolDialog from core.gui.graph.enums import GraphMode @@ -193,12 +192,6 @@ class Toolbar(ttk.Frame): node_draw.image_file, ) self.create_picker_button(image, func, self.node_picker, name) - # draw edit node - # image = icon(ImageEnum.EDITNODE, PICKER_SIZE) - image = self.get_icon(ImageEnum.EDITNODE, PICKER_SIZE) - self.create_picker_button( - image, self.click_edit_node, self.node_picker, "Custom" - ) self.design_select(self.node_button) self.node_button.after( 0, lambda: self.show_picker(self.node_button, self.node_picker) @@ -289,11 +282,6 @@ class Toolbar(ttk.Frame): self.design_select(self.link_button) self.app.canvas.mode = GraphMode.EDGE - def click_edit_node(self): - self.hide_pickers() - dialog = CustomNodesDialog(self.app, self.app) - dialog.show() - def update_button( self, button: ttk.Button, @@ -489,9 +477,6 @@ class Toolbar(ttk.Frame): self.marker_tool = MarkerDialog(self.app, self.app) self.marker_tool.show() - def click_two_node_button(self): - logging.debug("Click TWONODE button") - def scale_button(self, button, image_enum): image = icon(image_enum, int(TOOLBAR_SIZE * self.app.app_scale)) button.config(image=image) From 1d620a0b17e9b8bf3750c7e76a490444cb039d7c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 4 May 2020 22:50:59 -0700 Subject: [PATCH 032/929] pygui some cleanup for dialog constructors to avoid passing duplicate parameters in most cases --- daemon/core/gui/app.py | 2 +- daemon/core/gui/coreclient.py | 4 +- daemon/core/gui/dialogs/about.py | 4 +- daemon/core/gui/dialogs/alerts.py | 47 ++----------------- daemon/core/gui/dialogs/canvassizeandscale.py | 4 +- daemon/core/gui/dialogs/canvaswallpaper.py | 4 +- daemon/core/gui/dialogs/colorpicker.py | 8 ++-- .../core/gui/dialogs/configserviceconfig.py | 8 ++-- daemon/core/gui/dialogs/copyserviceconfig.py | 11 ++--- daemon/core/gui/dialogs/customnodes.py | 12 +++-- daemon/core/gui/dialogs/dialog.py | 8 +++- daemon/core/gui/dialogs/emaneconfig.py | 19 +++----- daemon/core/gui/dialogs/error.py | 4 +- daemon/core/gui/dialogs/executepython.py | 9 ++-- daemon/core/gui/dialogs/find.py | 11 +++-- daemon/core/gui/dialogs/hooks.py | 10 ++-- daemon/core/gui/dialogs/ipdialog.py | 4 +- daemon/core/gui/dialogs/linkconfig.py | 7 ++- daemon/core/gui/dialogs/macdialog.py | 4 +- daemon/core/gui/dialogs/marker.py | 7 +-- daemon/core/gui/dialogs/mobilityconfig.py | 8 +--- daemon/core/gui/dialogs/mobilityplayer.py | 21 ++------- daemon/core/gui/dialogs/nodeconfig.py | 6 +-- daemon/core/gui/dialogs/nodeconfigservice.py | 9 +--- daemon/core/gui/dialogs/nodeservice.py | 7 +-- daemon/core/gui/dialogs/observers.py | 4 +- daemon/core/gui/dialogs/preferences.py | 4 +- daemon/core/gui/dialogs/runtool.py | 9 ++-- daemon/core/gui/dialogs/servers.py | 4 +- daemon/core/gui/dialogs/serviceconfig.py | 8 ++-- daemon/core/gui/dialogs/sessionoptions.py | 4 +- daemon/core/gui/dialogs/sessions.py | 6 +-- daemon/core/gui/dialogs/shapemod.py | 4 +- daemon/core/gui/dialogs/throughput.py | 5 +- daemon/core/gui/dialogs/wlanconfig.py | 8 +--- daemon/core/gui/graph/edges.py | 2 +- daemon/core/gui/graph/graph.py | 2 +- daemon/core/gui/graph/node.py | 12 ++--- daemon/core/gui/graph/shape.py | 2 +- daemon/core/gui/menubar.py | 32 ++++++------- daemon/core/gui/statusbar.py | 2 +- daemon/core/gui/toolbar.py | 6 +-- 42 files changed, 143 insertions(+), 209 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 0d54e627..de0a5260 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -134,7 +134,7 @@ class Application(ttk.Frame): self.show_error(title, str(e)) def show_error(self, title: str, message: str) -> None: - self.after(0, lambda: ErrorDialog(self, self, title, message).show()) + self.after(0, lambda: ErrorDialog(self, title, message).show()) def on_closing(self): self.menubar.prompt_save_running_session(True) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 64255903..118138c6 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -467,11 +467,11 @@ class CoreClient: if len(sessions) == 0: self.create_new_session() else: - dialog = SessionsDialog(self.app, self.app, True) + dialog = SessionsDialog(self.app, True) dialog.show() except grpc.RpcError as e: logging.exception("core setup error") - dialog = ErrorDialog(self.app, self.app, "Setup Error", e.details()) + dialog = ErrorDialog(self.app, "Setup Error", e.details()) dialog.show() self.app.close() diff --git a/daemon/core/gui/dialogs/about.py b/daemon/core/gui/dialogs/about.py index 5402b1ab..2e649169 100644 --- a/daemon/core/gui/dialogs/about.py +++ b/daemon/core/gui/dialogs/about.py @@ -35,8 +35,8 @@ THE POSSIBILITY OF SUCH DAMAGE.\ class AboutDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "About CORE") + def __init__(self, app: "Application"): + super().__init__(app, "About CORE") self.draw() def draw(self): diff --git a/daemon/core/gui/dialogs/alerts.py b/daemon/core/gui/dialogs/alerts.py index b425a30c..a0c3e68b 100644 --- a/daemon/core/gui/dialogs/alerts.py +++ b/daemon/core/gui/dialogs/alerts.py @@ -15,9 +15,8 @@ if TYPE_CHECKING: class AlertsDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Alerts") - self.app = app + def __init__(self, app: "Application"): + super().__init__(app, "Alerts") self.tree = None self.codetext = None self.alarm_map = {} @@ -93,16 +92,10 @@ class AlertsDialog(Dialog): frame.grid(sticky="ew") frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) - frame.columnconfigure(2, weight=1) - frame.columnconfigure(3, weight=1) button = ttk.Button(frame, text="Reset", command=self.reset_alerts) button.grid(row=0, column=0, sticky="ew", padx=PADX) - button = ttk.Button(frame, text="Daemon Log", command=self.daemon_log) - button.grid(row=0, column=1, sticky="ew", padx=PADX) - button = ttk.Button(frame, text="Node Log") - button.grid(row=0, column=2, sticky="ew", padx=PADX) button = ttk.Button(frame, text="Close", command=self.destroy) - button.grid(row=0, column=3, sticky="ew") + button.grid(row=0, column=1, sticky="ew") def reset_alerts(self): self.codetext.text.delete("1.0", tk.END) @@ -110,10 +103,6 @@ class AlertsDialog(Dialog): self.tree.delete(item) self.app.statusbar.core_alarms.clear() - def daemon_log(self): - dialog = DaemonLog(self, self.app) - dialog.show() - def click_select(self, event: tk.Event): current = self.tree.selection()[0] alarm = self.alarm_map[current] @@ -121,33 +110,3 @@ class AlertsDialog(Dialog): self.codetext.text.delete("1.0", "end") self.codetext.text.insert("1.0", alarm.exception_event.text) self.codetext.text.config(state=tk.DISABLED) - - -class DaemonLog(Dialog): - def __init__(self, master: tk.Widget, app: "Application"): - super().__init__(master, app, "core-daemon log") - self.columnconfigure(0, weight=1) - self.path = tk.StringVar(value="/var/log/core-daemon.log") - self.draw() - - def draw(self): - self.top.columnconfigure(0, weight=1) - self.top.rowconfigure(1, weight=1) - frame = ttk.Frame(self.top) - frame.grid(row=0, column=0, sticky="ew", pady=PADY) - frame.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=9) - label = ttk.Label(frame, text="File", anchor="w") - label.grid(row=0, column=0, sticky="ew") - entry = ttk.Entry(frame, textvariable=self.path, state="disabled") - entry.grid(row=0, column=1, sticky="ew") - try: - file = open("/var/log/core-daemon.log", "r") - log = file.readlines() - except FileNotFoundError: - log = "Log file not found" - codetext = CodeText(self.top) - codetext.text.insert("1.0", log) - codetext.text.see("end") - codetext.text.config(state=tk.DISABLED) - codetext.grid(row=1, column=0, sticky="nsew") diff --git a/daemon/core/gui/dialogs/canvassizeandscale.py b/daemon/core/gui/dialogs/canvassizeandscale.py index 9543d8c6..3c3b8540 100644 --- a/daemon/core/gui/dialogs/canvassizeandscale.py +++ b/daemon/core/gui/dialogs/canvassizeandscale.py @@ -15,11 +15,11 @@ PIXEL_SCALE = 100 class SizeAndScaleDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): + def __init__(self, app: "Application"): """ create an instance for size and scale object """ - super().__init__(master, app, "Canvas Size and Scale") + super().__init__(app, "Canvas Size and Scale") self.canvas = self.app.canvas self.validation = app.validation self.section_font = font.Font(weight="bold") diff --git a/daemon/core/gui/dialogs/canvaswallpaper.py b/daemon/core/gui/dialogs/canvaswallpaper.py index 3b32572e..5e8460be 100644 --- a/daemon/core/gui/dialogs/canvaswallpaper.py +++ b/daemon/core/gui/dialogs/canvaswallpaper.py @@ -17,11 +17,11 @@ if TYPE_CHECKING: class CanvasWallpaperDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): + def __init__(self, app: "Application"): """ create an instance of CanvasWallpaper object """ - super().__init__(master, app, "Canvas Background") + super().__init__(app, "Canvas Background") self.canvas = self.app.canvas self.scale_option = tk.IntVar(value=self.canvas.scale_option.get()) self.adjust_to_dim = tk.BooleanVar(value=self.canvas.adjust_to_dim.get()) diff --git a/daemon/core/gui/dialogs/colorpicker.py b/daemon/core/gui/dialogs/colorpicker.py index 742e64f2..c4268788 100644 --- a/daemon/core/gui/dialogs/colorpicker.py +++ b/daemon/core/gui/dialogs/colorpicker.py @@ -3,7 +3,7 @@ custom color picker """ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from core.gui.dialogs.dialog import Dialog @@ -12,8 +12,10 @@ if TYPE_CHECKING: class ColorPickerDialog(Dialog): - def __init__(self, master: Any, app: "Application", initcolor: str = "#000000"): - super().__init__(master, app, "color picker") + def __init__( + self, master: tk.BaseWidget, app: "Application", initcolor: str = "#000000" + ): + super().__init__(app, "color picker", master=master) self.red_entry = None self.blue_entry = None self.green_entry = None diff --git a/daemon/core/gui/dialogs/configserviceconfig.py b/daemon/core/gui/dialogs/configserviceconfig.py index aaf67869..42041a8e 100644 --- a/daemon/core/gui/dialogs/configserviceconfig.py +++ b/daemon/core/gui/dialogs/configserviceconfig.py @@ -4,7 +4,7 @@ Service configuration dialog import logging import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING, Any, List +from typing import TYPE_CHECKING, List import grpc @@ -21,16 +21,14 @@ if TYPE_CHECKING: class ConfigServiceConfigDialog(Dialog): def __init__( self, - master: Any, + master: tk.BaseWidget, app: "Application", service_name: str, canvas_node: "CanvasNode", node_id: int, ): title = f"{service_name} Config Service" - super().__init__(master, app, title) - self.master = master - self.app = app + super().__init__(app, title, master=master) self.core = app.core self.canvas_node = canvas_node self.node_id = node_id diff --git a/daemon/core/gui/dialogs/copyserviceconfig.py b/daemon/core/gui/dialogs/copyserviceconfig.py index 87c86fd3..ff75a59a 100644 --- a/daemon/core/gui/dialogs/copyserviceconfig.py +++ b/daemon/core/gui/dialogs/copyserviceconfig.py @@ -4,7 +4,7 @@ copy service config dialog import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING, Any, Tuple +from typing import TYPE_CHECKING, Tuple from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX @@ -15,10 +15,9 @@ if TYPE_CHECKING: class CopyServiceConfigDialog(Dialog): - def __init__(self, master: Any, app: "Application", node_id: int): - super().__init__(master, app, f"Copy services to node {node_id}") + def __init__(self, master: tk.BaseWidget, app: "Application", node_id: int): + super().__init__(app, f"Copy services to node {node_id}", master=master) self.parent = master - self.app = app self.node_id = node_id self.service_configs = app.core.service_configs self.file_configs = app.core.file_configs @@ -171,13 +170,13 @@ class CopyServiceConfigDialog(Dialog): class ViewConfigDialog(Dialog): def __init__( self, - master: Any, + master: tk.BaseWidget, app: "Application", node_id: int, data: str, filename: str = None, ): - super().__init__(master, app, f"n{node_id} config data") + super().__init__(app, f"n{node_id} config data", master=master) self.data = data self.service_data = None self.filepath = tk.StringVar(value=f"/tmp/services.tmp-n{node_id}-{filename}") diff --git a/daemon/core/gui/dialogs/customnodes.py b/daemon/core/gui/dialogs/customnodes.py index 947e6312..28f33ffe 100644 --- a/daemon/core/gui/dialogs/customnodes.py +++ b/daemon/core/gui/dialogs/customnodes.py @@ -2,7 +2,7 @@ import logging import tkinter as tk from pathlib import Path from tkinter import ttk -from typing import TYPE_CHECKING, Any, Set +from typing import TYPE_CHECKING, Set from core.gui import nodeutils from core.gui.appconfig import ICONS_PATH @@ -17,8 +17,10 @@ if TYPE_CHECKING: class ServicesSelectDialog(Dialog): - def __init__(self, master: Any, app: "Application", current_services: Set[str]): - super().__init__(master, app, "Node Services") + def __init__( + self, master: tk.BaseWidget, app: "Application", current_services: Set[str] + ): + super().__init__(app, "Node Services", master=master) self.groups = None self.services = None self.current = None @@ -100,8 +102,8 @@ class ServicesSelectDialog(Dialog): class CustomNodesDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Custom Nodes") + def __init__(self, app: "Application"): + super().__init__(app, "Custom Nodes") self.edit_button = None self.delete_button = None self.nodes_list = None diff --git a/daemon/core/gui/dialogs/dialog.py b/daemon/core/gui/dialogs/dialog.py index 32708450..f3742c50 100644 --- a/daemon/core/gui/dialogs/dialog.py +++ b/daemon/core/gui/dialogs/dialog.py @@ -11,8 +11,14 @@ if TYPE_CHECKING: class Dialog(tk.Toplevel): def __init__( - self, master: tk.Widget, app: "Application", title: str, modal: bool = True + self, + app: "Application", + title: str, + modal: bool = True, + master: tk.BaseWidget = None, ): + if master is None: + master = app super().__init__(master) self.withdraw() self.app = app diff --git a/daemon/core/gui/dialogs/emaneconfig.py b/daemon/core/gui/dialogs/emaneconfig.py index 8914dc2c..000ebb05 100644 --- a/daemon/core/gui/dialogs/emaneconfig.py +++ b/daemon/core/gui/dialogs/emaneconfig.py @@ -4,7 +4,7 @@ emane configuration import tkinter as tk import webbrowser from tkinter import ttk -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import grpc @@ -19,8 +19,8 @@ if TYPE_CHECKING: class GlobalEmaneDialog(Dialog): - def __init__(self, master: Any, app: "Application"): - super().__init__(master, app, "EMANE Configuration") + def __init__(self, master: tk.BaseWidget, app: "Application"): + super().__init__(app, "EMANE Configuration", master=master) self.config_frame = None self.draw() @@ -52,14 +52,14 @@ class GlobalEmaneDialog(Dialog): class EmaneModelDialog(Dialog): def __init__( self, - master: Any, + master: tk.BaseWidget, app: "Application", canvas_node: "CanvasNode", model: str, interface: int = None, ): super().__init__( - master, app, f"{canvas_node.core_node.name} {model} Configuration" + app, f"{canvas_node.core_node.name} {model} Configuration", master=master ) self.canvas_node = canvas_node self.node = canvas_node.core_node @@ -109,13 +109,8 @@ class EmaneModelDialog(Dialog): class EmaneConfigDialog(Dialog): - def __init__( - self, master: "Application", app: "Application", canvas_node: "CanvasNode" - ): - super().__init__( - master, app, f"{canvas_node.core_node.name} EMANE Configuration" - ) - self.app = app + def __init__(self, app: "Application", canvas_node: "CanvasNode"): + super().__init__(app, f"{canvas_node.core_node.name} EMANE Configuration") self.canvas_node = canvas_node self.node = canvas_node.core_node self.radiovar = tk.IntVar() diff --git a/daemon/core/gui/dialogs/error.py b/daemon/core/gui/dialogs/error.py index 3703b533..5ff1dbc5 100644 --- a/daemon/core/gui/dialogs/error.py +++ b/daemon/core/gui/dialogs/error.py @@ -11,8 +11,8 @@ if TYPE_CHECKING: class ErrorDialog(Dialog): - def __init__(self, master, app: "Application", title: str, details: str) -> None: - super().__init__(master, app, "CORE Exception") + def __init__(self, app: "Application", title: str, details: str) -> None: + super().__init__(app, "CORE Exception") self.title = title self.details = details self.error_message = None diff --git a/daemon/core/gui/dialogs/executepython.py b/daemon/core/gui/dialogs/executepython.py index e0a1a40d..dd60c778 100644 --- a/daemon/core/gui/dialogs/executepython.py +++ b/daemon/core/gui/dialogs/executepython.py @@ -1,16 +1,19 @@ import logging import tkinter as tk from tkinter import filedialog, ttk +from typing import TYPE_CHECKING from core.gui.appconfig import SCRIPT_PATH from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX +if TYPE_CHECKING: + from core.gui.app import Application + class ExecutePythonDialog(Dialog): - def __init__(self, master, app): - super().__init__(master, app, "Execute Python Script") - self.app = app + def __init__(self, app: "Application"): + super().__init__(app, "Execute Python Script") self.with_options = tk.IntVar(value=0) self.options = tk.StringVar(value="") self.option_entry = None diff --git a/daemon/core/gui/dialogs/find.py b/daemon/core/gui/dialogs/find.py index 2541c74e..8f0094d4 100644 --- a/daemon/core/gui/dialogs/find.py +++ b/daemon/core/gui/dialogs/find.py @@ -1,14 +1,18 @@ import logging import tkinter as tk from tkinter import ttk +from typing import TYPE_CHECKING from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY +if TYPE_CHECKING: + from core.gui.app import Application + class FindDialog(Dialog): - def __init__(self, master, app) -> None: - super().__init__(master, app, "Find", modal=False) + def __init__(self, app: "Application") -> None: + super().__init__(app, "Find", modal=False) self.find_text = tk.StringVar(value="") self.tree = None self.draw() @@ -90,7 +94,8 @@ class FindDialog(Dialog): if not node_name or node_name == name: pos_x = round(node.core_node.position.x, 1) pos_y = round(node.core_node.position.y, 1) - # TODO I am not sure what to insert for Detail column, leaving in blank for now + # TODO: I am not sure what to insert for Detail column + # leaving it blank for now self.tree.insert( "", tk.END, diff --git a/daemon/core/gui/dialogs/hooks.py b/daemon/core/gui/dialogs/hooks.py index f9da431a..5895a2e1 100644 --- a/daemon/core/gui/dialogs/hooks.py +++ b/daemon/core/gui/dialogs/hooks.py @@ -1,6 +1,6 @@ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from core.api.grpc import core_pb2 from core.gui.dialogs.dialog import Dialog @@ -12,8 +12,8 @@ if TYPE_CHECKING: class HookDialog(Dialog): - def __init__(self, master: Any, app: "Application"): - super().__init__(master, app, "Hook") + def __init__(self, master: tk.BaseWidget, app: "Application"): + super().__init__(app, "Hook", master=master) self.name = tk.StringVar() self.codetext = None self.hook = core_pb2.Hook() @@ -88,8 +88,8 @@ class HookDialog(Dialog): class HooksDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Hooks") + def __init__(self, app: "Application"): + super().__init__(app, "Hooks") self.listbox = None self.edit_button = None self.delete_button = None diff --git a/daemon/core/gui/dialogs/ipdialog.py b/daemon/core/gui/dialogs/ipdialog.py index 72e0d73a..3c6944ab 100644 --- a/daemon/core/gui/dialogs/ipdialog.py +++ b/daemon/core/gui/dialogs/ipdialog.py @@ -14,8 +14,8 @@ if TYPE_CHECKING: class IpConfigDialog(Dialog): - def __init__(self, master: "Application", app: "Application") -> None: - super().__init__(master, app, "IP Configuration") + def __init__(self, app: "Application") -> None: + super().__init__(app, "IP Configuration") ip_config = self.app.guiconfig.setdefault("ips") self.ip4 = ip_config.setdefault("ip4", appconfig.DEFAULT_IP4) self.ip6 = ip_config.setdefault("ip6", appconfig.DEFAULT_IP6) diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index 5a93d3fa..4f569ef2 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -12,7 +12,7 @@ from core.gui.themes import PADX, PADY if TYPE_CHECKING: from core.gui.app import Application - from core.gui.graph.graph import CanvasGraph, CanvasEdge + from core.gui.graph.graph import CanvasEdge def get_int(var: tk.StringVar) -> Union[int, None]: @@ -32,9 +32,8 @@ def get_float(var: tk.StringVar) -> Union[float, None]: class LinkConfigurationDialog(Dialog): - def __init__(self, master: "CanvasGraph", app: "Application", edge: "CanvasEdge"): - super().__init__(master, app, "Link Configuration") - self.app = app + def __init__(self, app: "Application", edge: "CanvasEdge"): + super().__init__(app, "Link Configuration") self.edge = edge self.is_symmetric = edge.link.options.unidirectional is False if self.is_symmetric: diff --git a/daemon/core/gui/dialogs/macdialog.py b/daemon/core/gui/dialogs/macdialog.py index 558c3c29..18a330ba 100644 --- a/daemon/core/gui/dialogs/macdialog.py +++ b/daemon/core/gui/dialogs/macdialog.py @@ -13,8 +13,8 @@ if TYPE_CHECKING: class MacConfigDialog(Dialog): - def __init__(self, master: "Application", app: "Application") -> None: - super().__init__(master, app, "MAC Configuration") + def __init__(self, app: "Application") -> None: + super().__init__(app, "MAC Configuration") mac = self.app.guiconfig.get("mac", appconfig.DEFAULT_MAC) self.mac_var = tk.StringVar(value=mac) self.draw() diff --git a/daemon/core/gui/dialogs/marker.py b/daemon/core/gui/dialogs/marker.py index f07376b3..91cbfd06 100644 --- a/daemon/core/gui/dialogs/marker.py +++ b/daemon/core/gui/dialogs/marker.py @@ -17,11 +17,8 @@ MARKER_THICKNESS = [3, 5, 8, 10] class MarkerDialog(Dialog): - def __init__( - self, master: "Application", app: "Application", initcolor: str = "#000000" - ): - super().__init__(master, app, "Marker Tool", modal=False) - self.app = app + def __init__(self, app: "Application", initcolor: str = "#000000"): + super().__init__(app, "Marker Tool", modal=False) self.color = initcolor self.radius = MARKER_THICKNESS[0] self.marker_thickness = tk.IntVar(value=MARKER_THICKNESS[0]) diff --git a/daemon/core/gui/dialogs/mobilityconfig.py b/daemon/core/gui/dialogs/mobilityconfig.py index b4a6a163..dced5e44 100644 --- a/daemon/core/gui/dialogs/mobilityconfig.py +++ b/daemon/core/gui/dialogs/mobilityconfig.py @@ -16,12 +16,8 @@ if TYPE_CHECKING: class MobilityConfigDialog(Dialog): - def __init__( - self, master: "Application", app: "Application", canvas_node: "CanvasNode" - ): - super().__init__( - master, app, f"{canvas_node.core_node.name} Mobility Configuration" - ) + def __init__(self, app: "Application", canvas_node: "CanvasNode"): + super().__init__(app, f"{canvas_node.core_node.name} Mobility Configuration") self.canvas_node = canvas_node self.node = canvas_node.core_node self.config_frame = None diff --git a/daemon/core/gui/dialogs/mobilityplayer.py b/daemon/core/gui/dialogs/mobilityplayer.py index 1cd62684..e3baf140 100644 --- a/daemon/core/gui/dialogs/mobilityplayer.py +++ b/daemon/core/gui/dialogs/mobilityplayer.py @@ -1,6 +1,6 @@ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import grpc @@ -17,14 +17,7 @@ ICON_SIZE = 16 class MobilityPlayer: - def __init__( - self, - master: "Application", - app: "Application", - canvas_node: "CanvasNode", - config, - ): - self.master = master + def __init__(self, app: "Application", canvas_node: "CanvasNode", config): self.app = app self.canvas_node = canvas_node self.config = config @@ -34,9 +27,7 @@ class MobilityPlayer: def show(self): if self.dialog: self.dialog.destroy() - self.dialog = MobilityPlayerDialog( - self.master, self.app, self.canvas_node, self.config - ) + self.dialog = MobilityPlayerDialog(self.app, self.canvas_node, self.config) self.dialog.protocol("WM_DELETE_WINDOW", self.close) if self.state == MobilityAction.START: self.set_play() @@ -68,11 +59,9 @@ class MobilityPlayer: class MobilityPlayerDialog(Dialog): - def __init__( - self, master: Any, app: "Application", canvas_node: "CanvasNode", config - ): + def __init__(self, app: "Application", canvas_node: "CanvasNode", config): super().__init__( - master, app, f"{canvas_node.core_node.name} Mobility Player", modal=False + app, f"{canvas_node.core_node.name} Mobility Player", modal=False ) self.resizable(False, False) self.geometry("") diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index ff21f886..85a839e5 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -94,13 +94,11 @@ class InterfaceData: class NodeConfigDialog(Dialog): - def __init__( - self, master: "Application", app: "Application", canvas_node: "CanvasNode" - ): + def __init__(self, app: "Application", canvas_node: "CanvasNode"): """ create an instance of node configuration """ - super().__init__(master, app, f"{canvas_node.core_node.name} Configuration") + super().__init__(app, f"{canvas_node.core_node.name} Configuration") self.canvas_node = canvas_node self.node = canvas_node.core_node self.image = canvas_node.image diff --git a/daemon/core/gui/dialogs/nodeconfigservice.py b/daemon/core/gui/dialogs/nodeconfigservice.py index 0e5ba7bb..5f77ece3 100644 --- a/daemon/core/gui/dialogs/nodeconfigservice.py +++ b/daemon/core/gui/dialogs/nodeconfigservice.py @@ -18,15 +18,10 @@ if TYPE_CHECKING: class NodeConfigServiceDialog(Dialog): def __init__( - self, - master: tk.Widget, - app: "Application", - canvas_node: "CanvasNode", - services: Set[str] = None, + self, app: "Application", canvas_node: "CanvasNode", services: Set[str] = None ): title = f"{canvas_node.core_node.name} Config Services" - super().__init__(master, app, title) - self.app = app + super().__init__(app, title) self.canvas_node = canvas_node self.node_id = canvas_node.core_node.id self.groups = None diff --git a/daemon/core/gui/dialogs/nodeservice.py b/daemon/core/gui/dialogs/nodeservice.py index 6641bf56..13490d8c 100644 --- a/daemon/core/gui/dialogs/nodeservice.py +++ b/daemon/core/gui/dialogs/nodeservice.py @@ -16,12 +16,9 @@ if TYPE_CHECKING: class NodeServiceDialog(Dialog): - def __init__( - self, master: tk.Widget, app: "Application", canvas_node: "CanvasNode" - ): + def __init__(self, app: "Application", canvas_node: "CanvasNode"): title = f"{canvas_node.core_node.name} Services" - super().__init__(master, app, title) - self.app = app + super().__init__(app, title) self.canvas_node = canvas_node self.node_id = canvas_node.core_node.id self.groups = None diff --git a/daemon/core/gui/dialogs/observers.py b/daemon/core/gui/dialogs/observers.py index 1282789e..4ec03185 100644 --- a/daemon/core/gui/dialogs/observers.py +++ b/daemon/core/gui/dialogs/observers.py @@ -12,8 +12,8 @@ if TYPE_CHECKING: class ObserverDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Observer Widgets") + def __init__(self, app: "Application"): + super().__init__(app, "Observer Widgets") self.observers = None self.save_button = None self.delete_button = None diff --git a/daemon/core/gui/dialogs/preferences.py b/daemon/core/gui/dialogs/preferences.py index 9c6ba5b9..c650f42a 100644 --- a/daemon/core/gui/dialogs/preferences.py +++ b/daemon/core/gui/dialogs/preferences.py @@ -16,8 +16,8 @@ SCALE_INTERVAL = 0.01 class PreferencesDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Preferences") + def __init__(self, app: "Application"): + super().__init__(app, "Preferences") self.gui_scale = tk.DoubleVar(value=self.app.app_scale) preferences = self.app.guiconfig["preferences"] self.editor = tk.StringVar(value=preferences["editor"]) diff --git a/daemon/core/gui/dialogs/runtool.py b/daemon/core/gui/dialogs/runtool.py index c3e3dec9..98be730f 100644 --- a/daemon/core/gui/dialogs/runtool.py +++ b/daemon/core/gui/dialogs/runtool.py @@ -1,17 +1,20 @@ import tkinter as tk from tkinter import ttk +from typing import TYPE_CHECKING from core.gui.dialogs.dialog import Dialog from core.gui.nodeutils import NodeUtils from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CodeText, ListboxScroll +if TYPE_CHECKING: + from core.gui.app import Application + class RunToolDialog(Dialog): - def __init__(self, master, app) -> None: - super().__init__(master, app, "Run Tool") + def __init__(self, app: "Application") -> None: + super().__init__(app, "Run Tool") self.cmd = tk.StringVar(value="ps ax") - self.app = app self.result = None self.node_list = None self.executable_nodes = {} diff --git a/daemon/core/gui/dialogs/servers.py b/daemon/core/gui/dialogs/servers.py index 26a76835..62bcc675 100644 --- a/daemon/core/gui/dialogs/servers.py +++ b/daemon/core/gui/dialogs/servers.py @@ -16,8 +16,8 @@ DEFAULT_PORT = 50051 class ServersDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "CORE Servers") + def __init__(self, app: "Application"): + super().__init__(app, "CORE Servers") self.name = tk.StringVar(value=DEFAULT_NAME) self.address = tk.StringVar(value=DEFAULT_ADDRESS) self.port = tk.IntVar(value=DEFAULT_PORT) diff --git a/daemon/core/gui/dialogs/serviceconfig.py b/daemon/core/gui/dialogs/serviceconfig.py index 79e8871f..30607163 100644 --- a/daemon/core/gui/dialogs/serviceconfig.py +++ b/daemon/core/gui/dialogs/serviceconfig.py @@ -2,7 +2,7 @@ import logging import os import tkinter as tk from tkinter import filedialog, ttk -from typing import TYPE_CHECKING, Any, List +from typing import TYPE_CHECKING, List import grpc @@ -21,16 +21,14 @@ if TYPE_CHECKING: class ServiceConfigDialog(Dialog): def __init__( self, - master: Any, + master: tk.BaseWidget, app: "Application", service_name: str, canvas_node: "CanvasNode", node_id: int, ): title = f"{service_name} Service" - super().__init__(master, app, title) - self.master = master - self.app = app + super().__init__(app, title, master=master) self.core = app.core self.canvas_node = canvas_node self.node_id = node_id diff --git a/daemon/core/gui/dialogs/sessionoptions.py b/daemon/core/gui/dialogs/sessionoptions.py index b2c5ede5..c1455399 100644 --- a/daemon/core/gui/dialogs/sessionoptions.py +++ b/daemon/core/gui/dialogs/sessionoptions.py @@ -13,8 +13,8 @@ if TYPE_CHECKING: class SessionOptionsDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Session Options") + def __init__(self, app: "Application"): + super().__init__(app, "Session Options") self.config_frame = None self.has_error = False self.config = self.get_config() diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index f7c4722a..160854a6 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -16,10 +16,8 @@ if TYPE_CHECKING: class SessionsDialog(Dialog): - def __init__( - self, master: "Application", app: "Application", is_start_app: bool = False - ) -> None: - super().__init__(master, app, "Sessions") + def __init__(self, app: "Application", is_start_app: bool = False) -> None: + super().__init__(app, "Sessions") self.is_start_app = is_start_app self.selected_session = None self.selected_id = None diff --git a/daemon/core/gui/dialogs/shapemod.py b/daemon/core/gui/dialogs/shapemod.py index 9efb9fa3..4c84991b 100644 --- a/daemon/core/gui/dialogs/shapemod.py +++ b/daemon/core/gui/dialogs/shapemod.py @@ -20,12 +20,12 @@ BORDER_WIDTH = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] class ShapeDialog(Dialog): - def __init__(self, master: "Application", app: "Application", shape: "Shape"): + def __init__(self, app: "Application", shape: "Shape"): if is_draw_shape(shape.shape_type): title = "Add Shape" else: title = "Add Text" - super().__init__(master, app, title) + super().__init__(app, title) self.canvas = app.canvas self.fill = None self.border = None diff --git a/daemon/core/gui/dialogs/throughput.py b/daemon/core/gui/dialogs/throughput.py index 5c6b1d28..5210fe59 100644 --- a/daemon/core/gui/dialogs/throughput.py +++ b/daemon/core/gui/dialogs/throughput.py @@ -14,9 +14,8 @@ if TYPE_CHECKING: class ThroughputDialog(Dialog): - def __init__(self, master: "Application", app: "Application"): - super().__init__(master, app, "Throughput Config") - self.app = app + def __init__(self, app: "Application"): + super().__init__(app, "Throughput Config") self.canvas = app.canvas self.show_throughput = tk.IntVar(value=1) self.exponential_weight = tk.IntVar(value=1) diff --git a/daemon/core/gui/dialogs/wlanconfig.py b/daemon/core/gui/dialogs/wlanconfig.py index 5096cd0f..b0435a2f 100644 --- a/daemon/core/gui/dialogs/wlanconfig.py +++ b/daemon/core/gui/dialogs/wlanconfig.py @@ -16,12 +16,8 @@ RANGE_WIDTH = 3 class WlanConfigDialog(Dialog): - def __init__( - self, master: "Application", app: "Application", canvas_node: "CanvasNode" - ): - super().__init__( - master, app, f"{canvas_node.core_node.name} WLAN Configuration" - ) + def __init__(self, app: "Application", canvas_node: "CanvasNode"): + super().__init__(app, f"{canvas_node.core_node.name} WLAN Configuration") self.canvas_node = canvas_node self.node = canvas_node.core_node self.config_frame = None diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index 17809dcb..68c3823b 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -389,5 +389,5 @@ class CanvasEdge(Edge): self.canvas.delete_edge(self) def click_configure(self) -> None: - dialog = LinkConfigurationDialog(self.canvas, self.canvas.app, self) + dialog = LinkConfigurationDialog(self.canvas.app, self) dialog.show() diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 9f0e7bce..fa169598 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -720,7 +720,7 @@ class CanvasGraph(tk.Canvas): selected = self.get_selected(event) if selected is not None and selected in self.shapes: shape = self.shapes[selected] - dialog = ShapeDialog(self.app, self.app, shape) + dialog = ShapeDialog(self.app, shape) dialog.show() def add_node(self, x: float, y: float) -> CanvasNode: diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index c90be311..451298e0 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -265,16 +265,16 @@ class CanvasNode: self.canvas.copy() def show_config(self): - dialog = NodeConfigDialog(self.app, self.app, self) + dialog = NodeConfigDialog(self.app, self) dialog.show() def show_wlan_config(self): - dialog = WlanConfigDialog(self.app, self.app, self) + dialog = WlanConfigDialog(self.app, self) if not dialog.has_error: dialog.show() def show_mobility_config(self): - dialog = MobilityConfigDialog(self.app, self.app, self) + dialog = MobilityConfigDialog(self.app, self) if not dialog.has_error: dialog.show() @@ -283,15 +283,15 @@ class CanvasNode: mobility_player.show() def show_emane_config(self): - dialog = EmaneConfigDialog(self.app, self.app, self) + dialog = EmaneConfigDialog(self.app, self) dialog.show() def show_services(self): - dialog = NodeServiceDialog(self.app, self.app, self) + dialog = NodeServiceDialog(self.app, self) dialog.show() def show_config_services(self): - dialog = NodeConfigServiceDialog(self.app, self.app, self) + dialog = NodeConfigServiceDialog(self.app, self) dialog.show() def has_emane_link(self, interface_id: int) -> core_pb2.Node: diff --git a/daemon/core/gui/graph/shape.py b/daemon/core/gui/graph/shape.py index 6e3d682d..eeda09fd 100644 --- a/daemon/core/gui/graph/shape.py +++ b/daemon/core/gui/graph/shape.py @@ -148,7 +148,7 @@ class Shape: def shape_complete(self, x: float, y: float): for component in tags.ABOVE_SHAPE: self.canvas.tag_raise(component) - s = ShapeDialog(self.app, self.app, self) + s = ShapeDialog(self.app, self) s.show() def disappear(self): diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index 0d2f8e4e..c3ed071a 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -345,8 +345,8 @@ class Menubar(tk.Menu): task = ProgressTask(self.app, "Open XML", self.core.open_xml, args=(filename,)) task.start() - def execute_python(self): - dialog = ExecutePythonDialog(self.app, self.app) + def execute_python(self) -> None: + dialog = ExecutePythonDialog(self.app) dialog.show() def add_recent_file_to_gui_config(self, file_path) -> None: @@ -399,19 +399,19 @@ class Menubar(tk.Menu): self.core.xml_file = None def click_find(self, _event: tk.Event = None) -> None: - dialog = FindDialog(self.app, self.app) + dialog = FindDialog(self.app) dialog.show() def click_preferences(self) -> None: - dialog = PreferencesDialog(self.app, self.app) + dialog = PreferencesDialog(self.app) dialog.show() def click_canvas_size_and_scale(self) -> None: - dialog = SizeAndScaleDialog(self.app, self.app) + dialog = SizeAndScaleDialog(self.app) dialog.show() def click_canvas_wallpaper(self) -> None: - dialog = CanvasWallpaperDialog(self.app, self.app) + dialog = CanvasWallpaperDialog(self.app) dialog.show() def click_core_github(self) -> None: @@ -421,7 +421,7 @@ class Menubar(tk.Menu): webbrowser.open_new("http://coreemu.github.io/core/") def click_about(self) -> None: - dialog = AboutDialog(self.app, self.app) + dialog = AboutDialog(self.app) dialog.show() def click_throughput(self) -> None: @@ -431,7 +431,7 @@ class Menubar(tk.Menu): self.core.cancel_throughputs() def click_config_throughput(self) -> None: - dialog = ThroughputDialog(self.app, self.app) + dialog = ThroughputDialog(self.app) dialog.show() def click_copy(self, _event: tk.Event = None) -> None: @@ -449,27 +449,27 @@ class Menubar(tk.Menu): def click_session_options(self) -> None: logging.debug("Click options") - dialog = SessionOptionsDialog(self.app, self.app) + dialog = SessionOptionsDialog(self.app) if not dialog.has_error: dialog.show() def click_sessions(self) -> None: logging.debug("Click change sessions") - dialog = SessionsDialog(self.app, self.app) + dialog = SessionsDialog(self.app) dialog.show() def click_hooks(self) -> None: logging.debug("Click hooks") - dialog = HooksDialog(self.app, self.app) + dialog = HooksDialog(self.app) dialog.show() def click_servers(self) -> None: logging.debug("Click emulation servers") - dialog = ServersDialog(self.app, self.app) + dialog = ServersDialog(self.app) dialog.show() def click_edit_observer_widgets(self) -> None: - dialog = ObserverDialog(self.app, self.app) + dialog = ObserverDialog(self.app) dialog.show() def click_autogrid(self) -> None: @@ -492,13 +492,13 @@ class Menubar(tk.Menu): edge.draw_labels() def click_mac_config(self) -> None: - dialog = MacConfigDialog(self.app, self.app) + dialog = MacConfigDialog(self.app) dialog.show() def click_ip_config(self) -> None: - dialog = IpConfigDialog(self.app, self.app) + dialog = IpConfigDialog(self.app) dialog.show() def click_custom_nodes(self) -> None: - dialog = CustomNodesDialog(self.app, self.app) + dialog = CustomNodesDialog(self.app) dialog.show() diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py index 1f882b08..6c2e5e19 100644 --- a/daemon/core/gui/statusbar.py +++ b/daemon/core/gui/statusbar.py @@ -65,7 +65,7 @@ class StatusBar(ttk.Frame): self.alerts_button.grid(row=0, column=3, sticky="ew") def click_alerts(self): - dialog = AlertsDialog(self.app, self.app) + dialog = AlertsDialog(self.app) dialog.show() def set_status(self, message: str): diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 0989affd..01a6bc1b 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -459,12 +459,12 @@ class Toolbar(ttk.Frame): if is_marker(shape_type): if self.marker_tool: self.marker_tool.destroy() - self.marker_tool = MarkerDialog(self.app, self.app) + self.marker_tool = MarkerDialog(self.app) self.marker_tool.show() def click_run_button(self): logging.debug("Click on RUN button") - dialog = RunToolDialog(self.app, self.app) + dialog = RunToolDialog(self.app) dialog.show() def click_marker_button(self): @@ -474,7 +474,7 @@ class Toolbar(ttk.Frame): self.app.canvas.annotation_type = ShapeType.MARKER if self.marker_tool: self.marker_tool.destroy() - self.marker_tool = MarkerDialog(self.app, self.app) + self.marker_tool = MarkerDialog(self.app) self.marker_tool.show() def scale_button(self, button, image_enum): From 41b46b7e7a2c32a56272afa3cdc7626ff251a7f2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 5 May 2020 12:55:25 -0700 Subject: [PATCH 033/929] pygui display error and link to emane docs when attempting to use emane node and it is not installed, fix dialog refactoring breaking mobility player, updated emane docs --- daemon/core/gui/coreclient.py | 13 ++++-- daemon/core/gui/dialogs/emaneinstall.py | 25 ++++++++++++ daemon/core/gui/graph/graph.py | 38 ++++++++++-------- docs/emane.md | 53 ++++++++++++++----------- 4 files changed, 84 insertions(+), 45 deletions(-) create mode 100644 daemon/core/gui/dialogs/emaneinstall.py diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 118138c6..950d7013 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -6,7 +6,7 @@ import logging import os from pathlib import Path from tkinter import messagebox -from typing import TYPE_CHECKING, Dict, Iterable, List +from typing import TYPE_CHECKING, Dict, Iterable, List, Optional import grpc @@ -16,6 +16,7 @@ from core.api.grpc.mobility_pb2 import MobilityConfig from core.api.grpc.services_pb2 import NodeServiceData, ServiceConfig, ServiceFileConfig from core.api.grpc.wlan_pb2 import WlanConfig from core.gui import appconfig +from core.gui.dialogs.emaneinstall import EmaneInstallDialog from core.gui.dialogs.error import ErrorDialog from core.gui.dialogs.mobilityplayer import MobilityPlayer from core.gui.dialogs.sessions import SessionsDialog @@ -552,7 +553,7 @@ class CoreClient: continue if canvas_node.mobility_config: mobility_player = MobilityPlayer( - self.app, self.app, canvas_node, canvas_node.mobility_config + self.app, canvas_node, canvas_node.mobility_config ) node_id = canvas_node.core_node.id self.mobility_players[node_id] = mobility_player @@ -785,7 +786,7 @@ class CoreClient: def create_node( self, x: float, y: float, node_type: core_pb2.NodeType, model: str - ) -> core_pb2.Node: + ) -> Optional[core_pb2.Node]: """ Add node, with information filled in, to grpc manager """ @@ -796,6 +797,10 @@ class CoreClient: image = "ubuntu:latest" emane = None if node_type == core_pb2.NodeType.EMANE: + if not self.emane_models: + dialog = EmaneInstallDialog(self.app) + dialog.show() + return emane = self.emane_models[0] name = f"EMANE{node_id}" elif node_type == core_pb2.NodeType.WIRELESS_LAN: @@ -818,7 +823,7 @@ class CoreClient: node.services[:] = services # assign default services to CORE node else: - services = self.default_services.get(model, None) + services = self.default_services.get(model) if services: node.services[:] = services logging.info( diff --git a/daemon/core/gui/dialogs/emaneinstall.py b/daemon/core/gui/dialogs/emaneinstall.py new file mode 100644 index 00000000..93cf2ac4 --- /dev/null +++ b/daemon/core/gui/dialogs/emaneinstall.py @@ -0,0 +1,25 @@ +import webbrowser +from tkinter import ttk + +from core.gui.dialogs.dialog import Dialog +from core.gui.themes import PADY + + +class EmaneInstallDialog(Dialog): + def __init__(self, app) -> None: + super().__init__(app, "EMANE Error") + self.draw() + + def draw(self): + self.top.columnconfigure(0, weight=1) + label = ttk.Label(self.top, text="EMANE needs to be installed!") + label.grid(sticky="ew", pady=PADY) + button = ttk.Button( + self.top, text="EMANE Documentation", command=self.click_doc + ) + button.grid(sticky="ew", pady=PADY) + button = ttk.Button(self.top, text="Close", command=self.destroy) + button.grid(sticky="ew") + + def click_doc(self): + webbrowser.open_new("https://coreemu.github.io/core/emane.html") diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index fa169598..6913dd58 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -723,24 +723,26 @@ class CanvasGraph(tk.Canvas): dialog = ShapeDialog(self.app, shape) dialog.show() - def add_node(self, x: float, y: float) -> CanvasNode: - if self.selected is None or self.selected in self.shapes: - actual_x, actual_y = self.get_actual_coords(x, y) - core_node = self.core.create_node( - actual_x, actual_y, self.node_draw.node_type, self.node_draw.model + def add_node(self, x: float, y: float) -> None: + if self.selected is not None and self.selected not in self.shapes: + return + actual_x, actual_y = self.get_actual_coords(x, y) + core_node = self.core.create_node( + actual_x, actual_y, self.node_draw.node_type, self.node_draw.model + ) + if not core_node: + return + try: + self.node_draw.image = Images.get( + self.node_draw.image_enum, int(ICON_SIZE * self.app.app_scale) ) - try: - self.node_draw.image = Images.get( - self.node_draw.image_enum, int(ICON_SIZE * self.app.app_scale) - ) - except AttributeError: - self.node_draw.image = Images.get_custom( - self.node_draw.image_file, int(ICON_SIZE * self.app.app_scale) - ) - node = CanvasNode(self.app, x, y, core_node, self.node_draw.image) - self.core.canvas_nodes[core_node.id] = node - self.nodes[node.id] = node - return node + except AttributeError: + self.node_draw.image = Images.get_custom( + self.node_draw.image_file, int(ICON_SIZE * self.app.app_scale) + ) + node = CanvasNode(self.app, x, y, core_node, self.node_draw.image) + self.core.canvas_nodes[core_node.id] = node + self.nodes[node.id] = node def width_and_height(self): """ @@ -925,6 +927,8 @@ class CanvasGraph(tk.Canvas): copy = self.core.create_node( actual_x, actual_y, core_node.type, core_node.model ) + if not copy: + continue node = CanvasNode(self.app, scaled_x, scaled_y, copy, canvas_node.image) # copy configurations and services diff --git a/docs/emane.md b/docs/emane.md index b3289c6a..716d7059 100644 --- a/docs/emane.md +++ b/docs/emane.md @@ -50,10 +50,27 @@ can also subscribe to EMANE location events and move the nodes on the canvas as they are moved in the EMANE emulation. This would occur when an Emulation Script Generator, for example, is running a mobility script. +## EMANE Installation + +EMANE can be installed from deb or RPM packages or from source. See the +[EMANE GitHub](https://github.com/adjacentlink/emane) for full details. + +Here are quick instructions for installing all EMANE packages for Ubuntu 18.04: +```shell +# install dependencies +sudo apt-get install libssl-dev libxml-libxml-perl libxml-simple-perl +wget https://adjacentlink.com/downloads/emane/emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz +tar xzf emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz +# install base emane packages +sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/emane*.deb +# install python3 bindings +sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/python3*.deb +``` + ## EMANE Configuration -The CORE configuration file */etc/core/core.conf* has options specific to -EMANE. An example emane section from the *core.conf* file is shown below: +The CORE configuration file **/etc/core/core.conf** has options specific to +EMANE. An example emane section from the **core.conf** file is shown below: ```shell # EMANE configuration @@ -64,40 +81,28 @@ emane_event_monitor = False # EMANE log level range [0,4] default: 2 emane_log_level = 2 emane_realtime = True -``` - -EMANE can be installed from deb or RPM packages or from source. See the -[EMANE GitHub](https://github.com/adjacentlink/emane) for full details. - -Here are quick instructions for installing all EMANE packages: - -```shell -# install dependencies -sudo apt-get install libssl-dev libxml-libxml-perl libxml-simple-perl -wget https://adjacentlink.com/downloads/emane/emane-1.2.1-release-1.ubuntu-16_04.amd64.tar.gz -tar xzf emane-1.2.1-release-1.ubuntu-16_04.amd64.tar.gz -sudo dpkg -i emane-1.2.1-release-1/deb/ubuntu-16_04/amd64/*.deb +# prefix used for emane installation +# emane_prefix = /usr ``` If you have an EMANE event generator (e.g. mobility or pathloss scripts) and want to have CORE subscribe to EMANE location events, set the following line -in the */etc/core/core.conf* configuration file: +in the **core.conf** configuration file. + +> **NOTE:** Do not set this option to True if you want to manually drag nodes around +on the canvas to update their location in EMANE. ```shell emane_event_monitor = True ``` -Do not set the above option to True if you want to manually drag nodes around -on the canvas to update their location in EMANE. - Another common issue is if installing EMANE from source, the default configure -prefix will place the DTD files in */usr/local/share/emane/dtd* while CORE -expects them in */usr/share/emane/dtd*. - -A symbolic link will fix this: +prefix will place the DTD files in **/usr/local/share/emane/dtd** while CORE +expects them in **/usr/share/emane/dtd**. +Update the EMANE prefix configuration to resolve this problem. ```shell -sudo ln -s /usr/local/share/emane /usr/share/emane +emane_prefix = /usr/local ``` ## Custom EMANE Models From d9f48d14a7797667d70d0b84e7737163553b52e8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 5 May 2020 13:00:22 -0700 Subject: [PATCH 034/929] pygui fixed button layout on session options dialog --- daemon/core/gui/dialogs/sessionoptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/core/gui/dialogs/sessionoptions.py b/daemon/core/gui/dialogs/sessionoptions.py index c1455399..d31a5fb5 100644 --- a/daemon/core/gui/dialogs/sessionoptions.py +++ b/daemon/core/gui/dialogs/sessionoptions.py @@ -46,7 +46,7 @@ class SessionOptionsDialog(Dialog): button = ttk.Button(frame, text="Save", command=self.save) button.grid(row=0, column=0, padx=PADX, sticky="ew") button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, padx=PADX, sticky="ew") + button.grid(row=0, column=1, sticky="ew") def save(self): config = self.config_frame.parse_config() From 86ae87eafe29a8eaa8aa1c9f46d0074712093edf Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 8 May 2020 00:16:25 -0700 Subject: [PATCH 035/929] pygui: revamped config to leverage classes mapped to yaml, removes need for using keys all over and type hinting on glasses, future changes should support defaults better --- daemon/core/gui/app.py | 8 +- daemon/core/gui/appconfig.py | 187 ++++++++++++++---- daemon/core/gui/coreclient.py | 52 ++--- daemon/core/gui/dialogs/canvassizeandscale.py | 22 +-- daemon/core/gui/dialogs/customnodes.py | 20 +- daemon/core/gui/dialogs/find.py | 6 +- daemon/core/gui/dialogs/ipdialog.py | 20 +- daemon/core/gui/dialogs/macdialog.py | 5 +- daemon/core/gui/dialogs/observers.py | 10 +- daemon/core/gui/dialogs/preferences.py | 23 ++- daemon/core/gui/dialogs/servers.py | 37 +--- daemon/core/gui/dialogs/sessions.py | 2 +- daemon/core/gui/graph/graph.py | 6 +- daemon/core/gui/interface.py | 8 +- daemon/core/gui/menubar.py | 6 +- daemon/core/gui/nodeutils.py | 47 ++--- 16 files changed, 251 insertions(+), 208 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index de0a5260..5ca95ab5 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -43,7 +43,7 @@ class Application(ttk.Frame): # setup self.guiconfig = appconfig.read() - self.app_scale = self.guiconfig["scale"] + self.app_scale = self.guiconfig.scale self.setup_scaling() self.style = ttk.Style() self.setup_theme() @@ -65,7 +65,7 @@ class Application(ttk.Frame): themes.load(self.style) self.master.bind_class("Menu", "<>", themes.theme_change_menu) self.master.bind("<>", themes.theme_change) - self.style.theme_use(self.guiconfig["preferences"]["theme"]) + self.style.theme_use(self.guiconfig.preferences.theme) def setup_app(self): self.master.title("CORE") @@ -103,8 +103,8 @@ class Application(ttk.Frame): self.menubar = Menubar(self.master, self) def draw_canvas(self): - width = self.guiconfig["preferences"]["width"] - height = self.guiconfig["preferences"]["height"] + width = self.guiconfig.preferences.width + height = self.guiconfig.preferences.height canvas_frame = ttk.Frame(self.right_frame) canvas_frame.rowconfigure(0, weight=1) canvas_frame.columnconfigure(0, weight=1) diff --git a/daemon/core/gui/appconfig.py b/daemon/core/gui/appconfig.py index c19fb029..d1d9bcc2 100644 --- a/daemon/core/gui/appconfig.py +++ b/daemon/core/gui/appconfig.py @@ -1,6 +1,7 @@ import os import shutil from pathlib import Path +from typing import List, Optional import yaml @@ -37,11 +38,6 @@ TERMINALS = { "gnome-terminal": "gnome-terminal --window --", } EDITORS = ["$EDITOR", "vim", "emacs", "gedit", "nano", "vi"] -DEFAULT_IP4S = ["10.0.0.0", "192.168.0.0", "172.16.0.0"] -DEFAULT_IP4 = DEFAULT_IP4S[0] -DEFAULT_IP6S = ["2001::", "2002::", "a::"] -DEFAULT_IP6 = DEFAULT_IP6S[0] -DEFAULT_MAC = "00:00:00:aa:00:00" class IndentDumper(yaml.Dumper): @@ -49,13 +45,151 @@ class IndentDumper(yaml.Dumper): return super().increase_indent(flow, False) -def copy_files(current_path, new_path): +class CustomNode(yaml.YAMLObject): + yaml_tag = "!CustomNode" + yaml_loader = yaml.SafeLoader + + def __init__(self, name: str, image: str, services: List[str]) -> None: + self.name = name + self.image = image + self.services = services + + +class CoreServer(yaml.YAMLObject): + yaml_tag = "!CoreServer" + yaml_loader = yaml.SafeLoader + + def __init__(self, name: str, address: str) -> None: + self.name = name + self.address = address + + +class Observer(yaml.YAMLObject): + yaml_tag = "!Observer" + yaml_loader = yaml.SafeLoader + + def __init__(self, name: str, cmd: str) -> None: + self.name = name + self.cmd = cmd + + +class PreferencesConfig(yaml.YAMLObject): + yaml_tag = "!PreferencesConfig" + yaml_loader = yaml.SafeLoader + + def __init__( + self, + editor: str = EDITORS[1], + terminal: str = None, + theme: str = themes.THEME_DARK, + gui3d: str = "/usr/local/bin/std3d.sh", + width: int = 1000, + height: int = 750, + ) -> None: + self.theme = theme + self.editor = editor + self.terminal = terminal + self.gui3d = gui3d + self.width = width + self.height = height + + +class LocationConfig(yaml.YAMLObject): + yaml_tag = "!LocationConfig" + yaml_loader = yaml.SafeLoader + + def __init__( + self, + x: float = 0.0, + y: float = 0.0, + z: float = 0.0, + lat: float = 47.5791667, + lon: float = -122.132322, + alt: float = 2.0, + scale: float = 150.0, + ) -> None: + self.x = x + self.y = y + self.z = z + self.lat = lat + self.lon = lon + self.alt = alt + self.scale = scale + + +class IpConfigs(yaml.YAMLObject): + yaml_tag = "!IpConfigs" + yaml_loader = yaml.SafeLoader + + def __init__( + self, + ip4: str = None, + ip6: str = None, + ip4s: List[str] = None, + ip6s: List[str] = None, + ) -> None: + if ip4s is None: + ip4s = ["10.0.0.0", "192.168.0.0", "172.16.0.0"] + self.ip4s = ip4s + if ip6s is None: + ip6s = ["2001::", "2002::", "a::"] + self.ip6s = ip6s + if ip4 is None: + ip4 = self.ip4s[0] + self.ip4 = ip4 + if ip6 is None: + ip6 = self.ip6s[0] + self.ip6 = ip6 + + +class GuiConfig(yaml.YAMLObject): + yaml_tag = "!GuiConfig" + yaml_loader = yaml.SafeLoader + + def __init__( + self, + preferences: PreferencesConfig = None, + location: LocationConfig = None, + servers: List[CoreServer] = None, + nodes: List[CustomNode] = None, + recentfiles: List[str] = None, + observers: List[Observer] = None, + scale: float = 1.0, + ips: IpConfigs = None, + mac: str = "00:00:00:aa:00:00", + ) -> None: + if preferences is None: + preferences = PreferencesConfig() + self.preferences = preferences + if location is None: + location = LocationConfig() + self.location = location + if servers is None: + servers = [] + self.servers = servers + if nodes is None: + nodes = [] + self.nodes = nodes + if recentfiles is None: + recentfiles = [] + self.recentfiles = recentfiles + if observers is None: + observers = [] + self.observers = observers + self.scale = scale + if ips is None: + ips = IpConfigs() + self.ips = ips + self.mac = mac + + +def copy_files(current_path, new_path) -> None: for current_file in current_path.glob("*"): new_file = new_path.joinpath(current_file.name) shutil.copy(current_file, new_file) -def find_terminal(): +def find_terminal() -> Optional[str]: for term in sorted(TERMINALS): cmd = TERMINALS[term] if shutil.which(term): @@ -63,7 +197,7 @@ def find_terminal(): return None -def check_directory(): +def check_directory() -> None: if HOME_PATH.exists(): return HOME_PATH.mkdir() @@ -85,45 +219,16 @@ def check_directory(): editor = EDITORS[0] else: editor = EDITORS[1] - config = { - "preferences": { - "theme": themes.THEME_DARK, - "editor": editor, - "terminal": terminal, - "gui3d": "/usr/local/bin/std3d.sh", - "width": 1000, - "height": 750, - }, - "location": { - "x": 0.0, - "y": 0.0, - "z": 0.0, - "lat": 47.5791667, - "lon": -122.132322, - "alt": 2.0, - "scale": 150.0, - }, - "servers": [], - "nodes": [], - "recentfiles": [], - "observers": [], - "scale": 1.0, - "ips": { - "ip4": DEFAULT_IP4, - "ip6": DEFAULT_IP6, - "ip4s": DEFAULT_IP4S, - "ip6s": DEFAULT_IP6S, - }, - "mac": DEFAULT_MAC, - } + preferences = PreferencesConfig(editor, terminal) + config = GuiConfig(preferences=preferences) save(config) -def read(): +def read() -> GuiConfig: with CONFIG_PATH.open("r") as f: return yaml.load(f, Loader=yaml.SafeLoader) -def save(config): +def save(config: GuiConfig) -> None: with CONFIG_PATH.open("w") as f: yaml.dump(config, f, Dumper=IndentDumper, default_flow_style=False) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 950d7013..bc9cdc37 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -34,19 +34,6 @@ if TYPE_CHECKING: GUI_SOURCE = "gui" -class CoreServer: - def __init__(self, name: str, address: str, port: int): - self.name = name - self.address = address - self.port = port - - -class Observer: - def __init__(self, name: str, cmd: str): - self.name = name - self.cmd = cmd - - class CoreClient: def __init__(self, app: "Application", proxy: bool): """ @@ -126,22 +113,17 @@ class CoreClient: self.observer = value def read_config(self): - # read distributed server - for config in self.app.guiconfig.get("servers", []): - server = CoreServer(config["name"], config["address"], config["port"]) + # read distributed servers + for server in self.app.guiconfig.servers: self.servers[server.name] = server # read custom nodes - for config in self.app.guiconfig.get("nodes", []): - name = config["name"] - image_file = config["image"] - services = set(config["services"]) - node_draw = NodeDraw.from_custom(name, image_file, services) - self.custom_nodes[name] = node_draw + for custom_node in self.app.guiconfig.nodes: + node_draw = NodeDraw.from_custom(custom_node) + self.custom_nodes[custom_node.name] = node_draw # read observers - for config in self.app.guiconfig.get("observers", []): - observer = Observer(config["name"], config["cmd"]) + for observer in self.app.guiconfig.observers: self.custom_observers[observer.name] = observer def handle_events(self, event: core_pb2.Event): @@ -367,8 +349,8 @@ class CoreClient: self.app.canvas.adjust_to_dim.set(fit_image) wallpaper_style = canvas_config.get("wallpaper-style", 1) self.app.canvas.scale_option.set(wallpaper_style) - width = self.app.guiconfig["preferences"]["width"] - height = self.app.guiconfig["preferences"]["height"] + width = self.app.guiconfig.preferences.width + height = self.app.guiconfig.preferences.height dimensions = canvas_config.get("dimensions", [width, height]) self.app.canvas.redraw_canvas(dimensions) wallpaper = canvas_config.get("wallpaper") @@ -418,15 +400,15 @@ class CoreClient: try: response = self.client.create_session() logging.info("created session: %s", response) - location_config = self.app.guiconfig["location"] + location_config = self.app.guiconfig.location self.location = core_pb2.SessionLocation( - x=location_config["x"], - y=location_config["y"], - z=location_config["z"], - lat=location_config["lat"], - lon=location_config["lon"], - alt=location_config["alt"], - scale=location_config["scale"], + x=location_config.x, + y=location_config.y, + z=location_config.z, + lat=location_config.lat, + lon=location_config.lon, + alt=location_config.alt, + scale=location_config.scale, ) self.join_session(response.session_id, query_location=False) except grpc.RpcError as e: @@ -585,7 +567,7 @@ class CoreClient: def launch_terminal(self, node_id: int): try: - terminal = self.app.guiconfig["preferences"]["terminal"] + terminal = self.app.guiconfig.preferences.terminal if not terminal: messagebox.showerror( "Terminal Error", diff --git a/daemon/core/gui/dialogs/canvassizeandscale.py b/daemon/core/gui/dialogs/canvassizeandscale.py index 3c3b8540..5a042468 100644 --- a/daemon/core/gui/dialogs/canvassizeandscale.py +++ b/daemon/core/gui/dialogs/canvassizeandscale.py @@ -241,16 +241,16 @@ class SizeAndScaleDialog(Dialog): location.alt = self.alt.get() location.scale = self.scale.get() if self.save_default.get(): - location_config = self.app.guiconfig["location"] - location_config["x"] = location.x - location_config["y"] = location.y - location_config["z"] = location.z - location_config["lat"] = location.lat - location_config["lon"] = location.lon - location_config["alt"] = location.alt - location_config["scale"] = location.scale - preferences = self.app.guiconfig["preferences"] - preferences["width"] = width - preferences["height"] = height + location_config = self.app.guiconfig.location + location_config.x = location.x + location_config.y = location.y + location_config.z = location.z + location_config.lat = location.lat + location_config.lon = location.lon + location_config.alt = location.alt + location_config.scale = location.scale + preferences = self.app.guiconfig.preferences + preferences.width = width + preferences.height = height self.app.save_config() self.destroy() diff --git a/daemon/core/gui/dialogs/customnodes.py b/daemon/core/gui/dialogs/customnodes.py index 28f33ffe..56012780 100644 --- a/daemon/core/gui/dialogs/customnodes.py +++ b/daemon/core/gui/dialogs/customnodes.py @@ -5,7 +5,7 @@ from tkinter import ttk from typing import TYPE_CHECKING, Set from core.gui import nodeutils -from core.gui.appconfig import ICONS_PATH +from core.gui.appconfig import ICONS_PATH, CustomNode from core.gui.dialogs.dialog import Dialog from core.gui.images import Images from core.gui.nodeutils import NodeDraw @@ -201,17 +201,12 @@ class CustomNodesDialog(Dialog): self.services.update(dialog.current_services) def click_save(self): - self.app.guiconfig["nodes"].clear() - for name in sorted(self.app.core.custom_nodes): + self.app.guiconfig.nodes.clear() + for name in self.app.core.custom_nodes: node_draw = self.app.core.custom_nodes[name] - self.app.guiconfig["nodes"].append( - { - "name": name, - "image": node_draw.image_file, - "services": list(node_draw.services), - } - ) - logging.info("saving custom nodes: %s", self.app.guiconfig["nodes"]) + custom_node = CustomNode(name, node_draw.image_file, node_draw.services) + self.app.guiconfig.nodes.append(custom_node) + logging.info("saving custom nodes: %s", self.app.guiconfig.nodes) self.app.save_config() self.destroy() @@ -219,7 +214,8 @@ class CustomNodesDialog(Dialog): name = self.name.get() if name not in self.app.core.custom_nodes: image_file = Path(self.image_file).stem - node_draw = NodeDraw.from_custom(name, image_file, set(self.services)) + custom_node = CustomNode(name, image_file, list(self.services)) + node_draw = NodeDraw.from_custom(custom_node) logging.info( "created new custom node (%s), image file (%s), services: (%s)", name, diff --git a/daemon/core/gui/dialogs/find.py b/daemon/core/gui/dialogs/find.py index 8f0094d4..25da4b19 100644 --- a/daemon/core/gui/dialogs/find.py +++ b/daemon/core/gui/dialogs/find.py @@ -45,7 +45,7 @@ class FindDialog(Dialog): ) self.tree.grid(sticky="nsew", pady=PADY) style = ttk.Style() - heading_size = int(self.app.guiconfig["scale"] * 10) + heading_size = int(self.app.guiconfig.scale * 10) style.configure("Treeview.Heading", font=(None, heading_size, "bold")) self.tree.column("nodeid", stretch=tk.YES, anchor="center") self.tree.heading("nodeid", text="Node ID") @@ -124,7 +124,7 @@ class FindDialog(Dialog): canvas_node = self.app.core.canvas_nodes[node_id] x0, y0, x1, y1 = self.app.canvas.bbox(canvas_node.id) - dist = 5 * self.app.guiconfig["scale"] + dist = 5 * self.app.guiconfig.scale self.app.canvas.create_oval( x0 - dist, y0 - dist, @@ -132,7 +132,7 @@ class FindDialog(Dialog): y1 + dist, tags="find", outline="red", - width=3.0 * self.app.guiconfig["scale"], + width=3.0 * self.app.guiconfig.scale, ) _x, _y, _, _ = self.app.canvas.bbox(canvas_node.id) diff --git a/daemon/core/gui/dialogs/ipdialog.py b/daemon/core/gui/dialogs/ipdialog.py index 3c6944ab..62f5d0ba 100644 --- a/daemon/core/gui/dialogs/ipdialog.py +++ b/daemon/core/gui/dialogs/ipdialog.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING import netaddr -from core.gui import appconfig from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import ListboxScroll @@ -16,11 +15,10 @@ if TYPE_CHECKING: class IpConfigDialog(Dialog): def __init__(self, app: "Application") -> None: super().__init__(app, "IP Configuration") - ip_config = self.app.guiconfig.setdefault("ips") - self.ip4 = ip_config.setdefault("ip4", appconfig.DEFAULT_IP4) - self.ip6 = ip_config.setdefault("ip6", appconfig.DEFAULT_IP6) - self.ip4s = ip_config.setdefault("ip4s", appconfig.DEFAULT_IP4S) - self.ip6s = ip_config.setdefault("ip6s", appconfig.DEFAULT_IP6S) + self.ip4 = self.app.guiconfig.ips.ip4 + self.ip6 = self.app.guiconfig.ips.ip6 + self.ip4s = self.app.guiconfig.ips.ip4s + self.ip6s = self.app.guiconfig.ips.ip6s self.ip4_entry = None self.ip4_listbox = None self.ip6_entry = None @@ -143,11 +141,11 @@ class IpConfigDialog(Dialog): for index in range(self.ip6_listbox.listbox.size()): ip6 = self.ip6_listbox.listbox.get(index) ip6s.append(ip6) - ip_config = self.app.guiconfig["ips"] - ip_config["ip4"] = self.ip4 - ip_config["ip6"] = self.ip6 - ip_config["ip4s"] = ip4s - ip_config["ip6s"] = ip6s + ip_config = self.app.guiconfig.ips + ip_config.ip4 = self.ip4 + ip_config.ip6 = self.ip6 + ip_config.ip4s = ip4s + ip_config.ip6s = ip6s self.app.core.interfaces_manager.update_ips(self.ip4, self.ip6) self.app.save_config() self.destroy() diff --git a/daemon/core/gui/dialogs/macdialog.py b/daemon/core/gui/dialogs/macdialog.py index 18a330ba..caca9fd0 100644 --- a/daemon/core/gui/dialogs/macdialog.py +++ b/daemon/core/gui/dialogs/macdialog.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING import netaddr -from core.gui import appconfig from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY @@ -15,7 +14,7 @@ if TYPE_CHECKING: class MacConfigDialog(Dialog): def __init__(self, app: "Application") -> None: super().__init__(app, "MAC Configuration") - mac = self.app.guiconfig.get("mac", appconfig.DEFAULT_MAC) + mac = self.app.guiconfig.mac self.mac_var = tk.StringVar(value=mac) self.draw() @@ -57,6 +56,6 @@ class MacConfigDialog(Dialog): messagebox.showerror("MAC Error", f"{mac} is an invalid mac") else: self.app.core.interfaces_manager.mac = netaddr.EUI(mac) - self.app.guiconfig["mac"] = mac + self.app.guiconfig.mac = mac self.app.save_config() self.destroy() diff --git a/daemon/core/gui/dialogs/observers.py b/daemon/core/gui/dialogs/observers.py index 4ec03185..6911e1b8 100644 --- a/daemon/core/gui/dialogs/observers.py +++ b/daemon/core/gui/dialogs/observers.py @@ -2,7 +2,7 @@ import tkinter as tk from tkinter import messagebox, ttk from typing import TYPE_CHECKING -from core.gui.coreclient import Observer +from core.gui.appconfig import Observer from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY from core.gui.widgets import ListboxScroll @@ -89,11 +89,9 @@ class ObserverDialog(Dialog): button.grid(row=0, column=1, sticky="ew") def click_save_config(self): - observers = [] - for name in sorted(self.app.core.custom_observers): - observer = self.app.core.custom_observers[name] - observers.append({"name": observer.name, "cmd": observer.cmd}) - self.app.guiconfig["observers"] = observers + self.app.guiconfig.observers.clear() + for observer in self.app.core.custom_observers.values(): + self.app.guiconfig.observers.append(observer) self.app.save_config() self.destroy() diff --git a/daemon/core/gui/dialogs/preferences.py b/daemon/core/gui/dialogs/preferences.py index c650f42a..9c9ba16f 100644 --- a/daemon/core/gui/dialogs/preferences.py +++ b/daemon/core/gui/dialogs/preferences.py @@ -19,11 +19,11 @@ class PreferencesDialog(Dialog): def __init__(self, app: "Application"): super().__init__(app, "Preferences") self.gui_scale = tk.DoubleVar(value=self.app.app_scale) - preferences = self.app.guiconfig["preferences"] - self.editor = tk.StringVar(value=preferences["editor"]) - self.theme = tk.StringVar(value=preferences["theme"]) - self.terminal = tk.StringVar(value=preferences["terminal"]) - self.gui3d = tk.StringVar(value=preferences["gui3d"]) + preferences = self.app.guiconfig.preferences + self.editor = tk.StringVar(value=preferences.editor) + self.theme = tk.StringVar(value=preferences.theme) + self.terminal = tk.StringVar(value=preferences.terminal) + self.gui3d = tk.StringVar(value=preferences.gui3d) self.draw() def draw(self): @@ -110,15 +110,14 @@ class PreferencesDialog(Dialog): self.app.style.theme_use(theme) def click_save(self): - preferences = self.app.guiconfig["preferences"] - preferences["terminal"] = self.terminal.get() - preferences["editor"] = self.editor.get() - preferences["gui3d"] = self.gui3d.get() - preferences["theme"] = self.theme.get() + preferences = self.app.guiconfig.preferences + preferences.terminal = self.terminal.get() + preferences.editor = self.editor.get() + preferences.gui3d = self.gui3d.get() + preferences.theme = self.theme.get() self.gui_scale.set(round(self.gui_scale.get(), 2)) app_scale = self.gui_scale.get() - self.app.guiconfig["scale"] = app_scale - + self.app.guiconfig.scale = app_scale self.app.save_config() self.scale_adjust() self.destroy() diff --git a/daemon/core/gui/dialogs/servers.py b/daemon/core/gui/dialogs/servers.py index 62bcc675..7ca96e9f 100644 --- a/daemon/core/gui/dialogs/servers.py +++ b/daemon/core/gui/dialogs/servers.py @@ -2,7 +2,7 @@ import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING -from core.gui.coreclient import CoreServer +from core.gui.appconfig import CoreServer from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import ListboxScroll @@ -20,7 +20,6 @@ class ServersDialog(Dialog): super().__init__(app, "CORE Servers") self.name = tk.StringVar(value=DEFAULT_NAME) self.address = tk.StringVar(value=DEFAULT_ADDRESS) - self.port = tk.IntVar(value=DEFAULT_PORT) self.servers = None self.selected_index = None self.selected = None @@ -54,31 +53,17 @@ class ServersDialog(Dialog): frame.grid(pady=PADY, sticky="ew") frame.columnconfigure(1, weight=1) frame.columnconfigure(3, weight=1) - frame.columnconfigure(5, weight=1) label = ttk.Label(frame, text="Name") - label.grid(row=0, column=0, sticky="w", padx=PADX, pady=PADY) + label.grid(row=0, column=0, sticky="w", padx=PADX) entry = ttk.Entry(frame, textvariable=self.name) entry.grid(row=0, column=1, sticky="ew") label = ttk.Label(frame, text="Address") - label.grid(row=0, column=2, sticky="w", padx=PADX, pady=PADY) + label.grid(row=0, column=2, sticky="w", padx=PADX) entry = ttk.Entry(frame, textvariable=self.address) entry.grid(row=0, column=3, sticky="ew") - label = ttk.Label(frame, text="Port") - label.grid(row=0, column=4, sticky="w", padx=PADX, pady=PADY) - entry = ttk.Entry( - frame, - textvariable=self.port, - validate="key", - validatecommand=(self.app.validation.positive_int, "%P"), - ) - entry.bind( - "", lambda event: self.app.validation.focus_out(event, "50051") - ) - entry.grid(row=0, column=5, sticky="ew") - def draw_servers_buttons(self): frame = ttk.Frame(self.top) frame.grid(pady=PADY, sticky="ew") @@ -113,13 +98,9 @@ class ServersDialog(Dialog): button.grid(row=0, column=1, sticky="ew") def click_save_configuration(self): - servers = [] - for name in sorted(self.app.core.servers): - server = self.app.core.servers[name] - servers.append( - {"name": server.name, "address": server.address, "port": server.port} - ) - self.app.guiconfig["servers"] = servers + self.app.guiconfig.servers.clear() + for server in self.app.core.servers.values(): + self.app.guiconfig.servers.append(server) self.app.save_config() self.destroy() @@ -127,8 +108,7 @@ class ServersDialog(Dialog): name = self.name.get() if name not in self.app.core.servers: address = self.address.get() - port = self.port.get() - server = CoreServer(name, address, port) + server = CoreServer(name, address) self.app.core.servers[name] = server self.servers.insert(tk.END, name) @@ -140,7 +120,6 @@ class ServersDialog(Dialog): server = self.app.core.servers.pop(previous_name) server.name = name server.address = self.address.get() - server.port = self.port.get() self.app.core.servers[name] = server self.servers.delete(self.selected_index) self.servers.insert(self.selected_index, name) @@ -154,7 +133,6 @@ class ServersDialog(Dialog): self.selected_index = None self.name.set(DEFAULT_NAME) self.address.set(DEFAULT_ADDRESS) - self.port.set(DEFAULT_PORT) self.servers.selection_clear(0, tk.END) self.save_button.config(state=tk.DISABLED) self.delete_button.config(state=tk.DISABLED) @@ -167,7 +145,6 @@ class ServersDialog(Dialog): server = self.app.core.servers[self.selected] self.name.set(server.name) self.address.set(server.address) - self.port.set(server.port) self.save_button.config(state=tk.NORMAL) self.delete_button.config(state=tk.NORMAL) else: diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index 160854a6..9aa71a13 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -70,7 +70,7 @@ class SessionsDialog(Dialog): selectmode=tk.BROWSE, ) style = ttk.Style() - heading_size = int(self.app.guiconfig["scale"] * 10) + heading_size = int(self.app.guiconfig.scale * 10) style.configure("Treeview.Heading", font=(None, heading_size, "bold")) self.tree.grid(sticky="nsew") self.tree.column("id", stretch=tk.YES, anchor="center") diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 6913dd58..220e122f 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -1002,10 +1002,10 @@ class CanvasGraph(tk.Canvas): if NodeUtils.is_custom( canvas_node.core_node.type, canvas_node.core_node.model ): - for custom_node in self.app.guiconfig["nodes"]: - if custom_node["name"] == canvas_node.core_node.model: + for custom_node in self.app.guiconfig.nodes: + if custom_node.name == canvas_node.core_node.model: img = Images.get_custom( - custom_node["image"], int(ICON_SIZE * self.app.app_scale) + custom_node.image, int(ICON_SIZE * self.app.app_scale) ) else: image_enum = TypeToImage.get( diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 6f5f5fff..0ff018c7 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple import netaddr from netaddr import EUI, IPNetwork -from core.gui import appconfig from core.gui.nodeutils import NodeUtils if TYPE_CHECKING: @@ -44,14 +43,13 @@ class Subnets: class InterfaceManager: def __init__(self, app: "Application") -> None: self.app = app - ip_config = self.app.guiconfig.get("ips", {}) - ip4 = ip_config.get("ip4", appconfig.DEFAULT_IP4) - ip6 = ip_config.get("ip6", appconfig.DEFAULT_IP6) + ip4 = self.app.guiconfig.ips.ip4 + ip6 = self.app.guiconfig.ips.ip6 self.ip4_mask = 24 self.ip6_mask = 64 self.ip4_subnets = IPNetwork(f"{ip4}/{self.ip4_mask}") self.ip6_subnets = IPNetwork(f"{ip6}/{self.ip6_mask}") - mac = self.app.guiconfig.get("mac", appconfig.DEFAULT_MAC) + mac = self.app.guiconfig.mac self.mac = EUI(mac, dialect=netaddr.mac_unix_expanded) self.current_mac = None self.current_subnets = None diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index c3ed071a..2e07ed0a 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -93,7 +93,7 @@ class Menubar(tk.Menu): ) self.app.bind_all("", self.click_open_xml) self.recent_menu = tk.Menu(menu) - for i in self.app.guiconfig["recentfiles"]: + for i in self.app.guiconfig.recentfiles: self.recent_menu.add_command( label=i, command=partial(self.open_recent_files, i) ) @@ -298,7 +298,7 @@ class Menubar(tk.Menu): def update_recent_files(self) -> None: self.recent_menu.delete(0, tk.END) - for i in self.app.guiconfig["recentfiles"]: + for i in self.app.guiconfig.recentfiles: self.recent_menu.add_command( label=i, command=partial(self.open_recent_files, i) ) @@ -350,7 +350,7 @@ class Menubar(tk.Menu): dialog.show() def add_recent_file_to_gui_config(self, file_path) -> None: - recent_files = self.app.guiconfig["recentfiles"] + recent_files = self.app.guiconfig.recentfiles num_files = len(recent_files) if num_files == 0: recent_files.insert(0, file_path) diff --git a/daemon/core/gui/nodeutils.py b/daemon/core/gui/nodeutils.py index 24c01f06..4c2cec07 100644 --- a/daemon/core/gui/nodeutils.py +++ b/daemon/core/gui/nodeutils.py @@ -1,7 +1,8 @@ import logging -from typing import TYPE_CHECKING, Dict, List, Optional, Set, Union +from typing import TYPE_CHECKING, List, Optional, Set from core.api.grpc.core_pb2 import Node, NodeType +from core.gui.appconfig import CustomNode, GuiConfig from core.gui.images import ImageEnum, Images, TypeToImage if TYPE_CHECKING: @@ -41,16 +42,16 @@ class NodeDraw: return node_draw @classmethod - def from_custom(cls, name: str, image_file: str, services: Set[str]): + def from_custom(cls, custom_node: CustomNode): node_draw = NodeDraw() node_draw.custom = True - node_draw.image_file = image_file - node_draw.image = Images.get_custom(image_file, ICON_SIZE) + node_draw.image_file = custom_node.image + node_draw.image = Images.get_custom(custom_node.image, ICON_SIZE) node_draw.node_type = NodeType.DEFAULT - node_draw.services = services - node_draw.label = name - node_draw.model = name - node_draw.tooltip = name + node_draw.services = custom_node.services + node_draw.label = custom_node.name + node_draw.model = custom_node.name + node_draw.tooltip = custom_node.name return node_draw @@ -97,11 +98,7 @@ class NodeUtils: @classmethod def node_icon( - cls, - node_type: NodeType, - model: str, - gui_config: Dict[str, List[Dict[str, str]]], - scale=1.0, + cls, node_type: NodeType, model: str, gui_config: GuiConfig, scale=1.0 ) -> "ImageTk.PhotoImage": image_enum = TypeToImage.get(node_type, model) @@ -114,10 +111,7 @@ class NodeUtils: @classmethod def node_image( - cls, - core_node: "core_pb2.Node", - gui_config: Dict[str, List[Dict[str, str]]], - scale=1.0, + cls, core_node: "core_pb2.Node", gui_config: GuiConfig, scale=1.0 ) -> "ImageTk.PhotoImage": image = cls.node_icon(core_node.type, core_node.model, gui_config, scale) if core_node.icon: @@ -132,20 +126,17 @@ class NodeUtils: return node_type == NodeType.DEFAULT and model not in cls.NODE_MODELS @classmethod - def get_custom_node_services( - cls, gui_config: Dict[str, List[Dict[str, str]]], name: str - ) -> List[str]: - for m in gui_config["nodes"]: - if m["name"] == name: - return m["services"] + def get_custom_node_services(cls, gui_config: GuiConfig, name: str) -> List[str]: + for custom_node in gui_config.nodes: + if custom_node.name == name: + return custom_node.services return [] @classmethod - def get_image_file(cls, gui_config, name: str) -> Union[str, None]: - if "nodes" in gui_config: - for m in gui_config["nodes"]: - if m["name"] == name: - return m["image"] + def get_image_file(cls, gui_config: GuiConfig, name: str) -> Optional[str]: + for custom_node in gui_config.nodes: + if custom_node.name == name: + return custom_node.image return None @classmethod From 32558d15d22c27b2c322f68f5000659c4720702c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 8 May 2020 00:46:02 -0700 Subject: [PATCH 036/929] pygui: removed comment in appconfig --- daemon/core/gui/appconfig.py | 1 - 1 file changed, 1 deletion(-) diff --git a/daemon/core/gui/appconfig.py b/daemon/core/gui/appconfig.py index d1d9bcc2..049b9bfc 100644 --- a/daemon/core/gui/appconfig.py +++ b/daemon/core/gui/appconfig.py @@ -5,7 +5,6 @@ from typing import List, Optional import yaml -# gui home paths from core.gui import themes HOME_PATH = Path.home().joinpath(".coretk") From 4379ef32e9b6c9e64c799016de2982b51ff22a1c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 8 May 2020 08:29:45 -0700 Subject: [PATCH 037/929] pygui: removed restriction on wlan nodes context linking to mdrs only, since custom nodes and other types may be configured and desired to be linked in the same way --- daemon/core/gui/graph/node.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 451298e0..41b4704a 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -312,12 +312,10 @@ class CanvasNode: return result def wireless_link_selected(self): - for canvas_nid in [ - x for x in self.canvas.selection if "node" in self.canvas.gettags(x) - ]: - core_node = self.canvas.nodes[canvas_nid].core_node - if core_node.type == core_pb2.NodeType.DEFAULT and core_node.model == "mdr": - self.canvas.create_edge(self, self.canvas.nodes[canvas_nid]) + nodes = [x for x in self.canvas.selection if x in self.canvas.nodes] + for node_id in nodes: + canvas_node = self.canvas.nodes[node_id] + self.canvas.create_edge(self, canvas_node) self.canvas.clear_selection() def scale_antennas(self): From 0aba1aa9287c8e7e30143f707574dbd893ccee9d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 8 May 2020 09:08:01 -0700 Subject: [PATCH 038/929] pygui: updated gui home directory to ~/.coregui and changed config file name to config.yaml from gui.yaml to be more explicit --- daemon/core/gui/appconfig.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/daemon/core/gui/appconfig.py b/daemon/core/gui/appconfig.py index 049b9bfc..077f938d 100644 --- a/daemon/core/gui/appconfig.py +++ b/daemon/core/gui/appconfig.py @@ -7,14 +7,14 @@ import yaml from core.gui import themes -HOME_PATH = Path.home().joinpath(".coretk") +HOME_PATH = Path.home().joinpath(".coregui") BACKGROUNDS_PATH = HOME_PATH.joinpath("backgrounds") CUSTOM_EMANE_PATH = HOME_PATH.joinpath("custom_emane") CUSTOM_SERVICE_PATH = HOME_PATH.joinpath("custom_services") ICONS_PATH = HOME_PATH.joinpath("icons") MOBILITY_PATH = HOME_PATH.joinpath("mobility") XMLS_PATH = HOME_PATH.joinpath("xmls") -CONFIG_PATH = HOME_PATH.joinpath("gui.yaml") +CONFIG_PATH = HOME_PATH.joinpath("config.yaml") LOG_PATH = HOME_PATH.joinpath("gui.log") SCRIPT_PATH = HOME_PATH.joinpath("scripts") From 9b4802a5aeb3e6512f67208abc4ca78d7bedcde0 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 8 May 2020 09:22:22 -0700 Subject: [PATCH 039/929] updated install.sh to attempt to install the latest python dependencies on reinstall in case new dependencies have been introduced --- install.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/install.sh b/install.sh index 4e32bccd..a12072f1 100755 --- a/install.sh +++ b/install.sh @@ -138,6 +138,8 @@ else uninstall_core echo "pulling latest code" git pull + echo "installing python dependencies" + install_python_depencencies echo "building CORE" case ${os} in "ubuntu") From 5d99244596180d651acb6f341686f6fc2b9a3a2c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 9 May 2020 21:50:16 -0700 Subject: [PATCH 040/929] removed docker service and associated documentation, was not functioning and will cause confusion with new support in the new GUI --- daemon/core/services/dockersvc.py | 176 ------------------------------ docs/services.md | 1 - docs/services/docker.md | 43 -------- 3 files changed, 220 deletions(-) delete mode 100644 daemon/core/services/dockersvc.py delete mode 100644 docs/services/docker.md diff --git a/daemon/core/services/dockersvc.py b/daemon/core/services/dockersvc.py deleted file mode 100644 index 2c815e04..00000000 --- a/daemon/core/services/dockersvc.py +++ /dev/null @@ -1,176 +0,0 @@ -""" -Docker service allows running docker containers within CORE nodes. - -The running of Docker within a CORE node allows for additional extensibility to -the CORE services. This allows network applications and protocols to be easily -packaged and run on any node. - -This service that will add a new group to the services list. This -will have a service called Docker which will just start the docker service -within the node but not run anything. It will also scan all docker images on -the host machine. If any are tagged with 'core' then they will be added as a -service to the Docker group. The image will then be auto run if that service is -selected. - -This requires a recent version of Docker. This was tested using a PPA on Ubuntu - with version 1.2.0. The version in the standard Ubuntu repo is to old for -this purpose (we need --net host). - -It also requires docker-py (https://pypi.python.org/pypi/docker-py) which can be -installed with 'pip install docker-py'. This is used to interface with Docker -from the python service. - -An example use case is to pull an image from Docker.com. A test image has been -uploaded for this purpose: - -sudo docker pull stuartmarsden/multicastping - -This downloads an image which is based on Ubuntu 14.04 with python and twisted. -It runs a simple program that sends a multicast ping and listens and records -any it receives. - -In order for this to appear as a docker service it must be tagged with core. -Find out the id by running 'sudo docker images'. You should see all installed -images and the one you want looks like this: - -stuartmarsden/multicastping latest 4833487e66d2 20 hours -ago 487 MB - -The id will be different on your machine so use it in the following command: - -sudo docker tag 4833487e66d2 stuartmarsden/multicastping:core - -This image will be listed in the services after we restart the core-daemon: - -sudo service core-daemon restart - -You can set up a simple network with a number of PCs connected to a switch. Set -the stuartmarsden/multicastping service for all the PCs. When started they will -all begin sending Multicast pings. - -In order to see what is happening you can go in to the terminal of a node and -look at the docker log. Easy shorthand is: - -docker logs $(docker ps -q) - -Which just shows the log of the running docker container (usually just one per -node). I have added this as an observer node to my setup: Name: docker logs -Command: bash -c 'docker logs $(docker ps -q) | tail -20' - -So I can just hover over to see the log which looks like this: - -Datagram 'Client: Ping' received from ('10.0.0.20', 8005) -Datagram 'Client: Ping' received from ('10.0.5.21', 8005) -Datagram 'Client: Ping' received from ('10.0.3.20', 8005) -Datagram 'Client: Ping' received from ('10.0.4.20', 8005) -Datagram 'Client: Ping' received from ('10.0.4.20', 8005) -Datagram 'Client: Ping' received from ('10.0.1.21', 8005) -Datagram 'Client: Ping' received from ('10.0.4.21', 8005) -Datagram 'Client: Ping' received from ('10.0.4.21', 8005) -Datagram 'Client: Ping' received from ('10.0.5.20', 8005) -Datagram 'Client: Ping' received from ('10.0.0.21', 8005) -Datagram 'Client: Ping' received from ('10.0.3.21', 8005) -Datagram 'Client: Ping' received from ('10.0.0.20', 8005) -Datagram 'Client: Ping' received from ('10.0.5.21', 8005) -Datagram 'Client: Ping' received from ('10.0.3.20', 8005) -Datagram 'Client: Ping' received from ('10.0.4.20', 8005) -Datagram 'Client: Ping' received from ('10.0.4.20', 8005) -Datagram 'Client: Ping' received from ('10.0.1.21', 8005) -Datagram 'Client: Ping' received from ('10.0.4.21', 8005) -Datagram 'Client: Ping' received from ('10.0.4.21', 8005) -Datagram 'Client: Ping' received from ('10.0.5.20', 8005) - -Limitations: - -1. Docker images must be downloaded on the host as usually a CORE node does not - have access to the internet. -2. Each node isolates running containers (keeps things simple) -3. Recent version of docker needed so that --net host can be used. This does - not further abstract the network within a node and allows multicast which - is not enabled within Docker containers at the moment. -4. The core-daemon must be restarted for new images to show up. -5. A Docker-daemon is run within each node but the images are shared. This - does mean that the daemon attempts to access an SQLlite database within the - host. At startup all the nodes will try to access this and it will be locked - for most due to contention. The service just does a hackish wait for 1 second - and retry. This means all the docker containers can take a while to come up - depending on how many nodes you have. -""" - -import logging - -from core.services.coreservices import CoreService, ServiceManager - -try: - from docker import Client -except ImportError: - logging.debug("missing python docker bindings") - - -class DockerService(CoreService): - """ - This is a service which will allow running docker containers in a CORE - node. - """ - name = "Docker" - executables = ("docker",) - group = "Docker" - dirs = ('/var/lib/docker/containers/', '/run/shm', '/run/resolvconf',) - configs = ('docker.sh',) - startup = ('sh docker.sh',) - shutdown = ('service docker stop',) - # Container image to start - image = "" - - @classmethod - def generate_config(cls, node, filename): - """ - Returns a string having contents of a docker.sh script that - can be modified to start a specific docker image. - """ - cfg = "#!/bin/sh\n" - cfg += "# auto-generated by Docker (docker.py)\n" - # Docker likes to think it has DNS set up or it complains. - # Unless your network was attached to the Internet this is - # non-functional but hides error messages. - cfg += 'echo "nameserver 8.8.8.8" > /run/resolvconf/resolv.conf\n' - # Starts the docker service. In Ubuntu this is docker.io; in other - # distros may just be docker - cfg += 'service docker start\n' - cfg += "# you could add a command to start a image here eg:\n" - if not cls.image: - cfg += "# docker run -d --net host --name coreDock \n" - else: - cfg += """\ -result=1 -until [ $result -eq 0 ]; do - docker run -d --net host --name coreDock %s - result=$? - # this is to alleviate contention to docker's SQLite database - sleep 0.3 -done -""" % (cls.image,) - return cfg - - @classmethod - def on_load(cls): - logging.debug("loading custom docker services") - - if "Client" in globals(): - client = Client(version="1.10") - images = client.images() - del client - else: - images = [] - - for image in images: - if u"" in image["RepoTags"][0]: - continue - for repo in image["RepoTags"]: - if u":core" not in repo: - continue - dockerid = repo.encode("ascii", "ignore").split(":")[0] - sub_class = type("SubClass", (DockerService,), {"_name": dockerid, "_image": dockerid}) - ServiceManager.add(sub_class) - - del images diff --git a/docs/services.md b/docs/services.md index e70a0c75..d2911d81 100644 --- a/docs/services.md +++ b/docs/services.md @@ -24,7 +24,6 @@ shutdown commands, and meta-data associated with a node. | Service Group | Services | |---|---| |[BIRD](services/bird.md)|BGP, OSPF, RADV, RIP, Static| -|[Docker](services/docker.md)|Docker| |[EMANE](services/emane.md)|Transport Service| |[FRR](services/frr.md)|BABEL, BGP, OSPFv2, OSPFv3, PIMD, RIP, RIPNG, Zebra| |[NRL](services/nrl.md)|arouted, MGEN Sink, MGEN Actor, NHDP, OLSR, OLSRORG, OLSRv2, SMF| diff --git a/docs/services/docker.md b/docs/services/docker.md deleted file mode 100644 index 5757de1f..00000000 --- a/docs/services/docker.md +++ /dev/null @@ -1,43 +0,0 @@ -# Docker - -* Table of Contents -{:toc} - -## Overview - -Docker service allows running docker containers within CORE nodes. -The running of Docker within a CORE node allows for additional extensibility to -the CORE services. This allows network applications and protocols to be easily -packaged and run on any node. - -This service will add a new group to the services list. This will have a service called Docker which will just start the docker service within the node but not run anything. It will also scan all docker images on the host machine. If any are tagged with 'core' then they will be added as a service to the Docker group. The image will then be auto run if that service is selected. - -This requires a recent version of Docker. This was tested using a PPA on Ubuntu with version 1.2.0. The version in the standard Ubuntu repo is to old for this purpose (we need --net host). - -## Docker Installation - -To use Docker services, you must first install the Docker python image. This is used to interface with Docker from the python service. - -```shell -sudo apt-get install docker.io -sudo apt-get install python-pip -pip install docker-py -``` -Once everything runs successfully, a Docker group under services will appear. An example use case is to pull an image from [Docker](https://hub.docker.com/). A test image has been uploaded for this purpose: -```shell -sudo docker pull stuartmarsden/multicastping -``` -This downloads an image which is based on Ubuntu 14.04 with python and twisted. It runs a simple program that sends a multicast ping and listens and records any it receives. In order for this to appear as a docker service it must be tagged with core. -Find out the id by running 'sudo docker images'. You should see all installed images and the one you want looks like this: -```shell -stuartmarsden/multicastping latest 4833487e66d2 20 hours -ago 487 MB -``` -The id will be different on your machine so use it in the following command: -```shell -sudo docker tag 4833487e66d2 stuartmarsden/multicastping:core -``` -This image will be listed in the services after we restart the core-daemon: -```shell -sudo service core-daemon restart -``` From f77f37ef86957e77a858a56ea7626680bbd912b1 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 11 May 2020 07:59:13 -0700 Subject: [PATCH 041/929] bumped version for next release --- configure.ac | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configure.ac b/configure.ac index 588f3f34..90b731a9 100644 --- a/configure.ac +++ b/configure.ac @@ -2,7 +2,7 @@ # Process this file with autoconf to produce a configure script. # this defines the CORE version number, must be static for AC_INIT -AC_INIT(core, 6.3.0) +AC_INIT(core, 6.4.0) # autoconf and automake initialization AC_CONFIG_SRCDIR([netns/version.h.in]) From a36674aba99172638ccb136145926111d9b2142b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 11 May 2020 08:33:56 -0700 Subject: [PATCH 042/929] pygui: adjustment to compensate for ip4/ip6 address not being present on joined links --- daemon/core/gui/interface.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 0ff018c7..9df1f667 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -147,8 +147,13 @@ class InterfaceManager: return str(ip4), str(ip6) def get_subnets(self, interface: "core_pb2.Interface") -> Subnets: - ip4_subnet = IPNetwork(f"{interface.ip4}/{interface.ip4mask}").cidr - ip6_subnet = IPNetwork(f"{interface.ip6}/{interface.ip6mask}").cidr + logging.info("get subnets for interface: %s", interface) + ip4_subnet = self.ip4_subnets + if interface.ip4: + ip4_subnet = IPNetwork(f"{interface.ip4}/{interface.ip4mask}").cidr + ip6_subnet = self.ip6_subnets + if interface.ip6: + ip6_subnet = IPNetwork(f"{interface.ip6}/{interface.ip6mask}").cidr subnets = Subnets(ip4_subnet, ip6_subnet) return self.used_subnets.get(subnets.key(), subnets) From 88a98fff820d8d727862154cb07f7d91b2b4f988 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 11 May 2020 12:26:05 -0700 Subject: [PATCH 043/929] docs: added initial documentation for the python beta gui based on prior gui documentation --- daemon/scripts/core-pygui | 2 +- docs/index.md | 1 + docs/pygui.md | 653 ++++++++++++++++++++++ docs/static/core-pygui.png | Bin 0 -> 652318 bytes docs/static/pygui/OVS.gif | Bin 0 -> 744 bytes docs/static/pygui/alert.png | Bin 0 -> 2019 bytes docs/static/pygui/antenna.gif | Bin 0 -> 230 bytes docs/static/pygui/cancel.png | Bin 0 -> 1322 bytes docs/static/pygui/core-icon.png | Bin 0 -> 2931 bytes docs/static/pygui/delete.png | Bin 0 -> 387 bytes docs/static/pygui/docker.png | Bin 0 -> 1533 bytes docs/static/pygui/document-new.gif | Bin 0 -> 1054 bytes docs/static/pygui/document-properties.gif | Bin 0 -> 635 bytes docs/static/pygui/document-save.gif | Bin 0 -> 1049 bytes docs/static/pygui/edit-delete.gif | Bin 0 -> 1006 bytes docs/static/pygui/edit-node.png | Bin 0 -> 3050 bytes docs/static/pygui/emane.png | Bin 0 -> 3334 bytes docs/static/pygui/error.png | Bin 0 -> 2258 bytes docs/static/pygui/fileopen.gif | Bin 0 -> 1095 bytes docs/static/pygui/host.png | Bin 0 -> 642 bytes docs/static/pygui/hub.png | Bin 0 -> 2242 bytes docs/static/pygui/lanswitch.png | Bin 0 -> 1138 bytes docs/static/pygui/link.png | Bin 0 -> 1692 bytes docs/static/pygui/lxc.png | Bin 0 -> 1553 bytes docs/static/pygui/marker.png | Bin 0 -> 1211 bytes docs/static/pygui/markerclear.png | Bin 0 -> 1370 bytes docs/static/pygui/mdr.png | Bin 0 -> 2786 bytes docs/static/pygui/observe.gif | Bin 0 -> 1149 bytes docs/static/pygui/oval.png | Bin 0 -> 2407 bytes docs/static/pygui/pause.png | Bin 0 -> 2368 bytes docs/static/pygui/pc.png | Bin 0 -> 828 bytes docs/static/pygui/plot.gif | Bin 0 -> 265 bytes docs/static/pygui/prouter.png | Bin 0 -> 2590 bytes docs/static/pygui/rectangle.png | Bin 0 -> 259 bytes docs/static/pygui/rj45.png | Bin 0 -> 1121 bytes docs/static/pygui/router.png | Bin 0 -> 2082 bytes docs/static/pygui/run.png | Bin 0 -> 1805 bytes docs/static/pygui/select.png | Bin 0 -> 1038 bytes docs/static/pygui/shutdown.png | Bin 0 -> 1546 bytes docs/static/pygui/start.png | Bin 0 -> 3010 bytes docs/static/pygui/stop.png | Bin 0 -> 2305 bytes docs/static/pygui/text.png | Bin 0 -> 314 bytes docs/static/pygui/tunnel.png | Bin 0 -> 2256 bytes docs/static/pygui/twonode.png | Bin 0 -> 2494 bytes docs/static/pygui/wlan.png | Bin 0 -> 3457 bytes 45 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 docs/pygui.md create mode 100644 docs/static/core-pygui.png create mode 100755 docs/static/pygui/OVS.gif create mode 100644 docs/static/pygui/alert.png create mode 100644 docs/static/pygui/antenna.gif create mode 100644 docs/static/pygui/cancel.png create mode 100644 docs/static/pygui/core-icon.png create mode 100644 docs/static/pygui/delete.png create mode 100644 docs/static/pygui/docker.png create mode 100644 docs/static/pygui/document-new.gif create mode 100644 docs/static/pygui/document-properties.gif create mode 100644 docs/static/pygui/document-save.gif create mode 100644 docs/static/pygui/edit-delete.gif create mode 100644 docs/static/pygui/edit-node.png create mode 100644 docs/static/pygui/emane.png create mode 100644 docs/static/pygui/error.png create mode 100644 docs/static/pygui/fileopen.gif create mode 100644 docs/static/pygui/host.png create mode 100644 docs/static/pygui/hub.png create mode 100644 docs/static/pygui/lanswitch.png create mode 100644 docs/static/pygui/link.png create mode 100644 docs/static/pygui/lxc.png create mode 100644 docs/static/pygui/marker.png create mode 100644 docs/static/pygui/markerclear.png create mode 100644 docs/static/pygui/mdr.png create mode 100644 docs/static/pygui/observe.gif create mode 100644 docs/static/pygui/oval.png create mode 100644 docs/static/pygui/pause.png create mode 100644 docs/static/pygui/pc.png create mode 100644 docs/static/pygui/plot.gif create mode 100644 docs/static/pygui/prouter.png create mode 100644 docs/static/pygui/rectangle.png create mode 100644 docs/static/pygui/rj45.png create mode 100644 docs/static/pygui/router.png create mode 100644 docs/static/pygui/run.png create mode 100644 docs/static/pygui/select.png create mode 100644 docs/static/pygui/shutdown.png create mode 100644 docs/static/pygui/start.png create mode 100644 docs/static/pygui/stop.png create mode 100644 docs/static/pygui/text.png create mode 100644 docs/static/pygui/tunnel.png create mode 100644 docs/static/pygui/twonode.png create mode 100644 docs/static/pygui/wlan.png diff --git a/daemon/scripts/core-pygui b/daemon/scripts/core-pygui index 9723297b..f30b531b 100755 --- a/daemon/scripts/core-pygui +++ b/daemon/scripts/core-pygui @@ -9,7 +9,7 @@ from core.gui.images import Images if __name__ == "__main__": # parse flags - parser = argparse.ArgumentParser(description=f"CORE Python Tk GUI") + parser = argparse.ArgumentParser(description=f"CORE Python GUI") parser.add_argument("-l", "--level", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], default="INFO", help="logging level") parser.add_argument("-p", "--proxy", action="store_true", help="enable proxy") diff --git a/docs/index.md b/docs/index.md index 12932880..4e215916 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,6 +23,7 @@ networking scenarios, security studies, and increasing the size of physical test |[Architecture](architecture.md)|Overview of the architecture| |[Installation](install.md)|How to install CORE and its requirements| |[GUI](gui.md)|How to use the GUI| +|[(BETA) Python GUI](gui.md)|How to use the BETA python based GUI| |[Distributed](distributed.md)|Details for running CORE across multiple servers| |[Python Scripting](scripting.md)|How to write python scripts for creating a CORE session| |[gRPC API](grpc.md)|How to enable and use the gRPC API| diff --git a/docs/pygui.md b/docs/pygui.md new file mode 100644 index 00000000..4ed3fe09 --- /dev/null +++ b/docs/pygui.md @@ -0,0 +1,653 @@ + +# (BETA) Python GUI + +* Table of Contents +{:toc} + +![](static/core-pygui.png) + +## Overview + +The GUI is used to draw nodes and network devices on a canvas, linking them +together to create an emulated network session. + +After pressing the start button, CORE will proceed through these phases, +staying in the **runtime** phase. After the session is stopped, CORE will +proceed to the **data collection** phase before tearing down the emulated +state. + +CORE can be customized to perform any action at each state. See the +**Hooks...** entry on the [Session Menu](#session-menu) for details about +when these session states are reached. + +## Prerequisites + +Beyond installing CORE, you must have the CORE daemon running. This is done +on the command line with either systemd or sysv. + +```shell +# systemd service +sudo systemctl daemon-reload +sudo systemctl start core-daemon + +# sysv service +sudo service core-daemon start + +# direct invocation +sudo core-daemon +``` + +## GUI Files + +> **NOTE:** Previously the BETA GUI placed files under ~/.coretk, this has been +> updated to be ~/.coregui. The prior config file named ~/.coretk/gui.yaml is +> also now known as ~/.coregui/config.yaml and has a slightly different format + +The GUI will create a directory in your home directory on first run called +~/.coregui. This directory will help layout various files that the GUI may use. + +* .coregui/ + * backgrounds/ + * place backgrounds used for display in the GUI + * custom_emane/ + * place to keep custom emane models to use with the core-daemon + * custom_services/ + * place to keep custom services to use with the core-daemon + * icons/ + * icons the GUI uses along with customs icons desired + * mobility/ + * place to keep custom mobility files + * scripts/ + * place to keep core related scripts + * xmls/ + * place to keep saved session xml files + * gui.log + * log file when running the gui, look here when issues occur for exceptions etc + * config.yaml + * configuration file used to save/load various gui related settings (custom nodes, layouts, addresses, etc) + +## Modes of Operation + +The CORE GUI has two primary modes of operation, **Edit** and **Execute** +modes. Running the GUI, by typing **core-pygui** with no options, starts in +Edit mode. Nodes are drawn on a blank canvas using the toolbar on the left +and configured from right-click menus or by double-clicking them. The GUI +does not need to be run as root. + +Once editing is complete, pressing the green **Start** button instantiates +the topology and enters Execute mode. In execute mode, +the user can interact with the running emulated machines by double-clicking or +right-clicking on them. The editing toolbar disappears and is replaced by an +execute toolbar, which provides tools while running the emulation. Pressing +the red **Stop** button will destroy the running emulation and return CORE +to Edit mode. + +Once the emulation is running, the GUI can be closed, and a prompt will appear +asking if the emulation should be terminated. The emulation may be left +running and the GUI can reconnect to an existing session at a later time. + +The GUI can be run as a normal user on Linux. + +The python GUI currently provides the following options on startup. + +```shell +usage: core-pygui [-h] [-l {DEBUG,INFO,WARNING,ERROR,CRITICAL}] [-p] + +CORE Python GUI + +optional arguments: + -h, --help show this help message and exit + -l {DEBUG,INFO,WARNING,ERROR,CRITICAL}, --level {DEBUG,INFO,WARNING,ERROR,CRITICAL} + logging level + -p, --proxy enable proxy +``` + +## Toolbar + +The toolbar is a row of buttons that runs vertically along the left side of the +CORE GUI window. The toolbar changes depending on the mode of operation. + +### Editing Toolbar + +When CORE is in Edit mode (the default), the vertical Editing Toolbar exists on +the left side of the CORE window. Below are brief descriptions for each toolbar +item, starting from the top. Most of the tools are grouped into related +sub-menus, which appear when you click on their group icon. + +| Icon | Name | Description | +|---|---|---| +| ![](static/pygui/select.png) | Selection Tool | Tool for selecting, moving, configuring nodes. | +| ![](static/pygui/start.png) | Start Button | Starts Execute mode, instantiates the emulation. | +| ![](static/pygui/link.png) | Link | Allows network links to be drawn between two nodes by clicking and dragging the mouse. | + +### CORE Nodes + +These nodes will create a new node container and run associated services. + +| Icon | Name | Description | +|---|---|---| +| ![](static/pygui/router.png) | Router | Runs Quagga OSPFv2 and OSPFv3 routing to forward packets. | +| ![](static/pygui/host.png) | Host | Emulated server machine having a default route, runs SSH server. | +| ![](static/pygui/pc.png) | PC | Basic emulated machine having a default route, runs no processes by default. | +| ![](static/pygui/mdr.png) | MDR | Runs Quagga OSPFv3 MDR routing for MANET-optimized routing. | +| ![](static/pygui/router.png) | PRouter | Physical router represents a real testbed machine. | + +### Network Nodes + +These nodes are mostly used to create a Linux bridge that serves the +purpose described below. + +| Icon | Name | Description | +|---|---|---| +| ![](static/pygui/hub.png) | Hub | Ethernet hub forwards incoming packets to every connected node. | +| ![](static/pygui/lanswitch.png) | Switch | Ethernet switch intelligently forwards incoming packets to attached hosts using an Ethernet address hash table. | +| ![](static/pygui/wlan.png) | Wireless LAN | When routers are connected to this WLAN node, they join a wireless network and an antenna is drawn instead of a connecting line; the WLAN node typically controls connectivity between attached wireless nodes based on the distance between them. | +| ![](static/pygui/rj45.png) | RJ45 | RJ45 Physical Interface Tool, emulated nodes can be linked to real physical interfaces; using this tool, real networks and devices can be physically connected to the live-running emulation. | +| ![](static/pygui/tunnel.png) | Tunnel | Tool allows connecting together more than one CORE emulation using GRE tunnels. | + +### Annotation Tools + +| Icon | Name | Description | +|---|---|---| +| ![](static/pygui/marker.png) | Marker | For drawing marks on the canvas. | +| ![](static/pygui/oval.png) | Oval | For drawing circles on the canvas that appear in the background. | +| ![](static/pygui/rectangle.png) | Rectangle | For drawing rectangles on the canvas that appear in the background. | +| ![](static/pygui/text.png) | Text | For placing text captions on the canvas. | + +### Execution Toolbar + +When the Start button is pressed, CORE switches to Execute mode, and the Edit +toolbar on the left of the CORE window is replaced with the Execution toolbar +Below are the items on this toolbar, starting from the top. + +| Icon | Name | Description | +|---|---|---| +| ![](static/pygui/stop.png) | Stop Button | Stops Execute mode, terminates the emulation, returns CORE to edit mode. | +| ![](static/pygui/select.png) | Selection Tool | In Execute mode, the Selection Tool can be used for moving nodes around the canvas, and double-clicking on a node will open a shell window for that node; right-clicking on a node invokes a pop-up menu of run-time options for that node. | +| ![](static/pygui/marker.png) | Marker | For drawing freehand lines on the canvas, useful during demonstrations; markings are not saved. | +| ![](static/pygui/run.png) | Run Tool | This tool allows easily running a command on all or a subset of all nodes. A list box allows selecting any of the nodes. A text entry box allows entering any command. The command should return immediately, otherwise the display will block awaiting response. The *ping* command, for example, with no parameters, is not a good idea. The result of each command is displayed in a results box. The first occurrence of the special text "NODE" will be replaced with the node name. The command will not be attempted to run on nodes that are not routers, PCs, or hosts, even if they are selected. | + +## Menu + +The menubar runs along the top of the CORE GUI window and provides access to a +variety of features. Some of the menus are detachable, such as the *Widgets* +menu, by clicking the dashed line at the top. + +### File Menu + +The File menu contains options for manipulating the **.imn** Configuration +Files. Generally, these menu items should not be used in Execute mode. + +| Option | Description | +|---|---| +| New Session | This starts a new session with an empty canvas. | +| Save | Saves the current topology. If you have not yet specified a file name, the Save As dialog box is invoked. | +| Save As | Invokes the Save As dialog box for selecting a new **.xml** file for saving the current configuration in the XML file. | +| Open | Invokes the File Open dialog box for selecting a new XML file to open. | +| Recently used files | Above the Quit menu command is a list of recently use files, if any have been opened. You can clear this list in the Preferences dialog box. You can specify the number of files to keep in this list from the Preferences dialog. Click on one of the file names listed to open that configuration file. | +| Execute Python Script | Invokes a File Open dialog box for selecting a Python script to run and automatically connect to. After a selection is made, a Python Script Options dialog box is invoked to allow for command-line options to be added. The Python script must create a new CORE Session and add this session to the daemon's list of sessions in order for this to work. | +| Quit | The Quit command should be used to exit the CORE GUI. CORE may prompt for termination if you are currently in Execute mode. Preferences and the recently-used files list are saved. | + +### Edit Menu + +| Option | Description | +|---|---| +| Preferences | Invokes the Preferences dialog box. | +| Custom Nodes | Custom node creation dialog box. | +| Undo | (Disabled) Attempts to undo the last edit in edit mode. | +| Redo | (Disabled) Attempts to redo an edit that has been undone. | +| Cut, Copy, Paste, Delete | Used to cut, copy, paste, and delete a selection. When nodes are pasted, their node numbers are automatically incremented, and existing links are preserved with new IP addresses assigned. Services and their customizations are copied to the new node, but care should be taken as node IP addresses have changed with possibly old addresses remaining in any custom service configurations. Annotations may also be copied and pasted. + +### Canvas Menu + +The canvas menu provides commands related to the editing canvas. + +| Option | Description | +|---|---| +| Size/scale | Invokes a Canvas Size and Scale dialog that allows configuring the canvas size, scale, and geographic reference point. The size controls allow changing the width and height of the current canvas, in pixels or meters. The scale allows specifying how many meters are equivalent to 100 pixels. The reference point controls specify the latitude, longitude, and altitude reference point used to convert between geographic and Cartesian coordinate systems. By clicking the *Save as default* option, all new canvases will be created with these properties. The default canvas size can also be changed in the Preferences dialog box. +| Wallpaper | Used for setting the canvas background image. | + +### View Menu + +The View menu features items for toggling on and off their display on the canvas. + +| Option | Description | +|---|---| +| Interface Names | Display interface names on links. | +| IPv4 Addresses | Display IPv4 addresses on links. | +| IPv6 Addresses | Display IPv6 addresses on links. | +| Node Labels | Display node names. | +| Link Labels | Display link labels. | +| Annotations | Display annotations. | +| Canvas Grid | Display the canvas grid. | + +### Tools Menu + +The tools menu lists different utility functions. + +| Option | Description | +|---|---| +| Find | Display find dialog used for highlighting a node on the canvas. | +| Auto Grid | Automatically layout nodes in a grid. | +| IP addresses | Invokes the IP Addresses dialog box for configuring which IPv4/IPv6 prefixes are used when automatically addressing new interfaces. | +| MAC addresses | Invokes the MAC Addresses dialog box for configuring the starting number used as the lowest byte when generating each interface MAC address. This value should be changed when tunneling between CORE emulations to prevent MAC address conflicts. | + +### Widgets Menu + +Widgets are GUI elements that allow interaction with a running emulation. +Widgets typically automate the running of commands on emulated nodes to report +status information of some type and display this on screen. + +#### Periodic Widgets + +These Widgets are those available from the main *Widgets* menu. More than one +of these Widgets may be run concurrently. An event loop fires once every second +that the emulation is running. If one of these Widgets is enabled, its periodic +routine will be invoked at this time. Each Widget may have a configuration +dialog box which is also accessible from the *Widgets* menu. + +Here are some standard widgets: + +* **Adjacency** - displays router adjacency states for Quagga's OSPFv2 and OSPFv3 + routing protocols. A line is drawn from each router halfway to the router ID + of an adjacent router. The color of the line is based on the OSPF adjacency + state such as Two-way or Full. To learn about the different colors, see the + *Configure Adjacency...* menu item. The **vtysh** command is used to + dump OSPF neighbor information. + Only half of the line is drawn because each + router may be in a different adjacency state with respect to the other. +* **Throughput** - displays the kilobits-per-second throughput above each link, + using statistics gathered from the ng_pipe Netgraph node that implements each + link. If the throughput exceeds a certain threshold, the link will become + highlighted. For wireless nodes which broadcast data to all nodes in range, + the throughput rate is displayed next to the node and the node will become + circled if the threshold is exceeded. + +#### Observer Widgets + +These Widgets are available from the **Observer Widgets** submenu of the +**Widgets** menu, and from the Widgets Tool on the toolbar. Only one Observer Widget may +be used at a time. Mouse over a node while the session is running to pop up +an informational display about that node. + +Available Observer Widgets include IPv4 and IPv6 routing tables, socket +information, list of running processes, and OSPFv2/v3 neighbor information. + +Observer Widgets may be edited by the user and rearranged. Choosing +**Widgets->Observer Widgets->Edit Observers** from the Observer Widget menu will +invoke the Observer Widgets dialog. A list of Observer Widgets is displayed along +with up and down arrows for rearranging the list. Controls are available for +renaming each widget, for changing the command that is run during mouse over, and +for adding and deleting items from the list. Note that specified commands should +return immediately to avoid delays in the GUI display. Changes are saved to a +**config.yaml** file in the CORE configuration directory. + +### Session Menu + +The Session Menu has entries for starting, stopping, and managing sessions, +in addition to global options such as node types, comments, hooks, servers, +and options. + +| Option | Description | +|---|---| +| Sessions | Invokes the CORE Sessions dialog box containing a list of active CORE sessions in the daemon. Basic session information such as name, node count, start time, and a thumbnail are displayed. This dialog allows connecting to different sessions, shutting them down, or starting a new session. | +| Servers | Invokes the CORE emulation servers dialog for configuring. | +| Options | Presents per-session options, such as the IPv4 prefix to be used, if any, for a control network the ability to preserve the session directory; and an on/off switch for SDT3D support. | +| Hooks | Invokes the CORE Session Hooks window where scripts may be configured for a particular session state. The session states are defined in the [table](#session-states) below. The top of the window has a list of configured hooks, and buttons on the bottom left allow adding, editing, and removing hook scripts. The new or edit button will open a hook script editing window. A hook script is a shell script invoked on the host (not within a virtual node). | + +#### Session States + +| State | Description | +|---|---| +| Definition | Used by the GUI to tell the backend to clear any state. | +| Configuration | When the user presses the *Start* button, node, link, and other configuration data is sent to the backend. This state is also reached when the user customizes a service. | +| Instantiation | After configuration data has been sent, just before the nodes are created. | +| Runtime | All nodes and networks have been built and are running. (This is the same state at which the previously-named *global experiment script* was run.) +| Datacollect | The user has pressed the *Stop* button, but before services have been stopped and nodes have been shut down. This is a good time to collect log files and other data from the nodes. | +| Shutdown | All nodes and networks have been shut down and destroyed. | + +### Help Menu + +| Option | Description | +|---|---| +| CORE Github (www) | Link to the CORE GitHub page. | +| CORE Documentation (www) | Lnk to the CORE Documentation page. | +| About | Invokes the About dialog box for viewing version information. | + +## Connecting with Physical Networks + +CORE's emulated networks run in real time, so they can be connected to live +physical networks. The RJ45 tool and the Tunnel tool help with connecting to +the real world. These tools are available from the **Link-layer nodes** menu. + +When connecting two or more CORE emulations together, MAC address collisions +should be avoided. CORE automatically assigns MAC addresses to interfaces when +the emulation is started, starting with **00:00:00:aa:00:00** and incrementing +the bottom byte. The starting byte should be changed on the second CORE machine +using the **Tools->MAC Addresses** option the menu. + +### RJ45 Tool + +The RJ45 node in CORE represents a physical interface on the real CORE machine. +Any real-world network device can be connected to the interface and communicate +with the CORE nodes in real time. + +The main drawback is that one physical interface is required for each +connection. When the physical interface is assigned to CORE, it may not be used +for anything else. Another consideration is that the computer or network that +you are connecting to must be co-located with the CORE machine. + +To place an RJ45 connection, click on the **Link-layer nodes** toolbar and select +the **RJ45 Tool** from the submenu. Click on the canvas near the node you want to +connect to. This could be a router, hub, switch, or WLAN, for example. Now +click on the *Link Tool* and draw a link between the RJ45 and the other node. +The RJ45 node will display "UNASSIGNED". Double-click the RJ45 node to assign a +physical interface. A list of available interfaces will be shown, and one may +be selected by double-clicking its name in the list, or an interface name may +be entered into the text box. + +> **NOTE:** When you press the Start button to instantiate your topology, the + interface assigned to the RJ45 will be connected to the CORE topology. The + interface can no longer be used by the system. For example, if there was an + IP address assigned to the physical interface before execution, the address + will be removed and control given over to CORE. No IP address is needed; the + interface is put into promiscuous mode so it will receive all packets and + send them into the emulated world. + +Multiple RJ45 nodes can be used within CORE and assigned to the same physical +interface if 802.1x VLANs are used. This allows for more RJ45 nodes than +physical ports are available, but the (e.g. switching) hardware connected to +the physical port must support the VLAN tagging, and the available bandwidth +will be shared. + +You need to create separate VLAN virtual devices on the Linux host, +and then assign these devices to RJ45 nodes inside of CORE. The VLANning is +actually performed outside of CORE, so when the CORE emulated node receives a +packet, the VLAN tag will already be removed. + +Here are example commands for creating VLAN devices under Linux: + +```shell +ip link add link eth0 name eth0.1 type vlan id 1 +ip link add link eth0 name eth0.2 type vlan id 2 +ip link add link eth0 name eth0.3 type vlan id 3 +``` + +### Tunnel Tool + +The tunnel tool builds GRE tunnels between CORE emulations or other hosts. +Tunneling can be helpful when the number of physical interfaces is limited or +when the peer is located on a different network. Also a physical interface does +not need to be dedicated to CORE as with the RJ45 tool. + +The peer GRE tunnel endpoint may be another CORE machine or another +host that supports GRE tunneling. When placing a Tunnel node, initially +the node will display "UNASSIGNED". This text should be replaced with the IP +address of the tunnel peer. This is the IP address of the other CORE machine or +physical machine, not an IP address of another virtual node. + +> **NOTE:** Be aware of possible MTU (Maximum Transmission Unit) issues with GRE devices. The *gretap* device + has an interface MTU of 1,458 bytes; when joined to a Linux bridge, the + bridge's MTU + becomes 1,458 bytes. The Linux bridge will not perform fragmentation for + large packets if other bridge ports have a higher MTU such as 1,500 bytes. + +The GRE key is used to identify flows with GRE tunneling. This allows multiple +GRE tunnels to exist between that same pair of tunnel peers. A unique number +should be used when multiple tunnels are used with the same peer. When +configuring the peer side of the tunnel, ensure that the matching keys are +used. + +Here are example commands for building the other end of a tunnel on a Linux +machine. In this example, a router in CORE has the virtual address +**10.0.0.1/24** and the CORE host machine has the (real) address +**198.51.100.34/24**. The Linux box +that will connect with the CORE machine is reachable over the (real) network +at **198.51.100.76/24**. +The emulated router is linked with the Tunnel Node. In the +Tunnel Node configuration dialog, the address **198.51.100.76** is entered, with +the key set to **1**. The gretap interface on the Linux box will be assigned +an address from the subnet of the virtual router node, +**10.0.0.2/24**. + +```shell +# these commands are run on the tunnel peer +sudo ip link add gt0 type gretap remote 198.51.100.34 local 198.51.100.76 key 1 +sudo ip addr add 10.0.0.2/24 dev gt0 +sudo ip link set dev gt0 up +``` + +Now the virtual router should be able to ping the Linux machine: + +```shell +# from the CORE router node +ping 10.0.0.2 +``` + +And the Linux machine should be able to ping inside the CORE emulation: + +```shell +# from the tunnel peer +ping 10.0.0.1 +``` + +To debug this configuration, **tcpdump** can be run on the gretap devices, or +on the physical interfaces on the CORE or Linux machines. Make sure that a +firewall is not blocking the GRE traffic. + +### Communicating with the Host Machine + +The host machine that runs the CORE GUI and/or daemon is not necessarily +accessible from a node. Running an X11 application on a node, for example, +requires some channel of communication for the application to connect with +the X server for graphical display. There are several different ways to +connect from the node to the host and vice versa. + +#### Control Network + +The quickest way to connect with the host machine through the primary control +network. + +With a control network, the host can launch an X11 application on a node. +To run an X11 application on the node, the **SSH** service can be enabled on +the node, and SSH with X11 forwarding can be used from the host to the node. + +```shell +# SSH from host to node n5 to run an X11 app +ssh -X 172.16.0.5 xclock +``` + +Note that the **coresendmsg** utility can be used for a node to send +messages to the CORE daemon running on the host (if the **listenaddr = 0.0.0.0** +is set in the **/etc/core/core.conf** file) to interact with the running +emulation. For example, a node may move itself or other nodes, or change +its icon based on some node state. + +#### Other Methods + +There are still other ways to connect a host with a node. The RJ45 Tool +can be used in conjunction with a dummy interface to access a node: + +```shell +sudo modprobe dummy numdummies=1 +``` + +A **dummy0** interface should appear on the host. Use the RJ45 tool assigned +to **dummy0**, and link this to a node in your scenario. After starting the +session, configure an address on the host. + +```shell +sudo ip link show type bridge +# determine bridge name from the above command +# assign an IP address on the same network as the linked node +sudo ip addr add 10.0.1.2/24 dev b.48304.34658 +``` + +In the example shown above, the host will have the address **10.0.1.2** and +the node linked to the RJ45 may have the address **10.0.1.1**. + +## Building Sample Networks + +### Wired Networks + +Wired networks are created using the **Link Tool** to draw a link between two +nodes. This automatically draws a red line representing an Ethernet link and +creates new interfaces on network-layer nodes. + +Double-click on the link to invoke the **link configuration** dialog box. Here +you can change the Bandwidth, Delay, Loss, and Duplicate +rate parameters for that link. You can also modify the color and width of the +link, affecting its display. + +Link-layer nodes are provided for modeling wired networks. These do not create +a separate network stack when instantiated, but are implemented using Linux bridging. +These are the hub, switch, and wireless LAN nodes. The hub copies each packet from +the incoming link to every connected link, while the switch behaves more like an +Ethernet switch and keeps track of the Ethernet address of the connected peer, +forwarding unicast traffic only to the appropriate ports. + +The wireless LAN (WLAN) is covered in the next section. + +### Wireless Networks + +The wireless LAN node allows you to build wireless networks where moving nodes +around affects the connectivity between them. Connection between a pair of nodes is stronger +when the nodes are closer while connection is weaker when the nodes are further away. +The wireless LAN, or WLAN, node appears as a small cloud. The WLAN offers +several levels of wireless emulation fidelity, depending on your modeling needs. + +The WLAN tool can be extended with plug-ins for different levels of wireless +fidelity. The basic on/off range is the default setting available on all +platforms. Other plug-ins offer higher fidelity at the expense of greater +complexity and CPU usage. The availability of certain plug-ins varies depending +on platform. See the table below for a brief overview of wireless model types. + + +|Model|Type|Supported Platform(s)|Fidelity|Description| +|-----|----|---------------------|--------|-----------| +|Basic|on/off|Linux|Low|Ethernet bridging with ebtables| +|EMANE|Plug-in|Linux|High|TAP device connected to EMANE emulator with pluggable MAC and PHY radio types| + +To quickly build a wireless network, you can first place several router nodes +onto the canvas. If you have the +Quagga MDR software installed, it is +recommended that you use the **mdr** node type for reduced routing overhead. Next +choose the **WLAN** from the **Link-layer nodes** submenu. First set the +desired WLAN parameters by double-clicking the cloud icon. Then you can link +all selected right-clicking on the WLAN and choosing **Link to Selected**. + +Linking a router to the WLAN causes a small antenna to appear, but no red link +line is drawn. Routers can have multiple wireless links and both wireless and +wired links (however, you will need to manually configure route +redistribution.) The mdr node type will generate a routing configuration that +enables OSPFv3 with MANET extensions. This is a Boeing-developed extension to +Quagga's OSPFv3 that reduces flooding overhead and optimizes the flooding +procedure for mobile ad-hoc (MANET) networks. + +The default configuration of the WLAN is set to use the basic range model. Having this model +selected causes **core-daemon** to calculate the distance between nodes based +on screen pixels. A numeric range in screen pixels is set for the wireless +network using the **Range** slider. When two wireless nodes are within range of +each other, a green line is drawn between them and they are linked. Two +wireless nodes that are farther than the range pixels apart are not linked. +During Execute mode, users may move wireless nodes around by clicking and +dragging them, and wireless links will be dynamically made or broken. + +The **EMANE Nodes** leverage available EMANE models to use for wireless networking. +See the [EMANE](emane.md) chapter for details on using EMANE. + +### Mobility Scripting + +CORE has a few ways to script mobility. + +| Option | Description | +|---|---| +| ns-2 script | The script specifies either absolute positions or waypoints with a velocity. Locations are given with Cartesian coordinates. | +| CORE API | An external entity can move nodes by sending CORE API Node messages with updated X,Y coordinates; the **coresendmsg** utility allows a shell script to generate these messages. | +| EMANE events | See [EMANE](emane.md) for details on using EMANE scripts to move nodes around. Location information is typically given as latitude, longitude, and altitude. | + +For the first method, you can create a mobility script using a text +editor, or using a tool such as [BonnMotion](http://net.cs.uni-bonn.de/wg/cs/applications/bonnmotion/), and associate the script with one of the wireless +using the WLAN configuration dialog box. Click the *ns-2 mobility script...* +button, and set the *mobility script file* field in the resulting *ns2script* +configuration dialog. + +Here is an example for creating a BonnMotion script for 10 nodes: + +```shell +bm -f sample RandomWaypoint -n 10 -d 60 -x 1000 -y 750 +bm NSFile -f sample +# use the resulting 'sample.ns_movements' file in CORE +``` + +When the Execute mode is started and one of the WLAN nodes has a mobility +script, a mobility script window will appear. This window contains controls for +starting, stopping, and resetting the running time for the mobility script. The +**loop** checkbox causes the script to play continuously. The **resolution** text +box contains the number of milliseconds between each timer event; lower values +cause the mobility to appear smoother but consumes greater CPU time. + +The format of an ns-2 mobility script looks like: + +```shell +# nodes: 3, max time: 35.000000, max x: 600.00, max y: 600.00 +$node_(2) set X_ 144.0 +$node_(2) set Y_ 240.0 +$node_(2) set Z_ 0.00 +$ns_ at 1.00 "$node_(2) setdest 130.0 280.0 15.0" +``` + +The first three lines set an initial position for node 2. The last line in the +above example causes node 2 to move towards the destination **(130, 280)** at +speed **15**. All units are screen coordinates, with speed in units per second. +The total script time is learned after all nodes have reached their waypoints. +Initially, the time slider in the mobility script dialog will not be +accurate. + +Examples mobility scripts (and their associated topology files) can be found +in the **configs/** directory. + +## Alerts + +The alerts button is located in the bottom right-hand corner +of the status bar in the CORE GUI. This will change colors to indicate one or +more problems with the running emulation. Clicking on the alerts button will invoke the +alerts dialog. + +The alerts dialog contains a list of alerts received from +the CORE daemon. An alert has a time, severity level, optional node number, +and source. When the alerts button is red, this indicates one or more fatal +exceptions. An alert with a fatal severity level indicates that one or more +of the basic pieces of emulation could not be created, such as failure to +create a bridge or namespace, or the failure to launch EMANE processes for an +EMANE-based network. + +Clicking on an alert displays details for that +exceptio. The exception source is a text string +to help trace where the exception occurred; "service:UserDefined" for example, +would appear for a failed validation command with the UserDefined service. + +A button is available at the bottom of the dialog for clearing the exception +list. + +## Customizing your Topology's Look + +Several annotation tools are provided for changing the way your topology is +presented. Captions may be added with the Text tool. Ovals and rectangles may +be drawn in the background, helpful for visually grouping nodes together. + +During live demonstrations the marker tool may be helpful for drawing temporary +annotations on the canvas that may be quickly erased. A size and color palette +appears at the bottom of the toolbar when the marker tool is selected. Markings +are only temporary and are not saved in the topology file. + +The basic node icons can be replaced with a custom image of your choice. Icons +appear best when they use the GIF or PNG format with a transparent background. +To change a node's icon, double-click the node to invoke its configuration +dialog and click on the button to the right of the node name that shows the +node's current icon. + +A background image for the canvas may be set using the *Wallpaper...* option +from the *Canvas* menu. The image may be centered, tiled, or scaled to fit the +canvas size. An existing terrain, map, or network diagram could be used as a +background, for example, with CORE nodes drawn on top. diff --git a/docs/static/core-pygui.png b/docs/static/core-pygui.png new file mode 100644 index 0000000000000000000000000000000000000000..6d0fbd40cac02bef07abbabdd9c8aa6106dbef60 GIT binary patch literal 652318 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°??t*p1B~b+1mBnw3m#-N|it-paal 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 z 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ν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 !W+ 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%0Dsy((Pabtcg~UaQ<&Z1(Ckw57naXytHEPOL@$8s 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 literal 0 HcmV?d00001 diff --git a/docs/static/pygui/OVS.gif b/docs/static/pygui/OVS.gif new file mode 100755 index 0000000000000000000000000000000000000000..38fcbb2ea684f2cb3724f55bb668d66dd952ee4d GIT binary patch literal 744 zcmVJ9wZvc%wRbpgMV>J9(izdZ9gfp+0+}KYOA*e5OBqqdk75L4Bk` zeWOBtq(FhHMS!M3gR4h^r$~aQNrI?Dg|0+}u0w{dOogdTg{n`7s!xckMvAgji>+0Q zu1SuzRgA7lkGD#Xw@Q$=SB#%r9qZk@Ytp1W|LyjZ2mSf$H#qQ6_I&0DF?cci~~ zrNDcq!F#8|eW=5Ks>Fe;#fPuQh_T6uvdNFN%aggzn!VDVz|)|@)uF=GrpDN($JnUI z*{jOhtjpW3%-prm-?-G_xzypg)Z)9<;=R}7%*@Qp%*@Qp%*@QpA^8LW3IKlqEC2ui z03ZMs000O6fPaF6goTEOh=dv*jE#jucwYQHYIwy=OOkz4Hj~p>W&4i3Z zU|?EVaBDeOZhEkLdR2@d7~9=~8y-G!bar@qcWq-WUF1-iLJ&x!2p8np`UC026(iCh zJzDS}A-9DYGIZ!5v5|<6A4!l-8|AaEc70|*i*d`R)429F;{oItU%<%@ul z7Q3Zf+45z~nKf_b+}ZP|Np6FNvUCYkCQX|-b@KEHbQ>Y5MXMskx^$~ki(gevHEK0! zR;ET=O%?)6_2^ZlTAP-%V2Cc;v3TMBba2S8*t>7d8bD;&uG_Lr69G_J06~HV5GGUz aLDL40AWED>vBKp`qSL5Tt6ohg5CA)88DCBS literal 0 HcmV?d00001 diff --git a/docs/static/pygui/alert.png b/docs/static/pygui/alert.png new file mode 100644 index 0000000000000000000000000000000000000000..718fa9f1f2794dc3165f2cf71db560247721c52b GIT binary patch literal 2019 zcmV<92ORi`P)!Qp@lUfU%Db%W|ox?Bz|gZ z*$7A@n3z;FF-GY6FJY@OxU>;W{P&Sqq-~ZzOZ;Hshq}xxsKHjFsiN6x)MAUWrGc_2 zyVK)`ohj?ieQ)l&?@V`gdw_kR z|GUf&^2iDv0b1gIgwMZqfsyke2s+3p%KeKj?a)EK7AjAIj3~1WcsxPkm{j;us6m~K z!}(`#A>1kzp3EvUa{zP`S5o9K(3>K4)deBN`3+7XY?KOr%_29`6r=-m)5U($oDZvR zI`VKnz{MyB^ja>+B0JkV;6lEM@*%Ib3qcsoB)D>Er8Jh^_aTw^% zqF^OYM;V>pU>+EdD!=n->@@-ugc{!^tMeB+ugK{r(D?ve!1vVi;r5$?O56q96kyB( zD=Y{(oZp~CnNRpMY#Al}C~J6~qbQf>FsPxe#8wtkFZ}1n2^vfjkNe$9Rz;W)t+WF9N zKT+G{e1NN{aa&r0^Z*E-F*WD^c~=`{XG2>oEc3A=?Pg*CbdamTbJ3L*yi z;WEAeOteY*Cf&sPq?^ejK-o^VK|Tb|>$=5mdY3cmrq z(+1J&xsms|Zny&=e8k)Y;YHrYH_YW)Q)Cx#++1#fo86aPsJn44ls)n@UN@I(j7)MG zWskXFWyqt*0#L-jMP<;uJP&}+GXKDMH41~vj$)NdUF4kJu3!$hm)e@i{L z(8C-24d}}wX9*{%@(zJBi!U~hfD`m%>wJKHxt(8T8@>DyWj}B`&=2&~|N4R3Q4Y|{ zA9Rq9<GC%aCsMQ)4+mz zBLjjfECkL-m7Ty={HDigV2e~4 zS(<&ysQAv{zDX3DeVde${5Nokq3RiCdAA&5HjfY;pqsU9LD-BkfYeix%CY)S%?W0C zI(W`+=sAZe$*!fXWSp9oses{T81&J@r^#9JQFrWk{jYuehaK2DACyVgh)k-AIB2ST zf&26pdfFh@6dht6nkxz9M)HCRA(xbqvB1wr_-Pno>eK7D;*b`ac$3_Jv^2au-Q++$wW^gBC$hluu>-Vj=|AhRPH_0ms)xR;lwF zqy*usGR0Hp+#oIU(AiJ;mfG^UmySeBj4b+e@$zjdDipNL{`1NC4eCMIpYtT9fgv5V zuYayNpa1trBoc{4B9TZW5{X12kw_#Gi9{m){15#$Llp7S@m>G`002ovPDHLkV1gy+ B*;oJo literal 0 HcmV?d00001 diff --git a/docs/static/pygui/antenna.gif b/docs/static/pygui/antenna.gif new file mode 100644 index 0000000000000000000000000000000000000000..55814324be26242f061339e6cd85d0ebfa67c8b1 GIT binary patch literal 230 zcmZ?wbhEHb)Me0RIK;{T1Zin$?H&D_H*bFS?Ad=9Q2fWIk*sNCU|^=;l%JZJm(HO0 zlZBCsft^7Iqz9y%fq7=dt~>t>PI<20Yw>z*%LIA$wu$OaDpThjU$rSV+3545te3L4 z4c2bo87$x;EGEftbE0tw!@SeeF1l5zS^GY_bn3O=?wHJw67KH-e4D?=Sg*L9zEOBr z-}0AbT~_ipKg-wE^ENhdx3uO}bToH$b2Cop7bHOy`C3Coz bFfU_Yv1-jSmJO>mux#46e%q>aP6lfLpq*ob literal 0 HcmV?d00001 diff --git a/docs/static/pygui/cancel.png b/docs/static/pygui/cancel.png new file mode 100644 index 0000000000000000000000000000000000000000..1d95ba0ce40155f397a587da9463123509c9fac7 GIT binary patch literal 1322 zcmV+_1=aeAP)U*#STqfkhaVU`Am#aP!|dqJ4`b$?;aOS z`Z4cK=f0T%7xce8_uO;-|93ymz4yU1rg3jWY7s=cy4=xgzqTo?KqaA~3lQd@G!(LA zQ2r2=i-@L0BvrR`a7el_)#k2BKz7yQdB(^_lox@fh~2NCa?mmK#`_1aRLY?u0VAs) ze~6o2k037tGb>GAz)clC*Sx>uzW(&zQ9DN#km+oD0p(+0cGSLQa8;2_jeUcMqja(s zpozqc%q(}m!20Oi?4fX=;U9mqq|>)-{H+A&Gl}?(toH@z>gwoOLinb>&hL_A>1;Vy zr<{W(5;N{9c>rb2jjZ>TcD0mUUu6YkX1V+CD)}*JXXbkSN9DYxoI;u8;&mVgYey>5_L2hrO}0JYzy;vJJHpH!*NpGaFSW&%oI--`;r}NOFxz##U8NdB1!UWo z%q&;^EL(VMgQWCTuILIK;MWr|!eg@H)${b#Hl+ zx(^Nz+qxTE*D3`t(>LCRu!evD?Z!b_XBD7!)fI3K2pIjbZ3!^fCZ@Bn1uyy31cO6t%MQHul|l0DE62!>*`Dmcncr;1 zA7~oA`b#3fq1d3X!ZxBgcZS^C+rjt8jjl^<>+VS-;Vn-Rd!y)U8$<5B-I#M{Y|~*J z=_s~&0Vh}#<%E|XNCDf4ZR;5)SeQ-#+`1X~@)$$z-CgASzKxIxL?ti+0j`;3+fP7C zq{!T!4wFOxr~w6>2?Jdr`9i=m4SmCpO|*pVLUSsJOnx90oKE7@kUW3{xP=L5s4)7t z2orKsRIQRc0FA{4`)XnfDYb%hinhuTCLmL3u(5sK2?;CZ>x2bHiVa}`{;VYO#3D5Y z)ydPzqS#Fh4R8yq1DB$P4JB_3xp%tB^=zBGu&WRu$i-q?K!5|MqXaEUzOZ0Jcc%(P zs0O(O1c*q=HlWko8YJ)gQX&MI@>-GA(tN&qWb68u+N^D$r8xZ-e@($ptpZBBPi2?5a?$k9! zLRH^QsIp|w59_9Gh=zN{TBYaEaJ^{bjQ?t-Y~q1vB(9ZxhF%qB$F z0rMhuzYKcNQLi_w!rG!XzgfF+Mh6xxGLCeJNJiR gzged-je8yc0S7nSp}o%sl>h($07*qoM6N<$f=vT~fdBvi literal 0 HcmV?d00001 diff --git a/docs/static/pygui/core-icon.png b/docs/static/pygui/core-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..0b0ff5aa3a51f71807691032f516d7a15d7da896 GIT binary patch literal 2931 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 literal 0 HcmV?d00001 diff --git a/docs/static/pygui/delete.png b/docs/static/pygui/delete.png new file mode 100644 index 0000000000000000000000000000000000000000..01b498d4cb54e3e1f71db21b1ef0408faf28dbda GIT binary patch literal 387 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 Nm&*gnpz<`}>|gl)4y^zH literal 0 HcmV?d00001 diff --git a/docs/static/pygui/document-properties.gif b/docs/static/pygui/document-properties.gif new file mode 100644 index 0000000000000000000000000000000000000000..732d8436455ba607ff30774ef83faa434789f775 GIT binary patch literal 635 zcmZ?wbhEHb6krfwI9A3`UtizZ-QL^V)6>`4+Ydxt6Z*URCUj4n)H`Wv|CDJHr_Go& zZN}v3v!=|LJ$2^nX|v}}pFMZR-1#%-Eu6hz(VT@#<}O+?Z}GAP%a$!&x_sgCm5Z0J zTCxI&Rxe$-dg-b)D^{&twiXE1tzNrs?b`LL*REf)X2YuWo7QaFym8yMZ98^v+r4}D z-hI3FAKZQ9*uj%$j-Nbx?EKkN7cQN-eEIUFs~4`{xO(Hp^_#bD+`N6`*6o|O?p(Wh z_u4HGx_egDy&`;YEE0HVit9zMSJ`0=BM zPwqW>djIj$2algUeDdt!vuDqqJb(1!`IDzF9=~}0?D@;5FJHcR`SRtfmoHwweE#Ou zi?^>|zJ2}b^_!P(-oASC_Vv5>Z$Eu}|LN1G&!0Yi`}Xzw_wPTxfBW&{`%fVH2}D1B z{`&dr*Uvvd@b}MOAo%z99|-;X_y6C25W)u(f3h%gG1N2YFaQB4P8is4G}PBC$jQn` zOH0W%^)$&PmQ9?P6RzIdBv~967UJ*YYhIu;UA!+K3@N%$caBBCo?vDZnK?>>} z4H6mGRzwyX@-nz_KC4-2cu1l_BwJ^i<gzK44%5h8Cn(&TO1gfn3+!O Kwon&fum%8seSYfz literal 0 HcmV?d00001 diff --git a/docs/static/pygui/document-save.gif b/docs/static/pygui/document-save.gif new file mode 100644 index 0000000000000000000000000000000000000000..165bcb908bbcf12dfb5d0c06a933898ed8f3944c GIT binary patch literal 1049 zcmZ?wbhEHb6krfw_}@YinU=Z|&e<14NFF4ld44ZmzCg z9-cm4UOwJlzTVz`KE46IegS^|fqwo${sBS$K0$$j!2!Xcfx)3cp)oNjndzz7*?C3z zMP~yN%>eKber00=&?_cmGkk~>bNa@fGjG?NeY^JT=M^U&Y&`d1?~!8%PM$h?>ddk8*N$Dd za{9`Zi`TASzIprlov%mkeLME($C-x@?>~Na|H+SwPk&x|{`1<4pVuBfe(>n=gU62_ zK7R7>$Hy=K} z|M>C!r_Ue1e*OIQ+n2B3zJB}m^~aANzyJLH144iP{{8#^|9^&2K;sZl{K>+|z|h5@ z1F{E{Cm1-sF^F=Ccsy!8$gjkvB_r{uXI8tKnApdQix+hmo2Jb%Ol)TFmv^iC!Pvqf zz}X|^|3g!G#rb}@hBp$Mij5v0GmpRb7dnSnV*-j_uJ)us;Cr9J~LHYW5xwW<>NUDdbt97Hy(V% z$)(wGNaN(?!^}$Z!Q1vIwxqgFGm4t@LgC3m=0&Gx2#Oj_k-W&p?c^l?(@>>DK-r^7 z#8T-<69WeWk4D0U1x##QykZIr7Y;NuvhgYeEGT%$)W&^S(;-8_p_!5K0y9g4-(LnM KMj0k125SJ$J7sYI literal 0 HcmV?d00001 diff --git a/docs/static/pygui/edit-delete.gif b/docs/static/pygui/edit-delete.gif new file mode 100644 index 0000000000000000000000000000000000000000..d23f758c7ddf6f31eb685d8d30930ae6017d500f GIT binary patch literal 1006 zcmZ?wbhEHb6krfwc;3#SqOPv4qo<*(uc>FCZD^!zXsl;qqGw`eXkuh+ZenC%VQOh< zZe?Ll3!6;)m&HC-rC;Y-q_XA*4fqE)7jqL z(bCh|+S}FH+uPj_ME#wUCiG6}pEPyq;;P#E?lx?!SW@GRxDVwV)3f=%U5q$v3C82^&2;=+q`!D*3BEXZP~nI z!{!~Ex9-}yZTIHwyLa#0y?fXG?RyUF-+y5LzJvSsA3S*I=#k^6j+{Ps{Pelgr_Y@| zeg5>B^XJc8IDh`qg^O1%UAlVq($&kCu3frv{p!^l*RI~Ue(mO^Yd0@nyLtWQ?MpXr zU%7Gn`i&~^i_io<3f9L*#8xI~mdi?ak_?%XjbJeE9JC{fBoSKD_<%`Qzs=AHIBf|K-ca|D%9jA)xq^g^`QlKZ6bf5PpV3N^QRbX&vc*H5LFT%M>QaGKnot4Mr zibW8c*9qoImY*Ay)4iuC`K?fR`DxR+c}~qdf=ox$efcB}b=G$6JKk1V5QxKE#`RT&>|v=%>_EGRItEZYX>VOJtMJI25sS(Icmq NtJ&5n@o+F$0|0Y`xn=+W literal 0 HcmV?d00001 diff --git a/docs/static/pygui/edit-node.png b/docs/static/pygui/edit-node.png new file mode 100644 index 0000000000000000000000000000000000000000..28490eff07edae4f5c5a58b1f04ed3cbdb9b6d48 GIT binary patch literal 3050 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 QVE_OC07*qoM6N<$g150mDgXcg literal 0 HcmV?d00001 diff --git a/docs/static/pygui/error.png b/docs/static/pygui/error.png new file mode 100644 index 0000000000000000000000000000000000000000..d73d1dd402887d094863eba351eb62b72dec7d22 GIT binary patch literal 2258 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 literal 0 HcmV?d00001 diff --git a/docs/static/pygui/host.png b/docs/static/pygui/host.png new file mode 100644 index 0000000000000000000000000000000000000000..e6efda088f82d791ba5e3122f707581e05644165 GIT binary patch literal 642 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 MZDEIdh4F~{7sF09RR910 literal 0 HcmV?d00001 diff --git a/docs/static/pygui/lanswitch.png b/docs/static/pygui/lanswitch.png new file mode 100644 index 0000000000000000000000000000000000000000..eb9ba59397d4a6f64e9f0b3e5340fba31b56f5e0 GIT binary patch literal 1138 zcmV-&1daQNP)voKryltj5h*UR{qBdw4dN?6pRmZ z&Uzm0zxTSFgGo0a)t`z*XJQvPa1ubIoDWgKTrl1pFI0DBGueX70i+s`>QBX@jy=E` z-!4-hLQmtfRVkS!NS(P>q52|y`Ty84T{U}Nrcn3_NcZh)j&L>tbElnBTxPi&AMSnl zMx;piON;;~Wa;LNY2?V(Hc`Q#&?U>2seNMYej1e(jZzOGnFits0ple zG6gH**^IhEw7~eeytTdshnq66EnCa@#y0=}k~K+mHXp~Tc%AQKQv>ECVhJ4Ia16?R8+8TCyy*QT?RX&2x&TKpF$#GiiTad6<;_in& zIIiQ#F&9Etz#{xuoT&l-p?Yzq1}uTf#aUYe7-MK}5PozLI$9d==ZxCo<94qcj_csY zyW4pF<%Q?R_I($Wh$V2S>7bUoP8pLkleqmpi?7q)lpNpJ1|(~eIIylm+jAFD`Ki3} zZK!VzXsvHSdqcbOo13%G&I$c}sr!bP6C)TL8-!addhxMJQr-Z@7~0nEs+jqnbB>{p z!%F7+g(z)+ZP_@mu4BE>-e6ZrY|Q(aO?dYEthj8QZi5Er}F51bk6m_KHDW zoc;Roy%b+Z4&vf$Trw_*i?eabILCEx^W7cf#&bG`UyyOhxFRoVzYCp@l5qh*$+$96 zGA=|Y85aPQj0=EJCgUP3f>dLhuHgeiTQcs*`V2O$-lSuAVWCRK)!DVkG#x~pUF-MI zQYaax5hdd!P%>^FO2#dYl5u~bWSmBnjMIpcaT-xFPBY!VNl-FQGfKv3M#(tMC>f_2 zCF3+RJ{hN*@yR&dC>f_2CF3-cX~1_c1`c8}&I41T1>&zR20n?_804d9fy}FmheT@( zB78~9yu7;yfGb*O0M}x?SF}jD0gv|Idkx0BMe9P3@hkT`?!Of+liCHv3)Ni!p2*aO z)bQu=)u&_%r5cdUWDD`xsx*MhFnc<3!T6=m6ZPrb$*Z$62PpgBSnYYRXG8Ho4*;V89$Sp}%1k?i5JCtcgb+dqA;e6-0Yy6CHFJ9NV*mgE07*qoM6N<$ Ef}dIza{vGU literal 0 HcmV?d00001 diff --git a/docs/static/pygui/link.png b/docs/static/pygui/link.png new file mode 100644 index 0000000000000000000000000000000000000000..d6b6745b29e839599b85ea86df290f709977529a GIT binary patch literal 1692 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 Ze*ha8+La8vOAG)2002ovPDHLkV1g1)B>eyY literal 0 HcmV?d00001 diff --git a/docs/static/pygui/markerclear.png b/docs/static/pygui/markerclear.png new file mode 100644 index 0000000000000000000000000000000000000000..6f58c0059fa6447d91b1bbeb9d9153ab22af4e95 GIT binary patch literal 1370 zcmV-g1*Q6lP)Kcxg%+F)2cF(Mz(F z;MuF7f>zU}RC+MPG-*Kui5DSWgj#DNf|tZY^N8;_SrPb>p z+fhS|jipL!x2FN|W|#q34~bcKV0D}>!c!@CRQu)dA0X&V>D#_3)pDg7aDVvZX%ga^ zYNyQz0eN?MWkI1%4l#DDeSOXSRRD)%Ynzgom@BZiVV+P^h*??L9B8FajM4oIaE>C?#_hLby-NMDUe>+GDm_aJj& z8qR@(a1I;<0F*y?52)2*>(lAUAu~P+IE0O<^YCu|7F%C|#-pp_?cw{R`v=kS>e<+4 zg42^jW?~AI&4M~zWX?^($sLKMufSu{(dka%`@|(-d-(Nn7$6~W0lS6o6Sah$!mp1< zvxLM2w7r<$HGH3R{~$VEc`NkQ(Qe`U=#-GSfXdZ*Y~EN7^x5=t&xg7?3KIUq2&I0) zko-Bo^H83<99)9p;YcQYv0lQ))C9bnzf1KI1$wT6VwPY__<+tEv6v*-6h5H)PB>x- zwuKMq763#h!N%|b69fRECD|lBA23M( zpd=xE;#nZw_-mLLK48irXgiXJBb&nqOcVgfOV|aI!v{LnavSKLhw_KB zfoFnu`xYRQeLggC0p<568~!RK(#@2Bhoj1;AEUZ>HGF@PBraep+(-X3D3+b91`>3I z4`@0GKT1NZ+lV_!5CWC?Ic?zs8ZSftFCmdV-0c#CK;@HI=GsjW;`G?4x&MvP=WvX33vmq-$`&v+|!vZwJ0yM({G{XWk zqw>P;{aw<>zjy6?!d}~YH{ct=+aQ%CNAXP^ju27sD_N!!QiPFbu;m c3}X-f0nFy52u5{MYybcN07*qoM6N<$f=T6tMF0Q* literal 0 HcmV?d00001 diff --git a/docs/static/pygui/mdr.png b/docs/static/pygui/mdr.png new file mode 100644 index 0000000000000000000000000000000000000000..b0678ee7f9453539170ac9c51112f31a363536e9 GIT binary patch literal 2786 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 literal 0 HcmV?d00001 diff --git a/docs/static/pygui/observe.gif b/docs/static/pygui/observe.gif new file mode 100644 index 0000000000000000000000000000000000000000..6b66e7305f35484095a2182ed828c6507c72a746 GIT binary patch literal 1149 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 Z{tNmR$e-W{4OjpG002ovPDHLkV1nyAevtqG literal 0 HcmV?d00001 diff --git a/docs/static/pygui/pause.png b/docs/static/pygui/pause.png new file mode 100644 index 0000000000000000000000000000000000000000..9ac4e6eab0939e105f042d7b97722579b98b5a82 GIT binary patch literal 2368 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 gLkAR`nDB?m&P-g{{?ui+8M z{_E`i?Ck#S?f&lW|9_tDA^saEV<{jqFflS9bZBKDLT_bhZf77vZ*6d4Zg~J9`2+z9 z01p5x00000AOIi$00fut$w{ldIO|=y|6nLmg=3y*s-^|GzOdO&Wo)CfRNr_0?7Wx{ z7<>(jwj&azT$&g-m(u|NL}|P#3J?fbWzrac;9XF=-EZ@i;HCisbU;vTDio=PTh2!- z4^;pSCRKfgT}KOh00w)9eM1P2UXPDMla-Z2nVfzkot>MYnWCeXpQfiYsj51xheIN= PvmP6^x45~wyAc38TK{^> literal 0 HcmV?d00001 diff --git a/docs/static/pygui/prouter.png b/docs/static/pygui/prouter.png new file mode 100644 index 0000000000000000000000000000000000000000..b0ccf6641d7dccccc4502cbb2a8c5fcdf08eaa7e GIT binary patch literal 2590 zcmV+(3gPvMP)H!I{Uu%Ie?9gjg5_sjg5_sO}0UqHXs#M z%~kWFin?4;m132+n1Ese<}ok>Kt4wQ0>I}01_}6_fh!F30XU-oz44gqSXUtUpN#zo z4W^U>d|_WMV$Sufs815p8U~iYx{VB`lOR6Kih3w_#B(4R2;LH5uLv^kfcpA+<>=BA zkAZj#1DgTlNiK32Kr4uYIZ=0KFc3^gE-xh+bHM6`I`7Q{dM^ zZ+4Y+jby@rv^&5T_T?g>JOiemFmQjm%TERYpD@#Ia>aii_Jwbzx$N{&-cspfrac5K zN@uwSxCG)C+WoEXq^rQRIKUtBFNy=N0Vo%#yK4RlFcXemIVw^XU}_^*{JF3%d{M0M z-6*mP@P+*TIM4&Zm0y@whz$$YW8=bgD9jT)gb-J7+;P$u@(08UCn-vnaA{|$#}ReE z45l5TwF|s+ux-g!cpY8wBlBL{*i)G4O~;-Ep+~`|)u>5)UVsj#RW( z6pIuU=792W`Gf38ybD11Qv!_6cvjxGQdl~ZEKn1UgJn(C3q=VEaX?jbRRQA80|0~> zb|*%kY$-FNaflxzMeP(dCRlbrX=kY?rl{=z9u{EFU6|?HQ;iiOAmFPmS1erGS?aN> zW!V9asQWiE23)|}>_+CyXpF}kQTNMMMJzd>yruFxV0zZ75y=qo6w90$0RWhGY-+Bm zwjyELGJnXwh?xFhrSMdU>c=I|j6lS1SQ(U^W;4ctJpg8z&96n*h5hKdu>Y?A|Kb}z zPnqwRfB%&%lO|&}De9{L{AMznY5~5G-w(iNChv5FfXecgN>lY=*a5z14~@>PDAo; zIucLnNvO*Ktf;!gY;L0{>KZ-ibnOL=1rURmi zx*USyhYIrwvHg)c%*`nfAt#i4Pd@4vKP~boQ&AJjGR;KVDvHo300}?TGO`{F(ouJs z4p51OIsmxc)W0sKsary3*GM|*PIC|@ph%246Lg8NVc~kr&H1vB!P!*#3)YG1?~}rS zbq4^LpX{GnCsTS^F>e|2y|d8N*MdkQB1BUQufvO)B7e$m(CF_=Iy+-ZT6cG%XB{xp zkR>K4%qzsUC0nJ1k=#jI#~@}-48k`6$nTsndeNe(uLb?X{lc|-K0S`X$Pj9Z{ON23pJ^q~(*iU_L~u27 z4GkadlT!KP^bhyrjgx!v-&r4~9Q4zYt zY;MEE;vA4W;yFOTkTsLC2Lgt2qVB_b(&}wgtY!E}QgJ4J$G`=n`KCJrocnBO*6T*| z8FoO}7rv>8ttft=;L9Y07 z9Cv&hz-+S`sVH5v!Blukvb~}YFZbZxfo>6IC1cQ`s5{MOHXW7{_JuDJ(-sKYs-8&q zz|Y4^8=tY}yc%u2YUF9G00ScQCStT77d3uN z!XI`h8`|z`|5#DfjR2&)as0!}J-XhKt~Z@YE~-pxMZQ&CXmD;@Wm|7d zRUQS?KSk<}qxEFVobfPl+CltyXLU$uoRskB=c05tJgdqMvm0w>Nb41)aCN9XC+1qrzzbmdIGtr{ za0$TkH?GfKoX*MzGWa34(#}$kGwQAb(~Ar|lp!d&`38XN7`@MS1wbE| z&ak5Px>e;^s5&(D;^A#z_PXGV_07*qoM6N<$f*@Dt Ae*gdg literal 0 HcmV?d00001 diff --git a/docs/static/pygui/rectangle.png b/docs/static/pygui/rectangle.png new file mode 100644 index 0000000000000000000000000000000000000000..ca6c8c06a1b6ae83e78bb593209b5820fd314f01 GIT binary patch literal 259 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGoY)RhkE)4%caKYZ?lYt^TJY5_^ zD(1YsxRJ9VK!DY8tKsYoKC?F%^|JDoJ~W;j_i4vmHJ~yOa5LQ>_wDhY!s9=-ef@a- z^nL3c!U}2*J`FP%3>lM{j<9ra3ea2t;u3}f?s{*ZGZ*|h(Xa(1;_2$=vd$@?2>=~K BO+f$v literal 0 HcmV?d00001 diff --git a/docs/static/pygui/rj45.png b/docs/static/pygui/rj45.png new file mode 100644 index 0000000000000000000000000000000000000000..c9d87cfdc52472db6adb4f7d234172e13e7a2b68 GIT binary patch literal 1121 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 literal 0 HcmV?d00001 diff --git a/docs/static/pygui/select.png b/docs/static/pygui/select.png new file mode 100644 index 0000000000000000000000000000000000000000..04e18891bc3a3a1c3cea14a66c03019ecae22d20 GIT binary patch literal 1038 zcmV+p1o8WcP)7Hg;m8Hd-p$ z*|`*og{YNa;{p~oB9=)ZLQo+DB!!BIB!Xsh!9 z0sa6-WMsxmIR+SD3^*yGG*ZenAX}Ft#KuUa1{mNPFwMW@xTwYe1Ka}YeT$BXY7H>J z17N0a;jvH)0}Rjx%=N523TkD50iFR1{mKuYS{h)0E?}A06$DPL4KTo4V71Rhgp3OV z4DbQi=y4sv;>rL+lBDkNx0En(X@EhK)RMS1z@SNLQG6I+&?L1Wz6>yElA0Hv24qW< z)Cou!V9+FW94P|~a2rihMGY`$k}7V1A>~P`^zgHl+9Xwa@DnSQNvibFb1QX8s`S9~ zRjQIy>0uYD)Fi3WgDzI7NK&PTT&ki^Ql$rMQ&A_W(!;f_zj*j0NQ6^HV4s!p23Y5E z2WmiZMu7$#YM$<@aU?t^tOLdh>2ten^#Yq6&0w4~XX8JC0g^!vWU4Py-6Vj8EG1AH*QbtAV2!ucR7s2%;X2@iw1}yUj|=E9u*lbfsv)#b zz%Ss5#ddRm91_wPJ!i2qphHMs^fZ!u9cg32ZW)R3a~C@U9!Uv|oo(0| zFa~T8(ilDM*qLxsN?`09#Lj>bV5yMCsHrE3vTaN_FQhSg&R}Q2Ao{(r6BYwMurp!5 zkVYW9$5Lz=1nyaGyUL}IQp9Qiz;(;*`kkssIjs~w{RHy_U8rm#d8Vk40jK4prH5wV zHItA0De+TgX?bX=4%i8-CAmO9#=}2fD2k#eilQirq9{|4fA_FisMx}b!vFvP07*qo IM6N<$f(2yHl>h($ literal 0 HcmV?d00001 diff --git a/docs/static/pygui/shutdown.png b/docs/static/pygui/shutdown.png new file mode 100644 index 0000000000000000000000000000000000000000..532f2cb9762971efd01089debdf87bad56ec812b GIT binary patch literal 1546 zcmV+l2KD)gP)cYoO6L$%;NC|&4d$gR|#-+IQTH>YX*2ndh}d^a3++cM(K1c z>7=SSDR4OK|6j=i0AuIFr0*M!u5(;pJ|~6LwDC?p^zJ9R_QTh_&OEM6JP3UN;EY#=ianoOCRhCU7!|u~VPxWMb7?C<_)zZ8U|pn>d-2fK4(7V*OLPw%XkZ z_2xAfbgiNGbVI#yRUe~hgH)fGoJW9w)iMX7ph(xAxZL4_radMu>3tYuJPHP{G0LoV zp~W%>l%qdEYdzPU_&GxB>DI@XZ+A*36E~}~&>X4x-1)jT4-ZO1xjp;v@PKU$1G0?* z$J3}GoHun>FjDXq!zzJJ5>SAPG6(9FpLA_eP-H4> zULaPifmo%tS356B^_k7w%yodMMdm>3y9f1#zHkM0*MfpU7jzAAvO)+v_mWPgc6UO% z|EJuB*u>40K-Si_b^v!|PR7nO13a};C<_)LMvX=5K$L-*@ zj+jws9)SjEU^*f5qV@iU@e8dwnOLzJne}f#tS}Wy6U53wWFC1P!mxP9&!2;K_qL5C zpRjt0w7iWZ%SxAMf^R~0ClCUeb+1~^fAz*yOkD1O*3%6hN);)ZmkVWi0er;~)0|J) zT*F_Vz6YqPjQyy>Vr8)9!d+Jh06@Gl@)0o(%K9l7L`Ll^X)R(PMSk>nlkJI?2uDNT zw;!?WlNk*?vpH8xs9mtR7efdHs%qfhQttg~$r{GbpTpSclYnOXNZqfgYN71i)HZ6o zqI55TzjWfFEXqe<=Wd9##ZIaKQoGIAnP!JMp8_-XWVf`RlJz@&ws=M47y&Q4A*SRl zgjlx`V)Z&u&V10^)X9>E2Ec;@sr$pPKkIK|HXv|g=xj&5y^aGo8Wo9wTg?RQaus#v z@Lfm^?xNP#2|FeCkGmt{6c{^ahMOb44F%Pj8F}0V06HB@h90Q9m~bq8ZVs`)jUhGo ztQ+zveUFG&l++ODDC~Bp>40&cfx~tS)(RdkG(h&*j*}WtNT4PFqh9STFiM1@s$Ud( zB@d+glW+Uxd<|MXi1z_l>e2iTz{eH#HCk0zx0spVtPM~`e%Q20Q)oLeg;TEwl#i+V wbpk*y0oR#vk%(J@+d5)1@tbuPvv}O`HyS6|ll}eq2K zv;e?t045)^asX%oaUGay1-QI}f({(FWwa@;+J6k;fcWx4tL(s95Zk~=0ANXV{j_j~2$Fxsj9h?Xb&_!c%*70BuTIHn(nEme4u~zyj|0TB z0N)Sas};3y0Qitt{IxPAvr5Z?HFH2*S;5^b)6*bM*Rot+VbB)<{#2cuwNH!kR5wIy z**kN=rfmT3QM)XK*<+IV@uR6Zr`0S(H3vi&6$J-R>-sG*Z2}OYRyiuu0p_g{!oGRG z?a@v(3Q)xX@ulx3$q+jL+^R_bXtt?WyXnc`N?e}W`Mc_lJa!~t<-1-CKt zCjiY-BEJ#98HX%ZA4|=w4>ThYXa?4@!gyvJ7(>PffG8p4L)Maly8_K9AP2VzZ#%_0tgO7z$m~jWUQ%3&Hl`voBeXYl9G25 zgb<|wCi-*#=!37ZOsW1fQ+;;8lJfj{!a)ZCO!xKPQO-poEUUC-|JRqBd~$&GK+!ak zx+=!l3)dIUnB}hcBdHrMd3%HRFJWdPhxFFC@rNX;O9bVC+3N?}>z;7j@&(TT__bfM z#t;O|o6&f@?fblc{Kw1dhbOMIFpA}m)nGB95mU1JHJdsPm3YPhS0tb&}ADYY26n46)JE-`qMNzP!*1V3lf$1`uKPJf3GJfk$TD zj$MiOA$9tQ?E|KFhvN*!tpj8So`bxEYkOQor z7S3=6O&OA9Q)Z%gX*wR8vlu}l;Q1uYV8Vk5Wkoj)Ods?gkeS!PI1y`Z2{D=QtEfeI zKOqfEC*P>I9B(m6EM*T&9Q+6|)@dR~0cKAK$LmXy@xsEpFgeJgx13=Szout4a0O>bHVdtG|kahC{2r|^Gs~-$3i7hFJ>6_Ni0kXsoYb!}PBFw>fYF;dM z#I3^Z;gNdF@dyj_vvL0_h$uxcqHDATZP}vi^Bwy@x~7cNbY;yl*I_Yr$gIDOIM}3w}nTg`WG(0wEF@g=ycOaQox{iC*5HHe0j8<5Lv+uAp z4T+Nn^@lWtfoRupj{}IsZTiU3mRX_UczuZtTcVQ?8LZPZ2cgH-0_dK0WblX`>GFrv z>9epiFUBC`QfSLO7<~9a4Z@v$hrGB|h>p;-?_i1uYqArs)5nE??5Dn15O7J@!;vGG0N27?!LooF)J|5 z5~^A`?6i4z6$CO&FU2S%O__l=ttqewbzY4mQ{P_zjsF7NFe@}1cSfqyXCOiZpuiHP z(T-Zb02i)SP^+q?j8Ix6JHEW~gDQD5qXlqPL107ep<@KjU%!SYYYw2L%dSfPEV>_h zGXOACtAQtyG{wP;;NS@lyR!O(OA_MXv*h zsEK*(I-sS?j%}xpVb{5nnmvYO#u>orfaZ>fhKP`62gcZVkj#va&o|+flT~Q$(sX}6 zsii&QG~j#*J9ihzTsB61|UsRRgkKY{9+V}vY(Y2#vZ+Lq5?t-^yt3-QRB|F|| z{2K3{t%s~zwM%ubqh1Fv)dILz4>?*(zP^<4m4wNVrp7wsL`cIp^* z|Inzn6z?EWagJ(;9&{Zh~?{zHE5UA8?jUv$wa2Yfr*1=#Hv?iodfWp zCX)Q%MC)ZdTJWH{qDK8WMMKC=C1Fe*7k?eR=_aOwoVUlUH zyFa*{6L!9gKT%auAKHd^l4WLm`28t7@a1k4epfdVjSmoZd)gM?<6aRg;{_7h?08<= zauMgRU&D=-3H{RR+OA-8eFbW+UerQ{YRDd*eqgxWQc4SU14vg~ksIcQO~R&yiC7pO zi3`^|u;bekC_dW&$4GqQ`#_+NDwDGw@cj27uYtfae01HD;ivze>qcZre%#-CxHj zdv6yTcqbKuu&2tFbsvblYQA|NoJ+(e+5StQJDz16x16@v&AHwhAMi0c`bg@AOCZNj z8BpZ&9ixmN1srLI?#a09%S}Fqr&rn5p9Ikw0OJwWy|e;+Z$eyA!rr_!IB{a&C^k(&J(8219*aqeT3b`b8bw&j%gn?bf(G@69(0{x^dSw>OMRoCY~oSJ~E|3^XG})`%TT&8#=c z_5=WXl*rG}5dLMcn-i2Ues^|C!c1}d3m#{ttr&e@SCYW|V%6sjFJRLq#YPNOnR2w2 z6x<~Q-X2BU-q#UmLuGQ-VWl%xWtnPqYW5*Sv@RmzCjpG)1%Mr3e!iu1(j96TAJAaZ zJ*GTwj#&zN4a95IE=x%W^pVW+6Ka2JUbCo&`0_%lEMp^>*J@d=AC!RPKLpym^cKwx z5nog3F0S{AI^len~G#}rAJA7+SL0w?Jt;{+4unGv+Z zUC@*k7@{hOEh&g$L2Cgd6L=}qTO`s2;4lNF!X$q%Fi=(x4B>#j#C(z$W)3zlV_*d_ z76Y^Z;3y3Dy_>;E0G$E2jtRBIykF{a9PGO|!U!170q%${Dhdvcw9f^ZXA3wcGGQ`< zCJN`*TxFn*z-`31BE!^1W}LZxUYt6fkug46JI>JjAI+NGd=%fRB>(^b07*qoM6N<$ Ef+`}3D*ylh literal 0 HcmV?d00001 diff --git a/docs/static/pygui/stop.png b/docs/static/pygui/stop.png new file mode 100644 index 0000000000000000000000000000000000000000..1e87c929740d1a896decf7f4eca06e55ad65c745 GIT binary patch literal 2305 zcmV+c3I6tpP)^Hgx(Sl}$FqzCbBOD$t>7#Uddk*+i#Pr6@M< zo9ub~klnD^Y&QFH?!8Oy{e9ZKFVFeU`R}>+?mg!`2e2^}GFw18bZ*}5bQaw#XOLS; z%o9X+Fi=8_c>utK0Rs9Y^Z$gv0D(gz!wWjgUp+Kpvh*3rI|HtGc9j&7T*u7o3Ai7? zau8eqvK<~TXDCtbg5?9#Ik-3<7kwzjZ1Jm)C}?FUe#S2;Q}#6&OPCJvOI z_6_S%fG!5~cUy*uDPr#iM$0~nxQkM~I?`W1izm9g`HF(!J> z{h<3<+P`0_3aZS2kE*`?xv(f`0%(h>n0d-c$pt&hg1`NjieO3%=&owGj|9ICV6lpL zw*jGu;7v;c{;RnL61fKG_0+8<;xn*L)AY}W^BN<%7$`H&L=EsU1_`TN+u^|WI$)_6AKH%@*=Et!_9<{ zL-6W*1O6N7o{;uUm;rKx)y~X<6Gbv4RAR7 z_5xU@M3%K!O7r_5!F(wlC==DCF^0F!*t03II62P_8nZt~kG_{F!5G&$jwi z$#x{a2M9a^)&0dH=dK6i&_OFRo=#d+;)Y(&u6v?`rfk$w)4iqP?!=8#3}6x2iToL{ zHk;7MA-|Kjaf$(wvC|km7KErSanmFNLLP5LbV#_3K0Mgv@jej0VR8!~+HR;E3qlEX zoOlWPnt@s@2{AUtf1fbmif2~|fTYi=HVPp;^zYi*iN5p+1BwuN4E8H$BPbXcnYT9f z<<=#Lc)cldtO#L4?Bgv1m{yvh#flK~irDA47J&OqQDa5o>RJHf-HxzFI~Yl6w9dy3 za2DMxhyD7&NVI~Yn`J=M02vW3bM;sdCN~f@fCc88sl|#As04@_Ab=8cZ(Y;t4#5(M;_EyQX_qECU?B}hw9Ps_O{@7`SAP9P2^Ee~WzFZ}xd1lPBf+O*pC=5M zTR3nAz^EC5EJk?F5YEQFOc)T&D0|TqNfv^E3wOWKJP`Yy_%iO4!FsI7Tk&5e8Nfs@ z8>_{7I54@6CmB%M>iY|TzZ$B@TKsuYn{U$7=G6WGrlW?*u^0?ACvKZ!fPi=r_G<|} z7{e&Nl(=z<0gHo;9|34JM3R*t1WJGJ`#5oP(k@9R`Z=I*k}v>BWw}oEN4-`?L>x%A zBdGz=oAK)^6^#77Q)M_^8M&NuI?zcl@XB;)@uy#@Lh!SYq%7n4;Q9A({rQ7%F3<0D z(jy($l+Ka|07CL#|Mg zTEddx1rS!E%o>ami-vR-l1po7Vp*&IT`)b9yDn?Mz#ezokw2z$Y}zSl7@*#54FP7h zGpVHJxT~$HiqiZeoA%&b0$3yYT>znMmCbq>U0R{Tn{MR;8IF#=H{idaapW5Slq}yd zt6*H0(6%gUIsBhYPRn?B`l8^`4+YCg*yBm#=_7&7Wx>XbpBA&4AFw#se36*8!+tUX zfG~l-q#C*M;y48}zaaeCbhZ`)Z zvb5cIK9?X$G9zBA@h;OOy#}CEMZCO+(WN1>X;JX#hq;DQGDmDlz<<>tcnyH#D&plc z476)b#~Kxt59qg);R~33t1IRAQikz_YP^p?k`J>c?LBuexW2Tl`GV@mx=d4b2mI$g z`Mh%_G5-L-?K}Z+h>3nW8ouL8dQ?7Oz=F=9s=88;I0#CfygFYYqPB?8zOVOM^M)-g z9`blAAZRZbJB%zh3r@16ALsgbnJ$J`M9AZ<0MR}GHOQYFMG+8=Nz$Gx3pBoOWWlEW z68~A_{cMD3J2Sih9yLkEWdi%g#qz={CM5Gp(1UaAi17F_XAiCz>QR6n36Mw@xF@=0$-Bg({qM!HhywM zK9F|?B;$kH+QQ;+@lvLUi=-%q;5%7zF%cC505kWKpngdDFALcZ;?RI&_}Yq=7W-|j bO=|uR`17L!0e5Q$00000NkvXXu0mjf09{27 literal 0 HcmV?d00001 diff --git a/docs/static/pygui/text.png b/docs/static/pygui/text.png new file mode 100644 index 0000000000000000000000000000000000000000..14a85dc02261ccdfc217bad1a38ff926fffcab17 GIT binary patch literal 314 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGoY)RhkE)4%caKYZ?lYt`dJzX3_ zD(1Ys>&VySAi&_L^6&rYs;6m6hs}Ol*YgHT1G9@vg$QUwG vUEqf7{zl94q^+)4-KyO%F9_N*uF6a@fbW^{an^LB{Ts5sLo;l literal 0 HcmV?d00001 diff --git a/docs/static/pygui/tunnel.png b/docs/static/pygui/tunnel.png new file mode 100644 index 0000000000000000000000000000000000000000..2871b74f01e8f4080a3a88e30cabaed82a182b3a GIT binary patch literal 2256 zcmV;>2ru`EP)J3k%D_a<^qW!7*UKY22>d7`&}r zz{%E%)VMd*Ra-YoA9_(=s=g!-ReDJ+KQxL`rHYgkD!Hyz>Nu^esx3|$2u>ty?BY^` zi!m3oP;=YeGkw4qa53zeGv}Peo!=9z_WOUc^FNn4-^>|6AP@)y0)apv5C{YUfk5#8 z0x`M)>k*AcO#{<&)dZ$0BGauPWD6)QgG3#m!~?)__p5h6=xxa44hXqMAos~cx`L#9 zu{hiIL0el}(#YSj9%*MledKT#G-{Wp;!gwgD1dzcB#lxf0rDph`E~o;RQvh!=jSxalQM=H5DrJ3v$pv!1LQjZ z3iQlPK}RJbos1_eKk0g>Ev9Ggl^9|`P0cZjDfi~{gp%(8@EXyE6-)r+g~XWu<*u$@ z#Pv?Dy8(4g&3h%_Bn2MPJNE|k5#acRv%h^u&!oB;5DtekX07g*K=3u5c^}`ofbV|k zKQKwai?2HI*B+}HIZ6pm_{hSyJ32b%d9`spbTOdeKuZaw^i2TuytU8#d;&Qh82~UJ zCph-QZ1fG7RH~A03G$K7v#0-|zJ=?f)o9}$4Nc8YQc5pEgU$zlq`dsM%|h|Qb5pRG z)vD5J8RY%C#zXtmDs6Ku*5pSoMF9_sJw z=Aw8Xn%o2G8e3Wjq3zJn;FS;p>~EMyD3Ci+fs`fIM^I?5SV#7Uph1UE`sB zAkyy%VB@WAFbj~$p?A-ndYh*xR|D$zAFPCAdLJ5I^9?3ul1Rt>&et7JCN8D?fs}nEZYfUSXKy$*h)Ri?accb$faiFKaXAC(nwt0CeZox}N(++rIv)U@iOiyL51)ry zLUDXgr1cX#v{V}q4u{PIIDs@NZ7*^4y7_-bLtj5~hnGB|vV5YZ=9uaswrT@rGF;yR zP^DIfH}M4U{g+*sk1rn=obCev017-}@{Jrfs!k3J^e(V!sJaQy*%rP5z^6t-YdSAl(ek`zQx3ja zTl}j4USbtsJ}JZDaK^07eGNc>RpTMyrsT4gb+)z5vIH~V17>XQ&q8!Q0OTj97Ft<@ znGGPov#hZXA5UK|z0CMPgFcn!Th@8MF>BeC)|XisPQx{~0v4evU0Ep^8f~iVdI?Ta?D=&5m6SOr5{P z-u=zk{usj|4x+qol#hZ4wknR%5tGS`ro-wVQ6f)$9wxK0VkeQ6_YKQ1w1go>H@5Gp zhc_orwT>spk8M@E6~$13Ql)uV3~)2V=!VixTUTpmt1`BBGZ-n+($*I0H5BwJjW}`6u&(EHm2{Sc2v7_{204&9q|SJ z+ed5~0|;3>5_5wg5J}mIyLr=q;6uqh0%WjYWjt%Tj7 zd@d-I?}y*Fe3_yY=hwa*_NtIT@d{2(Sq zhCyX{<1eR3+8H285^{ne_yR$weev@Cqkr(`D2B%-nDxvSE8MNT>N3 zz*0QhEbu7OX=Z?$;u#B)MDT^?Mr36!?;G;&hnH~SqsyA*OvY5W+7Ggiur0`R(* zm*&ed#Rr$TZEUz9YKnBK4d7WkV`a?=ml=|70*~$Yr>_AL&=#HU!c&i+ya3>AP@)y0)apv e5C{Z<^yj~|p)*WPePx#1ZP1_K>z@;j|==^1poj532;bRa{vGi!vFvd!vV){sAK>D2~$Z#K~#8N?VV@L zHANN2Uq$5=lp;k0lrBU>6qFF`5Q4}D5C~NQexRsClmJFPKs1U(utW(h6#Za8g#gka zfC3310wRW{prH#$S1Bs~e>=PToSi#+@7>+mTb|4>`DbUJ_qLt0JLQ}+^Dapw5{c=t zOnyu>0ask(kG-hNQp?iLpomiw`41Qm~NG} zl@cAMiRq{AGTUu5cN_t$vd{sJ88t5ZexO4lwRON-uug*&qZjyGEp32SwBm)ex zzw}dAEO}iOKUQ4@41p0oEzwsDkPJ{|Yw0&q*>u$1@xurh0fTGJxYf(uZ8J z``tf;UGif|GJxZ=QZN0XXH34QLNb73{ruQBd{4#D$p8*~J(Gap?JBP5NCt4cEkE|z z{ujJGKV~EYIR2g=>s$&kc_Ke%Bm+1e&yRhBa}UpINiu)~e@!oVPt?u%u_PJ5fs5cp zSIjEyKY1}W7v;y2WB>=A%JW?@<4yUo6W-m=cl_EF7f1$J;G5E`uGmXw{1a)QuSo`2 z;BV3?uIP{v{+kyY;~DTD(sy04N^(b6VWM=sv{iPN(`0U-iho{*72R0|{Ru9J;3Jiu zfGV`p$GYMI<2Fb6>ulx2;ZpSWY_UQX#9yRPkiDs1mI!Yq#^lY?wUVtwo(P%{`?qmK zYRZSDL<3He{E9q4dap!^3G)R1->v>9{Z86j8oO^zov`B|=`>d?`JqJnU9g-)T$c`d zU1?>BEIEdjG+UQRk4lM#5GJIlv3D~mh*2hEiQ%jx;R&^sf1kwNo#+`doPRIb%6~!H zR0^@V^d0F6$-ajU_b90nXqg1-N!v;LNnez9lX#rDuvv$`?^lwoNM^k|Ng@1759t{R zBlQZ|1II~sOQ9cu{v};5F&dcF^#{jD{_p;}REPiCI$?xfo&;uhBo;ME(?;aY*WFdq z;&T#1M*BN|E)5$I@)|}u2TMf&pNi((eh%VqQQ1VgU-G|bTye1s=~zuNZ8nqq@jPw# zsAltIsqW4`SF)eSYokG%9?X${dhHEs6?(mdzkQlRyH%wJCHtEmlUQoh1v+5+ne-Q9 zGt3#sWa9#9rR*$66`W}pHLuxKI>Qww4sHfIT?P&XjQtrnJd&NYhkCM*;5#;T2H*!@ zD6N*AIbI%B@jijqC#`yb6b|C~! z$S$e`7)h7)YUxfoIHzr9Zl2FOaE#5$uGEYzAT)P2mY@qOqGF2pNF%it_I1 z=_w!whp~B|kYx2OS1kF8gjaTEz+xfr6xvMTn9jJTC6b*c;f&)3qZ75+wIt7OArTvx zI&}sha6*<>xq4jSj$WFV$0qOu?J3b7Pm0y0J0!al6`y^P4>VOmO%RX|N$m8=?98#W zX>$~K{h87vD<8N9CQf$Q(U#H=TyeV8Jpl#VNS8;@(@ZA53w_FtW13K8IGXMkaU8~p*np_zx7B};6``LP!;O5_hU8kY48 zfq8sg_BZ0&pNC|!3^EA5EfM#$7h%Olrrc>b!0P&#X+pEgVm$ksPnV{`s6JpAM4ZSn zz$omsfD3~O5F-x!JhRLZ(h06u@_C5_t;7W2#v|p%J_RpH?~@V}f{~q{sP!j_g&2tm zK{(N0oH5a6s7oIJACp*qU(6G_e%>@VOd@g1!XBUg=BU^4ReYg&h5T3U zVtXyjVR$kKQrW+cBYaJj)%G#q@e6&K)jY$^)pVjcObo?Y<{Ay(0OZx%b2RUKN%F6$ z5R*6jVx*{$(}ar#w>I(SA4|0Vq4yDkhyn({%SIDKIrK!#JGdlB9`>(`K4Mgc0^v?7 zn>dGkFJ9PQ!kUZG0Q@&3_Ijwirdt|sEV0zpehx;hTVd0h#by9z!MMup^|akj8utvV zZ|rAb+^8%z12ELI*J8IYYr`ic`+0QA?~SlE^L#2(nt zqa+!?fhW|$Hasz4C8b?SUqs+ak^vk=zEA;KfiX50Aq~@VX?~2tVw(y3yJvOvVlv{P zxv`%~oh51@l4UCkuO^PQHV|cFJ+b{fR@TpB$aZ4T923d1m4;Wd>2@|$GJDI}&p~XP zkQf0oRex77yqY-H#(`$Xzw^8P)z^8s|a ztE!iDy3*C5f8YAPSMT0_x9YuD@7?zb2ofYn5WhkGHlV3kEEZw!e~aU7+!zZlTlg=qR*lbE6Ngt*t|K%@v$GUyjnUA8?|i z6jrOvm0Ahl7h;XVU-I(uTxQf7zdB&tICGp;#3uwEsg^DX0@Bm&!kE#cke)ga;o;8u zUPrE7yN&|~595okcHzLmZ@|pXvKE9Pbo!Pp?^SvE7n+UV9AL_x-c1yQ{{ZMCPa`6X z9c{v_+-ZpK+ND`~&X-@n`x`z+{*Ika96?7V!uTDV^U9m0$V>d{(BcjlHf2iN2q|LQznhaBMassBqT%=-3<+mcxCN+Z2o+^TJmA5kxT`7d9|7-cY?R$Yp?0mv)^k3 zjI);!5f(0(hxv17!eG$OB!j_#jPz7QwQY+--+U`i?kY5J!l}~ZA8BTQnoTXKY|Mn5 zhd_A7UPeS%{MbTFoix71>_|>ZL~Lvf4jeq}m_XFGM^c}&r%H+x-qDH{bAV}rxwAm{ z6hOGWeE!^7m_Bu4OPTQ-y%G}u07t$rk|&Mq**)ovva;g3maxO=xZBr=g7hMQ7<*Yp zdMak+PHQO>6wIAv#-P*za&fFA8kV<|4K30MkDWXtS(4}&>>M7|wk=-&=W0YpN410r zPN}M{!Gn)1LT#TTs+%!&pk0}`F)Az4rrk3_d+&* zpF3l!ro+vqA_xL8UUpBeYcO6_Jrfq^I2_lk#x0*&M2+UQHd)D7F~{z@fHN2lUrc zWwS^fc&A(xSy9iLu1{#&1WC%7&b}$hnyPFL{Ze`>WCm9L2VbIbAHWW^EsCPrXhS!| zckKdEbQ~NoAxTS}T6e+@wcW8pdtHrw&?)Gj?y|hIUMG?FA?DyudetjT{ z&F<3!0;|=E@4i2VZQFNXXW>3)k#~o$8X7Y9Y})jTtFl}=z+$log$Ivq1$dl1tzSxS zJo@l_^i1$|5OZKrdh!(h@#-5mR#dE#N?V5yN}23x(}>GHKs0t(3gU%w`Q!;>@$}Ng zXx}c*3zJ(H?c!pQl{pGEH8m(Xc}kv~c((j<-Ko+dr;*rB9dLK{y$K{p8z391&Y78m zg$w3E5LzstLiY%QfQ*d%_=*)^(tK-R<=Jpd|!-4hZU6OHw2Uxu-%o{EZa0OBt1>BQWV5Fq9|f; zda8qr!2Pqc%^l=<6%LR>g#M8A4H>e+g19GRkfP4W2K18)6%Jsg1i37}tA5vU^-8yR zeTfU=f{MyYg^XEJOw1{t073XsF8jGcSy&L)R8}fyq6MN03YlCW!m60o zJ1U>w2;!C_Ds{$8in>)eASyyUiW`g1zp;1!L0{T-OR#s}*K$#HY$OyL(i9H3d3M4M zd)e_5r8sl;yiW~m&73}S7AH!}$MO|x6mnu=nX^NpoG!==OYi$S0S-M$ZCxFnw!9E@f(mEPmE&p4^Qf=C zDo@%sEIs8dXVHQg{|XVO zx!P~+HY{cI1?k1E5D!uWy}j8L_Y|e{0^GxUrAGW4sRQ zH#=6T}y*&)pC;KYQX0xI4x|AZ=C<#Penb8K| zR@PV0FGQ#X^N-9_92=MTMNSsE{>1%Ry@bHpqYRZeAxyC>A6X zFWJRw{cVr81myPKMTMLsNN`x-CPLE65me$Yo4h3;p8#Sgj#QTbO;0t^ihy)LYa-|X zKNFA+kVx5gsmTI?fHL}zdpN-+VKD<{FgiLU5`F?>0|={DjUdH-QN0on4lr+EL$@wi z2F4OV8DT7=fq@m}7xCx3T+;nyneH91crQng6`!!|qYoaiE!u`@mQ1>)iD5oM-+cfx zkrivbsC)nzUt@soU(j^@1T5Lf_cGxFxjZf$$mk42-dy*uZ2%5j0xDJRbt2@h96@Cyoou6~$Z`?ismSY@RU4a(}=+8j@>XG!KtNcrL zvsq%yA;8#7!xC8bLC>YBKqoh)UaSXRJggA^06-Bht|3-1cGg8=#j9!5=qjVz6P9W! z@hkyL0Dyq3CA<0J$`Pa*>$qem4`s%O2@@ip#7cj%^hZ5X6fC%{x7s!4) zYQ!FB^#23k%vS|OD0G*{Z4-3vBA)Hm2{j;?h_QL)D97D1_N+G1z9)8ZE&+1@H0z*f zc^^>U2wbXn-wa)-1Fk9~bXvR*AVNWu6)W6j)a`xshe94MN!V>K>k|hoNO5j716X$y zI4;{EN{nGEN0BZ*;4DYC!_c!oVjqB$_OhZ%Ag}bsm|u=n0ydN?D<42f)JW(yo>jL` z001oB$+;kWD3`~D1L>WB_?rjIm)8OZE&^58-;C5N^88AL&39D9T(rG`K=M@3v0DGR;#a)2k j!c#7+9;rNuBoO!?7n||9IY2L<00000NkvXXu0mjfsuors literal 0 HcmV?d00001 From 3fac6bc7ece7cc30bde17d97d33d519ef35ad555 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 11 May 2020 12:27:47 -0700 Subject: [PATCH 044/929] docs: fixed link to pygui from index.md --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 4e215916..e9c690d8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,7 +23,7 @@ networking scenarios, security studies, and increasing the size of physical test |[Architecture](architecture.md)|Overview of the architecture| |[Installation](install.md)|How to install CORE and its requirements| |[GUI](gui.md)|How to use the GUI| -|[(BETA) Python GUI](gui.md)|How to use the BETA python based GUI| +|[(BETA) Python GUI](pygui.md)|How to use the BETA python based GUI| |[Distributed](distributed.md)|Details for running CORE across multiple servers| |[Python Scripting](scripting.md)|How to write python scripts for creating a CORE session| |[gRPC API](grpc.md)|How to enable and use the gRPC API| From 124d655dc67dc72ffec1a19217aca3cf92a16922 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 11 May 2020 12:41:57 -0700 Subject: [PATCH 045/929] fixed issue when sorting hook when saving to xml, due to enum refactoring, updated test case to hit this potential issue in the future --- daemon/core/xml/corexml.py | 2 +- daemon/tests/test_xml.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 891db1cd..deedd139 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -312,7 +312,7 @@ class CoreXmlWriter: def write_session_hooks(self) -> None: # hook scripts hooks = etree.Element("session_hooks") - for state in sorted(self.session._hooks.keys()): + for state in sorted(self.session._hooks, key=lambda x: x.value): for file_name, data in self.session._hooks[state]: hook = etree.SubElement(hooks, "hook") add_attribute(hook, "name", file_name) diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index 783e2722..04f1192d 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -17,12 +17,17 @@ class TestXml: :param session: session for test :param tmpdir: tmpdir to create data in """ - # create hook + # create hooks file_name = "runtime_hook.sh" data = "#!/bin/sh\necho hello" state = EventTypes.RUNTIME_STATE session.add_hook(state, file_name, None, data) + file_name = "instantiation_hook.sh" + data = "#!/bin/sh\necho hello" + state = EventTypes.INSTANTIATION_STATE + session.add_hook(state, file_name, None, data) + # save xml xml_file = tmpdir.join("session.xml") file_path = xml_file.strpath From 12ed9c84224f2008b4ff212ccfddba37668ec996 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 11 May 2020 13:00:29 -0700 Subject: [PATCH 046/929] updated changelog for next release --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1535887b..f4557a3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## 2020-05-11 CORE 6.4.0 +* Enhancements + * updates to core-route-monitor, allow specific session, configurable settings, and properly + listen on all interfaces + * install.sh now has a "-r" option to help with reinstalling from current branch and installing + current python dependencies + * \#202 - enable OSPFv2 fast convergence + * \#178 - added comments to OVS service +* Python GUI Enhancements + * added initial documentation to help support usage + * supports drawing multiple links for wireless connections + * supports differentiating wireless networks with different colored links + * implemented unlink in node context menu to delete links to other nodes + * implemented node run tool dialog + * implemented find node dialog + * implemented address configuration dialog + * implemented mac configuration dialog + * updated link address creation to more closely mimic prior behavior + * updated configuration to use yaml class based configs + * implemented auto grid layout for nodes + * fixed drawn wlan ranges during configuration +* Bugfixes + * no longer writes link option data for WLAN/EMANE links in XML + * avoid configuring links for WLAN/EMANE link options in XML, due to them being written to XML prior + * updates to allow building python docs again + * \#431 - peer to peer node uplink link data was not using an enum properly due to code changes + * \#432 - loading XML was not setting EMANE nodes model + * \#435 - loading XML was not maintaining existing session options + * \#448 - fixed issue sorting hooks being saved to XML + ## 2020-04-13 CORE 6.3.0 * Features * \#424 - added FRR IS-IS service From 150db074977d857d76c19e10b13e01130ea2e44f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 11 May 2020 16:02:23 -0700 Subject: [PATCH 047/929] pygui: updated canvas size and scale dialog to allow negative values for lon,lat,alt --- daemon/core/gui/dialogs/canvassizeandscale.py | 6 +++--- daemon/core/gui/validation.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/daemon/core/gui/dialogs/canvassizeandscale.py b/daemon/core/gui/dialogs/canvassizeandscale.py index 5a042468..3418af8b 100644 --- a/daemon/core/gui/dialogs/canvassizeandscale.py +++ b/daemon/core/gui/dialogs/canvassizeandscale.py @@ -183,7 +183,7 @@ class SizeAndScaleDialog(Dialog): frame, textvariable=self.lat, validate="key", - validatecommand=(self.validation.positive_float, "%P"), + validatecommand=(self.validation.float, "%P"), ) entry.bind("", lambda event: self.validation.focus_out(event, "0")) entry.grid(row=0, column=1, sticky="ew", padx=PADX) @@ -194,7 +194,7 @@ class SizeAndScaleDialog(Dialog): frame, textvariable=self.lon, validate="key", - validatecommand=(self.validation.positive_float, "%P"), + validatecommand=(self.validation.float, "%P"), ) entry.bind("", lambda event: self.validation.focus_out(event, "0")) entry.grid(row=0, column=3, sticky="ew", padx=PADX) @@ -205,7 +205,7 @@ class SizeAndScaleDialog(Dialog): frame, textvariable=self.alt, validate="key", - validatecommand=(self.validation.positive_float, "%P"), + validatecommand=(self.validation.float, "%P"), ) entry.bind("", lambda event: self.validation.focus_out(event, "0")) entry.grid(row=0, column=5, sticky="ew") diff --git a/daemon/core/gui/validation.py b/daemon/core/gui/validation.py index af16dadd..fee075ad 100644 --- a/daemon/core/gui/validation.py +++ b/daemon/core/gui/validation.py @@ -20,6 +20,7 @@ class InputValidation: self.master = app.master self.positive_int = None self.positive_float = None + self.float = None self.app_scale = None self.name = None self.ip4 = None @@ -30,6 +31,7 @@ class InputValidation: def register(self): self.positive_int = self.master.register(self.check_positive_int) self.positive_float = self.master.register(self.check_positive_float) + self.float = self.master.register(self.check_float) self.app_scale = self.master.register(self.check_scale_value) self.name = self.master.register(self.check_node_name) self.ip4 = self.master.register(self.check_ip4) @@ -63,6 +65,16 @@ class InputValidation: except ValueError: return False + @classmethod + def check_float(cls, s: str) -> bool: + if len(s) == 0: + return True + try: + float(s) + return True + except ValueError: + return False + @classmethod def check_positive_float(cls, s: str) -> bool: if len(s) == 0: From 22d813df63e960aa608f96d45503e15e02e5a649 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 11 May 2020 22:00:52 -0700 Subject: [PATCH 048/929] pygui: updated validation to be wrapper classes around ttk.Entry for convenience and less code --- daemon/core/gui/app.py | 3 - daemon/core/gui/dialogs/canvassizeandscale.py | 82 +------ daemon/core/gui/dialogs/colorpicker.py | 33 +-- daemon/core/gui/dialogs/linkconfig.py | 78 ++----- daemon/core/gui/dialogs/nodeconfig.py | 13 +- daemon/core/gui/dialogs/preferences.py | 10 +- daemon/core/gui/validation.py | 219 ++++++------------ daemon/core/gui/widgets.py | 37 +-- 8 files changed, 125 insertions(+), 350 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 5ca95ab5..73aabb17 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -15,7 +15,6 @@ from core.gui.menubar import Menubar from core.gui.nodeutils import NodeUtils from core.gui.statusbar import StatusBar from core.gui.toolbar import Toolbar -from core.gui.validation import InputValidation WIDTH = 1000 HEIGHT = 800 @@ -33,7 +32,6 @@ class Application(ttk.Frame): self.right_frame = None self.canvas = None self.statusbar = None - self.validation = None self.progress = None # fonts @@ -73,7 +71,6 @@ class Application(ttk.Frame): self.master.protocol("WM_DELETE_WINDOW", self.on_closing) image = Images.get(ImageEnum.CORE, 16) self.master.tk.call("wm", "iconphoto", self.master._w, image) - self.validation = InputValidation(self) self.master.option_add("*tearOff", tk.FALSE) def center(self): diff --git a/daemon/core/gui/dialogs/canvassizeandscale.py b/daemon/core/gui/dialogs/canvassizeandscale.py index 3418af8b..6a63a1ae 100644 --- a/daemon/core/gui/dialogs/canvassizeandscale.py +++ b/daemon/core/gui/dialogs/canvassizeandscale.py @@ -5,6 +5,7 @@ import tkinter as tk from tkinter import font, ttk from typing import TYPE_CHECKING +from core.gui import validation from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY @@ -21,7 +22,6 @@ class SizeAndScaleDialog(Dialog): """ super().__init__(app, "Canvas Size and Scale") self.canvas = self.app.canvas - self.validation = app.validation self.section_font = font.Font(weight="bold") width, height = self.canvas.current_dimensions self.pixel_width = tk.IntVar(value=width) @@ -59,23 +59,11 @@ class SizeAndScaleDialog(Dialog): frame.columnconfigure(3, weight=1) label = ttk.Label(frame, text="Width") label.grid(row=0, column=0, sticky="w", padx=PADX) - entry = ttk.Entry( - frame, - textvariable=self.pixel_width, - validate="key", - validatecommand=(self.validation.positive_int, "%P"), - ) - entry.bind("", lambda event: self.validation.focus_out(event, "0")) + entry = validation.PositiveIntEntry(frame, textvariable=self.pixel_width) entry.grid(row=0, column=1, sticky="ew", padx=PADX) label = ttk.Label(frame, text="x Height") label.grid(row=0, column=2, sticky="w", padx=PADX) - entry = ttk.Entry( - frame, - textvariable=self.pixel_height, - validate="key", - validatecommand=(self.validation.positive_int, "%P"), - ) - entry.bind("", lambda event: self.validation.focus_out(event, "0")) + entry = validation.PositiveIntEntry(frame, textvariable=self.pixel_height) entry.grid(row=0, column=3, sticky="ew", padx=PADX) label = ttk.Label(frame, text="Pixels") label.grid(row=0, column=4, sticky="w") @@ -87,23 +75,11 @@ class SizeAndScaleDialog(Dialog): frame.columnconfigure(3, weight=1) label = ttk.Label(frame, text="Width") label.grid(row=0, column=0, sticky="w", padx=PADX) - entry = ttk.Entry( - frame, - textvariable=self.meters_width, - validate="key", - validatecommand=(self.validation.positive_float, "%P"), - ) - entry.bind("", lambda event: self.validation.focus_out(event, "0")) + entry = validation.PositiveFloatEntry(frame, textvariable=self.meters_width) entry.grid(row=0, column=1, sticky="ew", padx=PADX) label = ttk.Label(frame, text="x Height") label.grid(row=0, column=2, sticky="w", padx=PADX) - entry = ttk.Entry( - frame, - textvariable=self.meters_height, - validate="key", - validatecommand=(self.validation.positive_float, "%P"), - ) - entry.bind("", lambda event: self.validation.focus_out(event, "0")) + entry = validation.PositiveFloatEntry(frame, textvariable=self.meters_height) entry.grid(row=0, column=3, sticky="ew", padx=PADX) label = ttk.Label(frame, text="Meters") label.grid(row=0, column=4, sticky="w") @@ -118,13 +94,7 @@ class SizeAndScaleDialog(Dialog): frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text=f"{PIXEL_SCALE} Pixels =") label.grid(row=0, column=0, sticky="w", padx=PADX) - entry = ttk.Entry( - frame, - textvariable=self.scale, - validate="key", - validatecommand=(self.validation.positive_float, "%P"), - ) - entry.bind("", lambda event: self.validation.focus_out(event, "0")) + entry = validation.PositiveFloatEntry(frame, textvariable=self.scale) entry.grid(row=0, column=1, sticky="ew", padx=PADX) label = ttk.Label(frame, text="Meters") label.grid(row=0, column=2, sticky="w") @@ -148,24 +118,12 @@ class SizeAndScaleDialog(Dialog): label = ttk.Label(frame, text="X") label.grid(row=0, column=0, sticky="w", padx=PADX) - entry = ttk.Entry( - frame, - textvariable=self.x, - validate="key", - validatecommand=(self.validation.positive_float, "%P"), - ) - entry.bind("", lambda event: self.validation.focus_out(event, "0")) + entry = validation.PositiveFloatEntry(frame, textvariable=self.x) entry.grid(row=0, column=1, sticky="ew", padx=PADX) label = ttk.Label(frame, text="Y") label.grid(row=0, column=2, sticky="w", padx=PADX) - entry = ttk.Entry( - frame, - textvariable=self.y, - validate="key", - validatecommand=(self.validation.positive_float, "%P"), - ) - entry.bind("", lambda event: self.validation.focus_out(event, "0")) + entry = validation.PositiveFloatEntry(frame, textvariable=self.y) entry.grid(row=0, column=3, sticky="ew", padx=PADX) label = ttk.Label(label_frame, text="Translates To") @@ -179,35 +137,17 @@ class SizeAndScaleDialog(Dialog): label = ttk.Label(frame, text="Lat") label.grid(row=0, column=0, sticky="w", padx=PADX) - entry = ttk.Entry( - frame, - textvariable=self.lat, - validate="key", - validatecommand=(self.validation.float, "%P"), - ) - entry.bind("", lambda event: self.validation.focus_out(event, "0")) + entry = validation.FloatEntry(frame, textvariable=self.lat) entry.grid(row=0, column=1, sticky="ew", padx=PADX) label = ttk.Label(frame, text="Lon") label.grid(row=0, column=2, sticky="w", padx=PADX) - entry = ttk.Entry( - frame, - textvariable=self.lon, - validate="key", - validatecommand=(self.validation.float, "%P"), - ) - entry.bind("", lambda event: self.validation.focus_out(event, "0")) + entry = validation.FloatEntry(frame, textvariable=self.lon) entry.grid(row=0, column=3, sticky="ew", padx=PADX) label = ttk.Label(frame, text="Alt") label.grid(row=0, column=4, sticky="w", padx=PADX) - entry = ttk.Entry( - frame, - textvariable=self.alt, - validate="key", - validatecommand=(self.validation.float, "%P"), - ) - entry.bind("", lambda event: self.validation.focus_out(event, "0")) + entry = validation.FloatEntry(frame, textvariable=self.alt) entry.grid(row=0, column=5, sticky="ew") def draw_save_as_default(self): diff --git a/daemon/core/gui/dialogs/colorpicker.py b/daemon/core/gui/dialogs/colorpicker.py index c4268788..9087d6df 100644 --- a/daemon/core/gui/dialogs/colorpicker.py +++ b/daemon/core/gui/dialogs/colorpicker.py @@ -5,6 +5,7 @@ import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING +from core.gui import validation from core.gui.dialogs.dialog import Dialog if TYPE_CHECKING: @@ -50,13 +51,7 @@ class ColorPickerDialog(Dialog): frame.columnconfigure(3, weight=2) label = ttk.Label(frame, text="R: ") label.grid(row=0, column=0) - self.red_entry = ttk.Entry( - frame, - width=4, - textvariable=self.red, - validate="key", - validatecommand=(self.app.validation.rgb, "%P"), - ) + self.red_entry = validation.RgbEntry(frame, width=4, textvariable=self.red) self.red_entry.grid(row=0, column=1, sticky="nsew") scale = ttk.Scale( frame, @@ -82,20 +77,13 @@ class ColorPickerDialog(Dialog): frame.columnconfigure(3, weight=2) label = ttk.Label(frame, text="G: ") label.grid(row=0, column=0) - self.green_entry = ttk.Entry( - frame, - width=4, - textvariable=self.green, - validate="key", - validatecommand=(self.app.validation.rgb, "%P"), - ) + self.green_entry = validation.RgbEntry(frame, width=4, textvariable=self.green) self.green_entry.grid(row=0, column=1, sticky="nsew") scale = ttk.Scale( frame, from_=0, to=255, value=0, - # length=200, orient=tk.HORIZONTAL, variable=self.green_scale, command=lambda x: self.scale_callback(self.green_scale, self.green), @@ -114,13 +102,7 @@ class ColorPickerDialog(Dialog): frame.columnconfigure(3, weight=2) label = ttk.Label(frame, text="B: ") label.grid(row=0, column=0) - self.blue_entry = ttk.Entry( - frame, - width=4, - textvariable=self.blue, - validate="key", - validatecommand=(self.app.validation.rgb, "%P"), - ) + self.blue_entry = validation.RgbEntry(frame, width=4, textvariable=self.blue) self.blue_entry.grid(row=0, column=1, sticky="nsew") scale = ttk.Scale( frame, @@ -144,12 +126,7 @@ class ColorPickerDialog(Dialog): frame.columnconfigure(0, weight=1) label = ttk.Label(frame, text="Selection: ") label.grid(row=0, column=0, sticky="nsew") - self.hex_entry = ttk.Entry( - frame, - textvariable=self.hex, - validate="key", - validatecommand=(self.app.validation.hex, "%P"), - ) + self.hex_entry = validation.HexEntry(frame, textvariable=self.hex) self.hex_entry.grid(row=1, column=0, sticky="nsew") self.display = tk.Frame(frame, background=self.color, width=100, height=100) self.display.grid(row=2, column=0) diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index 4f569ef2..92361ed4 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -6,6 +6,7 @@ from tkinter import ttk from typing import TYPE_CHECKING, Union from core.api.grpc import core_pb2 +from core.gui import validation from core.gui.dialogs.colorpicker import ColorPickerDialog from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY @@ -120,95 +121,65 @@ class LinkConfigurationDialog(Dialog): label = ttk.Label(frame, text="Bandwidth (bps)") label.grid(row=row, column=0, sticky="ew") - entry = ttk.Entry( - frame, - textvariable=self.bandwidth, - validate="key", - validatecommand=(self.app.validation.positive_int, "%P"), + entry = validation.PositiveIntEntry( + frame, empty_enabled=False, textvariable=self.bandwidth ) entry.grid(row=row, column=1, sticky="ew", pady=PADY) if not self.is_symmetric: - entry = ttk.Entry( - frame, - textvariable=self.down_bandwidth, - validate="key", - validatecommand=(self.app.validation.positive_int, "%P"), + entry = validation.PositiveIntEntry( + frame, empty_enabled=False, textvariable=self.down_bandwidth ) entry.grid(row=row, column=2, sticky="ew", pady=PADY) row = row + 1 label = ttk.Label(frame, text="Delay (us)") label.grid(row=row, column=0, sticky="ew") - entry = ttk.Entry( - frame, - textvariable=self.delay, - validate="key", - validatecommand=(self.app.validation.positive_int, "%P"), + entry = validation.PositiveIntEntry( + frame, empty_enabled=False, textvariable=self.delay ) entry.grid(row=row, column=1, sticky="ew", pady=PADY) if not self.is_symmetric: - entry = ttk.Entry( - frame, - textvariable=self.down_delay, - validate="key", - validatecommand=(self.app.validation.positive_int, "%P"), + entry = validation.PositiveIntEntry( + frame, empty_enabled=False, textvariable=self.down_delay ) entry.grid(row=row, column=2, sticky="ew", pady=PADY) row = row + 1 label = ttk.Label(frame, text="Jitter (us)") label.grid(row=row, column=0, sticky="ew") - entry = ttk.Entry( - frame, - textvariable=self.jitter, - validate="key", - validatecommand=(self.app.validation.positive_int, "%P"), + entry = validation.PositiveIntEntry( + frame, empty_enabled=False, textvariable=self.jitter ) entry.grid(row=row, column=1, sticky="ew", pady=PADY) if not self.is_symmetric: - entry = ttk.Entry( - frame, - textvariable=self.down_jitter, - validate="key", - validatecommand=(self.app.validation.positive_int, "%P"), + entry = validation.PositiveIntEntry( + frame, empty_enabled=False, textvariable=self.down_jitter ) entry.grid(row=row, column=2, sticky="ew", pady=PADY) row = row + 1 label = ttk.Label(frame, text="Loss (%)") label.grid(row=row, column=0, sticky="ew") - entry = ttk.Entry( - frame, - textvariable=self.loss, - validate="key", - validatecommand=(self.app.validation.positive_float, "%P"), + entry = validation.PositiveFloatEntry( + frame, empty_enabled=False, textvariable=self.loss ) entry.grid(row=row, column=1, sticky="ew", pady=PADY) if not self.is_symmetric: - entry = ttk.Entry( - frame, - textvariable=self.down_loss, - validate="key", - validatecommand=(self.app.validation.positive_float, "%P"), + entry = validation.PositiveFloatEntry( + frame, empty_enabled=False, textvariable=self.down_loss ) entry.grid(row=row, column=2, sticky="ew", pady=PADY) row = row + 1 label = ttk.Label(frame, text="Duplicate (%)") label.grid(row=row, column=0, sticky="ew") - entry = ttk.Entry( - frame, - textvariable=self.duplicate, - validate="key", - validatecommand=(self.app.validation.positive_int, "%P"), + entry = validation.PositiveIntEntry( + frame, empty_enabled=False, textvariable=self.duplicate ) entry.grid(row=row, column=1, sticky="ew", pady=PADY) if not self.is_symmetric: - entry = ttk.Entry( - frame, - textvariable=self.down_duplicate, - validate="key", - validatecommand=(self.app.validation.positive_int, "%P"), + entry = validation.PositiveIntEntry( + frame, empty_enabled=False, textvariable=self.down_duplicate ) entry.grid(row=row, column=2, sticky="ew", pady=PADY) row = row + 1 @@ -229,11 +200,8 @@ class LinkConfigurationDialog(Dialog): label = ttk.Label(frame, text="Width") label.grid(row=row, column=0, sticky="ew") - entry = ttk.Entry( - frame, - textvariable=self.width, - validate="key", - validatecommand=(self.app.validation.positive_float, "%P"), + entry = validation.PositiveFloatEntry( + frame, empty_enabled=False, textvariable=self.width ) entry.grid(row=row, column=1, sticky="ew", pady=PADY) diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index 85a839e5..73f0ac09 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING import netaddr -from core.gui import nodeutils +from core.gui import nodeutils, validation from core.gui.appconfig import ICONS_PATH from core.gui.dialogs.dialog import Dialog from core.gui.dialogs.emaneconfig import EmaneModelDialog @@ -143,16 +143,7 @@ class NodeConfigDialog(Dialog): # name field label = ttk.Label(frame, text="Name") label.grid(row=row, column=0, sticky="ew", padx=PADX, pady=PADY) - entry = ttk.Entry( - frame, - textvariable=self.name, - validate="key", - validatecommand=(self.app.validation.name, "%P"), - state=state, - ) - entry.bind( - "", lambda event: self.app.validation.focus_out(event, "noname") - ) + entry = validation.NodeNameEntry(frame, textvariable=self.name, state=state) entry.grid(row=row, column=1, sticky="ew") row += 1 diff --git a/daemon/core/gui/dialogs/preferences.py b/daemon/core/gui/dialogs/preferences.py index 9c9ba16f..11d1ba95 100644 --- a/daemon/core/gui/dialogs/preferences.py +++ b/daemon/core/gui/dialogs/preferences.py @@ -4,7 +4,7 @@ import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING -from core.gui import appconfig +from core.gui import appconfig, validation from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY, scale_fonts from core.gui.validation import LARGEST_SCALE, SMALLEST_SCALE @@ -80,12 +80,8 @@ class PreferencesDialog(Dialog): variable=self.gui_scale, ) scale.grid(row=0, column=0, sticky="ew") - entry = ttk.Entry( - scale_frame, - textvariable=self.gui_scale, - width=4, - validate="key", - validatecommand=(self.app.validation.app_scale, "%P"), + entry = validation.AppScaleEntry( + scale_frame, textvariable=self.gui_scale, width=4 ) entry.grid(row=0, column=1) diff --git a/daemon/core/gui/validation.py b/daemon/core/gui/validation.py index fee075ad..873db189 100644 --- a/daemon/core/gui/validation.py +++ b/daemon/core/gui/validation.py @@ -3,71 +3,63 @@ input validation """ import re import tkinter as tk -from typing import TYPE_CHECKING - -import netaddr -from netaddr import IPNetwork - -if TYPE_CHECKING: - from core.gui.app import Application +from tkinter import ttk SMALLEST_SCALE = 0.5 LARGEST_SCALE = 5.0 +HEX_REGEX = re.compile("^([#]([0-9]|[a-f])+)$|^[#]$") -class InputValidation: - def __init__(self, app: "Application"): - self.master = app.master - self.positive_int = None - self.positive_float = None - self.float = None - self.app_scale = None - self.name = None - self.ip4 = None - self.rgb = None - self.hex = None - self.register() +class ValidationEntry(ttk.Entry): + empty = None - def register(self): - self.positive_int = self.master.register(self.check_positive_int) - self.positive_float = self.master.register(self.check_positive_float) - self.float = self.master.register(self.check_float) - self.app_scale = self.master.register(self.check_scale_value) - self.name = self.master.register(self.check_node_name) - self.ip4 = self.master.register(self.check_ip4) - self.rgb = self.master.register(self.check_rbg) - self.hex = self.master.register(self.check_hex) + def __init__(self, master=None, widget=None, empty_enabled=True, **kwargs) -> None: + super().__init__(master, widget, **kwargs) + cmd = self.register(self.is_valid) + self.configure(validate="key", validatecommand=(cmd, "%P")) + if self.empty is not None and empty_enabled: + self.bind("", self.focus_out) - @classmethod - def ip_focus_out(cls, event: tk.Event): - value = event.widget.get() - try: - IPNetwork(value) - except netaddr.core.AddrFormatError: - event.widget.delete(0, tk.END) - event.widget.insert(tk.END, "invalid") + def is_valid(self, s: str) -> bool: + raise NotImplementedError - @classmethod - def focus_out(cls, event: tk.Event, default: str): - value = event.widget.get() - if value == "": - event.widget.insert(tk.END, default) + def focus_out(self, _event: tk.Event) -> None: + value = self.get() + if not value: + self.insert(tk.END, self.empty) - @classmethod - def check_positive_int(cls, s: str) -> bool: - if len(s) == 0: + +class PositiveIntEntry(ValidationEntry): + empty = "0" + + def is_valid(self, s: str) -> bool: + if not s: return True try: - int_value = int(s) - if int_value >= 0: - return True - return False + value = int(s) + return value >= 0 except ValueError: return False - @classmethod - def check_float(cls, s: str) -> bool: - if len(s) == 0: + +class PositiveFloatEntry(ValidationEntry): + empty = "0.0" + + def is_valid(self, s: str) -> bool: + if not s: + return True + try: + value = float(s) + return value >= 0.0 + except ValueError: + return False + + +class FloatEntry(ValidationEntry): + empty = "0.0" + + def is_valid(self, s: str) -> bool: + if not s: return True try: float(s) @@ -75,109 +67,50 @@ class InputValidation: except ValueError: return False - @classmethod - def check_positive_float(cls, s: str) -> bool: - if len(s) == 0: - return True - try: - float_value = float(s) - if float_value >= 0.0: - return True - return False - except ValueError: - return False - @classmethod - def check_node_name(cls, s: str) -> bool: - if len(s) < 0: - return False - if len(s) == 0: - return True - for char in s: - if not char.isalnum() and char != "_": - return False - return True - - @classmethod - def check_canvas_int(cls, s: str) -> bool: - if len(s) == 0: - return True - try: - int_value = int(s) - if int_value >= 0: - return True - return False - except ValueError: - return False - - @classmethod - def check_canvas_float(cls, s: str) -> bool: - if not s: - return True - try: - float_value = float(s) - if float_value >= 0.0: - return True - return False - except ValueError: - return False - - @classmethod - def check_scale_value(cls, s: str) -> bool: - if not s: - return True - try: - float_value = float(s) - if SMALLEST_SCALE <= float_value <= LARGEST_SCALE or float_value == 0: - return True - return False - except ValueError: - return False - - @classmethod - def check_ip4(cls, s: str) -> bool: - if not s: - return True - pat = re.compile("^([0-9]+[.])*[0-9]*$") - if pat.match(s) is not None: - _32bits = s.split(".") - if len(_32bits) > 4: - return False - for _8bits in _32bits: - if ( - (_8bits and int(_8bits) > 255) - or len(_8bits) > 3 - or (_8bits.startswith("0") and len(_8bits) > 1) - ): - return False - return True - else: - return False - - @classmethod - def check_rbg(cls, s: str) -> bool: +class RgbEntry(ValidationEntry): + def is_valid(self, s: str) -> bool: if not s: return True if s.startswith("0") and len(s) >= 2: return False try: value = int(s) - if 0 <= value <= 255: - return True - else: - return False + return 0 <= value <= 255 except ValueError: return False - @classmethod - def check_hex(cls, s: str) -> bool: + +class HexEntry(ValidationEntry): + def is_valid(self, s: str) -> bool: if not s: return True - pat = re.compile("^([#]([0-9]|[a-f])+)$|^[#]$") - if pat.match(s): - if 0 <= len(s) <= 7: - return True - else: - return False + if HEX_REGEX.match(s): + return 0 <= len(s) <= 7 else: return False + + +class NodeNameEntry(ValidationEntry): + empty = "noname" + + def is_valid(self, s: str) -> bool: + if len(s) < 0: + return False + if len(s) == 0: + return True + for x in s: + if not x.isalnum() and x != "_": + return False + return True + + +class AppScaleEntry(ValidationEntry): + def is_valid(self, s: str) -> bool: + if not s: + return True + try: + float_value = float(s) + return SMALLEST_SCALE <= float_value <= LARGEST_SCALE or float_value == 0 + except ValueError: + return False diff --git a/daemon/core/gui/widgets.py b/daemon/core/gui/widgets.py index 5750e286..6f51bd8c 100644 --- a/daemon/core/gui/widgets.py +++ b/daemon/core/gui/widgets.py @@ -6,7 +6,7 @@ from tkinter import filedialog, font, ttk from typing import TYPE_CHECKING, Dict from core.api.grpc import common_pb2, core_pb2 -from core.gui import themes +from core.gui import themes, validation from core.gui.themes import FRAME_PAD, PADX, PADY if TYPE_CHECKING: @@ -127,43 +127,16 @@ class ConfigFrame(ttk.Notebook): button = ttk.Button(file_frame, text="...", command=func) button.grid(row=0, column=1) else: - if "controlnet" in option.name and "script" not in option.name: - entry = ttk.Entry( - tab.frame, - textvariable=value, - validate="key", - validatecommand=(self.app.validation.ip4, "%P"), - ) - entry.grid(row=index, column=1, sticky="ew") - else: - entry = ttk.Entry(tab.frame, textvariable=value) - entry.grid(row=index, column=1, sticky="ew") + entry = ttk.Entry(tab.frame, textvariable=value) + entry.grid(row=index, column=1, sticky="ew") elif option.type in INT_TYPES: value.set(option.value) - entry = ttk.Entry( - tab.frame, - textvariable=value, - validate="key", - validatecommand=(self.app.validation.positive_int, "%P"), - ) - entry.bind( - "", - lambda event: self.app.validation.focus_out(event, "0"), - ) + entry = validation.PositiveIntEntry(tab.frame, textvariable=value) entry.grid(row=index, column=1, sticky="ew") elif option.type == core_pb2.ConfigOptionType.FLOAT: value.set(option.value) - entry = ttk.Entry( - tab.frame, - textvariable=value, - validate="key", - validatecommand=(self.app.validation.positive_float, "%P"), - ) - entry.bind( - "", - lambda event: self.app.validation.focus_out(event, "0"), - ) + entry = validation.PositiveFloatEntry(tab.frame, textvariable=value) entry.grid(row=index, column=1, sticky="ew") else: logging.error("unhandled config option type: %s", option.type) From fa163c3ed6721be2ec1a91d8c9e71f9239c76c72 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 12 May 2020 08:31:53 -0700 Subject: [PATCH 049/929] pygui: update file dialogs to hide hidden files by default and provide a hidden file toggle --- daemon/core/gui/app.py | 42 +++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 73aabb17..90e5c36c 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -21,8 +21,8 @@ HEIGHT = 800 class Application(ttk.Frame): - def __init__(self, proxy: bool): - super().__init__(master=None) + def __init__(self, proxy: bool) -> None: + super().__init__() # load node icons NodeUtils.setup() @@ -50,7 +50,7 @@ class Application(ttk.Frame): self.draw() self.core.setup() - def setup_scaling(self): + def setup_scaling(self) -> None: self.fonts_size = {name: font.nametofont(name)["size"] for name in font.names()} text_scale = self.app_scale if self.app_scale < 1 else math.sqrt(self.app_scale) themes.scale_fonts(self.fonts_size, self.app_scale) @@ -59,21 +59,37 @@ class Application(ttk.Frame): family="TkDefaultFont", size=int(8 * text_scale), weight=font.BOLD ) - def setup_theme(self): + def setup_theme(self) -> None: themes.load(self.style) self.master.bind_class("Menu", "<>", themes.theme_change_menu) self.master.bind("<>", themes.theme_change) self.style.theme_use(self.guiconfig.preferences.theme) - def setup_app(self): + def setup_app(self) -> None: self.master.title("CORE") self.center() self.master.protocol("WM_DELETE_WINDOW", self.on_closing) image = Images.get(ImageEnum.CORE, 16) self.master.tk.call("wm", "iconphoto", self.master._w, image) self.master.option_add("*tearOff", tk.FALSE) + self.setup_file_dialogs() - def center(self): + def setup_file_dialogs(self) -> None: + """ + Hack code that needs to initialize a bad dialog so that we can apply, + global settings for dialogs to not show hidden files by default and display + the hidden file toggle. + + :return: nothing + """ + try: + self.master.tk.call("tk_getOpenFile", "-foobar") + except tk.TclError: + pass + self.master.tk.call("set", "::tk::dialog::file::showHiddenBtn", "1") + self.master.tk.call("set", "::tk::dialog::file::showHiddenVar", "0") + + def center(self) -> None: screen_width = self.master.winfo_screenwidth() screen_height = self.master.winfo_screenheight() x = int((screen_width / 2) - (WIDTH * self.app_scale / 2)) @@ -82,7 +98,7 @@ class Application(ttk.Frame): f"{int(WIDTH * self.app_scale)}x{int(HEIGHT * self.app_scale)}+{x}+{y}" ) - def draw(self): + def draw(self) -> None: self.master.rowconfigure(0, weight=1) self.master.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) @@ -99,7 +115,7 @@ class Application(ttk.Frame): self.progress = Progressbar(self.right_frame, mode="indeterminate") self.menubar = Menubar(self.master, self) - def draw_canvas(self): + def draw_canvas(self) -> None: width = self.guiconfig.preferences.width height = self.guiconfig.preferences.height canvas_frame = ttk.Frame(self.right_frame) @@ -117,7 +133,7 @@ class Application(ttk.Frame): self.canvas.configure(xscrollcommand=scroll_x.set) self.canvas.configure(yscrollcommand=scroll_y.set) - def draw_status(self): + def draw_status(self) -> None: self.statusbar = StatusBar(self.right_frame, self) self.statusbar.grid(sticky="ew") @@ -133,17 +149,17 @@ class Application(ttk.Frame): def show_error(self, title: str, message: str) -> None: self.after(0, lambda: ErrorDialog(self, title, message).show()) - def on_closing(self): + def on_closing(self) -> None: self.menubar.prompt_save_running_session(True) - def save_config(self): + def save_config(self) -> None: appconfig.save(self.guiconfig) - def joined_session_update(self): + def joined_session_update(self) -> None: if self.core.is_runtime(): self.toolbar.set_runtime() else: self.toolbar.set_design() - def close(self): + def close(self) -> None: self.master.destroy() From 454dc8091ec40016ef693772830e6ae781cb84f3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 13 May 2020 09:25:56 -0700 Subject: [PATCH 050/929] coresendmsg: small usage cleanup, removed printing enum values when listing tlvs, updated examples to use current expected values --- daemon/scripts/coresendmsg | 45 ++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/daemon/scripts/coresendmsg b/daemon/scripts/coresendmsg index a909522f..9f5fa776 100755 --- a/daemon/scripts/coresendmsg +++ b/daemon/scripts/coresendmsg @@ -19,7 +19,7 @@ def print_available_tlvs(t, tlv_class): """ print(f"TLVs available for {t} message:") for tlv in sorted([tlv for tlv in tlv_class.tlv_type_map], key=lambda x: x.name): - print(f"{tlv.value}:{tlv.name}") + print(tlv.name) def print_examples(name): @@ -27,27 +27,26 @@ def print_examples(name): Print example usage of this script. """ examples = [ - ("link n1number=2 n2number=3 delay=15000", - "set a 15ms delay on the link between n2 and n3"), - ("link n1number=2 n2number=3 guiattr=\"color=blue\"", - "change the color of the link between n2 and n3"), - ("node number=3 xpos=125 ypos=525", + ("NODE NUMBER=3 X_POSITION=125 Y_POSITION=525", "move node number 3 to x,y=(125,525)"), - ("node number=4 icon=/usr/local/share/core/icons/normal/router_red.gif", + ("NODE NUMBER=4 ICON=/usr/local/share/core/icons/normal/router_red.gif", "change node number 4\"s icon to red"), - ("node flags=add number=5 type=0 name=\"n5\" xpos=500 ypos=500", + ("NODE flags=ADD NUMBER=5 TYPE=0 NAME=\"n5\" X_POSITION=500 Y_POSITION=500", "add a new router node n5"), - ("link flags=add n1number=4 n2number=5 if1ip4=\"10.0.3.2\" " \ - "if1ip4mask=24 if2ip4=\"10.0.3.1\" if2ip4mask=24", + ("LINK N1_NUMBER=2 N2_NUMBER=3 DELAY=15000", + "set a 15ms delay on the link between n2 and n3"), + ("LINK N1_NUMBER=2 N2_NUMBER=3 GUI_ATTRIBUTES=\"color=blue\"", + "change the color of the link between n2 and n3"), + ("LINK flags=ADD N1_NUMBER=4 N2_NUMBER=5 INTERFACE1_IP4=\"10.0.3.2\" " + "INTERFACE1_IP4_MASK=24 INTERFACE2_IP4=\"10.0.3.1\" INTERFACE2_IP4_MASK=24", "link node n5 with n4 using the given interface addresses"), - ("exec flags=str,txt node=1 num=1000 cmd=\"uname -a\" -l", + ("EXECUTE flags=STRING,TEXT NODE=1 NUMBER=1000 COMMAND=\"uname -a\" -l", "run a command on node 1 and wait for the result"), - ("exec node=2 num=1001 cmd=\"killall ospfd\"", + ("EXECUTE NODE=2 NUMBER=1001 COMMAND=\"killall ospfd\"", "run a command on node 2 and ignore the result"), - ("file flags=add node=1 name=\"/var/log/test.log\" data=\"Hello World.\"", + ("FILE flags=ADD NODE=1 NAME=\"/var/log/test.log\" DATA=\"Hello World.\"", "write a test.log file on node 1 with the given contents"), - ("file flags=add node=2 name=\"test.log\" " \ - "srcname=\"./test.log\"", + ("FILE flags=ADD NODE=2 NAME=\"test.log\" SOURCE_NAME=\"./test.log\"", "move a test.log file from host to node 2"), ] print(f"Example {name} invocations:") @@ -154,10 +153,14 @@ def main(): """ types = [message_type.name for message_type in MessageTypes] flags = [flag.name for flag in MessageFlags] - usagestr = "usage: %prog [-h|-H] [options] [message-type] [flags=flags] " - usagestr += "[message-TLVs]\n\n" - usagestr += f"Supported message types:\n {types}\n" - usagestr += f"Supported message flags (flags=f1,f2,...):\n {flags}" + types_usage = " ".join(types) + flags_usage = " ".join(flags) + usagestr = ( + "usage: %prog [-h|-H] [options] [message-type] [flags=flags] " + "[message-TLVs]\n\n" + f"Supported message types:\n {types_usage}\n" + f"Supported message flags (flags=f1,f2,...):\n {flags_usage}" + ) parser = optparse.OptionParser(usage=usagestr) default_address = "localhost" default_session = None @@ -188,9 +191,9 @@ def main(): help=f"Use TCP instead of UDP and connect to a session default: {default_tcp}") def usage(msg=None, err=0): - sys.stdout.write("\n") + print() if msg: - sys.stdout.write(msg + "\n\n") + print(f"{msg}\n") parser.print_help() sys.exit(err) From 95d3a6ca8ca95a4a6fa36fe2dc089ede46f50146 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 13 May 2020 12:01:28 -0700 Subject: [PATCH 051/929] updates to force CoreCommandError to contain string values for stderr and stdout, couple bugfixes in handling bad commands when using execute commands from tlv based api or coresendmsg, also updates to coresendmsg to display everything in lowercase to mimic previous look and feel, however coresendmg will now work regardless of casing to avoid breaking things again --- daemon/core/api/tlv/corehandlers.py | 7 +---- daemon/core/emane/emanemanager.py | 1 - daemon/core/nodes/base.py | 1 - daemon/core/nodes/docker.py | 2 +- daemon/core/nodes/lxd.py | 2 +- daemon/core/utils.py | 9 ++++-- daemon/scripts/coresendmsg | 43 ++++++++++++++--------------- 7 files changed, 29 insertions(+), 36 deletions(-) diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 76bc16fe..d7e41a6c 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -880,12 +880,7 @@ class CoreHandler(socketserver.BaseRequestHandler): except CoreCommandError as e: res = e.stderr status = e.returncode - logging.info( - "done exec cmd=%s with status=%d res=(%d bytes)", - command, - status, - len(res), - ) + logging.info("done exec cmd=%s with status=%d", command, status) if message.flags & MessageFlags.TEXT.value: tlv_data += coreapi.CoreExecuteTlv.pack( ExecuteTlvs.RESULT.value, res diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 0b4ae891..8b4bade2 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -836,7 +836,6 @@ class EmaneManager(ModelManager): result = True except CoreCommandError: result = False - return result diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 721f643f..2749323a 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -522,7 +522,6 @@ class CoreNode(CoreNodeBase): self.host_cmd(f"kill -0 {self.pid}") except CoreCommandError: return False - return True def startup(self) -> None: diff --git a/daemon/core/nodes/docker.py b/daemon/core/nodes/docker.py index 60adfe32..f1335747 100644 --- a/daemon/core/nodes/docker.py +++ b/daemon/core/nodes/docker.py @@ -35,7 +35,7 @@ class DockerClient: output = self.run(args) data = json.loads(output) if not data: - raise CoreCommandError(-1, args, f"docker({self.name}) not present") + raise CoreCommandError(1, args, f"docker({self.name}) not present") return data[0] def is_alive(self) -> bool: diff --git a/daemon/core/nodes/lxd.py b/daemon/core/nodes/lxd.py index 3ca399b5..31623394 100644 --- a/daemon/core/nodes/lxd.py +++ b/daemon/core/nodes/lxd.py @@ -34,7 +34,7 @@ class LxdClient: output = self.run(args) data = json.loads(output) if not data: - raise CoreCommandError(-1, args, f"LXC({self.name}) not present") + raise CoreCommandError(1, args, f"LXC({self.name}) not present") return data[0] def is_alive(self) -> bool: diff --git a/daemon/core/utils.py b/daemon/core/utils.py index f1f74dbe..8a988ede 100644 --- a/daemon/core/utils.py +++ b/daemon/core/utils.py @@ -231,14 +231,17 @@ def cmd( p = Popen(args, stdout=PIPE, stderr=PIPE, env=env, cwd=cwd, shell=shell) if wait: stdout, stderr = p.communicate() + stdout = stdout.decode("utf-8").strip() + stderr = stderr.decode("utf-8").strip() status = p.wait() if status != 0: raise CoreCommandError(status, args, stdout, stderr) - return stdout.decode("utf-8").strip() + return stdout else: return "" - except OSError: - raise CoreCommandError(-1, args) + except OSError as e: + logging.error("cmd error: %s", e.strerror) + raise CoreCommandError(1, args, "", e.strerror) def file_munge(pathname: str, header: str, text: str) -> None: diff --git a/daemon/scripts/coresendmsg b/daemon/scripts/coresendmsg index 9f5fa776..ae89ecb1 100755 --- a/daemon/scripts/coresendmsg +++ b/daemon/scripts/coresendmsg @@ -19,7 +19,7 @@ def print_available_tlvs(t, tlv_class): """ print(f"TLVs available for {t} message:") for tlv in sorted([tlv for tlv in tlv_class.tlv_type_map], key=lambda x: x.name): - print(tlv.name) + print(tlv.name.lower()) def print_examples(name): @@ -27,26 +27,26 @@ def print_examples(name): Print example usage of this script. """ examples = [ - ("NODE NUMBER=3 X_POSITION=125 Y_POSITION=525", + ("node number=3 x_position=125 y_position=525", "move node number 3 to x,y=(125,525)"), - ("NODE NUMBER=4 ICON=/usr/local/share/core/icons/normal/router_red.gif", + ("node number=4 icon=/usr/local/share/core/icons/normal/router_red.gif", "change node number 4\"s icon to red"), - ("NODE flags=ADD NUMBER=5 TYPE=0 NAME=\"n5\" X_POSITION=500 Y_POSITION=500", + ("node flags=add number=5 type=0 name=\"n5\" x_position=500 y_position=500", "add a new router node n5"), - ("LINK N1_NUMBER=2 N2_NUMBER=3 DELAY=15000", + ("link n1_number=2 n2_number=3 delay=15000", "set a 15ms delay on the link between n2 and n3"), - ("LINK N1_NUMBER=2 N2_NUMBER=3 GUI_ATTRIBUTES=\"color=blue\"", + ("link n1_number=2 n2_number=3 gui_attributes=\"color=blue\"", "change the color of the link between n2 and n3"), - ("LINK flags=ADD N1_NUMBER=4 N2_NUMBER=5 INTERFACE1_IP4=\"10.0.3.2\" " - "INTERFACE1_IP4_MASK=24 INTERFACE2_IP4=\"10.0.3.1\" INTERFACE2_IP4_MASK=24", + ("link flags=add n1_number=4 n2_number=5 interface1_ip4=\"10.0.3.2\" " + "interface1_ip4_mask=24 interface2_ip4=\"10.0.3.1\" interface2_ip4_mask=24", "link node n5 with n4 using the given interface addresses"), - ("EXECUTE flags=STRING,TEXT NODE=1 NUMBER=1000 COMMAND=\"uname -a\" -l", + ("execute flags=string,text node=1 number=1000 command=\"uname -a\" -l", "run a command on node 1 and wait for the result"), - ("EXECUTE NODE=2 NUMBER=1001 COMMAND=\"killall ospfd\"", + ("execute node=2 number=1001 command=\"killall ospfd\"", "run a command on node 2 and ignore the result"), - ("FILE flags=ADD NODE=1 NAME=\"/var/log/test.log\" DATA=\"Hello World.\"", + ("file flags=add node=1 name=\"/var/log/test.log\" data=\"hello world.\"", "write a test.log file on node 1 with the given contents"), - ("FILE flags=ADD NODE=2 NAME=\"test.log\" SOURCE_NAME=\"./test.log\"", + ("file flags=add node=2 name=\"test.log\" source_name=\"./test.log\"", "move a test.log file from host to node 2"), ] print(f"Example {name} invocations:") @@ -151,8 +151,8 @@ def main(): """ Parse command-line arguments to build and send a CORE message. """ - types = [message_type.name for message_type in MessageTypes] - flags = [flag.name for flag in MessageFlags] + types = [message_type.name.lower() for message_type in MessageTypes] + flags = [flag.name.lower() for flag in MessageFlags] types_usage = " ".join(types) flags_usage = " ".join(flags) usagestr = ( @@ -174,7 +174,6 @@ def main(): tlvs=False, tcp=default_tcp ) - parser.add_option("-H", dest="examples", action="store_true", help="show example usage help message and exit") parser.add_option("-p", "--port", dest="port", type=int, @@ -207,9 +206,10 @@ def main(): # given a message type t, determine the message and TLV classes t = args.pop(0) + t = t.lower() if t not in types: usage(f"Unknown message type requested: {t}") - message_type = MessageTypes[t] + message_type = MessageTypes[t.upper()] msg_cls = coreapi.CLASS_MAP[message_type.value] tlv_cls = msg_cls.tlv_class @@ -225,26 +225,23 @@ def main(): typevalue = a.split("=") if len(typevalue) < 2: usage(f"Use \"type=value\" syntax instead of \"{a}\".") - tlv_typestr = typevalue[0] + tlv_typestr = typevalue[0].lower() tlv_valstr = "=".join(typevalue[1:]) if tlv_typestr == "flags": flagstr = tlv_valstr continue - - tlv_name = tlv_typestr try: - tlv_type = tlv_cls.tlv_type_map[tlv_name] + tlv_type = tlv_cls.tlv_type_map[tlv_typestr.upper()] tlvdata += tlv_cls.pack_string(tlv_type.value, tlv_valstr) except KeyError: - usage(f"Unknown TLV: \"{tlv_name}\"") + usage(f"Unknown TLV: \"{tlv_typestr}\"") flags = 0 for f in flagstr.split(","): if f == "": continue - try: - flag_enum = MessageFlags[f] + flag_enum = MessageFlags[f.upper()] n = flag_enum.value flags |= n except KeyError: From 79d7d66bff0b7704b70571edae36fdf7bba587a8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 13 May 2020 16:16:45 -0700 Subject: [PATCH 052/929] updates to sample3-bgp to avoid issues related to older formatted imn --- gui/configs/sample3-bgp.imn | 97 ------------------------------------- 1 file changed, 97 deletions(-) diff --git a/gui/configs/sample3-bgp.imn b/gui/configs/sample3-bgp.imn index d4a396ae..b31693ef 100644 --- a/gui/configs/sample3-bgp.imn +++ b/gui/configs/sample3-bgp.imn @@ -46,18 +46,6 @@ node n1 { ! } } - custom-config { - custom-config-id service:zebra - custom-command zebra - config { - ('/usr/local/etc/quagga', '/var/run/quagga') - ('/usr/local/etc/quagga/Quagga.conf', 'quaggaboot.sh') - 35 - ('sh quaggaboot.sh zebra',) - ('killall zebra',) - - } - } } node n2 { @@ -108,18 +96,6 @@ node n2 { ! } } - custom-config { - custom-config-id service:zebra - custom-command zebra - config { - ('/usr/local/etc/quagga', '/var/run/quagga') - ('/usr/local/etc/quagga/Quagga.conf', 'quaggaboot.sh') - 35 - ('sh quaggaboot.sh zebra',) - ('killall zebra',) - - } - } } node n3 { @@ -161,18 +137,6 @@ node n3 { ! } } - custom-config { - custom-config-id service:zebra - custom-command zebra - config { - ('/usr/local/etc/quagga', '/var/run/quagga') - ('/usr/local/etc/quagga/Quagga.conf', 'quaggaboot.sh') - 35 - ('sh quaggaboot.sh zebra',) - ('killall zebra',) - - } - } } node n4 { @@ -222,18 +186,6 @@ node n4 { ! } } - custom-config { - custom-config-id service:zebra - custom-command zebra - config { - ('/usr/local/etc/quagga', '/var/run/quagga') - ('/usr/local/etc/quagga/Quagga.conf', 'quaggaboot.sh') - 35 - ('sh quaggaboot.sh zebra',) - ('killall zebra',) - - } - } } node n5 { @@ -283,18 +235,6 @@ node n5 { ! } } - custom-config { - custom-config-id service:zebra - custom-command zebra - config { - ('/usr/local/etc/quagga', '/var/run/quagga') - ('/usr/local/etc/quagga/Quagga.conf', 'quaggaboot.sh') - 35 - ('sh quaggaboot.sh zebra',) - ('killall zebra',) - - } - } } node n6 { @@ -344,18 +284,6 @@ node n6 { ! } } - custom-config { - custom-config-id service:zebra - custom-command zebra - config { - ('/usr/local/etc/quagga', '/var/run/quagga') - ('/usr/local/etc/quagga/Quagga.conf', 'quaggaboot.sh') - 35 - ('sh quaggaboot.sh zebra',) - ('killall zebra',) - - } - } } node n7 { @@ -397,18 +325,6 @@ node n7 { ! } } - custom-config { - custom-config-id service:zebra - custom-command zebra - config { - ('/usr/local/etc/quagga', '/var/run/quagga') - ('/usr/local/etc/quagga/Quagga.conf', 'quaggaboot.sh') - 35 - ('sh quaggaboot.sh zebra',) - ('killall zebra',) - - } - } } node n8 { @@ -576,18 +492,6 @@ node n16 { ! } } - custom-config { - custom-config-id service:zebra - custom-command zebra - config { - ('/usr/local/etc/quagga', '/var/run/quagga') - ('/usr/local/etc/quagga/Quagga.conf', 'quaggaboot.sh') - 35 - ('sh quaggaboot.sh zebra',) - ('killall zebra',) - - } - } } link l0 { @@ -751,4 +655,3 @@ option global { annotations yes grid yes } - From 433fe4ae580f02c8e4c08f310e50d9357e094e82 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 13 May 2020 23:59:00 -0700 Subject: [PATCH 053/929] pygui: removed undesired logging in interface manager --- daemon/core/gui/interface.py | 1 - 1 file changed, 1 deletion(-) diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 9df1f667..fc5185f5 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -147,7 +147,6 @@ class InterfaceManager: return str(ip4), str(ip6) def get_subnets(self, interface: "core_pb2.Interface") -> Subnets: - logging.info("get subnets for interface: %s", interface) ip4_subnet = self.ip4_subnets if interface.ip4: ip4_subnet = IPNetwork(f"{interface.ip4}/{interface.ip4mask}").cidr From df03f1e173cde193ad01248fa6180e3e37fa810f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 14 May 2020 16:24:22 -0700 Subject: [PATCH 054/929] pygui: improvements to handling grpc events and updating gui --- daemon/core/gui/coreclient.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index bc9cdc37..a2a114ea 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -4,9 +4,10 @@ Incorporate grpc into python tkinter GUI import json import logging import os +from functools import partial from pathlib import Path from tkinter import messagebox -from typing import TYPE_CHECKING, Dict, Iterable, List, Optional +from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional import grpc @@ -84,7 +85,7 @@ class CoreClient: self.cancel_events() self._client.create_session(self.session_id) self.handling_events = self._client.events( - self.session_id, self.handle_events + self.session_id, self.handle_stream(self.handle_events) ) if throughputs_enabled: self.enable_throughputs() @@ -126,6 +127,9 @@ class CoreClient: for observer in self.app.guiconfig.observers: self.custom_observers[observer.name] = observer + def handle_stream(self, func: Callable) -> Callable: + return partial(self.app.after, 0, func) + def handle_events(self, event: core_pb2.Event): if event.session_id != self.session_id: logging.warning( @@ -199,7 +203,7 @@ class CoreClient: def enable_throughputs(self): self.handling_throughputs = self.client.throughputs( - self.session_id, self.handle_throughputs + self.session_id, self.handle_stream(self.handle_throughputs) ) def cancel_throughputs(self): From 3b1a9bc3e306839b678b770ad5e802eac51112e4 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 14 May 2020 17:57:32 -0700 Subject: [PATCH 055/929] pygui: changes to improve grpc event handling --- daemon/core/gui/coreclient.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index a2a114ea..e882227c 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -4,10 +4,9 @@ Incorporate grpc into python tkinter GUI import json import logging import os -from functools import partial from pathlib import Path from tkinter import messagebox -from typing import TYPE_CHECKING, Callable, Dict, Iterable, List, Optional +from typing import TYPE_CHECKING, Dict, Iterable, List, Optional import grpc @@ -85,7 +84,7 @@ class CoreClient: self.cancel_events() self._client.create_session(self.session_id) self.handling_events = self._client.events( - self.session_id, self.handle_stream(self.handle_events) + self.session_id, self.handle_events ) if throughputs_enabled: self.enable_throughputs() @@ -127,9 +126,6 @@ class CoreClient: for observer in self.app.guiconfig.observers: self.custom_observers[observer.name] = observer - def handle_stream(self, func: Callable) -> Callable: - return partial(self.app.after, 0, func) - def handle_events(self, event: core_pb2.Event): if event.session_id != self.session_id: logging.warning( @@ -140,7 +136,7 @@ class CoreClient: return if event.HasField("link_event"): - self.handle_link_event(event.link_event) + self.app.after(0, self.handle_link_event, event.link_event) elif event.HasField("session_event"): logging.info("session event: %s", event) session_event = event.session_event @@ -159,7 +155,7 @@ class CoreClient: else: logging.warning("unknown session event: %s", session_event) elif event.HasField("node_event"): - self.handle_node_event(event.node_event) + self.app.after(0, self.handle_node_event, event.node_event) elif event.HasField("config_event"): logging.info("config event: %s", event) elif event.HasField("exception_event"): @@ -203,7 +199,7 @@ class CoreClient: def enable_throughputs(self): self.handling_throughputs = self.client.throughputs( - self.session_id, self.handle_stream(self.handle_throughputs) + self.session_id, self.handle_throughputs ) def cancel_throughputs(self): @@ -225,7 +221,7 @@ class CoreClient: ) return logging.debug("handling throughputs event: %s", event) - self.app.canvas.set_throughputs(event) + self.app.after(0, self.app.canvas.set_throughputs, event) def handle_exception_event(self, event: core_pb2.ExceptionEvent): logging.info("exception event: %s", event) From 5e69ea48b3cc4091102a5f41f9c1c3b3eb4725fb Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 14 May 2020 23:07:21 -0700 Subject: [PATCH 056/929] pygui: fixed tracking for throughputs when joining a session --- daemon/core/gui/graph/graph.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 220e122f..2920f9b0 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -327,9 +327,15 @@ class CanvasGraph(tk.Canvas): self.edges[edge.token] = edge self.core.links[edge.token] = edge if link.HasField("interface_one"): + self.core.interface_to_edge[ + (node_one.id, link.interface_one.id) + ] = token canvas_node_one.interfaces.append(link.interface_one) edge.src_interface = link.interface_one if link.HasField("interface_two"): + self.core.interface_to_edge[ + (node_two.id, link.interface_two.id) + ] = edge.token canvas_node_two.interfaces.append(link.interface_two) edge.dst_interface = link.interface_two elif link.options.unidirectional: From ee5d5b98640e2dd52106cdae9d802aba3de47a55 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 15 May 2020 11:41:18 -0700 Subject: [PATCH 057/929] pygui: removed duplicate get_icon functionality, added more type hints, added enable/disable of toolbar button when running start/stop --- daemon/core/gui/toolbar.py | 116 ++++++++++++++++++++----------------- 1 file changed, 62 insertions(+), 54 deletions(-) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 01a6bc1b..46b70bee 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -5,6 +5,8 @@ from functools import partial from tkinter import ttk from typing import TYPE_CHECKING, Callable +from PIL.ImageTk import PhotoImage + from core.api.grpc import core_pb2 from core.gui.dialogs.marker import MarkerDialog from core.gui.dialogs.runtool import RunToolDialog @@ -18,7 +20,6 @@ from core.gui.tooltip import Tooltip if TYPE_CHECKING: from core.gui.app import Application - from PIL import ImageTk TOOLBAR_SIZE = 32 PICKER_SIZE = 24 @@ -30,8 +31,10 @@ class NodeTypeEnum(Enum): OTHER = 2 -def icon(image_enum, width=TOOLBAR_SIZE): - return Images.get(image_enum, width) +def enable_buttons(frame: ttk.Frame, enabled: bool) -> None: + state = tk.NORMAL if enabled else tk.DISABLED + for child in frame.winfo_children(): + child.configure(state=state) class Toolbar(ttk.Frame): @@ -39,7 +42,7 @@ class Toolbar(ttk.Frame): Core toolbar class """ - def __init__(self, master: tk.Widget, app: "Application", **kwargs): + def __init__(self, master: tk.Widget, app: "Application", **kwargs) -> None: """ Create a CoreToolbar instance """ @@ -71,7 +74,7 @@ class Toolbar(ttk.Frame): self.marker_tool = None # these variables help keep track of what images being drawn so that scaling - # is possible since ImageTk.PhotoImage does not have resize method + # is possible since PhotoImage does not have resize method self.node_enum = None self.network_enum = None self.annotation_enum = None @@ -79,35 +82,35 @@ class Toolbar(ttk.Frame): # draw components self.draw() - def get_icon(self, image_enum, width=TOOLBAR_SIZE): + def get_icon(self, image_enum: ImageEnum, width: int) -> PhotoImage: return Images.get(image_enum, int(width * self.app.app_scale)) - def draw(self): + def draw(self) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) self.draw_design_frame() self.draw_runtime_frame() self.design_frame.tkraise() - def draw_design_frame(self): + def draw_design_frame(self) -> None: self.design_frame = ttk.Frame(self) self.design_frame.grid(row=0, column=0, sticky="nsew") self.design_frame.columnconfigure(0, weight=1) self.play_button = self.create_button( self.design_frame, - self.get_icon(ImageEnum.START), + self.get_icon(ImageEnum.START, TOOLBAR_SIZE), self.click_start, "start the session", ) self.select_button = self.create_button( self.design_frame, - self.get_icon(ImageEnum.SELECT), + self.get_icon(ImageEnum.SELECT, TOOLBAR_SIZE), self.click_selection, "selection tool", ) self.link_button = self.create_button( self.design_frame, - self.get_icon(ImageEnum.LINK), + self.get_icon(ImageEnum.LINK, TOOLBAR_SIZE), self.click_link, "link tool", ) @@ -115,7 +118,7 @@ class Toolbar(ttk.Frame): self.create_network_button() self.create_annotation_button() - def design_select(self, button: ttk.Button): + def design_select(self, button: ttk.Button) -> None: logging.debug("selecting design button: %s", button) self.select_button.state(["!pressed"]) self.link_button.state(["!pressed"]) @@ -124,7 +127,7 @@ class Toolbar(ttk.Frame): self.annotation_button.state(["!pressed"]) button.state(["pressed"]) - def runtime_select(self, button: ttk.Button): + def runtime_select(self, button: ttk.Button) -> None: logging.debug("selecting runtime button: %s", button) self.runtime_select_button.state(["!pressed"]) self.stop_button.state(["!pressed"]) @@ -132,33 +135,36 @@ class Toolbar(ttk.Frame): self.run_command_button.state(["!pressed"]) button.state(["pressed"]) - def draw_runtime_frame(self): + def draw_runtime_frame(self) -> None: self.runtime_frame = ttk.Frame(self) self.runtime_frame.grid(row=0, column=0, sticky="nsew") self.runtime_frame.columnconfigure(0, weight=1) self.stop_button = self.create_button( self.runtime_frame, - self.get_icon(ImageEnum.STOP), + self.get_icon(ImageEnum.STOP, TOOLBAR_SIZE), self.click_stop, "stop the session", ) self.runtime_select_button = self.create_button( self.runtime_frame, - self.get_icon(ImageEnum.SELECT), + self.get_icon(ImageEnum.SELECT, TOOLBAR_SIZE), self.click_runtime_selection, "selection tool", ) self.runtime_marker_button = self.create_button( self.runtime_frame, - icon(ImageEnum.MARKER), + self.get_icon(ImageEnum.MARKER, TOOLBAR_SIZE), self.click_marker_button, "marker", ) self.run_command_button = self.create_button( - self.runtime_frame, icon(ImageEnum.RUN), self.click_run_button, "run" + self.runtime_frame, + self.get_icon(ImageEnum.RUN, TOOLBAR_SIZE), + self.click_run_button, + "run", ) - def draw_node_picker(self): + def draw_node_picker(self) -> None: self.hide_pickers() self.node_picker = ttk.Frame(self.master) # draw default nodes @@ -197,7 +203,7 @@ class Toolbar(ttk.Frame): 0, lambda: self.show_picker(self.node_button, self.node_picker) ) - def show_picker(self, button: ttk.Button, picker: ttk.Frame): + def show_picker(self, button: ttk.Button, picker: ttk.Frame) -> None: x = self.winfo_width() + 1 y = button.winfo_rooty() - picker.master.winfo_rooty() - 1 picker.place(x=x, y=y) @@ -208,8 +214,8 @@ class Toolbar(ttk.Frame): self.app.unbind_all("") def create_picker_button( - self, image: "ImageTk.PhotoImage", func: Callable, frame: ttk.Frame, label: str - ): + self, image: PhotoImage, func: Callable, frame: ttk.Frame, label: str + ) -> None: """ Create button and put it on the frame @@ -226,58 +232,58 @@ class Toolbar(ttk.Frame): button.grid(pady=1) def create_button( - self, - frame: ttk.Frame, - image: "ImageTk.PhotoImage", - func: Callable, - tooltip: str, - ): + self, frame: ttk.Frame, image: PhotoImage, func: Callable, tooltip: str + ) -> ttk.Button: button = ttk.Button(frame, image=image, command=func) button.image = image button.grid(sticky="ew") Tooltip(button, tooltip) return button - def click_selection(self): + def click_selection(self) -> None: logging.debug("clicked selection tool") self.design_select(self.select_button) self.app.canvas.mode = GraphMode.SELECT - def click_runtime_selection(self): + def click_runtime_selection(self) -> None: logging.debug("clicked selection tool") self.runtime_select(self.runtime_select_button) self.app.canvas.mode = GraphMode.SELECT - def click_start(self): + def click_start(self) -> None: """ Start session handler redraw buttons, send node and link messages to grpc server. """ self.app.menubar.change_menubar_item_state(is_runtime=True) self.app.canvas.mode = GraphMode.SELECT + enable_buttons(self.design_frame, enabled=False) task = ProgressTask( self.app, "Start", self.app.core.start_session, self.start_callback ) task.start() - def start_callback(self, response: core_pb2.StartSessionResponse): + def start_callback(self, response: core_pb2.StartSessionResponse) -> None: if response.result: self.set_runtime() self.app.core.set_metadata() self.app.core.show_mobility_players() else: + enable_buttons(self.design_frame, enabled=True) message = "\n".join(response.exceptions) self.app.show_error("Start Session Error", message) - def set_runtime(self): + def set_runtime(self) -> None: + enable_buttons(self.runtime_frame, enabled=True) self.runtime_frame.tkraise() self.click_runtime_selection() - def set_design(self): + def set_design(self) -> None: + enable_buttons(self.design_frame, enabled=True) self.design_frame.tkraise() self.click_selection() - def click_link(self): + def click_link(self) -> None: logging.debug("Click LINK button") self.design_select(self.link_button) self.app.canvas.mode = GraphMode.EDGE @@ -285,11 +291,11 @@ class Toolbar(ttk.Frame): def update_button( self, button: ttk.Button, - image: "ImageTk", + image: PhotoImage, node_draw: NodeDraw, type_enum, image_enum, - ): + ) -> None: logging.debug("update button(%s): %s", button, node_draw) self.hide_pickers() button.configure(image=image) @@ -301,7 +307,7 @@ class Toolbar(ttk.Frame): elif type_enum == NodeTypeEnum.NETWORK: self.network_enum = image_enum - def hide_pickers(self): + def hide_pickers(self) -> None: logging.debug("hiding pickers") if self.node_picker: self.node_picker.destroy() @@ -313,7 +319,7 @@ class Toolbar(ttk.Frame): self.annotation_picker.destroy() self.annotation_picker = None - def create_node_button(self): + def create_node_button(self) -> None: """ Create network layer button """ @@ -326,7 +332,7 @@ class Toolbar(ttk.Frame): Tooltip(self.node_button, "Network-layer virtual nodes") self.node_enum = ImageEnum.ROUTER - def draw_network_picker(self): + def draw_network_picker(self) -> None: """ Draw the options for link-layer button. """ @@ -353,7 +359,7 @@ class Toolbar(ttk.Frame): 0, lambda: self.show_picker(self.network_button, self.network_picker) ) - def create_network_button(self): + def create_network_button(self) -> None: """ Create link-layer node button and the options that represent different link-layer node types. @@ -367,7 +373,7 @@ class Toolbar(ttk.Frame): Tooltip(self.network_button, "link-layer nodes") self.network_enum = ImageEnum.HUB - def draw_annotation_picker(self): + def draw_annotation_picker(self) -> None: """ Draw the options for marker button. """ @@ -393,7 +399,7 @@ class Toolbar(ttk.Frame): 0, lambda: self.show_picker(self.annotation_button, self.annotation_picker) ) - def create_annotation_button(self): + def create_annotation_button(self) -> None: """ Create marker button and options that represent different marker types """ @@ -406,9 +412,10 @@ class Toolbar(ttk.Frame): Tooltip(self.annotation_button, "background annotation tools") self.annotation_enum = ImageEnum.MARKER - def create_observe_button(self): + def create_observe_button(self) -> None: + image = self.get_icon(ImageEnum.OBSERVE, TOOLBAR_SIZE) menu_button = ttk.Menubutton( - self.runtime_frame, image=icon(ImageEnum.OBSERVE), direction=tk.RIGHT + self.runtime_frame, image=image, direction=tk.RIGHT ) menu_button.grid(sticky="ew") menu = tk.Menu(menu_button, tearoff=0) @@ -430,25 +437,26 @@ class Toolbar(ttk.Frame): menu.add_command(label="PIM neighbors") menu.add_command(label="Edit...") - def click_stop(self): + def click_stop(self) -> None: """ redraw buttons on the toolbar, send node and link messages to grpc server """ logging.info("clicked stop button") self.app.menubar.change_menubar_item_state(is_runtime=False) self.app.core.close_mobility_players() + enable_buttons(self.runtime_frame, enabled=False) task = ProgressTask( self.app, "Stop", self.app.core.stop_session, self.stop_callback ) task.start() - def stop_callback(self, response: core_pb2.StopSessionResponse): + def stop_callback(self, response: core_pb2.StopSessionResponse) -> None: self.set_design() self.app.canvas.stopped_session() def update_annotation( - self, image: "ImageTk.PhotoImage", shape_type: ShapeType, image_enum - ): + self, image: PhotoImage, shape_type: ShapeType, image_enum + ) -> None: logging.debug("clicked annotation: ") self.hide_pickers() self.annotation_button.configure(image=image) @@ -462,12 +470,12 @@ class Toolbar(ttk.Frame): self.marker_tool = MarkerDialog(self.app) self.marker_tool.show() - def click_run_button(self): + def click_run_button(self) -> None: logging.debug("Click on RUN button") dialog = RunToolDialog(self.app) dialog.show() - def click_marker_button(self): + def click_marker_button(self) -> None: logging.debug("Click on marker button") self.runtime_select(self.runtime_marker_button) self.app.canvas.mode = GraphMode.ANNOTATION @@ -477,12 +485,12 @@ class Toolbar(ttk.Frame): self.marker_tool = MarkerDialog(self.app) self.marker_tool.show() - def scale_button(self, button, image_enum): - image = icon(image_enum, int(TOOLBAR_SIZE * self.app.app_scale)) + def scale_button(self, button, image_enum) -> None: + image = self.get_icon(image_enum, TOOLBAR_SIZE) button.config(image=image) button.image = image - def scale(self): + def scale(self) -> None: self.scale_button(self.play_button, ImageEnum.START) self.scale_button(self.select_button, ImageEnum.SELECT) self.scale_button(self.link_button, ImageEnum.LINK) From 0dcfcbf4ea1623617327e271a0a332716f6c3619 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 15 May 2020 11:43:54 -0700 Subject: [PATCH 058/929] pygui: simplified toolbar constructor, since there is no need for something more complicated --- daemon/core/gui/app.py | 2 +- daemon/core/gui/toolbar.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 90e5c36c..7ada71eb 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -104,7 +104,7 @@ class Application(ttk.Frame): self.rowconfigure(0, weight=1) self.columnconfigure(1, weight=1) self.grid(sticky="nsew") - self.toolbar = Toolbar(self, self) + self.toolbar = Toolbar(self) self.toolbar.grid(sticky="ns") self.right_frame = ttk.Frame(self) self.right_frame.columnconfigure(0, weight=1) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 46b70bee..2bf4e63c 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -42,11 +42,11 @@ class Toolbar(ttk.Frame): Core toolbar class """ - def __init__(self, master: tk.Widget, app: "Application", **kwargs) -> None: + def __init__(self, app: "Application") -> None: """ Create a CoreToolbar instance """ - super().__init__(master, **kwargs) + super().__init__(app) self.app = app # design buttons From 4eaecd6a7b260f00c1fabf74ca6a248b4c2a51d4 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 15 May 2020 14:46:35 -0700 Subject: [PATCH 059/929] pygui: simplified a couple of the other widget constructors --- daemon/core/gui/app.py | 7 +++---- daemon/core/gui/graph/graph.py | 37 +++++++++++++++------------------- daemon/core/gui/menubar.py | 5 ++--- daemon/core/gui/statusbar.py | 4 ++-- 4 files changed, 23 insertions(+), 30 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 7ada71eb..fe5c3659 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -113,16 +113,15 @@ class Application(ttk.Frame): self.draw_canvas() self.draw_status() self.progress = Progressbar(self.right_frame, mode="indeterminate") - self.menubar = Menubar(self.master, self) + self.menubar = Menubar(self) + self.master.config(menu=self.menubar) def draw_canvas(self) -> None: - width = self.guiconfig.preferences.width - height = self.guiconfig.preferences.height canvas_frame = ttk.Frame(self.right_frame) canvas_frame.rowconfigure(0, weight=1) canvas_frame.columnconfigure(0, weight=1) canvas_frame.grid(sticky="nsew", pady=1) - self.canvas = CanvasGraph(canvas_frame, self, self.core, width, height) + self.canvas = CanvasGraph(canvas_frame, self, self.core) self.canvas.grid(sticky="nsew") scroll_y = ttk.Scrollbar(canvas_frame, command=self.canvas.yview) scroll_y.grid(row=0, column=1, sticky="ns") diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 2920f9b0..74ca3bc2 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -41,19 +41,12 @@ class ShowVar(BooleanVar): def state(self) -> str: return tk.NORMAL if self.get() else tk.HIDDEN - def click_handler(self): + def click_handler(self) -> None: self.canvas.itemconfigure(self.tag, state=self.state()) class CanvasGraph(tk.Canvas): - def __init__( - self, - master: tk.Widget, - app: "Application", - core: "CoreClient", - width: int, - height: int, - ): + def __init__(self, master: tk.Widget, app: "Application", core: "CoreClient"): super().__init__(master, highlightthickness=0, background="#cccccc") self.app = app self.core = core @@ -74,6 +67,8 @@ class CanvasGraph(tk.Canvas): self.drawing_edge = None self.rect = None self.shape_drawing = False + width = self.app.guiconfig.preferences.width + height = self.app.guiconfig.preferences.height self.default_dimensions = (width, height) self.current_dimensions = self.default_dimensions self.ratio = 1.0 @@ -571,10 +566,10 @@ class CanvasGraph(tk.Canvas): self.offset[0] * factor + event.x * (1 - factor), self.offset[1] * factor + event.y * (1 - factor), ) - logging.info("ratio: %s", self.ratio) - logging.info("offset: %s", self.offset) - self.app.statusbar.zoom.config(text="%s" % (int(self.ratio * 100)) + "%") - + logging.debug("ratio: %s", self.ratio) + logging.debug("offset: %s", self.offset) + zoom_label = f"{self.ratio * 100:.0f}%" + self.app.statusbar.zoom.config(text=zoom_label) if self.wallpaper: self.redraw_wallpaper() @@ -720,7 +715,7 @@ class CanvasGraph(tk.Canvas): if not self.app.core.is_runtime(): self.delete_selected_objects() else: - logging.info("node deletion is disabled during runtime state") + logging.debug("node deletion is disabled during runtime state") def double_click(self, event: tk.Event): selected = self.get_selected(event) @@ -836,10 +831,10 @@ class CanvasGraph(tk.Canvas): self.draw_wallpaper(image) def redraw_canvas(self, dimensions: Tuple[int, int] = None): - logging.info("redrawing canvas to dimensions: %s", dimensions) + logging.debug("redrawing canvas to dimensions: %s", dimensions) # reset scale and move back to original position - logging.info("resetting scaling: %s %s", self.ratio, self.offset) + logging.debug("resetting scaling: %s %s", self.ratio, self.offset) factor = 1 / self.ratio self.scale(tk.ALL, self.offset[0], self.offset[1], factor, factor) self.move(tk.ALL, -self.offset[0], -self.offset[1]) @@ -858,11 +853,11 @@ class CanvasGraph(tk.Canvas): def redraw_wallpaper(self): if self.adjust_to_dim.get(): - logging.info("drawing wallpaper to canvas dimensions") + logging.debug("drawing wallpaper to canvas dimensions") self.resize_to_wallpaper() else: option = ScaleOption(self.scale_option.get()) - logging.info("drawing canvas using scaling option: %s", option) + logging.debug("drawing canvas using scaling option: %s", option) if option == ScaleOption.UPPER_LEFT: self.wallpaper_upper_left() elif option == ScaleOption.CENTERED: @@ -908,10 +903,10 @@ class CanvasGraph(tk.Canvas): def copy(self): if self.core.is_runtime(): - logging.info("copy is disabled during runtime state") + logging.debug("copy is disabled during runtime state") return if self.selection: - logging.info("to copy nodes: %s", self.selection) + logging.debug("to copy nodes: %s", self.selection) self.to_copy.clear() for node_id in self.selection.keys(): canvas_node = self.nodes[node_id] @@ -919,7 +914,7 @@ class CanvasGraph(tk.Canvas): def paste(self): if self.core.is_runtime(): - logging.info("paste is disabled during runtime state") + logging.debug("paste is disabled during runtime state") return # maps original node canvas id to copy node canvas id copy_map = {} diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index 2e07ed0a..69db0092 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -47,12 +47,11 @@ class Menubar(tk.Menu): Core menubar """ - def __init__(self, master: tk.Tk, app: "Application", **kwargs) -> None: + def __init__(self, app: "Application") -> None: """ Create a CoreMenubar instance """ - super().__init__(master, **kwargs) - self.master.config(menu=self) + super().__init__(app) self.app = app self.core = app.core self.canvas = app.canvas diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py index 6c2e5e19..3f58e7a0 100644 --- a/daemon/core/gui/statusbar.py +++ b/daemon/core/gui/statusbar.py @@ -13,8 +13,8 @@ if TYPE_CHECKING: class StatusBar(ttk.Frame): - def __init__(self, master: tk.Widget, app: "Application", **kwargs): - super().__init__(master, **kwargs) + def __init__(self, master: tk.Widget, app: "Application"): + super().__init__(master) self.app = app self.status = None self.statusvar = tk.StringVar() From 29fc5acb996691f4fdf3d8f9553fa676deefa712 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 15 May 2020 23:23:07 -0700 Subject: [PATCH 060/929] pygui: toolbar cleanup for buttonbar frames --- daemon/core/gui/app.py | 5 +- daemon/core/gui/toolbar.py | 198 +++++++++++++------------------------ 2 files changed, 74 insertions(+), 129 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index fe5c3659..2644a46d 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -1,7 +1,7 @@ import logging import math import tkinter as tk -from tkinter import font, ttk +from tkinter import PhotoImage, font, ttk from tkinter.ttk import Progressbar import grpc @@ -160,5 +160,8 @@ class Application(ttk.Frame): else: self.toolbar.set_design() + def get_icon(self, image_enum: ImageEnum, width: int) -> PhotoImage: + return Images.get(image_enum, int(width * self.app_scale)) + def close(self) -> None: self.master.destroy() diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 2bf4e63c..9fc81a74 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -37,6 +37,30 @@ def enable_buttons(frame: ttk.Frame, enabled: bool) -> None: child.configure(state=state) +class ButtonBar(ttk.Frame): + def __init__(self, master: tk.Widget, app: "Application"): + super().__init__(master) + self.app = app + self.radio_buttons = [] + + def create_button( + self, image_enum: ImageEnum, func: Callable, tooltip: str, radio: bool = False + ) -> ttk.Button: + image = self.app.get_icon(image_enum, TOOLBAR_SIZE) + button = ttk.Button(self, image=image, command=func) + button.image = image + button.grid(sticky="ew") + Tooltip(button, tooltip) + if radio: + self.radio_buttons.append(button) + return button + + def select_radio(self, selected: ttk.Button) -> None: + for button in self.radio_buttons: + button.state(["!pressed"]) + selected.state(["pressed"]) + + class Toolbar(ttk.Frame): """ Core toolbar class @@ -82,9 +106,6 @@ class Toolbar(ttk.Frame): # draw components self.draw() - def get_icon(self, image_enum: ImageEnum, width: int) -> PhotoImage: - return Images.get(image_enum, int(width * self.app.app_scale)) - def draw(self) -> None: self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) @@ -93,84 +114,59 @@ class Toolbar(ttk.Frame): self.design_frame.tkraise() def draw_design_frame(self) -> None: - self.design_frame = ttk.Frame(self) + self.design_frame = ButtonBar(self, self.app) self.design_frame.grid(row=0, column=0, sticky="nsew") self.design_frame.columnconfigure(0, weight=1) - self.play_button = self.create_button( - self.design_frame, - self.get_icon(ImageEnum.START, TOOLBAR_SIZE), - self.click_start, - "start the session", + self.play_button = self.design_frame.create_button( + ImageEnum.START, self.click_start, "Start Session" ) - self.select_button = self.create_button( - self.design_frame, - self.get_icon(ImageEnum.SELECT, TOOLBAR_SIZE), - self.click_selection, - "selection tool", + self.select_button = self.design_frame.create_button( + ImageEnum.SELECT, self.click_selection, "Selection Tool", radio=True ) - self.link_button = self.create_button( - self.design_frame, - self.get_icon(ImageEnum.LINK, TOOLBAR_SIZE), - self.click_link, - "link tool", + self.link_button = self.design_frame.create_button( + ImageEnum.LINK, self.click_link, "Link Tool", radio=True + ) + self.node_enum = ImageEnum.ROUTER + self.node_button = self.design_frame.create_button( + self.node_enum, self.draw_node_picker, "Container Nodes", radio=True + ) + self.network_enum = ImageEnum.HUB + self.network_button = self.design_frame.create_button( + self.network_enum, self.draw_network_picker, "Link Layer Nodes", radio=True + ) + self.annotation_enum = ImageEnum.MARKER + self.annotation_button = self.design_frame.create_button( + self.annotation_enum, + self.draw_annotation_picker, + "Annotation Tools", + radio=True, ) - self.create_node_button() - self.create_network_button() - self.create_annotation_button() - - def design_select(self, button: ttk.Button) -> None: - logging.debug("selecting design button: %s", button) - self.select_button.state(["!pressed"]) - self.link_button.state(["!pressed"]) - self.node_button.state(["!pressed"]) - self.network_button.state(["!pressed"]) - self.annotation_button.state(["!pressed"]) - button.state(["pressed"]) - - def runtime_select(self, button: ttk.Button) -> None: - logging.debug("selecting runtime button: %s", button) - self.runtime_select_button.state(["!pressed"]) - self.stop_button.state(["!pressed"]) - self.runtime_marker_button.state(["!pressed"]) - self.run_command_button.state(["!pressed"]) - button.state(["pressed"]) def draw_runtime_frame(self) -> None: - self.runtime_frame = ttk.Frame(self) + self.runtime_frame = ButtonBar(self, self.app) self.runtime_frame.grid(row=0, column=0, sticky="nsew") self.runtime_frame.columnconfigure(0, weight=1) - self.stop_button = self.create_button( - self.runtime_frame, - self.get_icon(ImageEnum.STOP, TOOLBAR_SIZE), - self.click_stop, - "stop the session", + self.stop_button = self.runtime_frame.create_button( + ImageEnum.STOP, self.click_stop, "Stop Session" ) - self.runtime_select_button = self.create_button( - self.runtime_frame, - self.get_icon(ImageEnum.SELECT, TOOLBAR_SIZE), - self.click_runtime_selection, - "selection tool", + self.runtime_select_button = self.runtime_frame.create_button( + ImageEnum.SELECT, self.click_runtime_selection, "Selection Tool", radio=True ) - self.runtime_marker_button = self.create_button( - self.runtime_frame, - self.get_icon(ImageEnum.MARKER, TOOLBAR_SIZE), - self.click_marker_button, - "marker", + self.runtime_marker_button = self.runtime_frame.create_button( + ImageEnum.MARKER, self.click_marker_button, "Marker Tool", radio=True ) - self.run_command_button = self.create_button( - self.runtime_frame, - self.get_icon(ImageEnum.RUN, TOOLBAR_SIZE), - self.click_run_button, - "run", + self.run_command_button = self.runtime_frame.create_button( + ImageEnum.RUN, self.click_run_button, "Run Tool" ) def draw_node_picker(self) -> None: + self.design_frame.select_radio(self.node_button) self.hide_pickers() self.node_picker = ttk.Frame(self.master) # draw default nodes for node_draw in NodeUtils.NODES: - toolbar_image = self.get_icon(node_draw.image_enum, TOOLBAR_SIZE) - image = self.get_icon(node_draw.image_enum, PICKER_SIZE) + toolbar_image = self.app.get_icon(node_draw.image_enum, TOOLBAR_SIZE) + image = self.app.get_icon(node_draw.image_enum, PICKER_SIZE) func = partial( self.update_button, self.node_button, @@ -198,7 +194,6 @@ class Toolbar(ttk.Frame): node_draw.image_file, ) self.create_picker_button(image, func, self.node_picker, name) - self.design_select(self.node_button) self.node_button.after( 0, lambda: self.show_picker(self.node_button, self.node_picker) ) @@ -231,23 +226,12 @@ class Toolbar(ttk.Frame): button.bind("", lambda e: func()) button.grid(pady=1) - def create_button( - self, frame: ttk.Frame, image: PhotoImage, func: Callable, tooltip: str - ) -> ttk.Button: - button = ttk.Button(frame, image=image, command=func) - button.image = image - button.grid(sticky="ew") - Tooltip(button, tooltip) - return button - def click_selection(self) -> None: - logging.debug("clicked selection tool") - self.design_select(self.select_button) + self.design_frame.select_radio(self.select_button) self.app.canvas.mode = GraphMode.SELECT def click_runtime_selection(self) -> None: - logging.debug("clicked selection tool") - self.runtime_select(self.runtime_select_button) + self.runtime_frame.select_radio(self.runtime_select_button) self.app.canvas.mode = GraphMode.SELECT def click_start(self) -> None: @@ -284,8 +268,7 @@ class Toolbar(ttk.Frame): self.click_selection() def click_link(self) -> None: - logging.debug("Click LINK button") - self.design_select(self.link_button) + self.design_frame.select_radio(self.link_button) self.app.canvas.mode = GraphMode.EDGE def update_button( @@ -319,28 +302,16 @@ class Toolbar(ttk.Frame): self.annotation_picker.destroy() self.annotation_picker = None - def create_node_button(self) -> None: - """ - Create network layer button - """ - image = self.get_icon(ImageEnum.ROUTER, TOOLBAR_SIZE) - self.node_button = ttk.Button( - self.design_frame, image=image, command=self.draw_node_picker - ) - self.node_button.image = image - self.node_button.grid(sticky="ew") - Tooltip(self.node_button, "Network-layer virtual nodes") - self.node_enum = ImageEnum.ROUTER - def draw_network_picker(self) -> None: """ Draw the options for link-layer button. """ + self.design_frame.select_radio(self.network_button) self.hide_pickers() self.network_picker = ttk.Frame(self.master) for node_draw in NodeUtils.NETWORK_NODES: - toolbar_image = self.get_icon(node_draw.image_enum, TOOLBAR_SIZE) - image = self.get_icon(node_draw.image_enum, PICKER_SIZE) + toolbar_image = self.app.get_icon(node_draw.image_enum, TOOLBAR_SIZE) + image = self.app.get_icon(node_draw.image_enum, PICKER_SIZE) self.create_picker_button( image, partial( @@ -354,29 +325,15 @@ class Toolbar(ttk.Frame): self.network_picker, node_draw.label, ) - self.design_select(self.network_button) self.network_button.after( 0, lambda: self.show_picker(self.network_button, self.network_picker) ) - def create_network_button(self) -> None: - """ - Create link-layer node button and the options that represent different - link-layer node types. - """ - image = self.get_icon(ImageEnum.HUB, TOOLBAR_SIZE) - self.network_button = ttk.Button( - self.design_frame, image=image, command=self.draw_network_picker - ) - self.network_button.image = image - self.network_button.grid(sticky="ew") - Tooltip(self.network_button, "link-layer nodes") - self.network_enum = ImageEnum.HUB - def draw_annotation_picker(self) -> None: """ Draw the options for marker button. """ + self.design_frame.select_radio(self.annotation_button) self.hide_pickers() self.annotation_picker = ttk.Frame(self.master) nodes = [ @@ -386,34 +343,20 @@ class Toolbar(ttk.Frame): (ImageEnum.TEXT, ShapeType.TEXT), ] for image_enum, shape_type in nodes: - toolbar_image = self.get_icon(image_enum, TOOLBAR_SIZE) - image = self.get_icon(image_enum, PICKER_SIZE) + toolbar_image = self.app.get_icon(image_enum, TOOLBAR_SIZE) + image = self.app.get_icon(image_enum, PICKER_SIZE) self.create_picker_button( image, partial(self.update_annotation, toolbar_image, shape_type, image_enum), self.annotation_picker, shape_type.value, ) - self.design_select(self.annotation_button) self.annotation_button.after( 0, lambda: self.show_picker(self.annotation_button, self.annotation_picker) ) - def create_annotation_button(self) -> None: - """ - Create marker button and options that represent different marker types - """ - image = self.get_icon(ImageEnum.MARKER, TOOLBAR_SIZE) - self.annotation_button = ttk.Button( - self.design_frame, image=image, command=self.draw_annotation_picker - ) - self.annotation_button.image = image - self.annotation_button.grid(sticky="ew") - Tooltip(self.annotation_button, "background annotation tools") - self.annotation_enum = ImageEnum.MARKER - def create_observe_button(self) -> None: - image = self.get_icon(ImageEnum.OBSERVE, TOOLBAR_SIZE) + image = self.app.get_icon(ImageEnum.OBSERVE, TOOLBAR_SIZE) menu_button = ttk.Menubutton( self.runtime_frame, image=image, direction=tk.RIGHT ) @@ -476,8 +419,7 @@ class Toolbar(ttk.Frame): dialog.show() def click_marker_button(self) -> None: - logging.debug("Click on marker button") - self.runtime_select(self.runtime_marker_button) + self.runtime_frame.select_radio(self.runtime_marker_button) self.app.canvas.mode = GraphMode.ANNOTATION self.app.canvas.annotation_type = ShapeType.MARKER if self.marker_tool: @@ -486,7 +428,7 @@ class Toolbar(ttk.Frame): self.marker_tool.show() def scale_button(self, button, image_enum) -> None: - image = self.get_icon(image_enum, TOOLBAR_SIZE) + image = self.app.get_icon(image_enum, TOOLBAR_SIZE) button.config(image=image) button.image = image From 50816b3b80ce620a8efa81c98e0e7adedfc1db95 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 16 May 2020 01:14:48 -0700 Subject: [PATCH 061/929] pygui: cleaned up toolbar picker code, fixed closing app when a picker is showing --- daemon/core/gui/app.py | 5 ++ daemon/core/gui/toolbar.py | 178 ++++++++++++++----------------------- 2 files changed, 71 insertions(+), 112 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 2644a46d..c795a46a 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -149,6 +149,8 @@ class Application(ttk.Frame): self.after(0, lambda: ErrorDialog(self, title, message).show()) def on_closing(self) -> None: + if self.toolbar.picker: + self.toolbar.picker.destroy() self.menubar.prompt_save_running_session(True) def save_config(self) -> None: @@ -163,5 +165,8 @@ class Application(ttk.Frame): def get_icon(self, image_enum: ImageEnum, width: int) -> PhotoImage: return Images.get(image_enum, int(width * self.app_scale)) + def get_custom_icon(self, image_file: str, width: int) -> PhotoImage: + return Images.get_custom(image_file, int(width * self.app_scale)) + def close(self) -> None: self.master.destroy() diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 9fc81a74..572a523d 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -12,7 +12,7 @@ from core.gui.dialogs.marker import MarkerDialog from core.gui.dialogs.runtool import RunToolDialog from core.gui.graph.enums import GraphMode from core.gui.graph.shapeutils import ShapeType, is_marker -from core.gui.images import ImageEnum, Images +from core.gui.images import ImageEnum from core.gui.nodeutils import NodeDraw, NodeUtils from core.gui.task import ProgressTask from core.gui.themes import Styles @@ -37,6 +37,46 @@ def enable_buttons(frame: ttk.Frame, enabled: bool) -> None: child.configure(state=state) +class PickerFrame(ttk.Frame): + def __init__(self, app: "Application", button: ttk.Button) -> None: + super().__init__(app) + self.app = app + self.button = button + + def create_button(self, label: str, image_enum: ImageEnum, func: Callable) -> None: + bar_image = self.app.get_icon(image_enum, TOOLBAR_SIZE) + image = self.app.get_icon(image_enum, PICKER_SIZE) + self._create_button(label, image, bar_image, func) + + def create_custom_button(self, label: str, image_file: str, func: Callable) -> None: + bar_image = self.app.get_custom_icon(image_file, TOOLBAR_SIZE) + image = self.app.get_custom_icon(image_file, PICKER_SIZE) + self._create_button(label, image, bar_image, func) + + def _create_button( + self, label: str, image: PhotoImage, bar_image: PhotoImage, func: Callable + ) -> None: + button = ttk.Button( + self, image=image, text=label, compound=tk.TOP, style=Styles.picker_button + ) + button.image = image + button.bind("", lambda e: func(bar_image)) + button.grid(pady=1) + + def show(self) -> None: + self.button.after(0, self._show) + + def _show(self) -> None: + x = self.button.winfo_width() + 1 + y = self.button.winfo_rooty() - self.app.winfo_rooty() - 1 + self.place(x=x, y=y) + self.app.bind_all("", lambda e: self.destroy()) + self.wait_visibility() + self.grab_set() + self.wait_window() + self.app.unbind_all("") + + class ButtonBar(ttk.Frame): def __init__(self, master: tk.Widget, app: "Application"): super().__init__(master) @@ -90,9 +130,7 @@ class Toolbar(ttk.Frame): # frames self.design_frame = None self.runtime_frame = None - self.node_picker = None - self.network_picker = None - self.annotation_picker = None + self.picker = None # dialog self.marker_tool = None @@ -161,70 +199,23 @@ class Toolbar(ttk.Frame): def draw_node_picker(self) -> None: self.design_frame.select_radio(self.node_button) - self.hide_pickers() - self.node_picker = ttk.Frame(self.master) + self.picker = PickerFrame(self.app, self.node_button) # draw default nodes for node_draw in NodeUtils.NODES: - toolbar_image = self.app.get_icon(node_draw.image_enum, TOOLBAR_SIZE) - image = self.app.get_icon(node_draw.image_enum, PICKER_SIZE) func = partial( - self.update_button, - self.node_button, - toolbar_image, - node_draw, - NodeTypeEnum.NODE, - node_draw.image_enum, + self.update_button, self.node_button, node_draw, NodeTypeEnum.NODE ) - self.create_picker_button(image, func, self.node_picker, node_draw.label) + self.picker.create_button(node_draw.label, node_draw.image_enum, func) # draw custom nodes for name in sorted(self.app.core.custom_nodes): node_draw = self.app.core.custom_nodes[name] - toolbar_image = Images.get_custom( - node_draw.image_file, int(TOOLBAR_SIZE * self.app.app_scale) - ) - image = Images.get_custom( - node_draw.image_file, int(PICKER_SIZE * self.app.app_scale) - ) func = partial( - self.update_button, - self.node_button, - toolbar_image, - node_draw, - NodeTypeEnum, - node_draw.image_file, + self.update_button, self.node_button, node_draw, NodeTypeEnum.NODE ) - self.create_picker_button(image, func, self.node_picker, name) - self.node_button.after( - 0, lambda: self.show_picker(self.node_button, self.node_picker) - ) - - def show_picker(self, button: ttk.Button, picker: ttk.Frame) -> None: - x = self.winfo_width() + 1 - y = button.winfo_rooty() - picker.master.winfo_rooty() - 1 - picker.place(x=x, y=y) - self.app.bind_all("", lambda e: self.hide_pickers()) - picker.wait_visibility() - picker.grab_set() - self.wait_window(picker) - self.app.unbind_all("") - - def create_picker_button( - self, image: PhotoImage, func: Callable, frame: ttk.Frame, label: str - ) -> None: - """ - Create button and put it on the frame - - :param image: button image - :param func: the command that is executed when button is clicked - :param frame: frame that contains the button - :param label: button label - """ - button = ttk.Button( - frame, image=image, text=label, compound=tk.TOP, style=Styles.picker_button - ) - button.image = image - button.bind("", lambda e: func()) - button.grid(pady=1) + self.picker.create_custom_button( + node_draw.label, node_draw.image_file, func + ) + self.picker.show() def click_selection(self) -> None: self.design_frame.select_radio(self.select_button) @@ -274,68 +265,39 @@ class Toolbar(ttk.Frame): def update_button( self, button: ttk.Button, - image: PhotoImage, node_draw: NodeDraw, - type_enum, - image_enum, + type_enum: NodeTypeEnum, + image: PhotoImage, ) -> None: logging.debug("update button(%s): %s", button, node_draw) - self.hide_pickers() button.configure(image=image) button.image = image self.app.canvas.mode = GraphMode.NODE self.app.canvas.node_draw = node_draw if type_enum == NodeTypeEnum.NODE: - self.node_enum = image_enum + self.node_enum = node_draw.image_enum elif type_enum == NodeTypeEnum.NETWORK: - self.network_enum = image_enum - - def hide_pickers(self) -> None: - logging.debug("hiding pickers") - if self.node_picker: - self.node_picker.destroy() - self.node_picker = None - if self.network_picker: - self.network_picker.destroy() - self.network_picker = None - if self.annotation_picker: - self.annotation_picker.destroy() - self.annotation_picker = None + self.network_enum = node_draw.image_enum def draw_network_picker(self) -> None: """ Draw the options for link-layer button. """ self.design_frame.select_radio(self.network_button) - self.hide_pickers() - self.network_picker = ttk.Frame(self.master) + self.picker = PickerFrame(self.app, self.network_button) for node_draw in NodeUtils.NETWORK_NODES: - toolbar_image = self.app.get_icon(node_draw.image_enum, TOOLBAR_SIZE) - image = self.app.get_icon(node_draw.image_enum, PICKER_SIZE) - self.create_picker_button( - image, - partial( - self.update_button, - self.network_button, - toolbar_image, - node_draw, - NodeTypeEnum.NETWORK, - node_draw.image_enum, - ), - self.network_picker, - node_draw.label, + func = partial( + self.update_button, self.network_button, node_draw, NodeTypeEnum.NETWORK ) - self.network_button.after( - 0, lambda: self.show_picker(self.network_button, self.network_picker) - ) + self.picker.create_button(node_draw.label, node_draw.image_enum, func) + self.picker.show() def draw_annotation_picker(self) -> None: """ Draw the options for marker button. """ self.design_frame.select_radio(self.annotation_button) - self.hide_pickers() - self.annotation_picker = ttk.Frame(self.master) + self.picker = PickerFrame(self.app, self.annotation_button) nodes = [ (ImageEnum.MARKER, ShapeType.MARKER), (ImageEnum.OVAL, ShapeType.OVAL), @@ -343,17 +305,10 @@ class Toolbar(ttk.Frame): (ImageEnum.TEXT, ShapeType.TEXT), ] for image_enum, shape_type in nodes: - toolbar_image = self.app.get_icon(image_enum, TOOLBAR_SIZE) - image = self.app.get_icon(image_enum, PICKER_SIZE) - self.create_picker_button( - image, - partial(self.update_annotation, toolbar_image, shape_type, image_enum), - self.annotation_picker, - shape_type.value, - ) - self.annotation_button.after( - 0, lambda: self.show_picker(self.annotation_button, self.annotation_picker) - ) + label = shape_type.value + func = partial(self.update_annotation, shape_type, image_enum) + self.picker.create_button(label, image_enum, func) + self.picker.show() def create_observe_button(self) -> None: image = self.app.get_icon(ImageEnum.OBSERVE, TOOLBAR_SIZE) @@ -398,10 +353,9 @@ class Toolbar(ttk.Frame): self.app.canvas.stopped_session() def update_annotation( - self, image: PhotoImage, shape_type: ShapeType, image_enum + self, shape_type: ShapeType, image_enum: ImageEnum, image: PhotoImage ) -> None: - logging.debug("clicked annotation: ") - self.hide_pickers() + logging.debug("clicked annotation") self.annotation_button.configure(image=image) self.annotation_button.image = image self.app.canvas.mode = GraphMode.ANNOTATION From 91220078f1787f55656a259ae49d91310ae7bbf1 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 16 May 2020 14:12:08 -0700 Subject: [PATCH 062/929] pygui: created a singular func for ordering items on canvas by tags, updates so that drawing edges are behind nodes --- daemon/core/gui/coreclient.py | 5 +---- daemon/core/gui/graph/edges.py | 2 -- daemon/core/gui/graph/graph.py | 10 ++++++---- daemon/core/gui/graph/shape.py | 3 +-- daemon/core/gui/graph/tags.py | 10 ++++++---- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index e882227c..dd7f8308 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -20,7 +20,6 @@ from core.gui.dialogs.emaneinstall import EmaneInstallDialog from core.gui.dialogs.error import ErrorDialog from core.gui.dialogs.mobilityplayer import MobilityPlayer from core.gui.dialogs.sessions import SessionsDialog -from core.gui.graph import tags from core.gui.graph.edges import CanvasEdge from core.gui.graph.node import CanvasNode from core.gui.graph.shape import AnnotationData, Shape @@ -389,9 +388,7 @@ class CoreClient: self.app.canvas.shapes[shape.id] = shape except ValueError: logging.exception("unknown shape: %s", shape_type) - - for tag in tags.ABOVE_WALLPAPER_TAGS: - self.app.canvas.tag_raise(tag) + self.app.canvas.organize() def create_new_session(self): """ diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index 68c3823b..00268c88 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -331,8 +331,6 @@ class CanvasEdge(Edge): dst_pos = self.canvas.coords(self.dst) self.move_dst(dst_pos) self.check_wireless() - self.canvas.tag_raise(self.src) - self.canvas.tag_raise(self.dst) logging.debug("Draw wired link from node %s to node %s", self.src, dst) def is_wireless(self) -> bool: diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 74ca3bc2..eacbf1f4 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -139,7 +139,7 @@ class CanvasGraph(tk.Canvas): self.show_ip6s.set(True) # delete any existing drawn items - for tag in tags.COMPONENT_TAGS: + for tag in tags.RESET_TAGS: self.delete(tag) # set the private variables to default value @@ -591,6 +591,7 @@ class CanvasGraph(tk.Canvas): if self.mode == GraphMode.EDGE and is_node: pos = self.coords(selected) self.drawing_edge = CanvasEdge(self, selected, pos, pos) + self.organize() if self.mode == GraphMode.ANNOTATION: if is_marker(self.annotation_type): @@ -866,10 +867,11 @@ class CanvasGraph(tk.Canvas): self.wallpaper_scaled() elif option == ScaleOption.TILED: logging.warning("tiled background not implemented yet") + self.organize() - # raise items above wallpaper - for component in tags.ABOVE_WALLPAPER_TAGS: - self.tag_raise(component) + def organize(self) -> None: + for tag in tags.ORGANIZE_TAGS: + self.tag_raise(tag) def set_wallpaper(self, filename: str): logging.debug("setting wallpaper: %s", filename) diff --git a/daemon/core/gui/graph/shape.py b/daemon/core/gui/graph/shape.py index eeda09fd..9dd01772 100644 --- a/daemon/core/gui/graph/shape.py +++ b/daemon/core/gui/graph/shape.py @@ -146,8 +146,7 @@ class Shape: self.canvas.coords(self.id, self.x1, self.y1, x1, y1) def shape_complete(self, x: float, y: float): - for component in tags.ABOVE_SHAPE: - self.canvas.tag_raise(component) + self.canvas.organize() s = ShapeDialog(self.app, self) s.show() diff --git a/daemon/core/gui/graph/tags.py b/daemon/core/gui/graph/tags.py index 8ac6476b..c0721193 100644 --- a/daemon/core/gui/graph/tags.py +++ b/daemon/core/gui/graph/tags.py @@ -11,19 +11,21 @@ NODE = "node" WALLPAPER = "wallpaper" SELECTION = "selectednodes" MARKER = "marker" -ABOVE_WALLPAPER_TAGS = [ +ORGANIZE_TAGS = [ + WALLPAPER, GRIDLINE, SHAPE, SHAPE_TEXT, EDGE, - LINK_LABEL, WIRELESS_EDGE, + LINK_LABEL, ANTENNA, NODE, NODE_LABEL, + SELECTION, + MARKER, ] -ABOVE_SHAPE = [GRIDLINE, EDGE, LINK_LABEL, WIRELESS_EDGE, ANTENNA, NODE, NODE_LABEL] -COMPONENT_TAGS = [ +RESET_TAGS = [ EDGE, NODE, NODE_LABEL, From 06e3d848620596465675296320cd6b2be2ec8fbf Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 16 May 2020 23:35:19 -0700 Subject: [PATCH 063/929] pygui: fixed interface creation after deletion, fixed issue reusing deleted subnets --- daemon/core/gui/coreclient.py | 17 ++++++++-------- daemon/core/gui/dialogs/nodeconfig.py | 6 +++--- daemon/core/gui/graph/graph.py | 29 +++++++++++++-------------- daemon/core/gui/graph/node.py | 8 +++++++- daemon/core/gui/interface.py | 3 +-- 5 files changed, 33 insertions(+), 30 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index dd7f8308..99966f02 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -330,6 +330,9 @@ class CoreClient: except grpc.RpcError as e: self.app.show_grpc_exception("Join Session Error", e) + # organize canvas + self.app.canvas.organize() + # update ui to represent current state self.app.after(0, self.app.joined_session_update) @@ -388,7 +391,6 @@ class CoreClient: self.app.canvas.shapes[shape.id] = shape except ValueError: logging.exception("unknown shape: %s", shape_type) - self.app.canvas.organize() def create_new_session(self): """ @@ -835,7 +837,7 @@ class CoreClient: ip4, ip6 = self.interfaces_manager.get_ips(node) ip4_mask = self.interfaces_manager.ip4_mask ip6_mask = self.interfaces_manager.ip6_mask - interface_id = len(canvas_node.interfaces) + interface_id = canvas_node.next_interface_id() name = f"eth{interface_id}" interface = core_pb2.Interface( id=interface_id, @@ -845,7 +847,8 @@ class CoreClient: ip6=ip6, ip6mask=ip6_mask, ) - logging.debug( + canvas_node.interfaces[interface.id] = interface + logging.info( "create node(%s) interface(%s) IPv4(%s) IPv6(%s)", node.name, interface.name, @@ -870,11 +873,13 @@ class CoreClient: src_interface = None if NodeUtils.is_container_node(src_node.type): src_interface = self.create_interface(canvas_src_node) + edge.src_interface = src_interface self.interface_to_edge[(src_node.id, src_interface.id)] = edge.token dst_interface = None if NodeUtils.is_container_node(dst_node.type): dst_interface = self.create_interface(canvas_dst_node) + edge.dst_interface = dst_interface self.interface_to_edge[(dst_node.id, dst_interface.id)] = edge.token link = core_pb2.Link( @@ -884,12 +889,6 @@ class CoreClient: interface_one=src_interface, interface_two=dst_interface, ) - if src_interface: - edge.src_interface = link.interface_one - canvas_src_node.interfaces.append(link.interface_one) - if dst_interface: - edge.dst_interface = link.interface_two - canvas_dst_node.interfaces.append(link.interface_two) edge.set_link(link) self.links[edge.token] = edge logging.info("Add link between %s and %s", src_node.name, dst_node.name) diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index 73f0ac09..0d46ae06 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -207,8 +207,8 @@ class NodeConfigDialog(Dialog): notebook.grid(sticky="nsew", pady=PADY) self.top.rowconfigure(notebook.grid_info()["row"], weight=1) state = tk.DISABLED if self.app.core.is_runtime() else tk.NORMAL - for interface in self.canvas_node.interfaces: - logging.info("interface: %s", interface) + for interface_id in sorted(self.canvas_node.interfaces): + interface = self.canvas_node.interfaces[interface_id] tab = ttk.Frame(notebook, padding=FRAME_PAD) tab.grid(sticky="nsew", pady=PADY) tab.columnconfigure(1, weight=1) @@ -309,7 +309,7 @@ class NodeConfigDialog(Dialog): self.canvas_node.image = self.image # update node interface data - for interface in self.canvas_node.interfaces: + for interface in self.canvas_node.interfaces.values(): data = self.interfaces[interface.id] # validate ip4 diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index eacbf1f4..fcc8c81b 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -322,26 +322,25 @@ class CanvasGraph(tk.Canvas): self.edges[edge.token] = edge self.core.links[edge.token] = edge if link.HasField("interface_one"): + interface_one = link.interface_one self.core.interface_to_edge[ - (node_one.id, link.interface_one.id) + (node_one.id, interface_one.id) ] = token - canvas_node_one.interfaces.append(link.interface_one) - edge.src_interface = link.interface_one + canvas_node_one.interfaces[interface_one.id] = interface_one + edge.src_interface = interface_one if link.HasField("interface_two"): + interface_two = link.interface_two self.core.interface_to_edge[ - (node_two.id, link.interface_two.id) + (node_two.id, interface_two.id) ] = edge.token - canvas_node_two.interfaces.append(link.interface_two) - edge.dst_interface = link.interface_two + canvas_node_two.interfaces[interface_two.id] = interface_two + edge.dst_interface = interface_two elif link.options.unidirectional: edge = self.edges[token] edge.asymmetric_link = link else: logging.error("duplicate link received: %s", link) - # raise the nodes so they on top of the links - self.tag_raise(tags.NODE) - def stopped_session(self): # clear wireless edges for edge in self.wireless_edges.values(): @@ -522,8 +521,8 @@ class CanvasGraph(tk.Canvas): other_interface = edge.dst_interface other_node = self.nodes[other_id] other_node.edges.remove(edge) - if other_interface in other_node.interfaces: - other_node.interfaces.remove(other_interface) + if other_interface: + del other_node.interfaces[other_interface.id] if is_wireless: other_node.delete_antenna() @@ -541,12 +540,12 @@ class CanvasGraph(tk.Canvas): del self.edges[edge.token] src_node = self.nodes[edge.src] src_node.edges.discard(edge) - if edge.src_interface in src_node.interfaces: - src_node.interfaces.remove(edge.src_interface) + if edge.src_interface: + del src_node.interfaces[edge.src_interface.id] dst_node = self.nodes[edge.dst] dst_node.edges.discard(edge) - if edge.dst_interface in dst_node.interfaces: - dst_node.interfaces.remove(edge.dst_interface) + if edge.dst_interface: + del dst_node.interfaces[edge.dst_interface.id] src_wireless = NodeUtils.is_wireless_node(src_node.core_node.type) if src_wireless: dst_node.delete_antenna() diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 41b4704a..0b4f2bc9 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -55,7 +55,7 @@ class CanvasNode: ) self.tooltip = CanvasTooltip(self.canvas) self.edges = set() - self.interfaces = [] + self.interfaces = {} self.wireless_edges = set() self.antennas = [] self.antenna_images = {} @@ -70,6 +70,12 @@ class CanvasNode: self.context = tk.Menu(self.canvas) themes.style_menu(self.context) + def next_interface_id(self) -> int: + i = 0 + while i in self.interfaces: + i += 1 + return i + def setup_bindings(self): self.canvas.tag_bind(self.id, "", self.double_click) self.canvas.tag_bind(self.id, "", self.on_enter) diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index fc5185f5..437bd37c 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -105,12 +105,11 @@ class InterfaceManager: for interface in interfaces: subnets = self.get_subnets(interface) if subnets not in remaining_subnets: - if self.current_subnets == subnets: - self.current_subnets = None self.used_subnets.pop(subnets.key(), None) else: index = get_index(interface) subnets.used_indexes.discard(index) + self.current_subnets = None def joined(self, links: List["core_pb2.Link"]) -> None: interfaces = [] From 41df8a57b82c6bca0b2fc14d77d225800aa774c1 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 16 May 2020 23:59:36 -0700 Subject: [PATCH 064/929] pygui: revert change to keep references for created interfaces properly --- daemon/core/gui/coreclient.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 99966f02..2b565e7f 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -847,7 +847,6 @@ class CoreClient: ip6=ip6, ip6mask=ip6_mask, ) - canvas_node.interfaces[interface.id] = interface logging.info( "create node(%s) interface(%s) IPv4(%s) IPv6(%s)", node.name, @@ -873,13 +872,11 @@ class CoreClient: src_interface = None if NodeUtils.is_container_node(src_node.type): src_interface = self.create_interface(canvas_src_node) - edge.src_interface = src_interface self.interface_to_edge[(src_node.id, src_interface.id)] = edge.token dst_interface = None if NodeUtils.is_container_node(dst_node.type): dst_interface = self.create_interface(canvas_dst_node) - edge.dst_interface = dst_interface self.interface_to_edge[(dst_node.id, dst_interface.id)] = edge.token link = core_pb2.Link( @@ -889,6 +886,15 @@ class CoreClient: interface_one=src_interface, interface_two=dst_interface, ) + # assign after creating link proto, since interfaces are copied + if src_interface: + interface_one = link.interface_one + edge.src_interface = interface_one + canvas_src_node.interfaces[interface_one.id] = interface_one + if dst_interface: + interface_two = link.interface_two + edge.dst_interface = interface_two + canvas_dst_node.interfaces[interface_two.id] = interface_two edge.set_link(link) self.links[edge.token] = edge logging.info("Add link between %s and %s", src_node.name, dst_node.name) From 8979c861875ab5ed27caf7e84d003301556a89a7 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 17 May 2020 00:11:28 -0700 Subject: [PATCH 065/929] pygui: fixed issue with moving text shapes --- daemon/core/gui/graph/shape.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/daemon/core/gui/graph/shape.py b/daemon/core/gui/graph/shape.py index 9dd01772..70f67d14 100644 --- a/daemon/core/gui/graph/shape.py +++ b/daemon/core/gui/graph/shape.py @@ -157,10 +157,11 @@ class Shape: original_position = self.canvas.coords(self.id) self.canvas.move(self.id, x_offset, y_offset) coords = self.canvas.coords(self.id) + if self.shape_type == ShapeType.TEXT: + coords = coords * 2 if not self.canvas.valid_position(*coords): self.canvas.coords(self.id, original_position) return - self.canvas.move_selection(self.id, x_offset, y_offset) if self.text_id is not None: self.canvas.move(self.text_id, x_offset, y_offset) From 34f86174a26f4f0f44558289fcd8371d3f751508 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 17 May 2020 00:28:03 -0700 Subject: [PATCH 066/929] pygui: cleaned up color picker layout --- daemon/core/gui/dialogs/colorpicker.py | 82 ++++++++++++-------------- 1 file changed, 38 insertions(+), 44 deletions(-) diff --git a/daemon/core/gui/dialogs/colorpicker.py b/daemon/core/gui/dialogs/colorpicker.py index 9087d6df..b1968cd4 100644 --- a/daemon/core/gui/dialogs/colorpicker.py +++ b/daemon/core/gui/dialogs/colorpicker.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING from core.gui import validation from core.gui.dialogs.dialog import Dialog +from core.gui.themes import PADX, PADY if TYPE_CHECKING: from core.gui.app import Application @@ -16,7 +17,7 @@ class ColorPickerDialog(Dialog): def __init__( self, master: tk.BaseWidget, app: "Application", initcolor: str = "#000000" ): - super().__init__(app, "color picker", master=master) + super().__init__(app, "Color Picker", master=master) self.red_entry = None self.blue_entry = None self.green_entry = None @@ -43,42 +44,40 @@ class ColorPickerDialog(Dialog): def draw(self): self.top.columnconfigure(0, weight=1) + self.top.rowconfigure(3, weight=1) + # rgb frames frame = ttk.Frame(self.top) - frame.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=1) - frame.columnconfigure(2, weight=6) - frame.columnconfigure(3, weight=2) - label = ttk.Label(frame, text="R: ") - label.grid(row=0, column=0) - self.red_entry = validation.RgbEntry(frame, width=4, textvariable=self.red) - self.red_entry.grid(row=0, column=1, sticky="nsew") + frame.grid(row=0, column=0, sticky="ew", pady=PADY) + frame.columnconfigure(2, weight=3) + frame.columnconfigure(3, weight=1) + label = ttk.Label(frame, text="R") + label.grid(row=0, column=0, padx=PADX) + self.red_entry = validation.RgbEntry(frame, width=3, textvariable=self.red) + self.red_entry.grid(row=0, column=1, sticky="ew", padx=PADX) scale = ttk.Scale( frame, from_=0, to=255, value=0, - # length=200, orient=tk.HORIZONTAL, variable=self.red_scale, command=lambda x: self.scale_callback(self.red_scale, self.red), ) - scale.grid(row=0, column=2, sticky="nsew") + scale.grid(row=0, column=2, sticky="ew", padx=PADX) self.red_label = ttk.Label( frame, background="#%02x%02x%02x" % (self.red.get(), 0, 0), width=5 ) - self.red_label.grid(row=0, column=3, sticky="nsew") - frame.grid(row=0, column=0, sticky="nsew") + self.red_label.grid(row=0, column=3, sticky="ew") frame = ttk.Frame(self.top) - frame.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=1) - frame.columnconfigure(2, weight=6) - frame.columnconfigure(3, weight=2) - label = ttk.Label(frame, text="G: ") - label.grid(row=0, column=0) - self.green_entry = validation.RgbEntry(frame, width=4, textvariable=self.green) - self.green_entry.grid(row=0, column=1, sticky="nsew") + frame.grid(row=1, column=0, sticky="ew", pady=PADY) + frame.columnconfigure(2, weight=3) + frame.columnconfigure(3, weight=1) + label = ttk.Label(frame, text="G") + label.grid(row=0, column=0, padx=PADX) + self.green_entry = validation.RgbEntry(frame, width=3, textvariable=self.green) + self.green_entry.grid(row=0, column=1, sticky="ew", padx=PADX) scale = ttk.Scale( frame, from_=0, @@ -88,59 +87,54 @@ class ColorPickerDialog(Dialog): variable=self.green_scale, command=lambda x: self.scale_callback(self.green_scale, self.green), ) - scale.grid(row=0, column=2, sticky="nsew") + scale.grid(row=0, column=2, sticky="ew", padx=PADX) self.green_label = ttk.Label( frame, background="#%02x%02x%02x" % (0, self.green.get(), 0), width=5 ) - self.green_label.grid(row=0, column=3, sticky="nsew") - frame.grid(row=1, column=0, sticky="nsew") + self.green_label.grid(row=0, column=3, sticky="ew") frame = ttk.Frame(self.top) - frame.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=1) - frame.columnconfigure(2, weight=6) - frame.columnconfigure(3, weight=2) - label = ttk.Label(frame, text="B: ") - label.grid(row=0, column=0) - self.blue_entry = validation.RgbEntry(frame, width=4, textvariable=self.blue) - self.blue_entry.grid(row=0, column=1, sticky="nsew") + frame.grid(row=2, column=0, sticky="ew", pady=PADY) + frame.columnconfigure(2, weight=3) + frame.columnconfigure(3, weight=1) + label = ttk.Label(frame, text="B") + label.grid(row=0, column=0, padx=PADX) + self.blue_entry = validation.RgbEntry(frame, width=3, textvariable=self.blue) + self.blue_entry.grid(row=0, column=1, sticky="ew", padx=PADX) scale = ttk.Scale( frame, from_=0, to=255, value=0, - # length=200, orient=tk.HORIZONTAL, variable=self.blue_scale, command=lambda x: self.scale_callback(self.blue_scale, self.blue), ) - scale.grid(row=0, column=2, sticky="nsew") + scale.grid(row=0, column=2, sticky="ew", padx=PADX) self.blue_label = ttk.Label( frame, background="#%02x%02x%02x" % (0, 0, self.blue.get()), width=5 ) - self.blue_label.grid(row=0, column=3, sticky="nsew") - frame.grid(row=2, column=0, sticky="nsew") + self.blue_label.grid(row=0, column=3, sticky="ew") # hex code and color display frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) - label = ttk.Label(frame, text="Selection: ") - label.grid(row=0, column=0, sticky="nsew") + frame.rowconfigure(1, weight=1) self.hex_entry = validation.HexEntry(frame, textvariable=self.hex) - self.hex_entry.grid(row=1, column=0, sticky="nsew") + self.hex_entry.grid(sticky="ew", pady=PADY) self.display = tk.Frame(frame, background=self.color, width=100, height=100) - self.display.grid(row=2, column=0) - frame.grid(row=3, column=0, sticky="nsew") + self.display.grid(sticky="nsew") + frame.grid(row=3, column=0, sticky="nsew", pady=PADY) # button frame frame = ttk.Frame(self.top) + frame.grid(row=4, column=0, sticky="ew") frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) button = ttk.Button(frame, text="OK", command=self.button_ok) - button.grid(row=0, column=0, sticky="nsew") + button.grid(row=0, column=0, sticky="ew", padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="nsew") - frame.grid(row=4, column=0, sticky="nsew") + button.grid(row=0, column=1, sticky="ew") def set_bindings(self): self.red_entry.bind("", lambda x: self.current_focus("rgb")) From d0520bf21d238fb1844a93d15dc154f939144c9c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 17 May 2020 08:51:51 -0700 Subject: [PATCH 067/929] pygui: fixed resizing toolbar with custom node selected --- daemon/core/gui/toolbar.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 572a523d..1c497b0d 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -138,6 +138,7 @@ class Toolbar(ttk.Frame): # these variables help keep track of what images being drawn so that scaling # is possible since PhotoImage does not have resize method self.node_enum = None + self.node_file = None self.network_enum = None self.annotation_enum = None @@ -275,7 +276,12 @@ class Toolbar(ttk.Frame): self.app.canvas.mode = GraphMode.NODE self.app.canvas.node_draw = node_draw if type_enum == NodeTypeEnum.NODE: - self.node_enum = node_draw.image_enum + if node_draw.image_enum: + self.node_enum = node_draw.image_enum + self.node_file = None + elif node_draw.image_file: + self.node_file = node_draw.image_file + self.node_enum = None elif type_enum == NodeTypeEnum.NETWORK: self.network_enum = node_draw.image_enum @@ -381,16 +387,26 @@ class Toolbar(ttk.Frame): self.marker_tool = MarkerDialog(self.app) self.marker_tool.show() - def scale_button(self, button, image_enum) -> None: - image = self.app.get_icon(image_enum, TOOLBAR_SIZE) - button.config(image=image) - button.image = image + def scale_button( + self, button: ttk.Button, image_enum: ImageEnum = None, image_file: str = None + ) -> None: + image = None + if image_enum: + image = self.app.get_icon(image_enum, TOOLBAR_SIZE) + elif image_file: + image = self.app.get_custom_icon(image_file, TOOLBAR_SIZE) + if image: + button.config(image=image) + button.image = image def scale(self) -> None: self.scale_button(self.play_button, ImageEnum.START) self.scale_button(self.select_button, ImageEnum.SELECT) self.scale_button(self.link_button, ImageEnum.LINK) - self.scale_button(self.node_button, self.node_enum) + if self.node_enum: + self.scale_button(self.node_button, self.node_enum) + if self.node_file: + self.scale_button(self.node_button, image_file=self.node_file) self.scale_button(self.network_button, self.network_enum) self.scale_button(self.annotation_button, self.annotation_enum) self.scale_button(self.runtime_select_button, ImageEnum.SELECT) From bd897efd0587b2d732afcaa3d6729da5160a6f53 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 17 May 2020 10:21:54 -0700 Subject: [PATCH 068/929] pygui: allow shapes to be moved in annotation/select modes and nodes in node/select modes --- daemon/core/gui/graph/graph.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index fcc8c81b..ad5b22b6 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -30,6 +30,8 @@ if TYPE_CHECKING: ZOOM_IN = 1.1 ZOOM_OUT = 0.9 ICON_SIZE = 48 +MOVE_NODE_MODES = {GraphMode.NODE, GraphMode.SELECT} +MOVE_SHAPE_MODES = {GraphMode.ANNOTATION, GraphMode.SELECT} class ShowVar(BooleanVar): @@ -653,9 +655,6 @@ class CanvasGraph(tk.Canvas): self.select_object(selected, choose_multiple=True) def click_motion(self, event: tk.Event): - """ - Redraw drawing edge according to the current position of the mouse - """ x, y = self.canvas_xy(event) if not self.inside_canvas(x, y): if self.select_box: @@ -677,6 +676,7 @@ class CanvasGraph(tk.Canvas): if is_draw_shape(self.annotation_type) and self.shape_drawing: shape = self.shapes[self.selected] shape.shape_motion(x, y) + return elif is_marker(self.annotation_type): r = self.app.toolbar.marker_tool.radius self.create_oval( @@ -688,7 +688,7 @@ class CanvasGraph(tk.Canvas): outline="", tags=(tags.MARKER, tags.ANNOTATION), ) - return + return if self.mode == GraphMode.EDGE: return @@ -696,11 +696,11 @@ class CanvasGraph(tk.Canvas): # move selected objects if self.selection: for selected_id in self.selection: - if selected_id in self.shapes: + if self.mode in MOVE_SHAPE_MODES and selected_id in self.shapes: shape = self.shapes[selected_id] shape.motion(x_offset, y_offset) - if selected_id in self.nodes: + if self.mode in MOVE_NODE_MODES and selected_id in self.nodes: node = self.nodes[selected_id] node.motion(x_offset, y_offset, update=self.core.is_runtime()) else: From cde053da738ac676f19f53cd2711c3ca23b9036e Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 17 May 2020 23:08:53 -0700 Subject: [PATCH 069/929] pygui: implemented toolbar based marker configuration, fixed some issues when switching between different node bar states --- daemon/core/gui/dialogs/marker.py | 72 ------------- daemon/core/gui/graph/graph.py | 8 +- daemon/core/gui/nodeutils.py | 1 + daemon/core/gui/toolbar.py | 166 ++++++++++++++++++++---------- 4 files changed, 119 insertions(+), 128 deletions(-) delete mode 100644 daemon/core/gui/dialogs/marker.py diff --git a/daemon/core/gui/dialogs/marker.py b/daemon/core/gui/dialogs/marker.py deleted file mode 100644 index 91cbfd06..00000000 --- a/daemon/core/gui/dialogs/marker.py +++ /dev/null @@ -1,72 +0,0 @@ -""" -marker dialog -""" - -import tkinter as tk -from tkinter import ttk -from typing import TYPE_CHECKING - -from core.gui.dialogs.colorpicker import ColorPickerDialog -from core.gui.dialogs.dialog import Dialog -from core.gui.graph import tags - -if TYPE_CHECKING: - from core.gui.app import Application - -MARKER_THICKNESS = [3, 5, 8, 10] - - -class MarkerDialog(Dialog): - def __init__(self, app: "Application", initcolor: str = "#000000"): - super().__init__(app, "Marker Tool", modal=False) - self.color = initcolor - self.radius = MARKER_THICKNESS[0] - self.marker_thickness = tk.IntVar(value=MARKER_THICKNESS[0]) - self.draw() - - def draw(self): - button = ttk.Button(self.top, text="clear", command=self.clear_marker) - button.grid(row=0, column=0, sticky="nsew") - - frame = ttk.Frame(self.top) - frame.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=4) - frame.grid(row=1, column=0, sticky="nsew") - label = ttk.Label(frame, text="Thickness: ") - label.grid(row=0, column=0, sticky="nsew") - combobox = ttk.Combobox( - frame, - textvariable=self.marker_thickness, - values=MARKER_THICKNESS, - state="readonly", - ) - combobox.grid(row=0, column=1, sticky="nsew") - combobox.bind("<>", self.change_thickness) - frame = ttk.Frame(self.top) - frame.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=4) - frame.grid(row=2, column=0, sticky="nsew") - label = ttk.Label(frame, text="Color: ") - label.grid(row=0, column=0, sticky="nsew") - label = ttk.Label(frame, background=self.color) - label.grid(row=0, column=1, sticky="nsew") - label.bind("", self.change_color) - - def clear_marker(self): - canvas = self.app.canvas - canvas.delete(tags.MARKER) - - def change_color(self, event: tk.Event): - color_picker = ColorPickerDialog(self, self.app, self.color) - color = color_picker.askcolor() - event.widget.configure(background=color) - self.color = color - - def change_thickness(self, event: tk.Event): - self.radius = self.marker_thickness.get() - - def show(self): - super().show() - self.geometry( - f"+{self.app.canvas.winfo_rootx()}+{self.app.canvas.master.winfo_rooty()}" - ) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index ad5b22b6..512e9cad 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -596,13 +596,13 @@ class CanvasGraph(tk.Canvas): if self.mode == GraphMode.ANNOTATION: if is_marker(self.annotation_type): - r = self.app.toolbar.marker_tool.radius + r = self.app.toolbar.marker_frame.size.get() self.create_oval( x - r, y - r, x + r, y + r, - fill=self.app.toolbar.marker_tool.color, + fill=self.app.toolbar.marker_frame.color, outline="", tags=(tags.MARKER, tags.ANNOTATION), state=self.show_annotations.state(), @@ -678,13 +678,13 @@ class CanvasGraph(tk.Canvas): shape.shape_motion(x, y) return elif is_marker(self.annotation_type): - r = self.app.toolbar.marker_tool.radius + r = self.app.toolbar.marker_frame.size.get() self.create_oval( x - r, y - r, x + r, y + r, - fill=self.app.toolbar.marker_tool.color, + fill=self.app.toolbar.marker_frame.color, outline="", tags=(tags.MARKER, tags.ANNOTATION), ) diff --git a/daemon/core/gui/nodeutils.py b/daemon/core/gui/nodeutils.py index 4c2cec07..40204662 100644 --- a/daemon/core/gui/nodeutils.py +++ b/daemon/core/gui/nodeutils.py @@ -22,6 +22,7 @@ class NodeDraw: self.node_type: core_pb2.NodeType = None self.model: Optional[str] = None self.services: Set[str] = set() + self.label = None @classmethod def from_setup( diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 1c497b0d..c985a503 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -8,8 +8,9 @@ from typing import TYPE_CHECKING, Callable from PIL.ImageTk import PhotoImage from core.api.grpc import core_pb2 -from core.gui.dialogs.marker import MarkerDialog +from core.gui.dialogs.colorpicker import ColorPickerDialog from core.gui.dialogs.runtool import RunToolDialog +from core.gui.graph import tags from core.gui.graph.enums import GraphMode from core.gui.graph.shapeutils import ShapeType, is_marker from core.gui.images import ImageEnum @@ -43,19 +44,24 @@ class PickerFrame(ttk.Frame): self.app = app self.button = button - def create_button(self, label: str, image_enum: ImageEnum, func: Callable) -> None: - bar_image = self.app.get_icon(image_enum, TOOLBAR_SIZE) - image = self.app.get_icon(image_enum, PICKER_SIZE) - self._create_button(label, image, bar_image, func) + def create_node_button(self, node_draw: NodeDraw, func: Callable) -> None: + self.create_button( + node_draw.label, func, node_draw.image_enum, node_draw.image_file + ) - def create_custom_button(self, label: str, image_file: str, func: Callable) -> None: - bar_image = self.app.get_custom_icon(image_file, TOOLBAR_SIZE) - image = self.app.get_custom_icon(image_file, PICKER_SIZE) - self._create_button(label, image, bar_image, func) - - def _create_button( - self, label: str, image: PhotoImage, bar_image: PhotoImage, func: Callable + def create_button( + self, + label: str, + func: Callable, + image_enum: ImageEnum = None, + image_file: str = None, ) -> None: + if image_enum: + bar_image = self.app.get_icon(image_enum, TOOLBAR_SIZE) + image = self.app.get_icon(image_enum, PICKER_SIZE) + else: + bar_image = self.app.get_custom_icon(image_file, TOOLBAR_SIZE) + image = self.app.get_custom_icon(image_file, PICKER_SIZE) button = ttk.Button( self, image=image, text=label, compound=tk.TOP, style=Styles.picker_button ) @@ -101,6 +107,51 @@ class ButtonBar(ttk.Frame): selected.state(["pressed"]) +class MarkerFrame(ttk.Frame): + PAD = 3 + + def __init__(self, master: tk.BaseWidget, app: "Application") -> None: + super().__init__(master, padding=self.PAD) + self.app = app + self.color = "#000000" + self.size = tk.DoubleVar() + self.color_frame = None + self.draw() + + def draw(self) -> None: + self.columnconfigure(0, weight=1) + + image = self.app.get_icon(ImageEnum.DELETE, 16) + button = ttk.Button(self, image=image, width=2, command=self.click_clear) + button.image = image + button.grid(sticky="ew", pady=self.PAD) + Tooltip(button, "Delete Marker") + + sizes = [1, 3, 8, 10] + self.size.set(sizes[0]) + sizes = ttk.Combobox( + self, state="readonly", textvariable=self.size, value=sizes, width=2 + ) + sizes.grid(sticky="ew", pady=self.PAD) + Tooltip(sizes, "Marker Size") + + frame_size = TOOLBAR_SIZE + self.color_frame = tk.Frame( + self, background=self.color, height=frame_size, width=frame_size + ) + self.color_frame.grid(sticky="ew") + self.color_frame.bind("", self.click_color) + Tooltip(self.color_frame, "Marker Color") + + def click_clear(self): + self.app.canvas.delete(tags.MARKER) + + def click_color(self, _event: tk.Event) -> None: + dialog = ColorPickerDialog(self.app, self.app, self.color) + self.color = dialog.askcolor() + self.color_frame.config(background=self.color) + + class Toolbar(ttk.Frame): """ Core toolbar class @@ -130,17 +181,15 @@ class Toolbar(ttk.Frame): # frames self.design_frame = None self.runtime_frame = None + self.marker_frame = None self.picker = None - # dialog - self.marker_tool = None - # these variables help keep track of what images being drawn so that scaling # is possible since PhotoImage does not have resize method - self.node_enum = None - self.node_file = None - self.network_enum = None - self.annotation_enum = None + self.current_node = NodeUtils.NODES[0] + self.current_network = NodeUtils.NETWORK_NODES[0] + self.current_annotation = ShapeType.MARKER + self.annotation_enum = ImageEnum.MARKER # draw components self.draw() @@ -151,6 +200,7 @@ class Toolbar(ttk.Frame): self.draw_design_frame() self.draw_runtime_frame() self.design_frame.tkraise() + self.marker_frame = MarkerFrame(self, self.app) def draw_design_frame(self) -> None: self.design_frame = ButtonBar(self, self.app) @@ -165,15 +215,18 @@ class Toolbar(ttk.Frame): self.link_button = self.design_frame.create_button( ImageEnum.LINK, self.click_link, "Link Tool", radio=True ) - self.node_enum = ImageEnum.ROUTER self.node_button = self.design_frame.create_button( - self.node_enum, self.draw_node_picker, "Container Nodes", radio=True + self.current_node.image_enum, + self.draw_node_picker, + "Container Nodes", + radio=True, ) - self.network_enum = ImageEnum.HUB self.network_button = self.design_frame.create_button( - self.network_enum, self.draw_network_picker, "Link Layer Nodes", radio=True + self.current_network.image_enum, + self.draw_network_picker, + "Link Layer Nodes", + radio=True, ) - self.annotation_enum = ImageEnum.MARKER self.annotation_button = self.design_frame.create_button( self.annotation_enum, self.draw_annotation_picker, @@ -199,6 +252,9 @@ class Toolbar(ttk.Frame): ) def draw_node_picker(self) -> None: + self.hide_marker() + self.app.canvas.mode = GraphMode.NODE + self.app.canvas.node_draw = self.current_node self.design_frame.select_radio(self.node_button) self.picker = PickerFrame(self.app, self.node_button) # draw default nodes @@ -206,25 +262,25 @@ class Toolbar(ttk.Frame): func = partial( self.update_button, self.node_button, node_draw, NodeTypeEnum.NODE ) - self.picker.create_button(node_draw.label, node_draw.image_enum, func) + self.picker.create_node_button(node_draw, func) # draw custom nodes for name in sorted(self.app.core.custom_nodes): node_draw = self.app.core.custom_nodes[name] func = partial( self.update_button, self.node_button, node_draw, NodeTypeEnum.NODE ) - self.picker.create_custom_button( - node_draw.label, node_draw.image_file, func - ) + self.picker.create_node_button(node_draw, func) self.picker.show() def click_selection(self) -> None: self.design_frame.select_radio(self.select_button) self.app.canvas.mode = GraphMode.SELECT + self.hide_marker() def click_runtime_selection(self) -> None: self.runtime_frame.select_radio(self.runtime_select_button) self.app.canvas.mode = GraphMode.SELECT + self.hide_marker() def click_start(self) -> None: """ @@ -253,15 +309,18 @@ class Toolbar(ttk.Frame): enable_buttons(self.runtime_frame, enabled=True) self.runtime_frame.tkraise() self.click_runtime_selection() + self.hide_marker() def set_design(self) -> None: enable_buttons(self.design_frame, enabled=True) self.design_frame.tkraise() self.click_selection() + self.hide_marker() def click_link(self) -> None: self.design_frame.select_radio(self.link_button) self.app.canvas.mode = GraphMode.EDGE + self.hide_marker() def update_button( self, @@ -273,29 +332,26 @@ class Toolbar(ttk.Frame): logging.debug("update button(%s): %s", button, node_draw) button.configure(image=image) button.image = image - self.app.canvas.mode = GraphMode.NODE self.app.canvas.node_draw = node_draw if type_enum == NodeTypeEnum.NODE: - if node_draw.image_enum: - self.node_enum = node_draw.image_enum - self.node_file = None - elif node_draw.image_file: - self.node_file = node_draw.image_file - self.node_enum = None + self.current_node = node_draw elif type_enum == NodeTypeEnum.NETWORK: - self.network_enum = node_draw.image_enum + self.current_network = node_draw def draw_network_picker(self) -> None: """ Draw the options for link-layer button. """ + self.hide_marker() + self.app.canvas.mode = GraphMode.NODE + self.app.canvas.node_draw = self.current_network self.design_frame.select_radio(self.network_button) self.picker = PickerFrame(self.app, self.network_button) for node_draw in NodeUtils.NETWORK_NODES: func = partial( self.update_button, self.network_button, node_draw, NodeTypeEnum.NETWORK ) - self.picker.create_button(node_draw.label, node_draw.image_enum, func) + self.picker.create_node_button(node_draw, func) self.picker.show() def draw_annotation_picker(self) -> None: @@ -303,6 +359,10 @@ class Toolbar(ttk.Frame): Draw the options for marker button. """ self.design_frame.select_radio(self.annotation_button) + self.app.canvas.mode = GraphMode.ANNOTATION + self.app.canvas.annotation_type = self.current_annotation + if is_marker(self.current_annotation): + self.show_marker() self.picker = PickerFrame(self.app, self.annotation_button) nodes = [ (ImageEnum.MARKER, ShapeType.MARKER), @@ -313,7 +373,7 @@ class Toolbar(ttk.Frame): for image_enum, shape_type in nodes: label = shape_type.value func = partial(self.update_annotation, shape_type, image_enum) - self.picker.create_button(label, image_enum, func) + self.picker.create_button(label, func, image_enum) self.picker.show() def create_observe_button(self) -> None: @@ -364,14 +424,19 @@ class Toolbar(ttk.Frame): logging.debug("clicked annotation") self.annotation_button.configure(image=image) self.annotation_button.image = image - self.app.canvas.mode = GraphMode.ANNOTATION self.app.canvas.annotation_type = shape_type + self.current_annotation = shape_type self.annotation_enum = image_enum if is_marker(shape_type): - if self.marker_tool: - self.marker_tool.destroy() - self.marker_tool = MarkerDialog(self.app) - self.marker_tool.show() + self.show_marker() + else: + self.hide_marker() + + def hide_marker(self) -> None: + self.marker_frame.grid_forget() + + def show_marker(self) -> None: + self.marker_frame.grid() def click_run_button(self) -> None: logging.debug("Click on RUN button") @@ -382,10 +447,7 @@ class Toolbar(ttk.Frame): self.runtime_frame.select_radio(self.runtime_marker_button) self.app.canvas.mode = GraphMode.ANNOTATION self.app.canvas.annotation_type = ShapeType.MARKER - if self.marker_tool: - self.marker_tool.destroy() - self.marker_tool = MarkerDialog(self.app) - self.marker_tool.show() + self.show_marker() def scale_button( self, button: ttk.Button, image_enum: ImageEnum = None, image_file: str = None @@ -403,11 +465,11 @@ class Toolbar(ttk.Frame): self.scale_button(self.play_button, ImageEnum.START) self.scale_button(self.select_button, ImageEnum.SELECT) self.scale_button(self.link_button, ImageEnum.LINK) - if self.node_enum: - self.scale_button(self.node_button, self.node_enum) - if self.node_file: - self.scale_button(self.node_button, image_file=self.node_file) - self.scale_button(self.network_button, self.network_enum) + if self.current_node.image_enum: + self.scale_button(self.node_button, self.current_node) + else: + self.scale_button(self.node_button, image_file=self.current_node.image_file) + self.scale_button(self.network_button, self.current_network.image_enum) self.scale_button(self.annotation_button, self.annotation_enum) self.scale_button(self.runtime_select_button, ImageEnum.SELECT) self.scale_button(self.stop_button, ImageEnum.STOP) From 773f733cb8ecb82e1a9c38ce71d3cfc21288b50b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 18 May 2020 23:25:42 -0700 Subject: [PATCH 070/929] pygui: changes to leverage common icon scaling function, fix issue with scaling toolbar --- daemon/core/gui/dialogs/mobilityplayer.py | 8 ++++---- daemon/core/gui/dialogs/serviceconfig.py | 8 ++++---- daemon/core/gui/graph/graph.py | 22 ++++++++-------------- daemon/core/gui/graph/node.py | 8 +++----- daemon/core/gui/toolbar.py | 2 +- 5 files changed, 20 insertions(+), 28 deletions(-) diff --git a/daemon/core/gui/dialogs/mobilityplayer.py b/daemon/core/gui/dialogs/mobilityplayer.py index e3baf140..b4801bcf 100644 --- a/daemon/core/gui/dialogs/mobilityplayer.py +++ b/daemon/core/gui/dialogs/mobilityplayer.py @@ -6,7 +6,7 @@ import grpc from core.api.grpc.mobility_pb2 import MobilityAction from core.gui.dialogs.dialog import Dialog -from core.gui.images import ImageEnum, Images +from core.gui.images import ImageEnum from core.gui.themes import PADX, PADY if TYPE_CHECKING: @@ -89,17 +89,17 @@ class MobilityPlayerDialog(Dialog): for i in range(3): frame.columnconfigure(i, weight=1) - image = Images.get(ImageEnum.START, width=int(ICON_SIZE * self.app.app_scale)) + image = self.app.get_icon(ImageEnum.START, ICON_SIZE) self.play_button = ttk.Button(frame, image=image, command=self.click_play) self.play_button.image = image self.play_button.grid(row=0, column=0, sticky="ew", padx=PADX) - image = Images.get(ImageEnum.PAUSE, width=int(ICON_SIZE * self.app.app_scale)) + image = self.app.get_icon(ImageEnum.PAUSE, ICON_SIZE) self.pause_button = ttk.Button(frame, image=image, command=self.click_pause) self.pause_button.image = image self.pause_button.grid(row=0, column=1, sticky="ew", padx=PADX) - image = Images.get(ImageEnum.STOP, width=int(ICON_SIZE * self.app.app_scale)) + image = self.app.get_icon(ImageEnum.STOP, ICON_SIZE) self.stop_button = ttk.Button(frame, image=image, command=self.click_stop) self.stop_button.image = image self.stop_button.grid(row=0, column=2, sticky="ew", padx=PADX) diff --git a/daemon/core/gui/dialogs/serviceconfig.py b/daemon/core/gui/dialogs/serviceconfig.py index 30607163..efeefa09 100644 --- a/daemon/core/gui/dialogs/serviceconfig.py +++ b/daemon/core/gui/dialogs/serviceconfig.py @@ -17,6 +17,8 @@ if TYPE_CHECKING: from core.gui.app import Application from core.gui.graph.node import CanvasNode +ICON_SIZE = 16 + class ServiceConfigDialog(Dialog): def __init__( @@ -51,10 +53,8 @@ class ServiceConfigDialog(Dialog): self.directory_entry = None self.default_directories = [] self.temp_directories = [] - self.documentnew_img = Images.get( - ImageEnum.DOCUMENTNEW, int(16 * app.app_scale) - ) - self.editdelete_img = Images.get(ImageEnum.EDITDELETE, int(16 * app.app_scale)) + self.documentnew_img = self.app.get_icon(ImageEnum.DOCUMENTNEW, ICON_SIZE) + self.editdelete_img = self.app.get_icon(ImageEnum.EDITDELETE, ICON_SIZE) self.notebook = None self.metadata_entry = None self.filename_combobox = None diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 512e9cad..3d6fd369 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -20,7 +20,7 @@ from core.gui.graph.enums import GraphMode, ScaleOption from core.gui.graph.node import CanvasNode from core.gui.graph.shape import Shape from core.gui.graph.shapeutils import ShapeType, is_draw_shape, is_marker -from core.gui.images import ImageEnum, Images, TypeToImage +from core.gui.images import ImageEnum, TypeToImage from core.gui.nodeutils import NodeUtils if TYPE_CHECKING: @@ -290,9 +290,7 @@ class CanvasGraph(tk.Canvas): ) # if the gui can't find node's image, default to the "edit-node" image if not image: - image = Images.get( - ImageEnum.EDITNODE, int(ICON_SIZE * self.app.app_scale) - ) + image = self.app.get_icon(ImageEnum.EDITNODE, ICON_SIZE) x = core_node.position.x y = core_node.position.y node = CanvasNode(self.app, x, y, core_node, image) @@ -734,13 +732,11 @@ class CanvasGraph(tk.Canvas): if not core_node: return try: - self.node_draw.image = Images.get( - self.node_draw.image_enum, int(ICON_SIZE * self.app.app_scale) - ) + image_enum = self.node_draw.image_enum + self.node_draw.image = self.app.get_icon(image_enum, ICON_SIZE) except AttributeError: - self.node_draw.image = Images.get_custom( - self.node_draw.image_file, int(ICON_SIZE * self.app.app_scale) - ) + image_file = self.node_draw.image_file + self.node_draw.image = self.app.get_custom_icon(image_file, ICON_SIZE) node = CanvasNode(self.app, x, y, core_node, self.node_draw.image) self.core.canvas_nodes[core_node.id] = node self.nodes[node.id] = node @@ -1006,14 +1002,12 @@ class CanvasGraph(tk.Canvas): ): for custom_node in self.app.guiconfig.nodes: if custom_node.name == canvas_node.core_node.model: - img = Images.get_custom( - custom_node.image, int(ICON_SIZE * self.app.app_scale) - ) + img = self.app.get_custom_icon(custom_node.image, ICON_SIZE) else: image_enum = TypeToImage.get( canvas_node.core_node.type, canvas_node.core_node.model ) - img = Images.get(image_enum, int(ICON_SIZE * self.app.app_scale)) + img = self.app.get_icon(image_enum, ICON_SIZE) self.itemconfig(nid, image=img) canvas_node.image = img diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 0b4f2bc9..8ad3f02a 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -17,7 +17,7 @@ from core.gui.dialogs.wlanconfig import WlanConfigDialog from core.gui.graph import tags from core.gui.graph.edges import CanvasEdge from core.gui.graph.tooltip import CanvasTooltip -from core.gui.images import ImageEnum, Images +from core.gui.images import ImageEnum from core.gui.nodeutils import ANTENNA_SIZE, NodeUtils if TYPE_CHECKING: @@ -91,7 +91,7 @@ class CanvasNode: def add_antenna(self): x, y = self.canvas.coords(self.id) offset = len(self.antennas) * 8 * self.app.app_scale - img = Images.get(ImageEnum.ANTENNA, int(ANTENNA_SIZE * self.app.app_scale)) + img = self.app.get_icon(ImageEnum.ANTENNA, ANTENNA_SIZE) antenna_id = self.canvas.create_image( x - 16 + offset, y - int(23 * self.app.app_scale), @@ -327,9 +327,7 @@ class CanvasNode: def scale_antennas(self): for i in range(len(self.antennas)): antenna_id = self.antennas[i] - image = Images.get( - ImageEnum.ANTENNA, int(ANTENNA_SIZE * self.app.app_scale) - ) + image = self.app.get_icon(ImageEnum.ANTENNA, ANTENNA_SIZE) self.canvas.itemconfig(antenna_id, image=image) self.antenna_images[antenna_id] = image node_x, node_y = self.canvas.coords(self.id) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index c985a503..da20948e 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -466,7 +466,7 @@ class Toolbar(ttk.Frame): self.scale_button(self.select_button, ImageEnum.SELECT) self.scale_button(self.link_button, ImageEnum.LINK) if self.current_node.image_enum: - self.scale_button(self.node_button, self.current_node) + self.scale_button(self.node_button, self.current_node.image_enum) else: self.scale_button(self.node_button, image_file=self.current_node.image_file) self.scale_button(self.network_button, self.current_network.image_enum) From 8bae0611a43377a18f8505790d242102c21b29ce Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 19 May 2020 00:35:48 -0700 Subject: [PATCH 071/929] pygui: updated icon for observers tool on run menu, added observers to run menu and created custom observers widget --- daemon/core/gui/data/icons/observe.gif | Bin 1149 -> 0 bytes daemon/core/gui/data/icons/observe.png | Bin 0 -> 3182 bytes daemon/core/gui/dialogs/observers.py | 6 ++- daemon/core/gui/menubar.py | 49 +----------------- daemon/core/gui/observers.py | 66 +++++++++++++++++++++++++ daemon/core/gui/toolbar.py | 26 +++------- 6 files changed, 80 insertions(+), 67 deletions(-) delete mode 100644 daemon/core/gui/data/icons/observe.gif create mode 100644 daemon/core/gui/data/icons/observe.png create mode 100644 daemon/core/gui/observers.py diff --git a/daemon/core/gui/data/icons/observe.gif b/daemon/core/gui/data/icons/observe.gif deleted file mode 100644 index 6b66e7305f35484095a2182ed828c6507c72a746..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1149 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 None: @@ -201,42 +189,9 @@ class Menubar(tk.Menu): """ Create observer widget menu item and create the sub menu items inside """ - self.observers_menu = tk.Menu(widget_menu) - self.observers_menu.add_command( - label="Edit Observers", command=self.click_edit_observer_widgets - ) - self.observers_menu.add_separator() - self.observers_menu.add_radiobutton( - label="None", - variable=self.observers_var, - value="none", - command=lambda: self.core.set_observer(None), - ) - for name in sorted(OBSERVERS): - cmd = OBSERVERS[name] - self.observers_menu.add_radiobutton( - label=name, - variable=self.observers_var, - value=name, - command=partial(self.core.set_observer, cmd), - ) - self.observers_custom_index = self.observers_menu.index(tk.END) + 1 - self.draw_custom_observers() + self.observers_menu = ObserversMenu(widget_menu, self.app) widget_menu.add_cascade(label="Observer Widgets", menu=self.observers_menu) - def draw_custom_observers(self) -> None: - current_observers_index = self.observers_menu.index(tk.END) + 1 - if self.observers_custom_index < current_observers_index: - self.observers_menu.delete(self.observers_custom_index, tk.END) - for name in sorted(self.core.custom_observers): - observer = self.core.custom_observers[name] - self.observers_menu.add_radiobutton( - label=name, - variable=self.observers_var, - value=name, - command=partial(self.core.set_observer, observer.cmd), - ) - def create_adjacency_menu(self, widget_menu: tk.Menu) -> None: """ Create adjacency menu item and the sub menu items inside diff --git a/daemon/core/gui/observers.py b/daemon/core/gui/observers.py new file mode 100644 index 00000000..27d0a26e --- /dev/null +++ b/daemon/core/gui/observers.py @@ -0,0 +1,66 @@ +import tkinter as tk +from functools import partial +from typing import TYPE_CHECKING + +from core.gui.dialogs.observers import ObserverDialog + +if TYPE_CHECKING: + from core.gui.app import Application + +OBSERVERS = { + "List Processes": "ps", + "Show Interfaces": "ip address", + "IPV4 Routes": "ip -4 route", + "IPV6 Routes": "ip -6 route", + "Listening Sockets": "ss -tuwnl", + "IPv4 MFC Entries": "ip -4 mroute show", + "IPv6 MFC Entries": "ip -6 mroute show", + "Firewall Rules": "iptables -L", + "IPSec Policies": "setkey -DP", +} + + +class ObserversMenu(tk.Menu): + def __init__(self, master: tk.BaseWidget, app: "Application") -> None: + super().__init__(master) + self.app = app + self.observer = tk.StringVar(value=tk.NONE) + self.custom_index = 0 + self.draw() + + def draw(self) -> None: + self.add_command(label="Edit Observers", command=self.click_edit) + self.add_separator() + self.add_radiobutton( + label="None", + variable=self.observer, + value="none", + command=lambda: self.app.core.set_observer(None), + ) + for name in sorted(OBSERVERS): + cmd = OBSERVERS[name] + self.add_radiobutton( + label=name, + variable=self.observer, + value=name, + command=partial(self.app.core.set_observer, cmd), + ) + self.custom_index = self.index(tk.END) + 1 + self.draw_custom() + + def draw_custom(self) -> None: + current_index = self.index(tk.END) + 1 + if self.custom_index < current_index: + self.delete(self.custom_index, tk.END) + for name in sorted(self.app.core.custom_observers): + observer = self.app.core.custom_observers[name] + self.add_radiobutton( + label=name, + variable=self.observer, + value=name, + command=partial(self.app.core.set_observer, observer.cmd), + ) + + def click_edit(self) -> None: + dialog = ObserverDialog(self.app) + dialog.show() diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index da20948e..54fac126 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -15,6 +15,7 @@ from core.gui.graph.enums import GraphMode from core.gui.graph.shapeutils import ShapeType, is_marker from core.gui.images import ImageEnum from core.gui.nodeutils import NodeDraw, NodeUtils +from core.gui.observers import ObserversMenu from core.gui.task import ProgressTask from core.gui.themes import Styles from core.gui.tooltip import Tooltip @@ -184,6 +185,9 @@ class Toolbar(ttk.Frame): self.marker_frame = None self.picker = None + # observers + self.observers_menu = None + # these variables help keep track of what images being drawn so that scaling # is possible since PhotoImage does not have resize method self.current_node = NodeUtils.NODES[0] @@ -244,6 +248,7 @@ class Toolbar(ttk.Frame): self.runtime_select_button = self.runtime_frame.create_button( ImageEnum.SELECT, self.click_runtime_selection, "Selection Tool", radio=True ) + self.create_observe_button() self.runtime_marker_button = self.runtime_frame.create_button( ImageEnum.MARKER, self.click_marker_button, "Marker Tool", radio=True ) @@ -381,25 +386,10 @@ class Toolbar(ttk.Frame): menu_button = ttk.Menubutton( self.runtime_frame, image=image, direction=tk.RIGHT ) + menu_button.image = image menu_button.grid(sticky="ew") - menu = tk.Menu(menu_button, tearoff=0) - menu_button["menu"] = menu - menu.add_command(label="None") - menu.add_command(label="processes") - menu.add_command(label="ifconfig") - menu.add_command(label="IPv4 routes") - menu.add_command(label="IPv6 routes") - menu.add_command(label="OSPFv2 neighbors") - menu.add_command(label="OSPFv3 neighbors") - menu.add_command(label="Listening sockets") - menu.add_command(label="IPv4 MFC entries") - menu.add_command(label="IPv6 MFC entries") - menu.add_command(label="firewall rules") - menu.add_command(label="IPSec policies") - menu.add_command(label="docker logs") - menu.add_command(label="OSPFv3 MDR level") - menu.add_command(label="PIM neighbors") - menu.add_command(label="Edit...") + self.observers_menu = ObserversMenu(menu_button, self.app) + menu_button["menu"] = self.observers_menu def click_stop(self) -> None: """ From d14056393b3dbc3776254474505c1f8e8e425f0e Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 19 May 2020 16:46:44 -0700 Subject: [PATCH 072/929] added grpc call to allow direct control of nodes connected through wlan to be linked or not --- daemon/core/api/grpc/client.py | 14 ++++++++++ daemon/core/api/grpc/server.py | 37 ++++++++++++++++++++++++++- daemon/core/location/mobility.py | 6 +---- daemon/proto/core/api/grpc/core.proto | 2 ++ daemon/proto/core/api/grpc/wlan.proto | 12 +++++++++ 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 76e20426..c5bbf50f 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -88,6 +88,8 @@ from core.api.grpc.wlan_pb2 import ( GetWlanConfigsResponse, SetWlanConfigRequest, SetWlanConfigResponse, + SetWlanLinkRequest, + SetWlanLinkResponse, WlanConfig, ) @@ -1204,6 +1206,18 @@ class CoreGrpcClient: request = ExecuteScriptRequest(script=script) return self.stub.ExecuteScript(request) + def set_wlan_link( + self, session_id: int, wlan: int, node_one: int, node_two: int, linked: bool + ) -> SetWlanLinkResponse: + request = SetWlanLinkRequest( + session_id=session_id, + wlan=wlan, + node_one=node_one, + node_two=node_two, + linked=linked, + ) + return self.stub.SetWlanLink(request) + def connect(self) -> None: """ Open connection to server, must be closed manually. diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index b6a298db..4e450a2a 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -102,6 +102,8 @@ from core.api.grpc.wlan_pb2 import ( GetWlanConfigsResponse, SetWlanConfigRequest, SetWlanConfigResponse, + SetWlanLinkRequest, + SetWlanLinkResponse, ) from core.emulator.coreemu import CoreEmu from core.emulator.data import LinkData @@ -111,6 +113,7 @@ from core.emulator.session import Session from core.errors import CoreCommandError, CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility from core.nodes.base import CoreNodeBase, NodeBase +from core.nodes.network import WlanNode from core.services.coreservices import ServiceManager _ONE_DAY_IN_SECONDS = 60 * 60 * 24 @@ -177,7 +180,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): :param session: session that has the node :param node_id: node id - :param context: + :param context: request :return: node object that satisfies. If node not found then raise an exception. :raises Exception: raises grpc exception when node does not exist """ @@ -1684,3 +1687,35 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): if new_sessions: new_session = new_sessions[0] return ExecuteScriptResponse(session_id=new_session) + + def SetWlanLink( + self, request: SetWlanLinkRequest, context: ServicerContext + ) -> SetWlanLinkResponse: + session = self.get_session(request.session_id, context) + wlan = self.get_node(session, request.wlan, context) + if not isinstance(wlan, WlanNode): + context.abort( + grpc.StatusCode.NOT_FOUND, f"wlan id {request.wlan} is not a wlan node" + ) + if not isinstance(wlan.model, BasicRangeModel): + context.abort( + grpc.StatusCode.NOT_FOUND, + f"wlan node {request.wlan} does not using BasicRangeModel", + ) + n1 = self.get_node(session, request.node_one, context) + n2 = self.get_node(session, request.node_two, context) + n1_netif, n2_netif = None, None + for net, netif1, netif2 in n1.commonnets(n2): + if net == wlan: + n1_netif = netif1 + n2_netif = netif2 + break + if n1_netif and n2_netif: + if request.linked: + wlan.link(n1_netif, n2_netif) + else: + wlan.unlink(n1_netif, n2_netif) + wlan.model.sendlinkmsg(n1_netif, n2_netif, unlink=not request.linked) + return SetWlanLinkResponse(result=True) + else: + return SetWlanLinkResponse(result=False) diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index 05a6ac3e..d2cb0c8a 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -509,11 +509,7 @@ class BasicRangeModel(WirelessModel): :param unlink: unlink or not :return: nothing """ - if unlink: - message_type = MessageFlags.DELETE - else: - message_type = MessageFlags.ADD - + message_type = MessageFlags.DELETE if unlink else MessageFlags.ADD link_data = self.create_link_data(netif, netif2, message_type) self.session.broadcast_link(link_data) diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 997d5287..421b6f0f 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -129,6 +129,8 @@ service CoreApi { } rpc SetWlanConfig (wlan.SetWlanConfigRequest) returns (wlan.SetWlanConfigResponse) { } + rpc SetWlanLink (wlan.SetWlanLinkRequest) returns (wlan.SetWlanLinkResponse) { + } // emane rpc rpc GetEmaneConfig (emane.GetEmaneConfigRequest) returns (emane.GetEmaneConfigResponse) { diff --git a/daemon/proto/core/api/grpc/wlan.proto b/daemon/proto/core/api/grpc/wlan.proto index 139c0a2e..a37c511f 100644 --- a/daemon/proto/core/api/grpc/wlan.proto +++ b/daemon/proto/core/api/grpc/wlan.proto @@ -34,3 +34,15 @@ message SetWlanConfigRequest { message SetWlanConfigResponse { bool result = 1; } + +message SetWlanLinkRequest { + int32 session_id = 1; + int32 wlan = 2; + int32 node_one = 3; + int32 node_two = 4; + bool linked = 5; +} + +message SetWlanLinkResponse { + bool result = 1; +} From 0a792f7b3fb0445b8a50b487725976f035e0cd78 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 19 May 2020 22:36:10 -0700 Subject: [PATCH 073/929] updates to grpc rpc wlan link, added node class type checking and hinting in grpc server code --- daemon/core/api/grpc/client.py | 12 ++--- daemon/core/api/grpc/server.py | 78 ++++++++++++++++----------- daemon/proto/core/api/grpc/core.proto | 2 +- daemon/proto/core/api/grpc/wlan.proto | 4 +- 4 files changed, 56 insertions(+), 40 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index c5bbf50f..a645c756 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -88,9 +88,9 @@ from core.api.grpc.wlan_pb2 import ( GetWlanConfigsResponse, SetWlanConfigRequest, SetWlanConfigResponse, - SetWlanLinkRequest, - SetWlanLinkResponse, WlanConfig, + WlanLinkRequest, + WlanLinkResponse, ) @@ -1206,17 +1206,17 @@ class CoreGrpcClient: request = ExecuteScriptRequest(script=script) return self.stub.ExecuteScript(request) - def set_wlan_link( + def wlan_link( self, session_id: int, wlan: int, node_one: int, node_two: int, linked: bool - ) -> SetWlanLinkResponse: - request = SetWlanLinkRequest( + ) -> WlanLinkResponse: + request = WlanLinkRequest( session_id=session_id, wlan=wlan, node_one=node_one, node_two=node_two, linked=linked, ) - return self.stub.SetWlanLink(request) + return self.stub.WlanLink(request) def connect(self) -> None: """ diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 4e450a2a..77e6bf08 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -6,7 +6,7 @@ import tempfile import threading import time from concurrent import futures -from typing import Type +from typing import Type, TypeVar import grpc from grpc import ServicerContext @@ -102,8 +102,8 @@ from core.api.grpc.wlan_pb2 import ( GetWlanConfigsResponse, SetWlanConfigRequest, SetWlanConfigResponse, - SetWlanLinkRequest, - SetWlanLinkResponse, + WlanLinkRequest, + WlanLinkResponse, ) from core.emulator.coreemu import CoreEmu from core.emulator.data import LinkData @@ -112,12 +112,13 @@ from core.emulator.enumerations import EventTypes, LinkTypes, MessageFlags from core.emulator.session import Session from core.errors import CoreCommandError, CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility -from core.nodes.base import CoreNodeBase, NodeBase +from core.nodes.base import CoreNode, CoreNodeBase, NodeBase from core.nodes.network import WlanNode from core.services.coreservices import ServiceManager _ONE_DAY_IN_SECONDS = 60 * 60 * 24 _INTERFACE_REGEX = re.compile(r"veth(?P[0-9a-fA-F]+)") +T = TypeVar("T") class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): @@ -173,19 +174,34 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): return session def get_node( - self, session: Session, node_id: int, context: ServicerContext - ) -> NodeBase: + self, + session: Session, + node_id: int, + context: ServicerContext, + node_class: Type[T], + ) -> T: """ Retrieve node given session and node id :param session: session that has the node :param node_id: node id :param context: request + :param node_class: type of node we are expecting :return: node object that satisfies. If node not found then raise an exception. :raises Exception: raises grpc exception when node does not exist """ try: - return session.get_node(node_id) + node = session.get_node(node_id) + if isinstance(node, node_class): + return node + else: + actual = node.__class__.__name__ + expected = node_class.__name__ + context.abort( + grpc.StatusCode.NOT_FOUND, + f"node({node_id}) class({actual}) " + f"was not expected class({expected})", + ) except CoreError: context.abort(grpc.StatusCode.NOT_FOUND, f"node {node_id} not found") @@ -264,7 +280,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): # config service configs for config in request.config_service_configs: - node = self.get_node(session, config.node_id, context) + node = self.get_node(session, config.node_id, context, CoreNode) service = node.config_services[config.name] if config.config: service.set_config(config.config) @@ -681,7 +697,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get node: %s", request) session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, NodeBase) interfaces = [] for interface_id in node._netif: interface = node._netif[interface_id] @@ -702,7 +718,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("edit node: %s", request) session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, NodeBase) options = NodeOptions() options.icon = request.icon if request.HasField("position"): @@ -754,7 +770,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("sending node command: %s", request) session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, CoreNode) try: output = node.cmd(request.command) except CoreCommandError as e: @@ -773,7 +789,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("getting node terminal: %s", request) session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, CoreNode) terminal = node.termcmdstring("/bin/bash") return core_pb2.GetNodeTerminalResponse(terminal=terminal) @@ -789,7 +805,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get node links: %s", request) session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, NodeBase) links = get_links(node) return core_pb2.GetNodeLinksResponse(links=links) @@ -806,8 +822,8 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): logging.debug("add link: %s", request) # validate session and nodes session = self.get_session(request.session_id, context) - self.get_node(session, request.link.node_one_id, context) - self.get_node(session, request.link.node_two_id, context) + self.get_node(session, request.link.node_one_id, context, NodeBase) + self.get_node(session, request.link.node_two_id, context, NodeBase) node_one_id = request.link.node_one_id node_two_id = request.link.node_two_id @@ -997,7 +1013,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("mobility action: %s", request) session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, WlanNode) result = True if request.action == MobilityAction.START: node.mobility.start() @@ -1124,7 +1140,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get node service file: %s", request) session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, CoreNode) file_data = session.services.get_service_file( node, request.service, request.file ) @@ -1179,7 +1195,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("service action: %s", request) session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, CoreNode) service = None for current_service in node.services: if current_service.name == request.service: @@ -1268,7 +1284,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): wlan_config.node_id, BasicRangeModel.name, wlan_config.config ) if session.state == EventTypes.RUNTIME_STATE: - node = self.get_node(session, wlan_config.node_id, context) + node = self.get_node(session, wlan_config.node_id, context, WlanNode) node.updatemodel(wlan_config.config) return SetWlanConfigResponse(result=True) @@ -1549,7 +1565,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): :return: get node config service response """ session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, CoreNode) self.validate_service(request.name, context) service = node.config_services.get(request.name) if service: @@ -1631,7 +1647,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): :return: get node config services response """ session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, CoreNode) services = node.config_services.keys() return GetNodeConfigServicesResponse(services=services) @@ -1646,7 +1662,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): :return: set node config service response """ session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context) + node = self.get_node(session, request.node_id, context, CoreNode) self.validate_service(request.name, context) service = node.config_services.get(request.name) if service: @@ -1688,11 +1704,11 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): new_session = new_sessions[0] return ExecuteScriptResponse(session_id=new_session) - def SetWlanLink( - self, request: SetWlanLinkRequest, context: ServicerContext - ) -> SetWlanLinkResponse: + def WlanLink( + self, request: WlanLinkRequest, context: ServicerContext + ) -> WlanLinkResponse: session = self.get_session(request.session_id, context) - wlan = self.get_node(session, request.wlan, context) + wlan = self.get_node(session, request.wlan, context, WlanNode) if not isinstance(wlan, WlanNode): context.abort( grpc.StatusCode.NOT_FOUND, f"wlan id {request.wlan} is not a wlan node" @@ -1702,20 +1718,20 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): grpc.StatusCode.NOT_FOUND, f"wlan node {request.wlan} does not using BasicRangeModel", ) - n1 = self.get_node(session, request.node_one, context) - n2 = self.get_node(session, request.node_two, context) + n1 = self.get_node(session, request.node_one, context, CoreNode) + n2 = self.get_node(session, request.node_two, context, CoreNode) n1_netif, n2_netif = None, None for net, netif1, netif2 in n1.commonnets(n2): if net == wlan: n1_netif = netif1 n2_netif = netif2 break + result = False if n1_netif and n2_netif: if request.linked: wlan.link(n1_netif, n2_netif) else: wlan.unlink(n1_netif, n2_netif) wlan.model.sendlinkmsg(n1_netif, n2_netif, unlink=not request.linked) - return SetWlanLinkResponse(result=True) - else: - return SetWlanLinkResponse(result=False) + result = True + return WlanLinkResponse(result=result) diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 421b6f0f..b0ae6642 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -129,7 +129,7 @@ service CoreApi { } rpc SetWlanConfig (wlan.SetWlanConfigRequest) returns (wlan.SetWlanConfigResponse) { } - rpc SetWlanLink (wlan.SetWlanLinkRequest) returns (wlan.SetWlanLinkResponse) { + rpc WlanLink (wlan.WlanLinkRequest) returns (wlan.WlanLinkResponse) { } // emane rpc diff --git a/daemon/proto/core/api/grpc/wlan.proto b/daemon/proto/core/api/grpc/wlan.proto index a37c511f..bbb9757f 100644 --- a/daemon/proto/core/api/grpc/wlan.proto +++ b/daemon/proto/core/api/grpc/wlan.proto @@ -35,7 +35,7 @@ message SetWlanConfigResponse { bool result = 1; } -message SetWlanLinkRequest { +message WlanLinkRequest { int32 session_id = 1; int32 wlan = 2; int32 node_one = 3; @@ -43,6 +43,6 @@ message SetWlanLinkRequest { bool linked = 5; } -message SetWlanLinkResponse { +message WlanLinkResponse { bool result = 1; } From d5254e6a91d9573542cdb948819f147778683e4c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 20 May 2020 14:44:34 -0700 Subject: [PATCH 074/929] changes to support better type checking for retrieving an arbitrary node from a session, get_node now requires an expected class that the node would be an instance of, if the returned node is not an instance a CoreError is thrown, this also helps editors pick up expected types to account for variable/function usage better as well --- daemon/core/api/grpc/server.py | 31 ++++---------- daemon/core/api/tlv/corehandlers.py | 13 +++--- daemon/core/emane/commeffect.py | 3 +- daemon/core/emane/emanemanager.py | 8 ++-- daemon/core/emane/emanemodel.py | 3 +- daemon/core/emulator/distributed.py | 2 +- daemon/core/emulator/session.py | 33 ++++++++------- daemon/core/location/mobility.py | 12 +++--- daemon/core/nodes/base.py | 22 ++++++++++ daemon/core/nodes/physical.py | 1 - daemon/core/plugins/sdt.py | 2 +- daemon/core/services/emaneservices.py | 7 +++- daemon/core/xml/corexml.py | 13 +++--- daemon/examples/python/emane80211.py | 5 ++- daemon/examples/python/switch.py | 5 ++- daemon/examples/python/wlan.py | 5 ++- daemon/tests/emane/test_emane.py | 12 +++--- daemon/tests/test_grpc.py | 5 ++- daemon/tests/test_gui.py | 30 +++++++------- daemon/tests/test_nodes.py | 3 +- daemon/tests/test_xml.py | 60 ++++++++++++++------------- 21 files changed, 149 insertions(+), 126 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 77e6bf08..19779320 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -6,7 +6,7 @@ import tempfile import threading import time from concurrent import futures -from typing import Type, TypeVar +from typing import Type import grpc from grpc import ServicerContext @@ -109,7 +109,7 @@ from core.emulator.coreemu import CoreEmu from core.emulator.data import LinkData from core.emulator.emudata import LinkOptions, NodeOptions from core.emulator.enumerations import EventTypes, LinkTypes, MessageFlags -from core.emulator.session import Session +from core.emulator.session import NT, Session from core.errors import CoreCommandError, CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility from core.nodes.base import CoreNode, CoreNodeBase, NodeBase @@ -118,7 +118,6 @@ from core.services.coreservices import ServiceManager _ONE_DAY_IN_SECONDS = 60 * 60 * 24 _INTERFACE_REGEX = re.compile(r"veth(?P[0-9a-fA-F]+)") -T = TypeVar("T") class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): @@ -174,36 +173,22 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): return session def get_node( - self, - session: Session, - node_id: int, - context: ServicerContext, - node_class: Type[T], - ) -> T: + self, session: Session, node_id: int, context: ServicerContext, _class: Type[NT] + ) -> NT: """ Retrieve node given session and node id :param session: session that has the node :param node_id: node id :param context: request - :param node_class: type of node we are expecting + :param _class: type of node we are expecting :return: node object that satisfies. If node not found then raise an exception. :raises Exception: raises grpc exception when node does not exist """ try: - node = session.get_node(node_id) - if isinstance(node, node_class): - return node - else: - actual = node.__class__.__name__ - expected = node_class.__name__ - context.abort( - grpc.StatusCode.NOT_FOUND, - f"node({node_id}) class({actual}) " - f"was not expected class({expected})", - ) - except CoreError: - context.abort(grpc.StatusCode.NOT_FOUND, f"node {node_id} not found") + return session.get_node(node_id, _class) + except CoreError as e: + context.abort(grpc.StatusCode.NOT_FOUND, str(e)) def validate_service( self, name: str, context: ServicerContext diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index d7e41a6c..7f647873 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -41,6 +41,7 @@ from core.emulator.enumerations import ( ) from core.errors import CoreCommandError, CoreError from core.location.mobility import BasicRangeModel +from core.nodes.base import CoreNodeBase, NodeBase from core.nodes.network import WlanNode from core.services.coreservices import ServiceManager, ServiceShim @@ -836,7 +837,7 @@ class CoreHandler(socketserver.BaseRequestHandler): return () try: - node = self.session.get_node(node_num) + node = self.session.get_node(node_num, CoreNodeBase) # build common TLV items for reply tlv_data = b"" @@ -1228,7 +1229,7 @@ class CoreHandler(socketserver.BaseRequestHandler): if not node_id: return replies - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, CoreNodeBase) if node is None: logging.warning( "request to configure service for unknown node %s", node_id @@ -1373,7 +1374,7 @@ class CoreHandler(socketserver.BaseRequestHandler): self.session.mobility.set_model_config(node_id, object_name, parsed_config) if self.session.state == EventTypes.RUNTIME_STATE and parsed_config: try: - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, WlanNode) if object_name == BasicRangeModel.name: node.updatemodel(parsed_config) except CoreError: @@ -1553,7 +1554,7 @@ class CoreHandler(socketserver.BaseRequestHandler): logging.debug("handling event %s at %s", event_type.name, time.ctime()) if event_type.value <= EventTypes.SHUTDOWN_STATE.value: if node_id is not None: - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, NodeBase) # configure mobility models for WLAN added during runtime if event_type == EventTypes.INSTANTIATION_STATE and isinstance( @@ -1647,7 +1648,7 @@ class CoreHandler(socketserver.BaseRequestHandler): name = event_data.name try: - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, CoreNodeBase) except CoreError: logging.warning( "ignoring event for service '%s', unknown node '%s'", name, node_id @@ -1883,7 +1884,7 @@ class CoreHandler(socketserver.BaseRequestHandler): data_types = tuple( repeat(ConfigDataTypes.STRING.value, len(ServiceShim.keys)) ) - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, CoreNodeBase) values = ServiceShim.tovaluelist(node, service) config_data = ConfigData( message_type=0, diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index 635291e0..f98f2454 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -10,6 +10,7 @@ from lxml import etree from core.config import ConfigGroup, Configuration from core.emane import emanemanifest, emanemodel +from core.emane.nodes import EmaneNet from core.nodes.interface import CoreInterface from core.xml import emanexml @@ -137,7 +138,7 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): # TODO: batch these into multiple events per transmission # TODO: may want to split out seconds portion of delay and jitter event = CommEffectEvent() - emane_node = self.session.get_node(self.id) + emane_node = self.session.get_node(self.id, EmaneNet) nemid = emane_node.getnemid(netif) nemid2 = emane_node.getnemid(netif2) mbw = bw diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 8b4bade2..16680e0e 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -21,7 +21,7 @@ from core.emane.rfpipe import EmaneRfPipeModel from core.emane.tdma import EmaneTdmaModel from core.emulator.enumerations import ConfigDataTypes, RegisterTlvs from core.errors import CoreCommandError, CoreError -from core.nodes.base import CoreNode +from core.nodes.base import CoreNode, NodeBase from core.nodes.interface import CoreInterface from core.nodes.network import CtrlNet from core.xml import emanexml @@ -801,8 +801,8 @@ class EmaneManager(ModelManager): zbit_check = z.bit_length() > 16 or z < 0 if any([xbit_check, ybit_check, zbit_check]): logging.error( - "Unable to build node location message, received lat/long/alt exceeds coordinate " - "space: NEM %s (%d, %d, %d)", + "Unable to build node location message, received lat/long/alt " + "exceeds coordinate space: NEM %s (%d, %d, %d)", nemid, x, y, @@ -812,7 +812,7 @@ class EmaneManager(ModelManager): # generate a node message for this location update try: - node = self.session.get_node(n) + node = self.session.get_node(n, NodeBase) except CoreError: logging.exception( "location event NEM %s has no corresponding node %s", nemid, n diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 001ea8b0..57a73012 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -7,6 +7,7 @@ from typing import Dict, List from core.config import ConfigGroup, Configuration from core.emane import emanemanifest +from core.emane.nodes import EmaneNet from core.emulator.enumerations import ConfigDataTypes from core.errors import CoreError from core.location.mobility import WirelessModel @@ -148,7 +149,7 @@ class EmaneModel(WirelessModel): :return: nothing """ try: - wlan = self.session.get_node(self.id) + wlan = self.session.get_node(self.id, EmaneNet) wlan.setnempositions(moved_netifs) except CoreError: logging.exception("error during update") diff --git a/daemon/core/emulator/distributed.py b/daemon/core/emulator/distributed.py index 30becfb5..4e7fcdde 100644 --- a/daemon/core/emulator/distributed.py +++ b/daemon/core/emulator/distributed.py @@ -244,7 +244,7 @@ class DistributedController: ) return key & 0xFFFFFFFF - def get_tunnel(self, n1_id: int, n2_id: int) -> Tuple[GreTap, GreTap]: + def get_tunnel(self, n1_id: int, n2_id: int) -> GreTap: """ Return the GreTap between two nodes if it exists. diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 95aa7c0b..17c46749 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -12,7 +12,7 @@ import subprocess import tempfile import threading import time -from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type +from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, TypeVar from core import constants, utils from core.emane.emanemanager import EmaneManager @@ -77,6 +77,7 @@ NODES = { NODES_TYPE = {NODES[x]: x for x in NODES} CTRL_NET_ID = 9001 LINK_COLORS = ["green", "blue", "orange", "purple", "turquoise"] +NT = TypeVar("NT", bound=NodeBase) class Session: @@ -194,7 +195,7 @@ class Session: def _link_nodes( self, node_one_id: int, node_two_id: int ) -> Tuple[ - CoreNode, CoreNode, CoreNetworkBase, CoreNetworkBase, Tuple[GreTap, GreTap] + Optional[NodeBase], Optional[NodeBase], CoreNetworkBase, CoreNetworkBase, GreTap ]: """ Convenience method for retrieving nodes within link data. @@ -212,8 +213,8 @@ class Session: net_two = None # retrieve node one - node_one = self.get_node(node_one_id) - node_two = self.get_node(node_two_id) + node_one = self.get_node(node_one_id, NodeBase) + node_two = self.get_node(node_two_id, NodeBase) # both node ids are provided tunnel = self.distributed.get_tunnel(node_one_id, node_two_id) @@ -225,6 +226,7 @@ class Session: else: node_two = None # physical node connected via gre tap tunnel + # TODO: double check this cases type elif tunnel: if tunnel.remotenum == node_one_id: node_one = None @@ -777,7 +779,7 @@ class Session: :raises core.CoreError: when node to update does not exist """ # get node to update - node = self.get_node(node_id) + node = self.get_node(node_id, NodeBase) # set node position and broadcast it self.set_node_position(node, options) @@ -908,9 +910,7 @@ class Session: :param data: file data :return: nothing """ - - node = self.get_node(node_id) - + node = self.get_node(node_id, CoreNodeBase) if source_name is not None: node.addfile(source_name, file_name) elif data is not None: @@ -1381,17 +1381,23 @@ class Session: self.nodes[node.id] = node return node - def get_node(self, _id: int) -> NodeBase: + def get_node(self, _id: int, _class: Type[NT]) -> NT: """ Get a session node. :param _id: node id to retrieve + :param _class: expected node class :return: node for the given id :raises core.CoreError: when node does not exist """ if _id not in self.nodes: raise CoreError(f"unknown node id {_id}") - return self.nodes[_id] + node = self.nodes[_id] + if not isinstance(node, _class): + actual = node.__class__.__name__ + expected = _class.__name__ + raise CoreError(f"node class({actual}) is not expected({expected})") + return node def delete_node(self, _id: int) -> bool: """ @@ -1709,10 +1715,7 @@ class Session: :return: control net :raises CoreError: when control net is not found """ - node = self.get_node(CTRL_NET_ID + net_index) - if not isinstance(node, CtrlNet): - raise CoreError("node is not a valid CtrlNet: %s", node.name) - return node + return self.get_node(CTRL_NET_ID + net_index, CtrlNet) def add_remove_control_net( self, net_index: int, remove: bool = False, conf_required: bool = True @@ -1959,7 +1962,7 @@ class Session: if not node_id: utils.mute_detach(data) else: - node = self.get_node(node_id) + node = self.get_node(node_id, CoreNodeBase) node.cmd(data, wait=False) def get_link_color(self, network_id: int) -> str: diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index d2cb0c8a..f2a47c1f 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -24,6 +24,7 @@ from core.emulator.enumerations import ( from core.errors import CoreError from core.nodes.base import CoreNode, NodeBase from core.nodes.interface import CoreInterface +from core.nodes.network import WlanNode if TYPE_CHECKING: from core.emulator.session import Session @@ -75,7 +76,7 @@ class MobilityManager(ModelManager): ) try: - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, WlanNode) except CoreError: logging.warning( "skipping mobility configuration for unknown node: %s", node_id @@ -103,9 +104,8 @@ class MobilityManager(ModelManager): event_type = event_data.event_type node_id = event_data.node name = event_data.name - try: - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, WlanNode) except CoreError: logging.exception( "Ignoring event for model '%s', unknown node '%s'", name, node_id @@ -190,7 +190,7 @@ class MobilityManager(ModelManager): """ for node_id in self.nodes(): try: - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, WlanNode) except CoreError: continue if node.model: @@ -299,7 +299,7 @@ class BasicRangeModel(WirelessModel): """ super().__init__(session, _id) self.session = session - self.wlan = session.get_node(_id) + self.wlan = session.get_node(_id, WlanNode) self._netifs = {} self._netifslock = threading.Lock() self.range = 0 @@ -590,7 +590,7 @@ class WayPointMobility(WirelessModel): self.initial = {} self.lasttime = None self.endtime = None - self.wlan = session.get_node(_id) + self.wlan = session.get_node(_id, WlanNode) # these are really set in child class via confmatrix self.loop = False self.refresh_ms = 50 diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 2749323a..61e9e8fb 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -433,6 +433,28 @@ class CoreNodeBase(NodeBase): common.append((netif1.net, netif1, netif2)) return common + def nodefile(self, filename: str, contents: str, mode: int = 0o644) -> None: + """ + Create a node file with a given mode. + + :param filename: name of file to create + :param contents: contents of file + :param mode: mode for file + :return: nothing + """ + raise NotImplementedError + + def addfile(self, srcname: str, filename: str) -> None: + """ + Add a file. + + :param srcname: source file name + :param filename: file name to add + :return: nothing + :raises CoreCommandError: when a non-zero exit status occurs + """ + raise NotImplementedError + def cmd(self, args: str, wait: bool = True, shell: bool = False) -> str: """ Runs a command within a node container. diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index e8c999c6..baef7922 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -296,7 +296,6 @@ class Rj45Node(CoreNodeBase, CoreInterface): self.localname = name self.old_up = False self.old_addrs = [] - if start: self.startup() diff --git a/daemon/core/plugins/sdt.py b/daemon/core/plugins/sdt.py index 93663052..06c23de5 100644 --- a/daemon/core/plugins/sdt.py +++ b/daemon/core/plugins/sdt.py @@ -344,7 +344,7 @@ class Sdt: """ result = False try: - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, NodeBase) result = isinstance(node, (WlanNode, EmaneNet)) except CoreError: pass diff --git a/daemon/core/services/emaneservices.py b/daemon/core/services/emaneservices.py index e145e842..9d09516e 100644 --- a/daemon/core/services/emaneservices.py +++ b/daemon/core/services/emaneservices.py @@ -1,4 +1,5 @@ from core.emane.nodes import EmaneNet +from core.errors import CoreError from core.services.coreservices import CoreService from core.xml import emanexml @@ -20,8 +21,8 @@ class EmaneTransportService(CoreService): if filename == cls.configs[0]: transport_commands = [] for interface in node.netifs(sort=True): - network_node = node.session.get_node(interface.net.id) - if isinstance(network_node, EmaneNet): + try: + network_node = node.session.get_node(interface.net.id, EmaneNet) config = node.session.emane.get_configs( network_node.id, network_node.model.name ) @@ -32,6 +33,8 @@ class EmaneTransportService(CoreService): % nem_id ) transport_commands.append(command) + except CoreError: + pass transport_commands = "\n".join(transport_commands) return """ emanegentransportxml -o ../ ../platform%s.xml diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index deedd139..3d174db0 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -10,7 +10,7 @@ from core.emulator.data import LinkData from core.emulator.emudata import InterfaceData, LinkOptions, NodeOptions from core.emulator.enumerations import EventTypes, NodeTypes from core.errors import CoreXmlError -from core.nodes.base import CoreNetworkBase, CoreNodeBase, NodeBase +from core.nodes.base import CoreNodeBase, NodeBase from core.nodes.docker import DockerNode from core.nodes.lxd import LxcNode from core.nodes.network import CtrlNet, WlanNode @@ -505,9 +505,9 @@ class CoreXmlWriter: ip6_mask: int, ) -> etree.Element: interface = etree.Element(element_name) - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, NodeBase) interface_name = None - if not isinstance(node, CoreNetworkBase): + if isinstance(node, CoreNodeBase): node_interface = node.netif(interface_id) interface_name = node_interface.name @@ -523,7 +523,6 @@ class CoreXmlWriter: add_attribute(interface, "ip4_mask", ip4_mask) add_attribute(interface, "ip6", ip6) add_attribute(interface, "ip6_mask", ip6_mask) - return interface def create_link_element(self, link_data: LinkData) -> etree.Element: @@ -560,8 +559,8 @@ class CoreXmlWriter: link_element.append(interface_two) # check for options, don't write for emane/wlan links - node_one = self.session.get_node(link_data.node1_id) - node_two = self.session.get_node(link_data.node2_id) + node_one = self.session.get_node(link_data.node1_id, NodeBase) + node_two = self.session.get_node(link_data.node2_id, NodeBase) is_node_one_wireless = isinstance(node_one, (WlanNode, EmaneNet)) is_node_two_wireless = isinstance(node_two, (WlanNode, EmaneNet)) if not any([is_node_one_wireless, is_node_two_wireless]): @@ -902,7 +901,7 @@ class CoreXmlReader: for configservice_element in configservice_configs.iterchildren(): name = configservice_element.get("name") node_id = get_int(configservice_element, "node") - node = self.session.get_node(node_id) + node = self.session.get_node(node_id, CoreNodeBase) service = node.config_services[name] configs_element = configservice_element.find("configs") diff --git a/daemon/examples/python/emane80211.py b/daemon/examples/python/emane80211.py index e9764a09..6d8655f3 100644 --- a/daemon/examples/python/emane80211.py +++ b/daemon/examples/python/emane80211.py @@ -11,6 +11,7 @@ from core.emane.ieee80211abg import EmaneIeee80211abgModel from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes, NodeTypes +from core.nodes.base import CoreNode NODES = 2 EMANE_DELAY = 10 @@ -51,8 +52,8 @@ def main(): time.sleep(EMANE_DELAY) # get nodes to run example - first_node = session.get_node(1) - last_node = session.get_node(NODES) + first_node = session.get_node(1, CoreNode) + last_node = session.get_node(NODES, CoreNode) address = prefixes.ip4_address(first_node) logging.info("node %s pinging %s", last_node.name, address) output = last_node.cmd(f"ping -c 3 {address}") diff --git a/daemon/examples/python/switch.py b/daemon/examples/python/switch.py index b4903457..d16303e6 100644 --- a/daemon/examples/python/switch.py +++ b/daemon/examples/python/switch.py @@ -8,6 +8,7 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes from core.emulator.enumerations import EventTypes, NodeTypes +from core.nodes.base import CoreNode NODES = 2 @@ -36,8 +37,8 @@ def main(): session.instantiate() # get nodes to run example - first_node = session.get_node(1) - last_node = session.get_node(NODES) + first_node = session.get_node(1, CoreNode) + last_node = session.get_node(NODES, CoreNode) address = prefixes.ip4_address(first_node) logging.info("node %s pinging %s", last_node.name, address) output = last_node.cmd(f"ping -c 3 {address}") diff --git a/daemon/examples/python/wlan.py b/daemon/examples/python/wlan.py index e9ae47f4..886d3ca9 100644 --- a/daemon/examples/python/wlan.py +++ b/daemon/examples/python/wlan.py @@ -9,6 +9,7 @@ from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes, NodeTypes from core.location.mobility import BasicRangeModel +from core.nodes.base import CoreNode NODES = 2 @@ -40,8 +41,8 @@ def main(): session.instantiate() # get nodes for example run - first_node = session.get_node(1) - last_node = session.get_node(NODES) + first_node = session.get_node(1, CoreNode) + last_node = session.get_node(NODES, CoreNode) address = prefixes.ip4_address(first_node) logging.info("node %s pinging %s", last_node.name, address) output = last_node.cmd(f"ping -c 3 {address}") diff --git a/daemon/tests/emane/test_emane.py b/daemon/tests/emane/test_emane.py index 4c507eee..ada8e903 100644 --- a/daemon/tests/emane/test_emane.py +++ b/daemon/tests/emane/test_emane.py @@ -9,11 +9,13 @@ import pytest from core.emane.bypass import EmaneBypassModel from core.emane.commeffect import EmaneCommEffectModel from core.emane.ieee80211abg import EmaneIeee80211abgModel +from core.emane.nodes import EmaneNet from core.emane.rfpipe import EmaneRfPipeModel from core.emane.tdma import EmaneTdmaModel from core.emulator.emudata import NodeOptions from core.emulator.enumerations import NodeTypes from core.errors import CoreCommandError, CoreError +from core.nodes.base import CoreNode _EMANE_MODELS = [ EmaneIeee80211abgModel, @@ -133,9 +135,9 @@ class TestEmane: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id) + assert not session.get_node(n1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id) + assert not session.get_node(n2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) @@ -146,7 +148,7 @@ class TestEmane: ) # verify nodes and configuration were restored - assert session.get_node(n1_id) - assert session.get_node(n2_id) - assert session.get_node(emane_id) + assert session.get_node(n1_id, CoreNode) + assert session.get_node(n2_id, CoreNode) + assert session.get_node(emane_id, EmaneNet) assert value == config_value diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 5d8bfa1d..2580020a 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -19,6 +19,7 @@ from core.emulator.emudata import NodeOptions from core.emulator.enumerations import EventTypes, ExceptionLevels, NodeTypes from core.errors import CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility +from core.nodes.base import CoreNode from core.xml.corexml import CoreXmlWriter @@ -355,7 +356,7 @@ class TestGrpc: # then assert response.node_id is not None - assert session.get_node(response.node_id) is not None + assert session.get_node(response.node_id, CoreNode) is not None def test_get_node(self, grpc_server): # given @@ -402,7 +403,7 @@ class TestGrpc: assert response.result is expected if expected is True: with pytest.raises(CoreError): - assert session.get_node(node.id) + assert session.get_node(node.id, CoreNode) def test_node_command(self, request, grpc_server): if request.config.getoption("mock"): diff --git a/daemon/tests/test_gui.py b/daemon/tests/test_gui.py index 481a0fa9..40bc3d0b 100644 --- a/daemon/tests/test_gui.py +++ b/daemon/tests/test_gui.py @@ -24,6 +24,8 @@ from core.emane.ieee80211abg import EmaneIeee80211abgModel from core.emulator.enumerations import EventTypes, MessageFlags, NodeTypes, RegisterTlvs from core.errors import CoreError from core.location.mobility import BasicRangeModel +from core.nodes.base import CoreNode, NodeBase +from core.nodes.network import SwitchNode def dict_to_str(values): @@ -57,8 +59,7 @@ class TestGui: ) coretlv.handle_message(message) - - assert coretlv.session.get_node(node_id) is not None + assert coretlv.session.get_node(node_id, NodeBase) is not None def test_node_update(self, coretlv): node_id = 1 @@ -76,7 +77,7 @@ class TestGui: coretlv.handle_message(message) - node = coretlv.session.get_node(node_id) + node = coretlv.session.get_node(node_id, NodeBase) assert node is not None assert node.position.x == x assert node.position.y == y @@ -91,7 +92,7 @@ class TestGui: coretlv.handle_message(message) with pytest.raises(CoreError): - coretlv.session.get_node(node_id) + coretlv.session.get_node(node_id, NodeBase) def test_link_add_node_to_net(self, coretlv): node_one = 1 @@ -113,7 +114,7 @@ class TestGui: coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch) + switch_node = coretlv.session.get_node(switch, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 @@ -137,7 +138,7 @@ class TestGui: coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch) + switch_node = coretlv.session.get_node(switch, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 @@ -189,7 +190,7 @@ class TestGui: ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch) + switch_node = coretlv.session.get_node(switch, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 link = all_links[0] @@ -207,7 +208,7 @@ class TestGui: ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch) + switch_node = coretlv.session.get_node(switch, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 link = all_links[0] @@ -275,7 +276,7 @@ class TestGui: ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch) + switch_node = coretlv.session.get_node(switch, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 @@ -289,7 +290,7 @@ class TestGui: ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch) + switch_node = coretlv.session.get_node(switch, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 0 @@ -311,7 +312,7 @@ class TestGui: ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch) + switch_node = coretlv.session.get_node(switch, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 @@ -325,7 +326,7 @@ class TestGui: ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch) + switch_node = coretlv.session.get_node(switch, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 0 @@ -556,8 +557,7 @@ class TestGui: ) coretlv.handle_message(message) - - assert coretlv.session.get_node(node.id) + assert coretlv.session.get_node(node.id, NodeBase) @pytest.mark.parametrize( "state", @@ -619,7 +619,7 @@ class TestGui: coretlv.handle_message(message) - assert coretlv.coreemu.sessions[1].get_node(node.id) + assert coretlv.coreemu.sessions[1].get_node(node.id, CoreNode) def test_register_python(self, coretlv, tmpdir): xml_file = tmpdir.join("test.py") diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index 8c85e0ca..42202f93 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -3,6 +3,7 @@ import pytest from core.emulator.emudata import NodeOptions from core.emulator.enumerations import NodeTypes from core.errors import CoreError +from core.nodes.base import CoreNode MODELS = ["router", "host", "PC", "mdr"] NET_TYPES = [NodeTypes.SWITCH, NodeTypes.HUB, NodeTypes.WIRELESS_LAN] @@ -45,7 +46,7 @@ class TestNodes: # then with pytest.raises(CoreError): - session.get_node(node.id) + session.get_node(node.id, CoreNode) def test_node_sethwaddr(self, session): # given diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index 04f1192d..3d0a67d3 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -6,6 +6,8 @@ from core.emulator.emudata import LinkOptions, NodeOptions from core.emulator.enumerations import EventTypes, NodeTypes from core.errors import CoreError from core.location.mobility import BasicRangeModel +from core.nodes.base import CoreNode +from core.nodes.network import SwitchNode, WlanNode from core.services.utility import SshService @@ -91,16 +93,16 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id) + assert not session.get_node(n1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id) + assert not session.get_node(n2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) # verify nodes have been recreated - assert session.get_node(n1_id) - assert session.get_node(n2_id) + assert session.get_node(n1_id, CoreNode) + assert session.get_node(n2_id, CoreNode) def test_xml_ptp_services(self, session, tmpdir, ip_prefixes): """ @@ -152,9 +154,9 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id) + assert not session.get_node(n1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id) + assert not session.get_node(n2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) @@ -163,8 +165,8 @@ class TestXml: service = session.services.get_service(node_one.id, SshService.name) # verify nodes have been recreated - assert session.get_node(n1_id) - assert session.get_node(n2_id) + assert session.get_node(n1_id, CoreNode) + assert session.get_node(n2_id, CoreNode) assert service.config_data.get(service_file) == file_data def test_xml_mobility(self, session, tmpdir, ip_prefixes): @@ -212,9 +214,9 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id) + assert not session.get_node(n1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id) + assert not session.get_node(n2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) @@ -223,9 +225,9 @@ class TestXml: value = str(session.mobility.get_config("test", wlan_id, BasicRangeModel.name)) # verify nodes and configuration were restored - assert session.get_node(n1_id) - assert session.get_node(n2_id) - assert session.get_node(wlan_id) + assert session.get_node(n1_id, CoreNode) + assert session.get_node(n2_id, CoreNode) + assert session.get_node(wlan_id, WlanNode) assert value == "1" def test_network_to_network(self, session, tmpdir): @@ -263,16 +265,16 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id) + assert not session.get_node(n1_id, SwitchNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id) + assert not session.get_node(n2_id, SwitchNode) # load saved xml session.open_xml(file_path, start=True) # verify nodes have been recreated - switch_one = session.get_node(n1_id) - switch_two = session.get_node(n2_id) + switch_one = session.get_node(n1_id, SwitchNode) + switch_two = session.get_node(n2_id, SwitchNode) assert switch_one assert switch_two assert len(switch_one.all_link_data() + switch_two.all_link_data()) == 1 @@ -322,16 +324,16 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id) + assert not session.get_node(n1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id) + assert not session.get_node(n2_id, SwitchNode) # load saved xml session.open_xml(file_path, start=True) # verify nodes have been recreated - assert session.get_node(n1_id) - assert session.get_node(n2_id) + assert session.get_node(n1_id, CoreNode) + assert session.get_node(n2_id, SwitchNode) links = [] for node_id in session.nodes: node = session.nodes[node_id] @@ -389,16 +391,16 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id) + assert not session.get_node(n1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id) + assert not session.get_node(n2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) # verify nodes have been recreated - assert session.get_node(n1_id) - assert session.get_node(n2_id) + assert session.get_node(n1_id, CoreNode) + assert session.get_node(n2_id, CoreNode) links = [] for node_id in session.nodes: node = session.nodes[node_id] @@ -471,16 +473,16 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id) + assert not session.get_node(n1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id) + assert not session.get_node(n2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) # verify nodes have been recreated - assert session.get_node(n1_id) - assert session.get_node(n2_id) + assert session.get_node(n1_id, CoreNode) + assert session.get_node(n2_id, CoreNode) links = [] for node_id in session.nodes: node = session.nodes[node_id] From c07766e1ebd7e5073006c348010d24517c28cee9 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 20 May 2020 22:14:03 -0700 Subject: [PATCH 075/929] updated session.add_node to use better type hinting and force usage of classes directly, instead of indirectly through NodeTypes --- daemon/core/api/grpc/grpcutils.py | 3 +- daemon/core/api/grpc/server.py | 3 +- daemon/core/api/tlv/corehandlers.py | 7 +- daemon/core/emulator/session.py | 51 +++++-------- daemon/core/xml/corexml.py | 6 +- daemon/examples/configservices/testing.py | 10 +-- daemon/examples/docker/docker2core.py | 8 ++- daemon/examples/docker/docker2docker.py | 7 +- daemon/examples/docker/switch.py | 13 ++-- daemon/examples/lxd/lxd2core.py | 8 ++- daemon/examples/lxd/lxd2lxd.py | 7 +- daemon/examples/lxd/switch.py | 13 ++-- daemon/examples/python/distributed_emane.py | 10 +-- daemon/examples/python/distributed_lxd.py | 7 +- daemon/examples/python/distributed_ptp.py | 5 +- daemon/examples/python/distributed_switch.py | 10 +-- daemon/examples/python/emane80211.py | 7 +- daemon/examples/python/switch.py | 7 +- daemon/examples/python/switch_inject.py | 11 +-- daemon/examples/python/wlan.py | 7 +- daemon/tests/emane/test_emane.py | 13 ++-- daemon/tests/test_conf.py | 9 +-- daemon/tests/test_core.py | 36 +++++----- daemon/tests/test_distributed.py | 7 +- daemon/tests/test_grpc.py | 74 +++++++++---------- daemon/tests/test_gui.py | 75 ++++++++++---------- daemon/tests/test_links.py | 31 ++++---- daemon/tests/test_nodes.py | 20 +++--- daemon/tests/test_services.py | 29 ++++---- daemon/tests/test_xml.py | 38 +++++----- docs/scripting.md | 59 +++++++++------ 31 files changed, 315 insertions(+), 276 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 4736f017..6281ec67 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -123,7 +123,8 @@ def create_nodes( funcs = [] for node_proto in node_protos: _type, _id, options = add_node_data(node_proto) - args = (_type, _id, options) + _class = session.get_node_class(_type) + args = (_class, _id, options) funcs.append((session.add_node, args, {})) start = time.monotonic() results, exceptions = utils.threadpool(funcs) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 19779320..5fae97dc 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -667,7 +667,8 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): logging.debug("add node: %s", request) session = self.get_session(request.session_id, context) _type, _id, options = grpcutils.add_node_data(request.node) - node = session.add_node(_type=_type, _id=_id, options=options) + _class = session.get_node_class(_type) + node = session.add_node(_class, _id, options) return core_pb2.AddNodeResponse(node_id=node.id) def GetNode( diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 7f647873..02a6294c 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -41,7 +41,7 @@ from core.emulator.enumerations import ( ) from core.errors import CoreCommandError, CoreError from core.location.mobility import BasicRangeModel -from core.nodes.base import CoreNodeBase, NodeBase +from core.nodes.base import CoreNode, CoreNodeBase, NodeBase from core.nodes.network import WlanNode from core.services.coreservices import ServiceManager, ServiceShim @@ -682,10 +682,11 @@ class CoreHandler(socketserver.BaseRequestHandler): logging.warning("ignoring invalid message: add and delete flag both set") return () - node_type = None + _class = CoreNode node_type_value = message.get_tlv(NodeTlvs.TYPE.value) if node_type_value is not None: node_type = NodeTypes(node_type_value) + _class = self.session.get_node_class(node_type) node_id = message.get_tlv(NodeTlvs.NUMBER.value) @@ -720,7 +721,7 @@ class CoreHandler(socketserver.BaseRequestHandler): options.services = services.split("|") if message.flags & MessageFlags.ADD.value: - node = self.session.add_node(node_type, node_id, options) + node = self.session.add_node(_class, node_id, options) if node: if message.flags & MessageFlags.STRING.value: self.node_status_request[node.id] = True diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 17c46749..6f112ccf 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -75,6 +75,7 @@ NODES = { NodeTypes.LXC: LxcNode, } NODES_TYPE = {NODES[x]: x for x in NODES} +CONTAINER_NODES = {DockerNode, LxcNode} CTRL_NET_ID = 9001 LINK_COLORS = ["green", "blue", "orange", "purple", "turquoise"] NT = TypeVar("NT", bound=NodeBase) @@ -348,7 +349,7 @@ class Session: node_two.name, ) start = self.state.should_start() - net_one = self.create_node(cls=PtpNet, start=start) + net_one = self.create_node(_class=PtpNet, start=start) # node to network if node_one and net_one: @@ -662,32 +663,21 @@ class Session: node_two.lock.release() def add_node( - self, - _type: NodeTypes = NodeTypes.DEFAULT, - _id: int = None, - options: NodeOptions = None, - _cls: Type[NodeBase] = None, - ) -> NodeBase: + self, _class: Type[NT], _id: int = None, options: NodeOptions = None + ) -> NT: """ Add a node to the session, based on the provided node data. - :param _type: type of node to create + :param _class: node class to create :param _id: id for node, defaults to None for generated id :param options: data to create node with - :param _cls: optional custom class to use for a created node :return: created node :raises core.CoreError: when an invalid node type is given """ - # validate node type, get class, or throw error - if _cls is None: - node_class = self.get_node_class(_type) - else: - node_class = _cls - # set node start based on current session state, override and check when rj45 start = self.state.should_start() enable_rj45 = self.options.get_config("enablerj45") == "1" - if _type == NodeTypes.RJ45 and not enable_rj45: + if _class == Rj45Node and not enable_rj45: start = False # determine node id @@ -703,7 +693,7 @@ class Session: options.set_position(0, 0) name = options.name if not name: - name = f"{node_class.__name__}{_id}" + name = f"{_class.__name__}{_id}" # verify distributed server server = self.distributed.servers.get(options.server) @@ -713,24 +703,15 @@ class Session: # create node logging.info( "creating node(%s) id(%s) name(%s) start(%s)", - node_class.__name__, + _class.__name__, _id, name, start, ) - if _type in [NodeTypes.DOCKER, NodeTypes.LXC]: - node = self.create_node( - cls=node_class, - _id=_id, - name=name, - start=start, - image=options.image, - server=server, - ) - else: - node = self.create_node( - cls=node_class, _id=_id, name=name, start=start, server=server - ) + kwargs = dict(_id=_id, name=name, start=start, server=server) + if _class in CONTAINER_NODES: + kwargs["image"] = options.image + node = self.create_node(_class, **kwargs) # set node attributes node.icon = options.icon @@ -1363,17 +1344,17 @@ class Session: break return node_id - def create_node(self, cls: Type[NodeBase], *args: Any, **kwargs: Any) -> NodeBase: + def create_node(self, _class: Type[NT], *args: Any, **kwargs: Any) -> NT: """ Create an emulation node. - :param cls: node class to create + :param _class: node class to create :param args: list of arguments for the class to create :param kwargs: dictionary of arguments for the class to create :return: the created node instance :raises core.CoreError: when id of the node to create already exists """ - node = cls(self, *args, **kwargs) + node = _class(self, *args, **kwargs) with self._nodes_lock: if node.id in self.nodes: node.shutdown() @@ -1791,7 +1772,7 @@ class Session: server_interface, ) control_net = self.create_node( - cls=CtrlNet, + _class=CtrlNet, _id=_id, prefix=prefix, assign_address=True, diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 3d174db0..ddb51b28 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -840,6 +840,7 @@ class CoreXmlReader: node_type = NodeTypes.DOCKER elif clazz == "lxc": node_type = NodeTypes.LXC + _class = self.session.get_node_class(node_type) service_elements = device_element.find("services") if service_elements is not None: @@ -865,12 +866,13 @@ class CoreXmlReader: options.set_location(lat, lon, alt) logging.info("reading node id(%s) model(%s) name(%s)", node_id, model, name) - self.session.add_node(_type=node_type, _id=node_id, options=options) + self.session.add_node(_class, node_id, options) def read_network(self, network_element: etree.Element) -> None: node_id = get_int(network_element, "id") name = network_element.get("name") node_type = NodeTypes[network_element.get("type")] + _class = self.session.get_node_class(node_type) icon = network_element.get("icon") options = NodeOptions(name) options.icon = icon @@ -891,7 +893,7 @@ class CoreXmlReader: logging.info( "reading node id(%s) node_type(%s) name(%s)", node_id, node_type, name ) - self.session.add_node(_type=node_type, _id=node_id, options=options) + self.session.add_node(_class, node_id, options) def read_configservice_configs(self) -> None: configservice_configs = self.scenario.find("configservice_configurations") diff --git a/daemon/examples/configservices/testing.py b/daemon/examples/configservices/testing.py index 5b193aee..bc67ff46 100644 --- a/daemon/examples/configservices/testing.py +++ b/daemon/examples/configservices/testing.py @@ -2,7 +2,9 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.base import CoreNode +from core.nodes.network import SwitchNode if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -13,16 +15,16 @@ if __name__ == "__main__": coreemu = CoreEmu() session = coreemu.create_session() session.set_state(EventTypes.CONFIGURATION_STATE) - switch = session.add_node(_type=NodeTypes.SWITCH) + switch = session.add_node(SwitchNode) # node one options.config_services = ["DefaultRoute", "IPForward"] - node_one = session.add_node(options=options) + node_one = session.add_node(CoreNode, options=options) interface = prefixes.create_interface(node_one) session.add_link(node_one.id, switch.id, interface_one=interface) # node two - node_two = session.add_node(options=options) + node_two = session.add_node(CoreNode, options=options) interface = prefixes.create_interface(node_two) session.add_link(node_two.id, switch.id, interface_one=interface) diff --git a/daemon/examples/docker/docker2core.py b/daemon/examples/docker/docker2core.py index 86cf3dfe..1211a16f 100644 --- a/daemon/examples/docker/docker2core.py +++ b/daemon/examples/docker/docker2core.py @@ -2,7 +2,9 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.base import CoreNode +from core.nodes.docker import DockerNode if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -15,11 +17,11 @@ if __name__ == "__main__": options = NodeOptions(model=None, image="ubuntu") # create node one - node_one = session.add_node(_type=NodeTypes.DOCKER, options=options) + node_one = session.add_node(DockerNode, options=options) interface_one = prefixes.create_interface(node_one) # create node two - node_two = session.add_node() + node_two = session.add_node(CoreNode) interface_two = prefixes.create_interface(node_two) # add link diff --git a/daemon/examples/docker/docker2docker.py b/daemon/examples/docker/docker2docker.py index 261a8f67..9e1ae11f 100644 --- a/daemon/examples/docker/docker2docker.py +++ b/daemon/examples/docker/docker2docker.py @@ -2,7 +2,8 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.docker import DockerNode if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -17,11 +18,11 @@ if __name__ == "__main__": options = NodeOptions(model=None, image="ubuntu") # create node one - node_one = session.add_node(_type=NodeTypes.DOCKER, options=options) + node_one = session.add_node(DockerNode, options=options) interface_one = prefixes.create_interface(node_one) # create node two - node_two = session.add_node(_type=NodeTypes.DOCKER, options=options) + node_two = session.add_node(DockerNode, options=options) interface_two = prefixes.create_interface(node_two) # add link diff --git a/daemon/examples/docker/switch.py b/daemon/examples/docker/switch.py index f66863e5..74d58fe0 100644 --- a/daemon/examples/docker/switch.py +++ b/daemon/examples/docker/switch.py @@ -2,7 +2,10 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.base import CoreNode +from core.nodes.docker import DockerNode +from core.nodes.network import SwitchNode if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -16,18 +19,18 @@ if __name__ == "__main__": options = NodeOptions(model=None, image="ubuntu") # create switch - switch = session.add_node(_type=NodeTypes.SWITCH) + switch = session.add_node(SwitchNode) # node one - node_one = session.add_node(_type=NodeTypes.DOCKER, options=options) + node_one = session.add_node(DockerNode, options=options) interface_one = prefixes.create_interface(node_one) # node two - node_two = session.add_node(_type=NodeTypes.DOCKER, options=options) + node_two = session.add_node(DockerNode, options=options) interface_two = prefixes.create_interface(node_two) # node three - node_three = session.add_node() + node_three = session.add_node(CoreNode) interface_three = prefixes.create_interface(node_three) # add links diff --git a/daemon/examples/lxd/lxd2core.py b/daemon/examples/lxd/lxd2core.py index 06b2b6ba..1365bd4c 100644 --- a/daemon/examples/lxd/lxd2core.py +++ b/daemon/examples/lxd/lxd2core.py @@ -2,7 +2,9 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.base import CoreNode +from core.nodes.lxd import LxcNode if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -15,11 +17,11 @@ if __name__ == "__main__": options = NodeOptions(image="ubuntu") # create node one - node_one = session.add_node(_type=NodeTypes.LXC, options=options) + node_one = session.add_node(LxcNode, options=options) interface_one = prefixes.create_interface(node_one) # create node two - node_two = session.add_node() + node_two = session.add_node(CoreNode) interface_two = prefixes.create_interface(node_two) # add link diff --git a/daemon/examples/lxd/lxd2lxd.py b/daemon/examples/lxd/lxd2lxd.py index 2449a223..53a360e8 100644 --- a/daemon/examples/lxd/lxd2lxd.py +++ b/daemon/examples/lxd/lxd2lxd.py @@ -2,7 +2,8 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.lxd import LxcNode if __name__ == "__main__": logging.basicConfig(level=logging.INFO) @@ -17,11 +18,11 @@ if __name__ == "__main__": options = NodeOptions(image="ubuntu:18.04") # create node one - node_one = session.add_node(_type=NodeTypes.LXC, options=options) + node_one = session.add_node(LxcNode, options=options) interface_one = prefixes.create_interface(node_one) # create node two - node_two = session.add_node(_type=NodeTypes.LXC, options=options) + node_two = session.add_node(LxcNode, options=options) interface_two = prefixes.create_interface(node_two) # add link diff --git a/daemon/examples/lxd/switch.py b/daemon/examples/lxd/switch.py index 7deaae5f..3b6226e4 100644 --- a/daemon/examples/lxd/switch.py +++ b/daemon/examples/lxd/switch.py @@ -2,7 +2,10 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.base import CoreNode +from core.nodes.lxd import LxcNode +from core.nodes.network import SwitchNode if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) @@ -16,18 +19,18 @@ if __name__ == "__main__": options = NodeOptions(image="ubuntu") # create switch - switch = session.add_node(_type=NodeTypes.SWITCH) + switch = session.add_node(SwitchNode) # node one - node_one = session.add_node(_type=NodeTypes.LXC, options=options) + node_one = session.add_node(LxcNode, options=options) interface_one = prefixes.create_interface(node_one) # node two - node_two = session.add_node(_type=NodeTypes.LXC, options=options) + node_two = session.add_node(LxcNode, options=options) interface_two = prefixes.create_interface(node_two) # node three - node_three = session.add_node() + node_three = session.add_node(CoreNode) interface_three = prefixes.create_interface(node_three) # add links diff --git a/daemon/examples/python/distributed_emane.py b/daemon/examples/python/distributed_emane.py index 4b748803..3248a8e3 100644 --- a/daemon/examples/python/distributed_emane.py +++ b/daemon/examples/python/distributed_emane.py @@ -7,9 +7,11 @@ import argparse import logging from core.emane.ieee80211abg import EmaneIeee80211abgModel +from core.emane.nodes import EmaneNet from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.base import CoreNode def parse(name): @@ -50,11 +52,11 @@ def main(args): # create local node, switch, and remote nodes options = NodeOptions(model="mdr") options.set_position(0, 0) - node_one = session.add_node(options=options) - emane_net = session.add_node(_type=NodeTypes.EMANE) + node_one = session.add_node(CoreNode, options=options) + emane_net = session.add_node(EmaneNet) session.emane.set_model(emane_net, EmaneIeee80211abgModel) options.server = server_name - node_two = session.add_node(options=options) + node_two = session.add_node(CoreNode, options=options) # create node interfaces and link interface_one = prefixes.create_interface(node_one) diff --git a/daemon/examples/python/distributed_lxd.py b/daemon/examples/python/distributed_lxd.py index 8d46d599..de919012 100644 --- a/daemon/examples/python/distributed_lxd.py +++ b/daemon/examples/python/distributed_lxd.py @@ -8,7 +8,8 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.lxd import LxcNode def parse(name): @@ -42,9 +43,9 @@ def main(args): # create local node, switch, and remote nodes options = NodeOptions(image="ubuntu:18.04") - node_one = session.add_node(_type=NodeTypes.LXC, options=options) + node_one = session.add_node(LxcNode, options=options) options.server = server_name - node_two = session.add_node(_type=NodeTypes.LXC, options=options) + node_two = session.add_node(LxcNode, options=options) # create node interfaces and link interface_one = prefixes.create_interface(node_one) diff --git a/daemon/examples/python/distributed_ptp.py b/daemon/examples/python/distributed_ptp.py index 85069603..26531399 100644 --- a/daemon/examples/python/distributed_ptp.py +++ b/daemon/examples/python/distributed_ptp.py @@ -9,6 +9,7 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes +from core.nodes.base import CoreNode def parse(name): @@ -42,9 +43,9 @@ def main(args): # create local node, switch, and remote nodes options = NodeOptions() - node_one = session.add_node(options=options) + node_one = session.add_node(CoreNode, options=options) options.server = server_name - node_two = session.add_node(options=options) + node_two = session.add_node(CoreNode, options=options) # create node interfaces and link interface_one = prefixes.create_interface(node_one) diff --git a/daemon/examples/python/distributed_switch.py b/daemon/examples/python/distributed_switch.py index 57c6141b..c52c1cc1 100644 --- a/daemon/examples/python/distributed_switch.py +++ b/daemon/examples/python/distributed_switch.py @@ -8,7 +8,9 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.base import CoreNode +from core.nodes.network import SwitchNode def parse(name): @@ -43,11 +45,11 @@ def main(args): session.set_state(EventTypes.CONFIGURATION_STATE) # create local node, switch, and remote nodes - node_one = session.add_node() - switch = session.add_node(_type=NodeTypes.SWITCH) + node_one = session.add_node(CoreNode) + switch = session.add_node(SwitchNode) options = NodeOptions() options.server = server_name - node_two = session.add_node(options=options) + node_two = session.add_node(CoreNode, options=options) # create node interfaces and link interface_one = prefixes.create_interface(node_one) diff --git a/daemon/examples/python/emane80211.py b/daemon/examples/python/emane80211.py index 6d8655f3..da93026b 100644 --- a/daemon/examples/python/emane80211.py +++ b/daemon/examples/python/emane80211.py @@ -8,9 +8,10 @@ import logging import time from core.emane.ieee80211abg import EmaneIeee80211abgModel +from core.emane.nodes import EmaneNet from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode NODES = 2 @@ -33,13 +34,13 @@ def main(): session.set_location(47.57917, -122.13232, 2.00000, 1.0) options = NodeOptions() options.set_position(80, 50) - emane_network = session.add_node(_type=NodeTypes.EMANE, options=options, _id=100) + emane_network = session.add_node(EmaneNet, options=options, _id=100) session.emane.set_model(emane_network, EmaneIeee80211abgModel) # create nodes options = NodeOptions(model="mdr") for i in range(NODES): - node = session.add_node(options=options) + node = session.add_node(CoreNode, options=options) node.setposition(x=150 * (i + 1), y=150) interface = prefixes.create_interface(node) session.add_link(node.id, emane_network.id, interface_one=interface) diff --git a/daemon/examples/python/switch.py b/daemon/examples/python/switch.py index d16303e6..9475fc47 100644 --- a/daemon/examples/python/switch.py +++ b/daemon/examples/python/switch.py @@ -7,8 +7,9 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode +from core.nodes.network import SwitchNode NODES = 2 @@ -25,11 +26,11 @@ def main(): session.set_state(EventTypes.CONFIGURATION_STATE) # create switch network node - switch = session.add_node(_type=NodeTypes.SWITCH, _id=100) + switch = session.add_node(SwitchNode, _id=100) # create nodes for _ in range(NODES): - node = session.add_node() + node = session.add_node(CoreNode) interface = prefixes.create_interface(node) session.add_link(node.id, switch.id, interface_one=interface) diff --git a/daemon/examples/python/switch_inject.py b/daemon/examples/python/switch_inject.py index e85880e6..8c929e91 100644 --- a/daemon/examples/python/switch_inject.py +++ b/daemon/examples/python/switch_inject.py @@ -7,8 +7,11 @@ same CoreEmu instance the GUI is using. import logging +from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes +from core.nodes.base import CoreNode +from core.nodes.network import SwitchNode NODES = 2 @@ -18,18 +21,18 @@ def main(): prefixes = IpPrefixes("10.83.0.0/16") # create emulator instance for creating sessions and utility methods - coreemu = globals()["coreemu"] + coreemu: CoreEmu = globals()["coreemu"] session = coreemu.create_session() # must be in configuration state for nodes to start, when using "node_add" below session.set_state(EventTypes.CONFIGURATION_STATE) # create switch network node - switch = session.add_node(_type=NodeTypes.SWITCH) + switch = session.add_node(SwitchNode) # create nodes for _ in range(NODES): - node = session.add_node() + node = session.add_node(CoreNode) interface = prefixes.create_interface(node) session.add_link(node.id, switch.id, interface_one=interface) diff --git a/daemon/examples/python/wlan.py b/daemon/examples/python/wlan.py index 886d3ca9..b09ae5ce 100644 --- a/daemon/examples/python/wlan.py +++ b/daemon/examples/python/wlan.py @@ -7,9 +7,10 @@ import logging from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes from core.location.mobility import BasicRangeModel from core.nodes.base import CoreNode +from core.nodes.network import WlanNode NODES = 2 @@ -26,14 +27,14 @@ def main(): session.set_state(EventTypes.CONFIGURATION_STATE) # create wlan network node - wlan = session.add_node(_type=NodeTypes.WIRELESS_LAN, _id=100) + wlan = session.add_node(WlanNode, _id=100) session.mobility.set_model(wlan, BasicRangeModel) # create nodes, must set a position for wlan basic range model options = NodeOptions(model="mdr") options.set_position(0, 0) for _ in range(NODES): - node = session.add_node(options=options) + node = session.add_node(CoreNode, options=options) interface = prefixes.create_interface(node) session.add_link(node.id, wlan.id, interface_one=interface) diff --git a/daemon/tests/emane/test_emane.py b/daemon/tests/emane/test_emane.py index ada8e903..b3499337 100644 --- a/daemon/tests/emane/test_emane.py +++ b/daemon/tests/emane/test_emane.py @@ -13,7 +13,6 @@ from core.emane.nodes import EmaneNet from core.emane.rfpipe import EmaneRfPipeModel from core.emane.tdma import EmaneTdmaModel from core.emulator.emudata import NodeOptions -from core.emulator.enumerations import NodeTypes from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNode @@ -52,7 +51,7 @@ class TestEmane: session.set_location(47.57917, -122.13232, 2.00000, 1.0) options = NodeOptions() options.set_position(80, 50) - emane_network = session.add_node(_type=NodeTypes.EMANE, options=options) + emane_network = session.add_node(EmaneNet, options=options) session.emane.set_model(emane_network, model) # configure tdma @@ -66,9 +65,9 @@ class TestEmane: # create nodes options = NodeOptions(model="mdr") options.set_position(150, 150) - node_one = session.add_node(options=options) + node_one = session.add_node(CoreNode, options=options) options.set_position(300, 150) - node_two = session.add_node(options=options) + node_two = session.add_node(CoreNode, options=options) for i, node in enumerate([node_one, node_two]): node.setposition(x=150 * (i + 1), y=150) @@ -94,7 +93,7 @@ class TestEmane: session.set_location(47.57917, -122.13232, 2.00000, 1.0) options = NodeOptions() options.set_position(80, 50) - emane_network = session.add_node(_type=NodeTypes.EMANE, options=options) + emane_network = session.add_node(EmaneNet, options=options) config_key = "txpower" config_value = "10" session.emane.set_model( @@ -104,9 +103,9 @@ class TestEmane: # create nodes options = NodeOptions(model="mdr") options.set_position(150, 150) - node_one = session.add_node(options=options) + node_one = session.add_node(CoreNode, options=options) options.set_position(300, 150) - node_two = session.add_node(options=options) + node_two = session.add_node(CoreNode, options=options) for i, node in enumerate([node_one, node_two]): node.setposition(x=150 * (i + 1), y=150) diff --git a/daemon/tests/test_conf.py b/daemon/tests/test_conf.py index 10b36df7..55f6260a 100644 --- a/daemon/tests/test_conf.py +++ b/daemon/tests/test_conf.py @@ -7,8 +7,9 @@ from core.config import ( ModelManager, ) from core.emane.ieee80211abg import EmaneIeee80211abgModel -from core.emulator.enumerations import ConfigDataTypes, NodeTypes +from core.emulator.enumerations import ConfigDataTypes from core.location.mobility import BasicRangeModel +from core.nodes.network import WlanNode class TestConfigurableOptions(ConfigurableOptions): @@ -147,7 +148,7 @@ class TestConf: def test_model_set(self, session): # given - wlan_node = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan_node = session.add_node(WlanNode) # when session.mobility.set_model(wlan_node, BasicRangeModel) @@ -157,7 +158,7 @@ class TestConf: def test_model_set_error(self, session): # given - wlan_node = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan_node = session.add_node(WlanNode) # when / then with pytest.raises(ValueError): @@ -165,7 +166,7 @@ class TestConf: def test_get_models(self, session): # given - wlan_node = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan_node = session.add_node(WlanNode) session.mobility.set_model(wlan_node, BasicRangeModel) # when diff --git a/daemon/tests/test_core.py b/daemon/tests/test_core.py index cc9ba2a4..e663b85f 100644 --- a/daemon/tests/test_core.py +++ b/daemon/tests/test_core.py @@ -8,13 +8,15 @@ import threading import pytest from core.emulator.emudata import NodeOptions -from core.emulator.enumerations import MessageFlags, NodeTypes +from core.emulator.enumerations import MessageFlags from core.errors import CoreCommandError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility +from core.nodes.base import CoreNode +from core.nodes.network import HubNode, PtpNet, SwitchNode, WlanNode _PATH = os.path.abspath(os.path.dirname(__file__)) _MOBILITY_FILE = os.path.join(_PATH, "mobility.scen") -_WIRED = [NodeTypes.PEER_TO_PEER, NodeTypes.HUB, NodeTypes.SWITCH] +_WIRED = [PtpNet, HubNode, SwitchNode] def ping(from_node, to_node, ip_prefixes): @@ -39,11 +41,11 @@ class TestCore: """ # create net node - net_node = session.add_node(_type=net_type) + net_node = session.add_node(net_type) # create nodes - node_one = session.add_node() - node_two = session.add_node() + node_one = session.add_node(CoreNode) + node_two = session.add_node(CoreNode) # link nodes to net node for node in [node_one, node_two]: @@ -66,11 +68,11 @@ class TestCore: :param ip_prefixes: generates ip addresses for nodes """ # create ptp - ptp_node = session.add_node(_type=NodeTypes.PEER_TO_PEER) + ptp_node = session.add_node(PtpNet) # create nodes - node_one = session.add_node() - node_two = session.add_node() + node_one = session.add_node(CoreNode) + node_two = session.add_node(CoreNode) # link nodes to ptp net for node in [node_one, node_two]: @@ -99,11 +101,11 @@ class TestCore: """ # create ptp - ptp_node = session.add_node(_type=NodeTypes.PEER_TO_PEER) + ptp_node = session.add_node(PtpNet) # create nodes - node_one = session.add_node() - node_two = session.add_node() + node_one = session.add_node(CoreNode) + node_two = session.add_node(CoreNode) # link nodes to ptp net for node in [node_one, node_two]: @@ -143,14 +145,14 @@ class TestCore: """ # create wlan - wlan_node = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan_node = session.add_node(WlanNode) session.mobility.set_model(wlan_node, BasicRangeModel) # create nodes options = NodeOptions(model="mdr") options.set_position(0, 0) - node_one = session.add_node(options=options) - node_two = session.add_node(options=options) + node_one = session.add_node(CoreNode, options=options) + node_two = session.add_node(CoreNode, options=options) # link nodes for node in [node_one, node_two]: @@ -173,14 +175,14 @@ class TestCore: """ # create wlan - wlan_node = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan_node = session.add_node(WlanNode) session.mobility.set_model(wlan_node, BasicRangeModel) # create nodes options = NodeOptions(model="mdr") options.set_position(0, 0) - node_one = session.add_node(options=options) - node_two = session.add_node(options=options) + node_one = session.add_node(CoreNode, options=options) + node_two = session.add_node(CoreNode, options=options) # link nodes for node in [node_one, node_two]: diff --git a/daemon/tests/test_distributed.py b/daemon/tests/test_distributed.py index 07f17ecb..2308db3d 100644 --- a/daemon/tests/test_distributed.py +++ b/daemon/tests/test_distributed.py @@ -1,5 +1,6 @@ from core.emulator.emudata import NodeOptions -from core.emulator.enumerations import NodeTypes +from core.nodes.base import CoreNode +from core.nodes.network import HubNode class TestDistributed: @@ -12,7 +13,7 @@ class TestDistributed: session.distributed.add_server(server_name, host) options = NodeOptions() options.server = server_name - node = session.add_node(options=options) + node = session.add_node(CoreNode, options=options) session.instantiate() # then @@ -30,7 +31,7 @@ class TestDistributed: session.distributed.add_server(server_name, host) options = NodeOptions() options.server = server_name - node = session.add_node(_type=NodeTypes.HUB, options=options) + node = session.add_node(HubNode, options=options) session.instantiate() # then diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 2580020a..5f34e2e2 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -14,12 +14,14 @@ from core.api.grpc.wlan_pb2 import WlanConfig from core.api.tlv.dataconversion import ConfigShim from core.api.tlv.enumerations import ConfigFlags from core.emane.ieee80211abg import EmaneIeee80211abgModel +from core.emane.nodes import EmaneNet from core.emulator.data import EventData from core.emulator.emudata import NodeOptions from core.emulator.enumerations import EventTypes, ExceptionLevels, NodeTypes from core.errors import CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility from core.nodes.base import CoreNode +from core.nodes.network import SwitchNode, WlanNode from core.xml.corexml import CoreXmlWriter @@ -195,7 +197,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - session.add_node() + session.add_node(CoreNode) session.set_state(EventTypes.DEFINITION_STATE) # then @@ -362,7 +364,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) # then with client.context_connect(): @@ -375,7 +377,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) # then x, y = 10, 10 @@ -393,7 +395,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) # then with client.context_connect(): @@ -414,7 +416,7 @@ class TestGrpc: session = grpc_server.coreemu.create_session() session.set_state(EventTypes.CONFIGURATION_STATE) options = NodeOptions(model="Host") - node = session.add_node(options=options) + node = session.add_node(CoreNode, options=options) session.instantiate() output = "hello world" @@ -432,7 +434,7 @@ class TestGrpc: session = grpc_server.coreemu.create_session() session.set_state(EventTypes.CONFIGURATION_STATE) options = NodeOptions(model="Host") - node = session.add_node(options=options) + node = session.add_node(CoreNode, options=options) session.instantiate() # then @@ -509,8 +511,8 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - switch = session.add_node(_type=NodeTypes.SWITCH) - node = session.add_node() + switch = session.add_node(SwitchNode) + node = session.add_node(CoreNode) interface = ip_prefixes.create_interface(node) session.add_link(node.id, switch.id, interface) @@ -525,8 +527,8 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - switch = session.add_node(_type=NodeTypes.SWITCH) - node = session.add_node() + switch = session.add_node(SwitchNode) + node = session.add_node(CoreNode) interface = ip_prefixes.create_interface(node) session.add_link(node.id, switch.id, interface) @@ -539,8 +541,8 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - switch = session.add_node(_type=NodeTypes.SWITCH) - node = session.add_node() + switch = session.add_node(SwitchNode) + node = session.add_node(CoreNode) assert len(switch.all_link_data()) == 0 # then @@ -556,7 +558,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) # then interface = interface_helper.create_interface(node.id, 0) @@ -568,8 +570,8 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - switch = session.add_node(_type=NodeTypes.SWITCH) - node = session.add_node() + switch = session.add_node(SwitchNode) + node = session.add_node(CoreNode) interface = ip_prefixes.create_interface(node) session.add_link(node.id, switch.id, interface) options = core_pb2.LinkOptions(bandwidth=30000) @@ -591,9 +593,9 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node_one = session.add_node() + node_one = session.add_node(CoreNode) interface_one = ip_prefixes.create_interface(node_one) - node_two = session.add_node() + node_two = session.add_node(CoreNode) interface_two = ip_prefixes.create_interface(node_two) session.add_link(node_one.id, node_two.id, interface_one, interface_two) link_node = None @@ -618,7 +620,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - wlan = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = session.add_node(WlanNode) # then with client.context_connect(): @@ -632,7 +634,7 @@ class TestGrpc: client = CoreGrpcClient() session = grpc_server.coreemu.create_session() session.set_state(EventTypes.CONFIGURATION_STATE) - wlan = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = session.add_node(WlanNode) wlan.setmodel(BasicRangeModel, BasicRangeModel.default_values()) session.instantiate() range_key = "range" @@ -695,7 +697,7 @@ class TestGrpc: session.set_location(47.57917, -122.13232, 2.00000, 1.0) options = NodeOptions() options.emane = EmaneIeee80211abgModel.name - emane_network = session.add_node(_type=NodeTypes.EMANE, options=options) + emane_network = session.add_node(EmaneNet, options=options) session.emane.set_model(emane_network, EmaneIeee80211abgModel) config_key = "platform_id_start" config_value = "2" @@ -722,7 +724,7 @@ class TestGrpc: session.set_location(47.57917, -122.13232, 2.00000, 1.0) options = NodeOptions() options.emane = EmaneIeee80211abgModel.name - emane_network = session.add_node(_type=NodeTypes.EMANE, options=options) + emane_network = session.add_node(EmaneNet, options=options) session.emane.set_model(emane_network, EmaneIeee80211abgModel) config_key = "bandwidth" config_value = "900000" @@ -750,7 +752,7 @@ class TestGrpc: session.set_location(47.57917, -122.13232, 2.00000, 1.0) options = NodeOptions() options.emane = EmaneIeee80211abgModel.name - emane_network = session.add_node(_type=NodeTypes.EMANE, options=options) + emane_network = session.add_node(EmaneNet, options=options) session.emane.set_model(emane_network, EmaneIeee80211abgModel) # then @@ -778,7 +780,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - wlan = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = session.add_node(WlanNode) session.mobility.set_model_config(wlan.id, Ns2ScriptedMobility.name, {}) # then @@ -795,7 +797,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - wlan = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = session.add_node(WlanNode) session.mobility.set_model_config(wlan.id, Ns2ScriptedMobility.name, {}) # then @@ -809,7 +811,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - wlan = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = session.add_node(WlanNode) config_key = "refresh_ms" config_value = "60" @@ -828,7 +830,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - wlan = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = session.add_node(WlanNode) session.mobility.set_model_config(wlan.id, Ns2ScriptedMobility.name, {}) session.instantiate() @@ -881,7 +883,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) service_name = "DefaultRoute" session.services.set_service(node.id, service_name) @@ -899,7 +901,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) # then with client.context_connect(): @@ -912,7 +914,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) # then with client.context_connect(): @@ -927,7 +929,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) service_name = "DefaultRoute" validate = ["echo hello"] @@ -948,7 +950,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) service_name = "DefaultRoute" file_name = "defaultroute.sh" file_data = "echo hello" @@ -968,7 +970,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) service_name = "DefaultRoute" # then @@ -984,7 +986,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) queue = Queue() def handle_event(event_data): @@ -1005,8 +1007,8 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - wlan = session.add_node(_type=NodeTypes.WIRELESS_LAN) - node = session.add_node() + wlan = session.add_node(WlanNode) + node = session.add_node(CoreNode) interface = ip_prefixes.create_interface(node) session.add_link(node.id, wlan.id, interface) link_data = wlan.all_link_data()[0] @@ -1127,7 +1129,7 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node = session.add_node() + node = session.add_node(CoreNode) queue = Queue() def handle_event(event_data): diff --git a/daemon/tests/test_gui.py b/daemon/tests/test_gui.py index 40bc3d0b..4a086e53 100644 --- a/daemon/tests/test_gui.py +++ b/daemon/tests/test_gui.py @@ -25,7 +25,7 @@ from core.emulator.enumerations import EventTypes, MessageFlags, NodeTypes, Regi from core.errors import CoreError from core.location.mobility import BasicRangeModel from core.nodes.base import CoreNode, NodeBase -from core.nodes.network import SwitchNode +from core.nodes.network import SwitchNode, WlanNode def dict_to_str(values): @@ -63,7 +63,7 @@ class TestGui: def test_node_update(self, coretlv): node_id = 1 - coretlv.session.add_node(_id=node_id) + coretlv.session.add_node(CoreNode, _id=node_id) x = 50 y = 100 message = coreapi.CoreNodeMessage.create( @@ -84,7 +84,7 @@ class TestGui: def test_node_delete(self, coretlv): node_id = 1 - coretlv.session.add_node(_id=node_id) + coretlv.session.add_node(CoreNode, _id=node_id) message = coreapi.CoreNodeMessage.create( MessageFlags.DELETE.value, [(NodeTlvs.NUMBER, node_id)] ) @@ -96,9 +96,9 @@ class TestGui: def test_link_add_node_to_net(self, coretlv): node_one = 1 - coretlv.session.add_node(_id=node_one) + coretlv.session.add_node(CoreNode, _id=node_one) switch = 2 - coretlv.session.add_node(_id=switch, _type=NodeTypes.SWITCH) + coretlv.session.add_node(SwitchNode, _id=switch) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") interface_one = str(ip_prefix[node_one]) message = coreapi.CoreLinkMessage.create( @@ -120,9 +120,9 @@ class TestGui: def test_link_add_net_to_node(self, coretlv): node_one = 1 - coretlv.session.add_node(_id=node_one) + coretlv.session.add_node(CoreNode, _id=node_one) switch = 2 - coretlv.session.add_node(_id=switch, _type=NodeTypes.SWITCH) + coretlv.session.add_node(SwitchNode, _id=switch) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") interface_one = str(ip_prefix[node_one]) message = coreapi.CoreLinkMessage.create( @@ -144,9 +144,9 @@ class TestGui: def test_link_add_node_to_node(self, coretlv): node_one = 1 - coretlv.session.add_node(_id=node_one) + coretlv.session.add_node(CoreNode, _id=node_one) node_two = 2 - coretlv.session.add_node(_id=node_two) + coretlv.session.add_node(CoreNode, _id=node_two) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") interface_one = str(ip_prefix[node_one]) interface_two = str(ip_prefix[node_two]) @@ -174,9 +174,9 @@ class TestGui: def test_link_update(self, coretlv): node_one = 1 - coretlv.session.add_node(_id=node_one) + coretlv.session.add_node(CoreNode, _id=node_one) switch = 2 - coretlv.session.add_node(_id=switch, _type=NodeTypes.SWITCH) + coretlv.session.add_node(SwitchNode, _id=switch) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") interface_one = str(ip_prefix[node_one]) message = coreapi.CoreLinkMessage.create( @@ -216,9 +216,9 @@ class TestGui: def test_link_delete_node_to_node(self, coretlv): node_one = 1 - coretlv.session.add_node(_id=node_one) + coretlv.session.add_node(CoreNode, _id=node_one) node_two = 2 - coretlv.session.add_node(_id=node_two) + coretlv.session.add_node(CoreNode, _id=node_two) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") interface_one = str(ip_prefix[node_one]) interface_two = str(ip_prefix[node_two]) @@ -260,9 +260,9 @@ class TestGui: def test_link_delete_node_to_net(self, coretlv): node_one = 1 - coretlv.session.add_node(_id=node_one) + coretlv.session.add_node(CoreNode, _id=node_one) switch = 2 - coretlv.session.add_node(_id=switch, _type=NodeTypes.SWITCH) + coretlv.session.add_node(SwitchNode, _id=switch) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") interface_one = str(ip_prefix[node_one]) message = coreapi.CoreLinkMessage.create( @@ -296,9 +296,9 @@ class TestGui: def test_link_delete_net_to_node(self, coretlv): node_one = 1 - coretlv.session.add_node(_id=node_one) + coretlv.session.add_node(CoreNode, _id=node_one) switch = 2 - coretlv.session.add_node(_id=switch, _type=NodeTypes.SWITCH) + coretlv.session.add_node(SwitchNode, _id=switch) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") interface_one = str(ip_prefix[node_one]) message = coreapi.CoreLinkMessage.create( @@ -396,7 +396,7 @@ class TestGui: assert file_data == data def test_file_service_file_set(self, coretlv): - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) service = "DefaultRoute" file_name = "defaultroute.sh" file_data = "echo hello" @@ -419,7 +419,7 @@ class TestGui: def test_file_node_file_copy(self, request, coretlv): file_name = "/var/log/test/node.log" - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) node.makenodedir() file_data = "echo hello" message = coreapi.CoreFileMessage.create( @@ -441,7 +441,7 @@ class TestGui: def test_exec_node_tty(self, coretlv): coretlv.dispatch_replies = mock.MagicMock() - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) message = coreapi.CoreExecMessage.create( MessageFlags.TTY.value, [ @@ -462,7 +462,7 @@ class TestGui: pytest.skip("mocking calls") coretlv.dispatch_replies = mock.MagicMock() - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) cmd = "echo hello" message = coreapi.CoreExecMessage.create( MessageFlags.TEXT.value | MessageFlags.LOCAL.value, @@ -481,7 +481,7 @@ class TestGui: def test_exec_node_command(self, coretlv): coretlv.dispatch_replies = mock.MagicMock() - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) cmd = "echo hello" message = coreapi.CoreExecMessage.create( MessageFlags.TEXT.value, @@ -516,7 +516,7 @@ class TestGui: def test_event_schedule(self, coretlv): coretlv.session.add_event = mock.MagicMock() - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) message = coreapi.CoreEventMessage.create( MessageFlags.ADD.value, [ @@ -535,7 +535,7 @@ class TestGui: def test_event_save_xml(self, coretlv, tmpdir): xml_file = tmpdir.join("coretlv.session.xml") file_path = xml_file.strpath - coretlv.session.add_node() + coretlv.session.add_node(CoreNode) message = coreapi.CoreEventMessage.create( 0, [(EventTlvs.TYPE, EventTypes.FILE_SAVE.value), (EventTlvs.NAME, file_path)], @@ -548,7 +548,7 @@ class TestGui: def test_event_open_xml(self, coretlv, tmpdir): xml_file = tmpdir.join("coretlv.session.xml") file_path = xml_file.strpath - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) coretlv.session.save_xml(file_path) coretlv.session.delete_node(node.id) message = coreapi.CoreEventMessage.create( @@ -571,7 +571,7 @@ class TestGui: ) def test_event_service(self, coretlv, state): coretlv.session.broadcast_event = mock.MagicMock() - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) message = coreapi.CoreEventMessage.create( 0, [ @@ -609,7 +609,7 @@ class TestGui: def test_register_xml(self, coretlv, tmpdir): xml_file = tmpdir.join("coretlv.session.xml") file_path = xml_file.strpath - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) coretlv.session.save_xml(file_path) coretlv.session.delete_node(node.id) message = coreapi.CoreRegMessage.create( @@ -625,9 +625,10 @@ class TestGui: xml_file = tmpdir.join("test.py") file_path = xml_file.strpath with open(file_path, "w") as f: + f.write("from core.nodes.base import CoreNode\n") f.write("coreemu = globals()['coreemu']\n") f.write(f"session = coreemu.sessions[{coretlv.session.id}]\n") - f.write("session.add_node()\n") + f.write("session.add_node(CoreNode)\n") message = coreapi.CoreRegMessage.create( 0, [(RegisterTlvs.EXECUTE_SERVER, file_path)] ) @@ -773,7 +774,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() def test_config_services_request_specific(self, coretlv): - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) message = coreapi.CoreConfMessage.create( 0, [ @@ -790,7 +791,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() def test_config_services_request_specific_file(self, coretlv): - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) message = coreapi.CoreConfMessage.create( 0, [ @@ -807,7 +808,7 @@ class TestGui: coretlv.session.broadcast_file.assert_called_once() def test_config_services_reset(self, coretlv): - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) service = "DefaultRoute" coretlv.session.services.set_service(node.id, service) message = coreapi.CoreConfMessage.create( @@ -824,7 +825,7 @@ class TestGui: assert coretlv.session.services.get_service(node.id, service) is None def test_config_services_set(self, coretlv): - node = coretlv.session.add_node() + node = coretlv.session.add_node(CoreNode) service = "DefaultRoute" values = {"meta": "metadata"} message = coreapi.CoreConfMessage.create( @@ -844,7 +845,7 @@ class TestGui: assert coretlv.session.services.get_service(node.id, service) is not None def test_config_mobility_reset(self, coretlv): - wlan = coretlv.session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = coretlv.session.add_node(WlanNode) message = coreapi.CoreConfMessage.create( 0, [ @@ -860,7 +861,7 @@ class TestGui: assert len(coretlv.session.mobility.node_configurations) == 0 def test_config_mobility_model_request(self, coretlv): - wlan = coretlv.session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = coretlv.session.add_node(WlanNode) message = coreapi.CoreConfMessage.create( 0, [ @@ -876,7 +877,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() def test_config_mobility_model_update(self, coretlv): - wlan = coretlv.session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = coretlv.session.add_node(WlanNode) config_key = "range" config_value = "1000" values = {config_key: config_value} @@ -898,7 +899,7 @@ class TestGui: assert config[config_key] == config_value def test_config_emane_model_request(self, coretlv): - wlan = coretlv.session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = coretlv.session.add_node(WlanNode) message = coreapi.CoreConfMessage.create( 0, [ @@ -914,7 +915,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() def test_config_emane_model_update(self, coretlv): - wlan = coretlv.session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan = coretlv.session.add_node(WlanNode) config_key = "distance" config_value = "50051" values = {config_key: config_value} diff --git a/daemon/tests/test_links.py b/daemon/tests/test_links.py index d32a1c5f..afbdaab1 100644 --- a/daemon/tests/test_links.py +++ b/daemon/tests/test_links.py @@ -1,11 +1,12 @@ from core.emulator.emudata import LinkOptions -from core.emulator.enumerations import NodeTypes +from core.nodes.base import CoreNode +from core.nodes.network import SwitchNode def create_ptp_network(session, ip_prefixes): # create nodes - node_one = session.add_node() - node_two = session.add_node() + node_one = session.add_node(CoreNode) + node_two = session.add_node(CoreNode) # link nodes to net node interface_one = ip_prefixes.create_interface(node_one) @@ -21,8 +22,8 @@ def create_ptp_network(session, ip_prefixes): class TestLinks: def test_ptp(self, session, ip_prefixes): # given - node_one = session.add_node() - node_two = session.add_node() + node_one = session.add_node(CoreNode) + node_two = session.add_node(CoreNode) interface_one = ip_prefixes.create_interface(node_one) interface_two = ip_prefixes.create_interface(node_two) @@ -35,8 +36,8 @@ class TestLinks: def test_node_to_net(self, session, ip_prefixes): # given - node_one = session.add_node() - node_two = session.add_node(_type=NodeTypes.SWITCH) + node_one = session.add_node(CoreNode) + node_two = session.add_node(SwitchNode) interface_one = ip_prefixes.create_interface(node_one) # when @@ -48,8 +49,8 @@ class TestLinks: def test_net_to_node(self, session, ip_prefixes): # given - node_one = session.add_node(_type=NodeTypes.SWITCH) - node_two = session.add_node() + node_one = session.add_node(SwitchNode) + node_two = session.add_node(CoreNode) interface_two = ip_prefixes.create_interface(node_two) # when @@ -61,8 +62,8 @@ class TestLinks: def test_net_to_net(self, session): # given - node_one = session.add_node(_type=NodeTypes.SWITCH) - node_two = session.add_node(_type=NodeTypes.SWITCH) + node_one = session.add_node(SwitchNode) + node_two = session.add_node(SwitchNode) # when session.add_link(node_one.id, node_two.id) @@ -77,8 +78,8 @@ class TestLinks: per = 25 dup = 25 jitter = 10 - node_one = session.add_node() - node_two = session.add_node(_type=NodeTypes.SWITCH) + node_one = session.add_node(CoreNode) + node_two = session.add_node(SwitchNode) interface_one_data = ip_prefixes.create_interface(node_one) session.add_link(node_one.id, node_two.id, interface_one_data) interface_one = node_one.netif(interface_one_data.id) @@ -111,8 +112,8 @@ class TestLinks: def test_link_delete(self, session, ip_prefixes): # given - node_one = session.add_node() - node_two = session.add_node() + node_one = session.add_node(CoreNode) + node_two = session.add_node(CoreNode) interface_one = ip_prefixes.create_interface(node_one) interface_two = ip_prefixes.create_interface(node_two) session.add_link(node_one.id, node_two.id, interface_one, interface_two) diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index 42202f93..f87e8e80 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -1,12 +1,12 @@ import pytest from core.emulator.emudata import NodeOptions -from core.emulator.enumerations import NodeTypes from core.errors import CoreError from core.nodes.base import CoreNode +from core.nodes.network import HubNode, SwitchNode, WlanNode MODELS = ["router", "host", "PC", "mdr"] -NET_TYPES = [NodeTypes.SWITCH, NodeTypes.HUB, NodeTypes.WIRELESS_LAN] +NET_TYPES = [SwitchNode, HubNode, WlanNode] class TestNodes: @@ -16,7 +16,7 @@ class TestNodes: options = NodeOptions(model=model) # when - node = session.add_node(options=options) + node = session.add_node(CoreNode, options=options) # then assert node @@ -25,7 +25,7 @@ class TestNodes: def test_node_update(self, session): # given - node = session.add_node() + node = session.add_node(CoreNode) position_value = 100 update_options = NodeOptions() update_options.set_position(x=position_value, y=position_value) @@ -39,7 +39,7 @@ class TestNodes: def test_node_delete(self, session): # given - node = session.add_node() + node = session.add_node(CoreNode) # when session.delete_node(node.id) @@ -50,7 +50,7 @@ class TestNodes: def test_node_sethwaddr(self, session): # given - node = session.add_node() + node = session.add_node(CoreNode) index = node.newnetif() interface = node.netif(index) mac = "aa:aa:aa:ff:ff:ff" @@ -63,7 +63,7 @@ class TestNodes: def test_node_sethwaddr_exception(self, session): # given - node = session.add_node() + node = session.add_node(CoreNode) index = node.newnetif() node.netif(index) mac = "aa:aa:aa:ff:ff:fff" @@ -74,7 +74,7 @@ class TestNodes: def test_node_addaddr(self, session): # given - node = session.add_node() + node = session.add_node(CoreNode) index = node.newnetif() interface = node.netif(index) addr = "192.168.0.1/24" @@ -87,7 +87,7 @@ class TestNodes: def test_node_addaddr_exception(self, session): # given - node = session.add_node() + node = session.add_node(CoreNode) index = node.newnetif() node.netif(index) addr = "256.168.0.1/24" @@ -101,7 +101,7 @@ class TestNodes: # given # when - node = session.add_node(_type=net_type) + node = session.add_node(net_type) # then assert node diff --git a/daemon/tests/test_services.py b/daemon/tests/test_services.py index 489a9ab7..c5a51461 100644 --- a/daemon/tests/test_services.py +++ b/daemon/tests/test_services.py @@ -4,6 +4,7 @@ import pytest from mock import MagicMock from core.errors import CoreCommandError +from core.nodes.base import CoreNode from core.services.coreservices import CoreService, ServiceDependencies, ServiceManager _PATH = os.path.abspath(os.path.dirname(__file__)) @@ -52,7 +53,7 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) file_name = "myservice.sh" - node = session.add_node() + node = session.add_node(CoreNode) # when session.services.set_service_file(node.id, SERVICE_ONE, file_name, "# test") @@ -66,7 +67,7 @@ class TestServices: def test_service_all_configs(self, session): # given ServiceManager.add_services(_SERVICES_PATH) - node = session.add_node() + node = session.add_node(CoreNode) # when session.services.set_service(node.id, SERVICE_ONE) @@ -80,7 +81,7 @@ class TestServices: def test_service_add_services(self, session): # given ServiceManager.add_services(_SERVICES_PATH) - node = session.add_node() + node = session.add_node(CoreNode) total_service = len(node.services) # when @@ -94,7 +95,7 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) - node = session.add_node() + node = session.add_node(CoreNode) file_name = my_service.configs[0] file_path = node.hostfilename(file_name) @@ -109,7 +110,7 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) - node = session.add_node() + node = session.add_node(CoreNode) session.services.create_service_files(node, my_service) # when @@ -122,7 +123,7 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_TWO) - node = session.add_node() + node = session.add_node(CoreNode) session.services.create_service_files(node, my_service) node.cmd = MagicMock(side_effect=CoreCommandError(-1, "invalid")) @@ -136,7 +137,7 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) - node = session.add_node() + node = session.add_node(CoreNode) session.services.create_service_files(node, my_service) # when @@ -149,7 +150,7 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_TWO) - node = session.add_node() + node = session.add_node(CoreNode) session.services.create_service_files(node, my_service) node.cmd = MagicMock(side_effect=CoreCommandError(-1, "invalid")) @@ -163,7 +164,7 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) - node = session.add_node() + node = session.add_node(CoreNode) session.services.create_service_files(node, my_service) # when @@ -176,7 +177,7 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_TWO) - node = session.add_node() + node = session.add_node(CoreNode) session.services.create_service_files(node, my_service) node.cmd = MagicMock(side_effect=CoreCommandError(-1, "invalid")) @@ -190,7 +191,7 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) - node = session.add_node() + node = session.add_node(CoreNode) # when session.services.set_service(node.id, my_service.name) @@ -204,8 +205,8 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) - node_one = session.add_node() - node_two = session.add_node() + node_one = session.add_node(CoreNode) + node_two = session.add_node(CoreNode) file_name = my_service.configs[0] file_data_one = "# custom file one" file_data_two = "# custom file two" @@ -234,7 +235,7 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) - node = session.add_node() + node = session.add_node(CoreNode) # when no_service = session.services.get_service(node.id, SERVICE_ONE) diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index 3d0a67d3..897bb6fb 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -3,11 +3,11 @@ from xml.etree import ElementTree import pytest from core.emulator.emudata import LinkOptions, NodeOptions -from core.emulator.enumerations import EventTypes, NodeTypes +from core.emulator.enumerations import EventTypes from core.errors import CoreError from core.location.mobility import BasicRangeModel from core.nodes.base import CoreNode -from core.nodes.network import SwitchNode, WlanNode +from core.nodes.network import PtpNet, SwitchNode, WlanNode from core.services.utility import SshService @@ -61,11 +61,11 @@ class TestXml: :param ip_prefixes: generates ip addresses for nodes """ # create ptp - ptp_node = session.add_node(_type=NodeTypes.PEER_TO_PEER) + ptp_node = session.add_node(PtpNet) # create nodes - node_one = session.add_node() - node_two = session.add_node() + node_one = session.add_node(CoreNode) + node_two = session.add_node(CoreNode) # link nodes to ptp net for node in [node_one, node_two]: @@ -113,12 +113,12 @@ class TestXml: :param ip_prefixes: generates ip addresses for nodes """ # create ptp - ptp_node = session.add_node(_type=NodeTypes.PEER_TO_PEER) + ptp_node = session.add_node(PtpNet) # create nodes options = NodeOptions(model="host") - node_one = session.add_node(options=options) - node_two = session.add_node() + node_one = session.add_node(CoreNode, options=options) + node_two = session.add_node(CoreNode) # link nodes to ptp net for node in [node_one, node_two]: @@ -178,14 +178,14 @@ class TestXml: :param ip_prefixes: generates ip addresses for nodes """ # create wlan - wlan_node = session.add_node(_type=NodeTypes.WIRELESS_LAN) + wlan_node = session.add_node(WlanNode) session.mobility.set_model(wlan_node, BasicRangeModel, {"test": "1"}) # create nodes options = NodeOptions(model="mdr") options.set_position(0, 0) - node_one = session.add_node(options=options) - node_two = session.add_node(options=options) + node_one = session.add_node(CoreNode, options=options) + node_two = session.add_node(CoreNode, options=options) # link nodes for node in [node_one, node_two]: @@ -238,8 +238,8 @@ class TestXml: :param tmpdir: tmpdir to create data in """ # create nodes - switch_one = session.add_node(_type=NodeTypes.SWITCH) - switch_two = session.add_node(_type=NodeTypes.SWITCH) + switch_one = session.add_node(SwitchNode) + switch_two = session.add_node(SwitchNode) # link nodes session.add_link(switch_one.id, switch_two.id) @@ -288,9 +288,9 @@ class TestXml: :param ip_prefixes: generates ip addresses for nodes """ # create nodes - node_one = session.add_node() + node_one = session.add_node(CoreNode) interface_one = ip_prefixes.create_interface(node_one) - switch = session.add_node(_type=NodeTypes.SWITCH) + switch = session.add_node(SwitchNode) # create link link_options = LinkOptions() @@ -354,9 +354,9 @@ class TestXml: :param ip_prefixes: generates ip addresses for nodes """ # create nodes - node_one = session.add_node() + node_one = session.add_node(CoreNode) interface_one = ip_prefixes.create_interface(node_one) - node_two = session.add_node() + node_two = session.add_node(CoreNode) interface_two = ip_prefixes.create_interface(node_two) # create link @@ -421,9 +421,9 @@ class TestXml: :param ip_prefixes: generates ip addresses for nodes """ # create nodes - node_one = session.add_node() + node_one = session.add_node(CoreNode) interface_one = ip_prefixes.create_interface(node_one) - node_two = session.add_node() + node_two = session.add_node(CoreNode) interface_two = ip_prefixes.create_interface(node_two) # create link diff --git a/docs/scripting.md b/docs/scripting.md index aafbb7d3..7c8205c3 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -28,35 +28,54 @@ connections. Here are the basic elements of a CORE Python script: ```python +""" +This is a standalone script to run a small switch based scenario and will not +interact with the GUI. +""" + +import logging + from core.emulator.coreemu import CoreEmu from core.emulator.emudata import IpPrefixes from core.emulator.enumerations import EventTypes -from core.emulator.enumerations import NodeTypes +from core.nodes.base import CoreNode +from core.nodes.network import SwitchNode -# ip generator for example -prefixes = IpPrefixes(ip4_prefix="10.83.0.0/16") +NODES = 2 -# create emulator instance for creating sessions and utility methods -coreemu = CoreEmu() -session = coreemu.create_session() -# must be in configuration state for nodes to start, when using "node_add" below -session.set_state(EventTypes.CONFIGURATION_STATE) +def main(): + # ip generator for example + prefixes = IpPrefixes(ip4_prefix="10.83.0.0/16") -# create switch network node -switch = session.add_node(_type=NodeTypes.SWITCH) + # create emulator instance for creating sessions and utility methods + coreemu = CoreEmu() + session = coreemu.create_session() -# create nodes -for _ in range(2): - node = session.add_node() - interface = prefixes.create_interface(node) - session.add_link(node.id, switch.id, interface_one=interface) + # must be in configuration state for nodes to start, when using "node_add" below + session.set_state(EventTypes.CONFIGURATION_STATE) -# instantiate session -session.instantiate() + # create switch network node + switch = session.add_node(SwitchNode, _id=100) -# shutdown session -coreemu.shutdown() + # create nodes + for _ in range(NODES): + node = session.add_node(CoreNode) + interface = prefixes.create_interface(node) + session.add_link(node.id, switch.id, interface_one=interface) + + # instantiate session + session.instantiate() + + # run any desired logic here + + # shutdown session + coreemu.shutdown() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + main() ``` The above script creates a CORE session having two nodes connected with a @@ -136,7 +155,7 @@ session = coreemu.create_session() session.set_location(47.57917, -122.13232, 2.00000, 1.0) options = NodeOptions() options.set_position(80, 50) -emane_network = session.add_node(_type=NodeTypes.EMANE, options=options) +emane_network = session.add_node(EmaneNet, options=options) # set custom emane model config config = {} From d5016bf44fa81bf884b3d624a31daf4b105d938f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 20 May 2020 22:36:04 -0700 Subject: [PATCH 076/929] removed pointless wlan instance check in grpc wlan_link api since it is already being done when retrieving the wlan node --- daemon/core/api/grpc/server.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 5fae97dc..1095c3f7 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -1695,10 +1695,6 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): ) -> WlanLinkResponse: session = self.get_session(request.session_id, context) wlan = self.get_node(session, request.wlan, context, WlanNode) - if not isinstance(wlan, WlanNode): - context.abort( - grpc.StatusCode.NOT_FOUND, f"wlan id {request.wlan} is not a wlan node" - ) if not isinstance(wlan.model, BasicRangeModel): context.abort( grpc.StatusCode.NOT_FOUND, From 4b6ba9033152560d790a0f34e3a92f0103b26c27 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 20 May 2020 23:27:17 -0700 Subject: [PATCH 077/929] fixed bad type hint for EventData --- daemon/core/emulator/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index 7dff6be0..d3283974 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -38,7 +38,7 @@ class EventData: event_type: EventTypes = None name: str = None data: str = None - time: float = None + time: str = None session: int = None From bcd9e4ceb16d54e9558e22225f7060cfb99bb3c6 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 21 May 2020 00:20:05 -0700 Subject: [PATCH 078/929] fixed session.add_hook to not require a source, since it was not typically used an None was being passed, cleaned up some bad type hinting in related to session.py --- daemon/core/api/grpc/server.py | 4 ++-- daemon/core/api/tlv/corehandlers.py | 2 +- daemon/core/emulator/emudata.py | 10 +++++----- daemon/core/emulator/session.py | 12 ++++++++---- daemon/core/nodes/base.py | 12 ++++++------ daemon/core/xml/corexml.py | 2 +- daemon/tests/test_grpc.py | 2 +- daemon/tests/test_xml.py | 4 ++-- 8 files changed, 26 insertions(+), 22 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 5fae97dc..ae3ec1ec 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -232,7 +232,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): # add all hooks for hook in request.hooks: state = EventTypes(hook.state) - session.add_hook(state, hook.file, None, hook.data) + session.add_hook(state, hook.file, hook.data) # create nodes _, exceptions = grpcutils.create_nodes(session, request.nodes) @@ -918,7 +918,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): session = self.get_session(request.session_id, context) hook = request.hook state = EventTypes(hook.state) - session.add_hook(state, hook.file, None, hook.data) + session.add_hook(state, hook.file, hook.data) return core_pb2.AddHookResponse(result=True) def GetMobilityConfigs( diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 02a6294c..1a22cedd 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -1501,7 +1501,7 @@ class CoreHandler(socketserver.BaseRequestHandler): return () state = int(state) state = EventTypes(state) - self.session.add_hook(state, file_name, source_name, data) + self.session.add_hook(state, file_name, data, source_name) return () # writing a file to the host diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index 6a0ec8a6..79c586a3 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -1,4 +1,4 @@ -from typing import List, Optional +from typing import List, Optional, Union import netaddr @@ -21,7 +21,7 @@ class IdGen: def link_config( - network: CoreNetworkBase, + node: Union[CoreNetworkBase, PhysicalNode], interface: CoreInterface, link_options: LinkOptions, devname: str = None, @@ -30,7 +30,7 @@ def link_config( """ Convenience method for configuring a link, - :param network: network to configure link for + :param node: network to configure link for :param interface: interface to configure :param link_options: data to configure link with :param devname: device name, default is None @@ -49,10 +49,10 @@ def link_config( # hacky check here, because physical and emane nodes do not conform to the same # linkconfig interface - if not isinstance(network, (EmaneNet, PhysicalNode)): + if not isinstance(node, (EmaneNet, PhysicalNode)): config["devname"] = devname - network.linkconfig(**config) + node.linkconfig(**config) class NodeOptions: diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 6f112ccf..8259803d 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -196,7 +196,11 @@ class Session: def _link_nodes( self, node_one_id: int, node_two_id: int ) -> Tuple[ - Optional[NodeBase], Optional[NodeBase], CoreNetworkBase, CoreNetworkBase, GreTap + Optional[CoreNode], + Optional[CoreNode], + Optional[CoreNetworkBase], + Optional[CoreNetworkBase], + GreTap, ]: """ Convenience method for retrieving nodes within link data. @@ -856,19 +860,19 @@ class Session: CoreXmlWriter(self).write(file_name) def add_hook( - self, state: EventTypes, file_name: str, source_name: str, data: str + self, state: EventTypes, file_name: str, data: str, source_name: str = None ) -> None: """ Store a hook from a received file message. :param state: when to run hook :param file_name: file name for hook - :param source_name: source name :param data: hook data + :param source_name: source name :return: nothing """ logging.info( - "setting state hook: %s - %s from %s", state, file_name, source_name + "setting state hook: %s - %s source(%s)", state, file_name, source_name ) hook = file_name, data state_hooks = self._hooks.setdefault(state, []) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 61e9e8fb..71870cd9 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -413,14 +413,14 @@ class CoreNodeBase(NodeBase): netif.setposition() def commonnets( - self, obj: "CoreNodeBase", want_ctrl: bool = False - ) -> List[Tuple[NodeBase, CoreInterface, CoreInterface]]: + self, node: "CoreNodeBase", want_ctrl: bool = False + ) -> List[Tuple["CoreNetworkBase", CoreInterface, CoreInterface]]: """ Given another node or net object, return common networks between this node and that object. A list of tuples is returned, with each tuple consisting of (network, interface1, interface2). - :param obj: object to get common network with + :param node: node to get common network with :param want_ctrl: flag set to determine if control network are wanted :return: tuples of common networks """ @@ -428,7 +428,7 @@ class CoreNodeBase(NodeBase): for netif1 in self.netifs(): if not want_ctrl and hasattr(netif1, "control"): continue - for netif2 in obj.netifs(): + for netif2 in node.netifs(): if netif1.net == netif2.net: common.append((netif1.net, netif1, netif2)) return common @@ -1041,7 +1041,7 @@ class CoreNetworkBase(NodeBase): """ pass - def getlinknetif(self, net: "CoreNetworkBase") -> CoreInterface: + def getlinknetif(self, net: "CoreNetworkBase") -> Optional[CoreInterface]: """ Return the interface of that links this net with another net. @@ -1049,7 +1049,7 @@ class CoreNetworkBase(NodeBase): :return: interface the provided network is linked to """ for netif in self.netifs(): - if hasattr(netif, "othernet") and netif.othernet == net: + if getattr(netif, "othernet", None) == net: return netif return None diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index ddb51b28..efbf85c8 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -662,7 +662,7 @@ class CoreXmlReader: state = EventTypes(state) data = hook.text logging.info("reading hook: state(%s) name(%s)", state, name) - self.session.add_hook(state, name, None, data) + self.session.add_hook(state, name, data) def read_session_origin(self) -> None: session_origin = self.scenario.find("session_origin") diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 5f34e2e2..5e765f42 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -450,7 +450,7 @@ class TestGrpc: session = grpc_server.coreemu.create_session() file_name = "test" file_data = "echo hello" - session.add_hook(EventTypes.RUNTIME_STATE, file_name, None, file_data) + session.add_hook(EventTypes.RUNTIME_STATE, file_name, file_data) # then with client.context_connect(): diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index 897bb6fb..bb5a6bf9 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -23,12 +23,12 @@ class TestXml: file_name = "runtime_hook.sh" data = "#!/bin/sh\necho hello" state = EventTypes.RUNTIME_STATE - session.add_hook(state, file_name, None, data) + session.add_hook(state, file_name, data) file_name = "instantiation_hook.sh" data = "#!/bin/sh\necho hello" state = EventTypes.INSTANTIATION_STATE - session.add_hook(state, file_name, None, data) + session.add_hook(state, file_name, data) # save xml xml_file = tmpdir.join("session.xml") From 56fbc0e3c5e28b8e66ff8bb7567d36e5b9fefc50 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 21 May 2020 22:26:54 -0700 Subject: [PATCH 079/929] docker changes to avoid issues running commands without mount and running containers as prvileged to allow changing files in /sys --- daemon/core/nodes/docker.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/daemon/core/nodes/docker.py b/daemon/core/nodes/docker.py index f1335747..4899b8f4 100644 --- a/daemon/core/nodes/docker.py +++ b/daemon/core/nodes/docker.py @@ -24,8 +24,9 @@ class DockerClient: def create_container(self) -> str: self.run( - f"docker run -td --init --net=none --hostname {self.name} --name {self.name} " - f"--sysctl net.ipv6.conf.all.disable_ipv6=0 {self.image} /bin/bash" + f"docker run -td --init --net=none --hostname {self.name} " + f"--name {self.name} --sysctl net.ipv6.conf.all.disable_ipv6=0 " + f"--privileged {self.image} /bin/bash" ) self.pid = self.get_pid() return self.pid @@ -53,11 +54,7 @@ class DockerClient: return utils.cmd(f"docker exec {self.name} {cmd}", wait=wait, shell=shell) def create_ns_cmd(self, cmd: str) -> str: - return f"nsenter -t {self.pid} -u -i -p -n {cmd}" - - def ns_cmd(self, cmd: str, wait: bool) -> str: - args = f"nsenter -t {self.pid} -u -i -p -n {cmd}" - return utils.cmd(args, wait=wait) + return f"nsenter -t {self.pid} -a {cmd}" def get_pid(self) -> str: args = f"docker inspect -f '{{{{.State.Pid}}}}' {self.name}" From fe09b3781998e528aef0014faff47c0aa80f3128 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 21 May 2020 22:41:03 -0700 Subject: [PATCH 080/929] removed bootsh from CoreNode types as it was not being used --- daemon/core/nodes/base.py | 5 ----- daemon/core/nodes/docker.py | 4 +--- daemon/core/nodes/lxd.py | 4 +--- 3 files changed, 2 insertions(+), 11 deletions(-) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 71870cd9..74268129 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -491,7 +491,6 @@ class CoreNode(CoreNodeBase): _id: int = None, name: str = None, nodedir: str = None, - bootsh: str = "boot.sh", start: bool = True, server: "DistributedServer" = None, ) -> None: @@ -502,7 +501,6 @@ class CoreNode(CoreNodeBase): :param _id: object id :param name: object name :param nodedir: node directory - :param bootsh: boot shell to use :param start: start flag :param server: remote server node will run on, default is None for localhost @@ -516,11 +514,8 @@ class CoreNode(CoreNodeBase): self.pid = None self.lock = threading.RLock() self._mounts = [] - self.bootsh = bootsh - use_ovs = session.options.get_config("ovs") == "True" self.node_net_client = self.create_node_net_client(use_ovs) - if start: self.startup() diff --git a/daemon/core/nodes/docker.py b/daemon/core/nodes/docker.py index 4899b8f4..684e8452 100644 --- a/daemon/core/nodes/docker.py +++ b/daemon/core/nodes/docker.py @@ -77,7 +77,6 @@ class DockerNode(CoreNode): _id: int = None, name: str = None, nodedir: str = None, - bootsh: str = "boot.sh", start: bool = True, server: DistributedServer = None, image: str = None @@ -89,7 +88,6 @@ class DockerNode(CoreNode): :param _id: object id :param name: object name :param nodedir: node directory - :param bootsh: boot shell to use :param start: start flag :param server: remote server node will run on, default is None for localhost @@ -98,7 +96,7 @@ class DockerNode(CoreNode): if image is None: image = "ubuntu" self.image = image - super().__init__(session, _id, name, nodedir, bootsh, start, server) + super().__init__(session, _id, name, nodedir, start, server) def create_node_net_client(self, use_ovs: bool) -> LinuxNetClient: """ diff --git a/daemon/core/nodes/lxd.py b/daemon/core/nodes/lxd.py index 31623394..3b4c88c0 100644 --- a/daemon/core/nodes/lxd.py +++ b/daemon/core/nodes/lxd.py @@ -74,7 +74,6 @@ class LxcNode(CoreNode): _id: int = None, name: str = None, nodedir: str = None, - bootsh: str = "boot.sh", start: bool = True, server: DistributedServer = None, image: str = None, @@ -86,7 +85,6 @@ class LxcNode(CoreNode): :param _id: object id :param name: object name :param nodedir: node directory - :param bootsh: boot shell to use :param start: start flag :param server: remote server node will run on, default is None for localhost @@ -95,7 +93,7 @@ class LxcNode(CoreNode): if image is None: image = "ubuntu" self.image = image - super().__init__(session, _id, name, nodedir, bootsh, start, server) + super().__init__(session, _id, name, nodedir, start, server) def alive(self) -> bool: """ From dd13bc83795a0f40048ab3120154c24cce218951 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 22 May 2020 23:44:10 -0700 Subject: [PATCH 081/929] moved linkconfig to CoreNetworkBase and made linkconfig defined the same across the board --- daemon/core/emane/commeffect.py | 1 + daemon/core/emane/emanemodel.py | 2 ++ daemon/core/emane/nodes.py | 9 ++------- daemon/core/emulator/emudata.py | 27 ++++++++++----------------- daemon/core/location/mobility.py | 7 +------ daemon/core/nodes/base.py | 26 ++++++++++++++++++++++++++ daemon/core/nodes/physical.py | 9 ++------- 7 files changed, 44 insertions(+), 37 deletions(-) diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index f98f2454..90ea5c91 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -121,6 +121,7 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): duplicate: float = None, jitter: float = None, netif2: CoreInterface = None, + devname: str = None, ) -> None: """ Generate CommEffect events when a Link Message is received having diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 57a73012..3b80e8aa 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -163,6 +163,7 @@ class EmaneModel(WirelessModel): duplicate: float = None, jitter: float = None, netif2: CoreInterface = None, + devname: str = None, ) -> None: """ Invoked when a Link Message is received. Default is unimplemented. @@ -174,6 +175,7 @@ class EmaneModel(WirelessModel): :param duplicate: duplicate percentage to set to :param jitter: jitter to set to :param netif2: interface two + :param devname: device name :return: nothing """ logging.warning( diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index d8984f7c..33023ac1 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -62,6 +62,7 @@ class EmaneNet(CoreNetworkBase): duplicate: float = None, jitter: float = None, netif2: CoreInterface = None, + devname: str = None, ) -> None: """ The CommEffect model supports link configuration. @@ -69,13 +70,7 @@ class EmaneNet(CoreNetworkBase): if not self.model: return self.model.linkconfig( - netif=netif, - bw=bw, - delay=delay, - loss=loss, - duplicate=duplicate, - jitter=jitter, - netif2=netif2, + netif, bw, delay, loss, duplicate, jitter, netif2, devname ) def config(self, conf: str) -> None: diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index 79c586a3..796396e4 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -4,7 +4,6 @@ import netaddr from core import utils from core.api.grpc.core_pb2 import LinkOptions -from core.emane.nodes import EmaneNet from core.emulator.enumerations import LinkTypes from core.nodes.base import CoreNetworkBase, CoreNode from core.nodes.interface import CoreInterface @@ -37,22 +36,16 @@ def link_config( :param interface_two: other interface associated, default is None :return: nothing """ - config = { - "netif": interface, - "bw": link_options.bandwidth, - "delay": link_options.delay, - "loss": link_options.per, - "duplicate": link_options.dup, - "jitter": link_options.jitter, - "netif2": interface_two, - } - - # hacky check here, because physical and emane nodes do not conform to the same - # linkconfig interface - if not isinstance(node, (EmaneNet, PhysicalNode)): - config["devname"] = devname - - node.linkconfig(**config) + node.linkconfig( + interface, + link_options.bandwidth, + link_options.delay, + link_options.per, + link_options.dup, + link_options.jitter, + interface_two, + devname, + ) class NodeOptions: diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index f2a47c1f..5041f144 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -335,12 +335,7 @@ class BasicRangeModel(WirelessModel): with self._netifslock: for netif in self._netifs: self.wlan.linkconfig( - netif, - bw=self.bw, - delay=self.delay, - loss=self.loss, - duplicate=None, - jitter=self.jitter, + netif, self.bw, self.delay, self.loss, jitter=self.jitter ) def get_position(self, netif: CoreInterface) -> Tuple[float, float, float]: diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 74268129..da1aef38 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -1165,6 +1165,32 @@ class CoreNetworkBase(NodeBase): return all_links + def linkconfig( + self, + netif: CoreInterface, + bw: float = None, + delay: float = None, + loss: float = None, + duplicate: float = None, + jitter: float = None, + netif2: float = None, + devname: str = None, + ) -> None: + """ + Configure link parameters by applying tc queuing disciplines on the interface. + + :param netif: interface one + :param bw: bandwidth to set to + :param delay: packet delay to set to + :param loss: packet loss to set to + :param duplicate: duplicate percentage to set to + :param jitter: jitter to set to + :param netif2: interface two + :param devname: device name + :return: nothing + """ + raise NotImplementedError + class Position: """ diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index baef7922..bca374fb 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -151,6 +151,7 @@ class PhysicalNode(CoreNodeBase): duplicate: float = None, jitter: float = None, netif2: CoreInterface = None, + devname: str = None, ) -> None: """ Apply tc queing disciplines using linkconfig. @@ -158,13 +159,7 @@ class PhysicalNode(CoreNodeBase): linux_bridge = CoreNetwork(session=self.session, start=False) linux_bridge.up = True linux_bridge.linkconfig( - netif, - bw=bw, - delay=delay, - loss=loss, - duplicate=duplicate, - jitter=jitter, - netif2=netif2, + netif, bw, delay, loss, duplicate, jitter, netif2, devname ) del linux_bridge From 26b0868f6512fbee0eb66fc45aad2fc71c05498f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 23 May 2020 00:00:40 -0700 Subject: [PATCH 082/929] removed devname from linkconfig as the only usage was of it was using a parametr that was already being passed in --- daemon/core/emane/commeffect.py | 1 - daemon/core/emane/emanemodel.py | 6 +----- daemon/core/emane/nodes.py | 5 +---- daemon/core/emulator/emudata.py | 3 --- daemon/core/emulator/session.py | 27 +++++---------------------- daemon/core/nodes/base.py | 2 -- daemon/core/nodes/network.py | 5 +---- daemon/core/nodes/physical.py | 5 +---- 8 files changed, 9 insertions(+), 45 deletions(-) diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index 90ea5c91..f98f2454 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -121,7 +121,6 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): duplicate: float = None, jitter: float = None, netif2: CoreInterface = None, - devname: str = None, ) -> None: """ Generate CommEffect events when a Link Message is received having diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 3b80e8aa..4104d3d5 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -163,7 +163,6 @@ class EmaneModel(WirelessModel): duplicate: float = None, jitter: float = None, netif2: CoreInterface = None, - devname: str = None, ) -> None: """ Invoked when a Link Message is received. Default is unimplemented. @@ -175,9 +174,6 @@ class EmaneModel(WirelessModel): :param duplicate: duplicate percentage to set to :param jitter: jitter to set to :param netif2: interface two - :param devname: device name :return: nothing """ - logging.warning( - "emane model(%s) does not support link configuration", self.name - ) + logging.warning("emane model(%s) does not support link config", self.name) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index 33023ac1..5b435fbf 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -62,16 +62,13 @@ class EmaneNet(CoreNetworkBase): duplicate: float = None, jitter: float = None, netif2: CoreInterface = None, - devname: str = None, ) -> None: """ The CommEffect model supports link configuration. """ if not self.model: return - self.model.linkconfig( - netif, bw, delay, loss, duplicate, jitter, netif2, devname - ) + self.model.linkconfig(netif, bw, delay, loss, duplicate, jitter, netif2) def config(self, conf: str) -> None: self.conf = conf diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index 796396e4..4e3ebf8a 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -23,7 +23,6 @@ def link_config( node: Union[CoreNetworkBase, PhysicalNode], interface: CoreInterface, link_options: LinkOptions, - devname: str = None, interface_two: CoreInterface = None, ) -> None: """ @@ -32,7 +31,6 @@ def link_config( :param node: network to configure link for :param interface: interface to configure :param link_options: data to configure link with - :param devname: device name, default is None :param interface_two: other interface associated, default is None :return: nothing """ @@ -44,7 +42,6 @@ def link_config( link_options.dup, link_options.jitter, interface_two, - devname, ) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 8259803d..de7b1fe8 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -394,9 +394,7 @@ class Session: if not link_options.unidirectional: interface.swapparams("_params_up") - link_config( - net_two, interface, link_options, devname=interface.name - ) + link_config(net_two, interface, link_options) interface.swapparams("_params_up") # a tunnel node was found for the nodes @@ -606,9 +604,7 @@ class Session: if upstream: interface.swapparams("_params_up") - link_config( - net_one, interface, link_options, devname=interface.name - ) + link_config(net_one, interface, link_options) interface.swapparams("_params_up") else: link_config(net_one, interface, link_options) @@ -618,12 +614,7 @@ class Session: link_config(net_two, interface, link_options) else: interface.swapparams("_params_up") - link_config( - net_two, - interface, - link_options, - devname=interface.name, - ) + link_config(net_two, interface, link_options) interface.swapparams("_params_up") else: raise CoreError("modify link for unknown nodes") @@ -647,18 +638,10 @@ class Session: ): continue - link_config( - net_one, - interface_one, - link_options, - interface_two=interface_two, - ) + link_config(net_one, interface_one, link_options, interface_two) if not link_options.unidirectional: link_config( - net_one, - interface_two, - link_options, - interface_two=interface_one, + net_one, interface_two, link_options, interface_one ) finally: if node_one: diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index da1aef38..efad8c0a 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -1174,7 +1174,6 @@ class CoreNetworkBase(NodeBase): duplicate: float = None, jitter: float = None, netif2: float = None, - devname: str = None, ) -> None: """ Configure link parameters by applying tc queuing disciplines on the interface. @@ -1186,7 +1185,6 @@ class CoreNetworkBase(NodeBase): :param duplicate: duplicate percentage to set to :param jitter: jitter to set to :param netif2: interface two - :param devname: device name :return: nothing """ raise NotImplementedError diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index f2c16bd0..92a8c336 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -448,7 +448,6 @@ class CoreNetwork(CoreNetworkBase): duplicate: float = None, jitter: float = None, netif2: float = None, - devname: str = None, ) -> None: """ Configure link parameters by applying tc queuing disciplines on the interface. @@ -460,11 +459,9 @@ class CoreNetwork(CoreNetworkBase): :param duplicate: duplicate percentage to set to :param jitter: jitter to set to :param netif2: interface two - :param devname: device name :return: nothing """ - if devname is None: - devname = netif.localname + devname = netif.localname tc = f"{TC_BIN} qdisc replace dev {devname}" parent = "root" changed = False diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index bca374fb..3f45c9ab 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -151,16 +151,13 @@ class PhysicalNode(CoreNodeBase): duplicate: float = None, jitter: float = None, netif2: CoreInterface = None, - devname: str = None, ) -> None: """ Apply tc queing disciplines using linkconfig. """ linux_bridge = CoreNetwork(session=self.session, start=False) linux_bridge.up = True - linux_bridge.linkconfig( - netif, bw, delay, loss, duplicate, jitter, netif2, devname - ) + linux_bridge.linkconfig(netif, bw, delay, loss, duplicate, jitter, netif2) del linux_bridge def newifindex(self) -> int: From 37ff989aa41a6216cc74e595f080c3f137361849 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 23 May 2020 00:19:32 -0700 Subject: [PATCH 083/929] fixed bad check in linkconfig --- daemon/core/nodes/network.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 92a8c336..1b5f702c 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -467,11 +467,10 @@ class CoreNetwork(CoreNetworkBase): changed = False if netif.setparam("bw", bw): # from tc-tbf(8): minimum value for burst is rate / kernel_hz - if bw is not None: - burst = max(2 * netif.mtu, bw / 1000) - # max IP payload - limit = 0xFFFF - tbf = f"tbf rate {bw} burst {burst} limit {limit}" + burst = max(2 * netif.mtu, int(bw / 1000)) + # max IP payload + limit = 0xFFFF + tbf = f"tbf rate {bw} burst {burst} limit {limit}" if bw > 0: if self.up: cmd = f"{tc} {parent} handle 1: {tbf}" From c580e15f8e55eeeba98a22bebb5860880ecf2cf5 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 23 May 2020 01:05:46 -0700 Subject: [PATCH 084/929] moved common variables up and localname to CoreInterface, they were being created in all subclasses and avoids type hinting errors --- daemon/core/emane/nodes.py | 1 - daemon/core/nodes/base.py | 6 ++---- daemon/core/nodes/interface.py | 23 +++++++++-------------- daemon/core/nodes/netclient.py | 2 +- daemon/core/nodes/network.py | 3 +-- daemon/core/nodes/physical.py | 4 +--- 6 files changed, 14 insertions(+), 25 deletions(-) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index 5b435fbf..d5f243cb 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -48,7 +48,6 @@ class EmaneNet(CoreNetworkBase): ) -> None: super().__init__(session, _id, name, start, server) self.conf = "" - self.up = False self.nemidmap = {} self.model = None self.mobility = None diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index efad8c0a..1dbdcf53 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -65,17 +65,15 @@ class NodeBase: name = f"o{self.id}" self.name = name self.server = server - self.type = None self.services = None - # ifindex is key, CoreInterface instance is value self._netif = {} self.ifindex = 0 self.canvas = None self.icon = None self.opaque = None self.position = Position() - + self.up = False use_ovs = session.options.get_config("ovs") == "True" self.net_client = get_net_client(use_ovs, self.host_cmd) @@ -272,7 +270,6 @@ class CoreNodeBase(NodeBase): self.config_services = {} self.nodedir = None self.tmpnodedir = False - self.up = False def add_config_service(self, service_class: "ConfigServiceType") -> None: """ @@ -1008,6 +1005,7 @@ class CoreNetworkBase(NodeBase): will run on, default is None for localhost """ super().__init__(session, _id, name, start, server) + self.brname = None self._linked = {} self._linked_lock = threading.Lock() diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index 8da7c95b..8235878c 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -27,6 +27,7 @@ class CoreInterface: session: "Session", node: "CoreNode", name: str, + localname: str, mtu: int, server: "DistributedServer" = None, ) -> None: @@ -36,6 +37,7 @@ class CoreInterface: :param session: core session instance :param node: node for interface :param name: interface name + :param localname: interface local name :param mtu: mtu value :param server: remote server node will run on, default is None for localhost @@ -43,6 +45,8 @@ class CoreInterface: self.session = session self.node = node self.name = name + self.localname = localname + self.up = False if not isinstance(mtu, int): raise ValueError self.mtu = mtu @@ -258,9 +262,7 @@ class Veth(CoreInterface): :raises CoreCommandError: when there is a command exception """ # note that net arg is ignored - super().__init__(session, node, name, mtu, server) - self.localname = localname - self.up = False + super().__init__(session, node, name, localname, mtu, server) if start: self.startup() @@ -326,9 +328,7 @@ class TunTap(CoreInterface): will run on, default is None for localhost :param start: start flag """ - super().__init__(session, node, name, mtu, server) - self.localname = localname - self.up = False + super().__init__(session, node, name, localname, mtu, server) self.transport_type = "virtual" if start: self.startup() @@ -509,22 +509,17 @@ class GreTap(CoreInterface): will run on, default is None for localhost :raises CoreCommandError: when there is a command exception """ - super().__init__(session, node, name, mtu, server) if _id is None: - # from PyCoreObj _id = ((id(self) >> 16) ^ (id(self) & 0xFFFF)) & 0xFFFF self.id = _id - sessionid = self.session.short_session_id() - # interface name on the local host machine - self.localname = f"gt.{self.id}.{sessionid}" + sessionid = session.short_session_id() + localname = f"gt.{self.id}.{sessionid}" + super().__init__(session, node, name, localname, mtu, server) self.transport_type = "raw" if not start: - self.up = False return - if remoteip is None: raise ValueError("missing remote IP required for GRE TAP device") - self.net_client.create_gretap(self.localname, remoteip, localip, ttl, key) self.net_client.device_up(self.localname) self.up = True diff --git a/daemon/core/nodes/netclient.py b/daemon/core/nodes/netclient.py index 12ab8dc1..5062dead 100644 --- a/daemon/core/nodes/netclient.py +++ b/daemon/core/nodes/netclient.py @@ -243,7 +243,7 @@ class LinuxNetClient: def create_interface(self, bridge_name: str, interface_name: str) -> None: """ - Create an interface associated with a Linux bridge. + Assign interface master to a Linux bridge. :param bridge_name: bridge name :param interface_name: interface name diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 1b5f702c..17fb4fc2 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -284,7 +284,6 @@ class CoreNetwork(CoreNetworkBase): self.name = name sessionid = self.session.short_session_id() self.brname = f"b.{self.id}.{sessionid}" - self.up = False self.has_ebtables_chain = False if start: self.startup() @@ -561,7 +560,7 @@ class CoreNetwork(CoreNetworkBase): netif = Veth(self.session, None, name, localname, start=self.up) self.attach(netif) - if net.up: + if net.up and net.brname: # this is similar to net.attach() but uses netif.name instead of localname netif.net_client.create_interface(net.brname, netif.name) i = net.newifindex() diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 3f45c9ab..b6ae8e8d 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -280,12 +280,10 @@ class Rj45Node(CoreNodeBase, CoreInterface): will run on, default is None for localhost """ CoreNodeBase.__init__(self, session, _id, name, start, server) - CoreInterface.__init__(self, session, self, name, mtu, server) + CoreInterface.__init__(self, session, self, name, name, mtu, server) self.lock = threading.RLock() self.ifindex = None - # the following are PyCoreNetIf attributes self.transport_type = "raw" - self.localname = name self.old_up = False self.old_addrs = [] if start: From 964f78f06a15cfd618be09f00019e7d1454509a2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 23 May 2020 01:14:47 -0700 Subject: [PATCH 085/929] added othernet to CoreInterface to avoid hasattr checks --- daemon/core/nodes/base.py | 4 ++-- daemon/core/nodes/interface.py | 1 + daemon/core/nodes/network.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 1dbdcf53..e1267530 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -1086,11 +1086,11 @@ class CoreNetworkBase(NodeBase): for netif in self.netifs(sort=True): if not hasattr(netif, "node"): continue - linked_node = netif.node uni = False + linked_node = netif.node if linked_node is None: # two layer-2 switches/hubs linked together via linknet() - if not hasattr(netif, "othernet"): + if not netif.othernet: continue linked_node = netif.othernet if linked_node.id == self.id: diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index 8235878c..97b494b7 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -51,6 +51,7 @@ class CoreInterface: raise ValueError self.mtu = mtu self.net = None + self.othernet = None self._params = {} self.addrlist = [] self.hwaddr = None diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 17fb4fc2..5f6c635c 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -580,7 +580,7 @@ class CoreNetwork(CoreNetworkBase): :return: interface the provided network is linked to """ for netif in self.netifs(): - if hasattr(netif, "othernet") and netif.othernet == net: + if netif.othernet == net: return netif return None From ba8b16ec3499fa494727c30b8d15e710a7677fa2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 24 May 2020 23:37:38 -0700 Subject: [PATCH 086/929] added some type hinting with tests to help in refactoring in the future --- daemon/core/services/coreservices.py | 2 +- daemon/tests/test_conf.py | 15 ++-- daemon/tests/test_core.py | 15 ++-- daemon/tests/test_distributed.py | 5 +- daemon/tests/test_grpc.py | 129 +++++++++++++++------------ daemon/tests/test_gui.py | 90 ++++++++++--------- daemon/tests/test_links.py | 19 ++-- daemon/tests/test_nodes.py | 13 +-- daemon/tests/test_services.py | 27 +++--- daemon/tests/test_utils.py | 8 +- daemon/tests/test_xml.py | 32 +++++-- 11 files changed, 199 insertions(+), 156 deletions(-) diff --git a/daemon/core/services/coreservices.py b/daemon/core/services/coreservices.py index 35cd3ed3..491113ff 100644 --- a/daemon/core/services/coreservices.py +++ b/daemon/core/services/coreservices.py @@ -39,7 +39,7 @@ class ServiceDependencies: that all services will be booted and that all dependencies exist within the services provided. """ - def __init__(self, services: List["CoreService"]) -> None: + def __init__(self, services: List[Type["CoreService"]]) -> None: # helpers to check validity self.dependents = {} self.booted = set() diff --git a/daemon/tests/test_conf.py b/daemon/tests/test_conf.py index 55f6260a..1973dcee 100644 --- a/daemon/tests/test_conf.py +++ b/daemon/tests/test_conf.py @@ -8,6 +8,7 @@ from core.config import ( ) from core.emane.ieee80211abg import EmaneIeee80211abgModel from core.emulator.enumerations import ConfigDataTypes +from core.emulator.session import Session from core.location.mobility import BasicRangeModel from core.nodes.network import WlanNode @@ -41,7 +42,7 @@ class TestConf: def test_nodes(self): # given config_manager = ConfigurableManager() - test_config = {1: 2} + test_config = {"1": "2"} node_id = 1 config_manager.set_configs(test_config) config_manager.set_configs(test_config, node_id=node_id) @@ -56,7 +57,7 @@ class TestConf: def test_config_reset_all(self): # given config_manager = ConfigurableManager() - test_config = {1: 2} + test_config = {"1": "2"} node_id = 1 config_manager.set_configs(test_config) config_manager.set_configs(test_config, node_id=node_id) @@ -70,7 +71,7 @@ class TestConf: def test_config_reset_node(self): # given config_manager = ConfigurableManager() - test_config = {1: 2} + test_config = {"1": "2"} node_id = 1 config_manager.set_configs(test_config) config_manager.set_configs(test_config, node_id=node_id) @@ -85,7 +86,7 @@ class TestConf: def test_configs_setget(self): # given config_manager = ConfigurableManager() - test_config = {1: 2} + test_config = {"1": "2"} node_id = 1 config_manager.set_configs(test_config) config_manager.set_configs(test_config, node_id=node_id) @@ -146,7 +147,7 @@ class TestConf: with pytest.raises(ValueError): manager.get_model_config(1, bad_name) - def test_model_set(self, session): + def test_model_set(self, session: Session): # given wlan_node = session.add_node(WlanNode) @@ -156,7 +157,7 @@ class TestConf: # then assert session.mobility.get_model_config(wlan_node.id, BasicRangeModel.name) - def test_model_set_error(self, session): + def test_model_set_error(self, session: Session): # given wlan_node = session.add_node(WlanNode) @@ -164,7 +165,7 @@ class TestConf: with pytest.raises(ValueError): session.mobility.set_model(wlan_node, EmaneIeee80211abgModel) - def test_get_models(self, session): + def test_get_models(self, session: Session): # given wlan_node = session.add_node(WlanNode) session.mobility.set_model(wlan_node, BasicRangeModel) diff --git a/daemon/tests/test_core.py b/daemon/tests/test_core.py index e663b85f..b9e0c1df 100644 --- a/daemon/tests/test_core.py +++ b/daemon/tests/test_core.py @@ -7,8 +7,9 @@ import threading import pytest -from core.emulator.emudata import NodeOptions +from core.emulator.emudata import IpPrefixes, NodeOptions from core.emulator.enumerations import MessageFlags +from core.emulator.session import Session from core.errors import CoreCommandError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility from core.nodes.base import CoreNode @@ -59,7 +60,7 @@ class TestCore: status = ping(node_one, node_two, ip_prefixes) assert not status - def test_vnode_client(self, request, session, ip_prefixes): + def test_vnode_client(self, request, session: Session, ip_prefixes: IpPrefixes): """ Test vnode client methods. @@ -92,7 +93,7 @@ class TestCore: if not request.config.getoption("mock"): assert client.check_cmd("echo hello") == "hello" - def test_netif(self, session, ip_prefixes): + def test_netif(self, session: Session, ip_prefixes: IpPrefixes): """ Test netif methods. @@ -123,8 +124,8 @@ class TestCore: assert node_two.commonnets(node_one) # check we can retrieve netif index - assert node_one.getifindex(0) - assert node_two.getifindex(0) + assert node_one.ifname(0) + assert node_two.ifname(0) # check interface parameters interface = node_one.netif(0) @@ -136,7 +137,7 @@ class TestCore: node_one.delnetif(0) assert not node_one.netif(0) - def test_wlan_ping(self, session, ip_prefixes): + def test_wlan_ping(self, session: Session, ip_prefixes: IpPrefixes): """ Test basic wlan network. @@ -166,7 +167,7 @@ class TestCore: status = ping(node_one, node_two, ip_prefixes) assert not status - def test_mobility(self, session, ip_prefixes): + def test_mobility(self, session: Session, ip_prefixes: IpPrefixes): """ Test basic wlan network. diff --git a/daemon/tests/test_distributed.py b/daemon/tests/test_distributed.py index 2308db3d..86ddaf99 100644 --- a/daemon/tests/test_distributed.py +++ b/daemon/tests/test_distributed.py @@ -1,10 +1,11 @@ from core.emulator.emudata import NodeOptions +from core.emulator.session import Session from core.nodes.base import CoreNode from core.nodes.network import HubNode class TestDistributed: - def test_remote_node(self, session): + def test_remote_node(self, session: Session): # given server_name = "core2" host = "127.0.0.1" @@ -21,7 +22,7 @@ class TestDistributed: assert node.server.name == server_name assert node.server.host == host - def test_remote_bridge(self, session): + def test_remote_bridge(self, session: Session): # given server_name = "core2" host = "127.0.0.1" diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 5e765f42..5e55f346 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -1,5 +1,7 @@ import time from queue import Queue +from tempfile import TemporaryFile +from typing import Optional import grpc import pytest @@ -9,6 +11,7 @@ from core.api.grpc import core_pb2 from core.api.grpc.client import CoreGrpcClient, InterfaceHelper from core.api.grpc.emane_pb2 import EmaneModelConfig from core.api.grpc.mobility_pb2 import MobilityAction, MobilityConfig +from core.api.grpc.server import CoreGrpcServer from core.api.grpc.services_pb2 import ServiceAction, ServiceConfig, ServiceFileConfig from core.api.grpc.wlan_pb2 import WlanConfig from core.api.tlv.dataconversion import ConfigShim @@ -16,7 +19,7 @@ from core.api.tlv.enumerations import ConfigFlags from core.emane.ieee80211abg import EmaneIeee80211abgModel from core.emane.nodes import EmaneNet from core.emulator.data import EventData -from core.emulator.emudata import NodeOptions +from core.emulator.emudata import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes, ExceptionLevels, NodeTypes from core.errors import CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility @@ -26,7 +29,7 @@ from core.xml.corexml import CoreXmlWriter class TestGrpc: - def test_start_session(self, grpc_server): + def test_start_session(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -159,7 +162,9 @@ class TestGrpc: assert service_file.data == service_file_config.data @pytest.mark.parametrize("session_id", [None, 6013]) - def test_create_session(self, grpc_server, session_id): + def test_create_session( + self, grpc_server: CoreGrpcServer, session_id: Optional[int] + ): # given client = CoreGrpcClient() @@ -178,7 +183,9 @@ class TestGrpc: assert session.id == session_id @pytest.mark.parametrize("session_id, expected", [(None, True), (6013, False)]) - def test_delete_session(self, grpc_server, session_id, expected): + def test_delete_session( + self, grpc_server: CoreGrpcServer, session_id: Optional[int], expected: bool + ): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -193,7 +200,7 @@ class TestGrpc: assert response.result is expected assert grpc_server.coreemu.sessions.get(session_id) is None - def test_get_session(self, grpc_server): + def test_get_session(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -209,7 +216,7 @@ class TestGrpc: assert len(response.session.nodes) == 1 assert len(response.session.links) == 0 - def test_get_sessions(self, grpc_server): + def test_get_sessions(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -227,7 +234,7 @@ class TestGrpc: assert len(response.sessions) == 1 assert found_session is not None - def test_get_session_options(self, grpc_server): + def test_get_session_options(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -239,7 +246,7 @@ class TestGrpc: # then assert len(response.config) > 0 - def test_get_session_location(self, grpc_server): + def test_get_session_location(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -257,7 +264,7 @@ class TestGrpc: assert response.location.lon == 0 assert response.location.alt == 0 - def test_set_session_location(self, grpc_server): + def test_set_session_location(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -284,7 +291,7 @@ class TestGrpc: assert session.location.refscale == scale assert session.location.refgeo == lat_lon_alt - def test_set_session_options(self, grpc_server): + def test_set_session_options(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -301,7 +308,7 @@ class TestGrpc: config = session.options.get_configs() assert len(config) > 0 - def test_set_session_metadata(self, grpc_server): + def test_set_session_metadata(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -316,7 +323,7 @@ class TestGrpc: assert response.result is True assert session.metadata[key] == value - def test_get_session_metadata(self, grpc_server): + def test_get_session_metadata(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -331,7 +338,7 @@ class TestGrpc: # then assert response.config[key] == value - def test_set_session_state(self, grpc_server): + def test_set_session_state(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -346,7 +353,7 @@ class TestGrpc: assert response.result is True assert session.state == EventTypes.DEFINITION_STATE - def test_add_node(self, grpc_server): + def test_add_node(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -360,7 +367,7 @@ class TestGrpc: assert response.node_id is not None assert session.get_node(response.node_id, CoreNode) is not None - def test_get_node(self, grpc_server): + def test_get_node(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -373,7 +380,7 @@ class TestGrpc: # then assert response.node.id == node.id - def test_edit_node(self, grpc_server): + def test_edit_node(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -391,7 +398,9 @@ class TestGrpc: assert node.position.y == y @pytest.mark.parametrize("node_id, expected", [(1, True), (2, False)]) - def test_delete_node(self, grpc_server, node_id, expected): + def test_delete_node( + self, grpc_server: CoreGrpcServer, node_id: int, expected: bool + ): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -407,7 +416,7 @@ class TestGrpc: with pytest.raises(CoreError): assert session.get_node(node.id, CoreNode) - def test_node_command(self, request, grpc_server): + def test_node_command(self, request, grpc_server: CoreGrpcServer): if request.config.getoption("mock"): pytest.skip("mocking calls") @@ -428,7 +437,7 @@ class TestGrpc: # then assert response.output == output - def test_get_node_terminal(self, grpc_server): + def test_get_node_terminal(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -444,7 +453,7 @@ class TestGrpc: # then assert response.terminal is not None - def test_get_hooks(self, grpc_server): + def test_get_hooks(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -463,7 +472,7 @@ class TestGrpc: assert hook.file == file_name assert hook.data == file_data - def test_add_hook(self, grpc_server): + def test_add_hook(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -479,7 +488,7 @@ class TestGrpc: # then assert response.result is True - def test_save_xml(self, grpc_server, tmpdir): + def test_save_xml(self, grpc_server: CoreGrpcServer, tmpdir: TemporaryFile): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -492,7 +501,7 @@ class TestGrpc: # then assert tmp.exists() - def test_open_xml_hook(self, grpc_server, tmpdir): + def test_open_xml_hook(self, grpc_server: CoreGrpcServer, tmpdir: TemporaryFile): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -507,7 +516,7 @@ class TestGrpc: assert response.result is True assert response.session_id is not None - def test_get_node_links(self, grpc_server, ip_prefixes): + def test_get_node_links(self, grpc_server: CoreGrpcServer, ip_prefixes: IpPrefixes): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -523,7 +532,9 @@ class TestGrpc: # then assert len(response.links) == 1 - def test_get_node_links_exception(self, grpc_server, ip_prefixes): + def test_get_node_links_exception( + self, grpc_server: CoreGrpcServer, ip_prefixes: IpPrefixes + ): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -537,7 +548,9 @@ class TestGrpc: with client.context_connect(): client.get_node_links(session.id, 3) - def test_add_link(self, grpc_server, interface_helper): + def test_add_link( + self, grpc_server: CoreGrpcServer, interface_helper: InterfaceHelper + ): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -554,7 +567,9 @@ class TestGrpc: assert response.result is True assert len(switch.all_link_data()) == 1 - def test_add_link_exception(self, grpc_server, interface_helper): + def test_add_link_exception( + self, grpc_server: CoreGrpcServer, interface_helper: InterfaceHelper + ): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -566,7 +581,7 @@ class TestGrpc: with client.context_connect(): client.add_link(session.id, 1, 3, interface) - def test_edit_link(self, grpc_server, ip_prefixes): + def test_edit_link(self, grpc_server: CoreGrpcServer, ip_prefixes: IpPrefixes): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -589,7 +604,7 @@ class TestGrpc: link = switch.all_link_data()[0] assert options.bandwidth == link.bandwidth - def test_delete_link(self, grpc_server, ip_prefixes): + def test_delete_link(self, grpc_server: CoreGrpcServer, ip_prefixes: IpPrefixes): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -616,7 +631,7 @@ class TestGrpc: assert response.result is True assert len(link_node.all_link_data(0)) == 0 - def test_get_wlan_config(self, grpc_server): + def test_get_wlan_config(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -629,7 +644,7 @@ class TestGrpc: # then assert len(response.config) > 0 - def test_set_wlan_config(self, grpc_server): + def test_set_wlan_config(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -661,7 +676,7 @@ class TestGrpc: assert config[range_key] == range_value assert wlan.model.range == int(range_value) - def test_get_emane_config(self, grpc_server): + def test_get_emane_config(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -673,7 +688,7 @@ class TestGrpc: # then assert len(response.config) > 0 - def test_set_emane_config(self, grpc_server): + def test_set_emane_config(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -690,7 +705,7 @@ class TestGrpc: assert len(config) > 1 assert config[config_key] == config_value - def test_get_emane_model_configs(self, grpc_server): + def test_get_emane_model_configs(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -717,7 +732,7 @@ class TestGrpc: assert len(model_config.config) > 0 assert model_config.interface == -1 - def test_set_emane_model_config(self, grpc_server): + def test_set_emane_model_config(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -745,7 +760,7 @@ class TestGrpc: ) assert config[config_key] == config_value - def test_get_emane_model_config(self, grpc_server): + def test_get_emane_model_config(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -764,7 +779,7 @@ class TestGrpc: # then assert len(response.config) > 0 - def test_get_emane_models(self, grpc_server): + def test_get_emane_models(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -776,7 +791,7 @@ class TestGrpc: # then assert len(response.models) > 0 - def test_get_mobility_configs(self, grpc_server): + def test_get_mobility_configs(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -793,7 +808,7 @@ class TestGrpc: mapped_config = response.configs[wlan.id] assert len(mapped_config.config) > 0 - def test_get_mobility_config(self, grpc_server): + def test_get_mobility_config(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -807,7 +822,7 @@ class TestGrpc: # then assert len(response.config) > 0 - def test_set_mobility_config(self, grpc_server): + def test_set_mobility_config(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -826,7 +841,7 @@ class TestGrpc: config = session.mobility.get_model_config(wlan.id, Ns2ScriptedMobility.name) assert config[config_key] == config_value - def test_mobility_action(self, grpc_server): + def test_mobility_action(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -841,7 +856,7 @@ class TestGrpc: # then assert response.result is True - def test_get_services(self, grpc_server): + def test_get_services(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() @@ -852,7 +867,7 @@ class TestGrpc: # then assert len(response.services) > 0 - def test_get_service_defaults(self, grpc_server): + def test_get_service_defaults(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -864,7 +879,7 @@ class TestGrpc: # then assert len(response.defaults) > 0 - def test_set_service_defaults(self, grpc_server): + def test_set_service_defaults(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -879,7 +894,7 @@ class TestGrpc: assert response.result is True assert session.services.default_services[node_type] == services - def test_get_node_service_configs(self, grpc_server): + def test_get_node_service_configs(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -897,7 +912,7 @@ class TestGrpc: assert service_config.node_id == node.id assert service_config.service == service_name - def test_get_node_service(self, grpc_server): + def test_get_node_service(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -910,7 +925,7 @@ class TestGrpc: # then assert len(response.service.configs) > 0 - def test_get_node_service_file(self, grpc_server): + def test_get_node_service_file(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -925,7 +940,7 @@ class TestGrpc: # then assert response.data is not None - def test_set_node_service(self, grpc_server): + def test_set_node_service(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -946,7 +961,7 @@ class TestGrpc: ) assert service.validate == tuple(validate) - def test_set_node_service_file(self, grpc_server): + def test_set_node_service_file(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -966,7 +981,7 @@ class TestGrpc: service_file = session.services.get_service_file(node, service_name, file_name) assert service_file.data == file_data - def test_service_action(self, grpc_server): + def test_service_action(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -982,7 +997,7 @@ class TestGrpc: # then assert response.result is True - def test_node_events(self, grpc_server): + def test_node_events(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -1003,7 +1018,7 @@ class TestGrpc: # then queue.get(timeout=5) - def test_link_events(self, grpc_server, ip_prefixes): + def test_link_events(self, grpc_server: CoreGrpcServer, ip_prefixes: IpPrefixes): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -1028,7 +1043,7 @@ class TestGrpc: # then queue.get(timeout=5) - def test_throughputs(self, request, grpc_server): + def test_throughputs(self, request, grpc_server: CoreGrpcServer): if request.config.getoption("mock"): pytest.skip("mocking calls") @@ -1049,7 +1064,7 @@ class TestGrpc: # then queue.get(timeout=5) - def test_session_events(self, grpc_server): + def test_session_events(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -1072,7 +1087,7 @@ class TestGrpc: # then queue.get(timeout=5) - def test_config_events(self, grpc_server): + def test_config_events(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -1096,7 +1111,7 @@ class TestGrpc: # then queue.get(timeout=5) - def test_exception_events(self, grpc_server): + def test_exception_events(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -1125,7 +1140,7 @@ class TestGrpc: # then queue.get(timeout=5) - def test_file_events(self, grpc_server): + def test_file_events(self, grpc_server: CoreGrpcServer): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() diff --git a/daemon/tests/test_gui.py b/daemon/tests/test_gui.py index 4a086e53..89dcd7ab 100644 --- a/daemon/tests/test_gui.py +++ b/daemon/tests/test_gui.py @@ -3,6 +3,7 @@ Tests for testing tlv message handling. """ import os import time +from typing import Optional import mock import netaddr @@ -10,6 +11,7 @@ import pytest from mock import MagicMock from core.api.tlv import coreapi +from core.api.tlv.corehandlers import CoreHandler from core.api.tlv.enumerations import ( ConfigFlags, ConfigTlvs, @@ -28,7 +30,7 @@ from core.nodes.base import CoreNode, NodeBase from core.nodes.network import SwitchNode, WlanNode -def dict_to_str(values): +def dict_to_str(values) -> str: return "|".join(f"{x}={values[x]}" for x in values) @@ -44,7 +46,9 @@ class TestGui: (NodeTypes.TUNNEL, None), ], ) - def test_node_add(self, coretlv, node_type, model): + def test_node_add( + self, coretlv: CoreHandler, node_type: NodeTypes, model: Optional[str] + ): node_id = 1 message = coreapi.CoreNodeMessage.create( MessageFlags.ADD.value, @@ -61,7 +65,7 @@ class TestGui: coretlv.handle_message(message) assert coretlv.session.get_node(node_id, NodeBase) is not None - def test_node_update(self, coretlv): + def test_node_update(self, coretlv: CoreHandler): node_id = 1 coretlv.session.add_node(CoreNode, _id=node_id) x = 50 @@ -82,7 +86,7 @@ class TestGui: assert node.position.x == x assert node.position.y == y - def test_node_delete(self, coretlv): + def test_node_delete(self, coretlv: CoreHandler): node_id = 1 coretlv.session.add_node(CoreNode, _id=node_id) message = coreapi.CoreNodeMessage.create( @@ -94,7 +98,7 @@ class TestGui: with pytest.raises(CoreError): coretlv.session.get_node(node_id, NodeBase) - def test_link_add_node_to_net(self, coretlv): + def test_link_add_node_to_net(self, coretlv: CoreHandler): node_one = 1 coretlv.session.add_node(CoreNode, _id=node_one) switch = 2 @@ -118,7 +122,7 @@ class TestGui: all_links = switch_node.all_link_data() assert len(all_links) == 1 - def test_link_add_net_to_node(self, coretlv): + def test_link_add_net_to_node(self, coretlv: CoreHandler): node_one = 1 coretlv.session.add_node(CoreNode, _id=node_one) switch = 2 @@ -142,7 +146,7 @@ class TestGui: all_links = switch_node.all_link_data() assert len(all_links) == 1 - def test_link_add_node_to_node(self, coretlv): + def test_link_add_node_to_node(self, coretlv: CoreHandler): node_one = 1 coretlv.session.add_node(CoreNode, _id=node_one) node_two = 2 @@ -172,7 +176,7 @@ class TestGui: all_links += node.all_link_data() assert len(all_links) == 1 - def test_link_update(self, coretlv): + def test_link_update(self, coretlv: CoreHandler): node_one = 1 coretlv.session.add_node(CoreNode, _id=node_one) switch = 2 @@ -214,7 +218,7 @@ class TestGui: link = all_links[0] assert link.bandwidth == bandwidth - def test_link_delete_node_to_node(self, coretlv): + def test_link_delete_node_to_node(self, coretlv: CoreHandler): node_one = 1 coretlv.session.add_node(CoreNode, _id=node_one) node_two = 2 @@ -258,7 +262,7 @@ class TestGui: all_links += node.all_link_data() assert len(all_links) == 0 - def test_link_delete_node_to_net(self, coretlv): + def test_link_delete_node_to_net(self, coretlv: CoreHandler): node_one = 1 coretlv.session.add_node(CoreNode, _id=node_one) switch = 2 @@ -294,7 +298,7 @@ class TestGui: all_links = switch_node.all_link_data() assert len(all_links) == 0 - def test_link_delete_net_to_node(self, coretlv): + def test_link_delete_net_to_node(self, coretlv: CoreHandler): node_one = 1 coretlv.session.add_node(CoreNode, _id=node_one) switch = 2 @@ -330,7 +334,7 @@ class TestGui: all_links = switch_node.all_link_data() assert len(all_links) == 0 - def test_session_update(self, coretlv): + def test_session_update(self, coretlv: CoreHandler): session_id = coretlv.session.id name = "test" message = coreapi.CoreSessionMessage.create( @@ -341,7 +345,7 @@ class TestGui: assert coretlv.session.name == name - def test_session_query(self, coretlv): + def test_session_query(self, coretlv: CoreHandler): coretlv.dispatch_replies = mock.MagicMock() message = coreapi.CoreSessionMessage.create(MessageFlags.STRING.value, []) @@ -351,7 +355,7 @@ class TestGui: replies = args[0] assert len(replies) == 1 - def test_session_join(self, coretlv): + def test_session_join(self, coretlv: CoreHandler): coretlv.dispatch_replies = mock.MagicMock() session_id = coretlv.session.id message = coreapi.CoreSessionMessage.create( @@ -362,7 +366,7 @@ class TestGui: assert coretlv.session.id == session_id - def test_session_delete(self, coretlv): + def test_session_delete(self, coretlv: CoreHandler): assert len(coretlv.coreemu.sessions) == 1 session_id = coretlv.session.id message = coreapi.CoreSessionMessage.create( @@ -373,7 +377,7 @@ class TestGui: assert len(coretlv.coreemu.sessions) == 0 - def test_file_hook_add(self, coretlv): + def test_file_hook_add(self, coretlv: CoreHandler): state = EventTypes.DATACOLLECT_STATE assert coretlv.session._hooks.get(state) is None file_name = "test.sh" @@ -395,7 +399,7 @@ class TestGui: assert file_name == name assert file_data == data - def test_file_service_file_set(self, coretlv): + def test_file_service_file_set(self, coretlv: CoreHandler): node = coretlv.session.add_node(CoreNode) service = "DefaultRoute" file_name = "defaultroute.sh" @@ -417,7 +421,7 @@ class TestGui: ) assert file_data == service_file.data - def test_file_node_file_copy(self, request, coretlv): + def test_file_node_file_copy(self, request, coretlv: CoreHandler): file_name = "/var/log/test/node.log" node = coretlv.session.add_node(CoreNode) node.makenodedir() @@ -439,7 +443,7 @@ class TestGui: create_path = os.path.join(node.nodedir, created_directory, basename) assert os.path.exists(create_path) - def test_exec_node_tty(self, coretlv): + def test_exec_node_tty(self, coretlv: CoreHandler): coretlv.dispatch_replies = mock.MagicMock() node = coretlv.session.add_node(CoreNode) message = coreapi.CoreExecMessage.create( @@ -457,7 +461,7 @@ class TestGui: replies = args[0] assert len(replies) == 1 - def test_exec_local_command(self, request, coretlv): + def test_exec_local_command(self, request, coretlv: CoreHandler): if request.config.getoption("mock"): pytest.skip("mocking calls") @@ -479,7 +483,7 @@ class TestGui: replies = args[0] assert len(replies) == 1 - def test_exec_node_command(self, coretlv): + def test_exec_node_command(self, coretlv: CoreHandler): coretlv.dispatch_replies = mock.MagicMock() node = coretlv.session.add_node(CoreNode) cmd = "echo hello" @@ -514,7 +518,7 @@ class TestGui: assert coretlv.session.state == state - def test_event_schedule(self, coretlv): + def test_event_schedule(self, coretlv: CoreHandler): coretlv.session.add_event = mock.MagicMock() node = coretlv.session.add_node(CoreNode) message = coreapi.CoreEventMessage.create( @@ -602,7 +606,7 @@ class TestGui: coretlv.handle_message(message) - def test_register_gui(self, coretlv): + def test_register_gui(self, coretlv: CoreHandler): message = coreapi.CoreRegMessage.create(0, [(RegisterTlvs.GUI, "gui")]) coretlv.handle_message(message) @@ -638,7 +642,7 @@ class TestGui: assert len(coretlv.session.nodes) == 1 - def test_config_all(self, coretlv): + def test_config_all(self, coretlv: CoreHandler): message = coreapi.CoreConfMessage.create( MessageFlags.ADD.value, [(ConfigTlvs.OBJECT, "all"), (ConfigTlvs.TYPE, ConfigFlags.RESET.value)], @@ -649,7 +653,7 @@ class TestGui: assert coretlv.session.location.refxyz == (0, 0, 0) - def test_config_options_request(self, coretlv): + def test_config_options_request(self, coretlv: CoreHandler): message = coreapi.CoreConfMessage.create( 0, [ @@ -663,7 +667,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() - def test_config_options_update(self, coretlv): + def test_config_options_update(self, coretlv: CoreHandler): test_key = "test" test_value = "test" values = {test_key: test_value} @@ -680,7 +684,7 @@ class TestGui: assert coretlv.session.options.get_config(test_key) == test_value - def test_config_location_reset(self, coretlv): + def test_config_location_reset(self, coretlv: CoreHandler): message = coreapi.CoreConfMessage.create( 0, [ @@ -694,7 +698,7 @@ class TestGui: assert coretlv.session.location.refxyz == (0, 0, 0) - def test_config_location_update(self, coretlv): + def test_config_location_update(self, coretlv: CoreHandler): message = coreapi.CoreConfMessage.create( 0, [ @@ -710,7 +714,7 @@ class TestGui: assert coretlv.session.location.refgeo == (70, 50, 0) assert coretlv.session.location.refscale == 0.5 - def test_config_metadata_request(self, coretlv): + def test_config_metadata_request(self, coretlv: CoreHandler): message = coreapi.CoreConfMessage.create( 0, [ @@ -724,7 +728,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() - def test_config_metadata_update(self, coretlv): + def test_config_metadata_update(self, coretlv: CoreHandler): test_key = "test" test_value = "test" values = {test_key: test_value} @@ -741,7 +745,7 @@ class TestGui: assert coretlv.session.metadata[test_key] == test_value - def test_config_broker_request(self, coretlv): + def test_config_broker_request(self, coretlv: CoreHandler): server = "test" host = "10.0.0.1" port = 50000 @@ -759,7 +763,7 @@ class TestGui: coretlv.session.distributed.add_server.assert_called_once_with(server, host) - def test_config_services_request_all(self, coretlv): + def test_config_services_request_all(self, coretlv: CoreHandler): message = coreapi.CoreConfMessage.create( 0, [ @@ -773,7 +777,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() - def test_config_services_request_specific(self, coretlv): + def test_config_services_request_specific(self, coretlv: CoreHandler): node = coretlv.session.add_node(CoreNode) message = coreapi.CoreConfMessage.create( 0, @@ -790,7 +794,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() - def test_config_services_request_specific_file(self, coretlv): + def test_config_services_request_specific_file(self, coretlv: CoreHandler): node = coretlv.session.add_node(CoreNode) message = coreapi.CoreConfMessage.create( 0, @@ -807,7 +811,7 @@ class TestGui: coretlv.session.broadcast_file.assert_called_once() - def test_config_services_reset(self, coretlv): + def test_config_services_reset(self, coretlv: CoreHandler): node = coretlv.session.add_node(CoreNode) service = "DefaultRoute" coretlv.session.services.set_service(node.id, service) @@ -824,7 +828,7 @@ class TestGui: assert coretlv.session.services.get_service(node.id, service) is None - def test_config_services_set(self, coretlv): + def test_config_services_set(self, coretlv: CoreHandler): node = coretlv.session.add_node(CoreNode) service = "DefaultRoute" values = {"meta": "metadata"} @@ -844,7 +848,7 @@ class TestGui: assert coretlv.session.services.get_service(node.id, service) is not None - def test_config_mobility_reset(self, coretlv): + def test_config_mobility_reset(self, coretlv: CoreHandler): wlan = coretlv.session.add_node(WlanNode) message = coreapi.CoreConfMessage.create( 0, @@ -860,7 +864,7 @@ class TestGui: assert len(coretlv.session.mobility.node_configurations) == 0 - def test_config_mobility_model_request(self, coretlv): + def test_config_mobility_model_request(self, coretlv: CoreHandler): wlan = coretlv.session.add_node(WlanNode) message = coreapi.CoreConfMessage.create( 0, @@ -876,7 +880,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() - def test_config_mobility_model_update(self, coretlv): + def test_config_mobility_model_update(self, coretlv: CoreHandler): wlan = coretlv.session.add_node(WlanNode) config_key = "range" config_value = "1000" @@ -898,7 +902,7 @@ class TestGui: ) assert config[config_key] == config_value - def test_config_emane_model_request(self, coretlv): + def test_config_emane_model_request(self, coretlv: CoreHandler): wlan = coretlv.session.add_node(WlanNode) message = coreapi.CoreConfMessage.create( 0, @@ -914,7 +918,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() - def test_config_emane_model_update(self, coretlv): + def test_config_emane_model_update(self, coretlv: CoreHandler): wlan = coretlv.session.add_node(WlanNode) config_key = "distance" config_value = "50051" @@ -936,7 +940,7 @@ class TestGui: ) assert config[config_key] == config_value - def test_config_emane_request(self, coretlv): + def test_config_emane_request(self, coretlv: CoreHandler): message = coreapi.CoreConfMessage.create( 0, [ @@ -950,7 +954,7 @@ class TestGui: coretlv.handle_broadcast_config.assert_called_once() - def test_config_emane_update(self, coretlv): + def test_config_emane_update(self, coretlv: CoreHandler): config_key = "eventservicedevice" config_value = "eth4" values = {config_key: config_value} diff --git a/daemon/tests/test_links.py b/daemon/tests/test_links.py index afbdaab1..94b2e53f 100644 --- a/daemon/tests/test_links.py +++ b/daemon/tests/test_links.py @@ -1,9 +1,14 @@ -from core.emulator.emudata import LinkOptions +from typing import Tuple + +from core.emulator.emudata import IpPrefixes, LinkOptions +from core.emulator.session import Session from core.nodes.base import CoreNode from core.nodes.network import SwitchNode -def create_ptp_network(session, ip_prefixes): +def create_ptp_network( + session: Session, ip_prefixes: IpPrefixes +) -> Tuple[CoreNode, CoreNode]: # create nodes node_one = session.add_node(CoreNode) node_two = session.add_node(CoreNode) @@ -20,7 +25,7 @@ def create_ptp_network(session, ip_prefixes): class TestLinks: - def test_ptp(self, session, ip_prefixes): + def test_ptp(self, session: Session, ip_prefixes: IpPrefixes): # given node_one = session.add_node(CoreNode) node_two = session.add_node(CoreNode) @@ -34,7 +39,7 @@ class TestLinks: assert node_one.netif(interface_one.id) assert node_two.netif(interface_two.id) - def test_node_to_net(self, session, ip_prefixes): + def test_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): # given node_one = session.add_node(CoreNode) node_two = session.add_node(SwitchNode) @@ -47,7 +52,7 @@ class TestLinks: assert node_two.all_link_data() assert node_one.netif(interface_one.id) - def test_net_to_node(self, session, ip_prefixes): + def test_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): # given node_one = session.add_node(SwitchNode) node_two = session.add_node(CoreNode) @@ -71,7 +76,7 @@ class TestLinks: # then assert node_one.all_link_data() - def test_link_update(self, session, ip_prefixes): + def test_link_update(self, session: Session, ip_prefixes: IpPrefixes): # given delay = 50 bandwidth = 5000000 @@ -110,7 +115,7 @@ class TestLinks: assert interface_one.getparam("duplicate") == dup assert interface_one.getparam("jitter") == jitter - def test_link_delete(self, session, ip_prefixes): + def test_link_delete(self, session: Session, ip_prefixes: IpPrefixes): # given node_one = session.add_node(CoreNode) node_two = session.add_node(CoreNode) diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index f87e8e80..65b17949 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -1,6 +1,7 @@ import pytest from core.emulator.emudata import NodeOptions +from core.emulator.session import Session from core.errors import CoreError from core.nodes.base import CoreNode from core.nodes.network import HubNode, SwitchNode, WlanNode @@ -11,7 +12,7 @@ NET_TYPES = [SwitchNode, HubNode, WlanNode] class TestNodes: @pytest.mark.parametrize("model", MODELS) - def test_node_add(self, session, model): + def test_node_add(self, session: Session, model: str): # given options = NodeOptions(model=model) @@ -23,7 +24,7 @@ class TestNodes: assert node.alive() assert node.up - def test_node_update(self, session): + def test_node_update(self, session: Session): # given node = session.add_node(CoreNode) position_value = 100 @@ -37,7 +38,7 @@ class TestNodes: assert node.position.x == position_value assert node.position.y == position_value - def test_node_delete(self, session): + def test_node_delete(self, session: Session): # given node = session.add_node(CoreNode) @@ -48,7 +49,7 @@ class TestNodes: with pytest.raises(CoreError): session.get_node(node.id, CoreNode) - def test_node_sethwaddr(self, session): + def test_node_sethwaddr(self, session: Session): # given node = session.add_node(CoreNode) index = node.newnetif() @@ -61,7 +62,7 @@ class TestNodes: # then assert interface.hwaddr == mac - def test_node_sethwaddr_exception(self, session): + def test_node_sethwaddr_exception(self, session: Session): # given node = session.add_node(CoreNode) index = node.newnetif() @@ -72,7 +73,7 @@ class TestNodes: with pytest.raises(CoreError): node.sethwaddr(index, mac) - def test_node_addaddr(self, session): + def test_node_addaddr(self, session: Session): # given node = session.add_node(CoreNode) index = node.newnetif() diff --git a/daemon/tests/test_services.py b/daemon/tests/test_services.py index c5a51461..e304a275 100644 --- a/daemon/tests/test_services.py +++ b/daemon/tests/test_services.py @@ -3,6 +3,7 @@ import os import pytest from mock import MagicMock +from core.emulator.session import Session from core.errors import CoreCommandError from core.nodes.base import CoreNode from core.services.coreservices import CoreService, ServiceDependencies, ServiceManager @@ -49,7 +50,7 @@ class ServiceCycleDependency(CoreService): class TestServices: - def test_service_all_files(self, session): + def test_service_all_files(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) file_name = "myservice.sh" @@ -64,7 +65,7 @@ class TestServices: assert service assert all_files and len(all_files) == 1 - def test_service_all_configs(self, session): + def test_service_all_configs(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) node = session.add_node(CoreNode) @@ -78,7 +79,7 @@ class TestServices: assert all_configs assert len(all_configs) == 2 - def test_service_add_services(self, session): + def test_service_add_services(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) node = session.add_node(CoreNode) @@ -91,7 +92,7 @@ class TestServices: assert node.services assert len(node.services) == total_service + 2 - def test_service_file(self, request, session): + def test_service_file(self, request, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) @@ -106,7 +107,7 @@ class TestServices: if not request.config.getoption("mock"): assert os.path.exists(file_path) - def test_service_validate(self, session): + def test_service_validate(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) @@ -119,7 +120,7 @@ class TestServices: # then assert not status - def test_service_validate_error(self, session): + def test_service_validate_error(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_TWO) @@ -133,7 +134,7 @@ class TestServices: # then assert status - def test_service_startup(self, session): + def test_service_startup(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) @@ -146,7 +147,7 @@ class TestServices: # then assert not status - def test_service_startup_error(self, session): + def test_service_startup_error(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_TWO) @@ -160,7 +161,7 @@ class TestServices: # then assert status - def test_service_stop(self, session): + def test_service_stop(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) @@ -173,7 +174,7 @@ class TestServices: # then assert not status - def test_service_stop_error(self, session): + def test_service_stop_error(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_TWO) @@ -187,7 +188,7 @@ class TestServices: # then assert status - def test_service_custom_startup(self, session): + def test_service_custom_startup(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) @@ -201,7 +202,7 @@ class TestServices: # then assert my_service.startup != custom_my_service.startup - def test_service_set_file(self, session): + def test_service_set_file(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) @@ -231,7 +232,7 @@ class TestServices: assert ServiceManager.get(SERVICE_ONE) assert ServiceManager.get(SERVICE_TWO) - def test_service_setget(self, session): + def test_service_setget(self, session: Session): # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) diff --git a/daemon/tests/test_utils.py b/daemon/tests/test_utils.py index 0c6b84c4..3e43b789 100644 --- a/daemon/tests/test_utils.py +++ b/daemon/tests/test_utils.py @@ -34,12 +34,12 @@ class TestUtils: ("2001::/64", "2001::/64"), ], ) - def test_validate_ip(self, data, expected): + def test_validate_ip(self, data: str, expected: str): value = utils.validate_ip(data) assert value == expected @pytest.mark.parametrize("data", ["256", "1270.0.0.1", "127.0.0.0.1"]) - def test_validate_ip_exception(self, data): + def test_validate_ip_exception(self, data: str): with pytest.raises(CoreError): utils.validate_ip("") @@ -50,14 +50,14 @@ class TestUtils: ("00:00:00:FF:FF:FF", "00:00:00:ff:ff:ff"), ], ) - def test_validate_mac(self, data, expected): + def test_validate_mac(self, data: str, expected: str): value = utils.validate_mac(data) assert value == expected @pytest.mark.parametrize( "data", ["AAA:AA:AA:FF:FF:FF", "AA:AA:AA:FF:FF", "AA/AA/AA/FF/FF/FF"] ) - def test_validate_mac_exception(self, data): + def test_validate_mac_exception(self, data: str): with pytest.raises(CoreError): utils.validate_mac(data) diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index bb5a6bf9..70117fb8 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -1,9 +1,11 @@ +from tempfile import TemporaryFile from xml.etree import ElementTree import pytest -from core.emulator.emudata import LinkOptions, NodeOptions +from core.emulator.emudata import IpPrefixes, LinkOptions, NodeOptions from core.emulator.enumerations import EventTypes +from core.emulator.session import Session from core.errors import CoreError from core.location.mobility import BasicRangeModel from core.nodes.base import CoreNode @@ -12,7 +14,7 @@ from core.services.utility import SshService class TestXml: - def test_xml_hooks(self, session, tmpdir): + def test_xml_hooks(self, session: Session, tmpdir: TemporaryFile): """ Test save/load hooks in xml. @@ -52,7 +54,9 @@ class TestXml: assert file_name == runtime_hook[0] assert data == runtime_hook[1] - def test_xml_ptp(self, session, tmpdir, ip_prefixes): + def test_xml_ptp( + self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes + ): """ Test xml client methods for a ptp network. @@ -104,7 +108,9 @@ class TestXml: assert session.get_node(n1_id, CoreNode) assert session.get_node(n2_id, CoreNode) - def test_xml_ptp_services(self, session, tmpdir, ip_prefixes): + def test_xml_ptp_services( + self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes + ): """ Test xml client methods for a ptp neetwork. @@ -169,7 +175,9 @@ class TestXml: assert session.get_node(n2_id, CoreNode) assert service.config_data.get(service_file) == file_data - def test_xml_mobility(self, session, tmpdir, ip_prefixes): + def test_xml_mobility( + self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes + ): """ Test xml client methods for mobility. @@ -230,7 +238,7 @@ class TestXml: assert session.get_node(wlan_id, WlanNode) assert value == "1" - def test_network_to_network(self, session, tmpdir): + def test_network_to_network(self, session: Session, tmpdir: TemporaryFile): """ Test xml generation when dealing with network to network nodes. @@ -279,7 +287,9 @@ class TestXml: assert switch_two assert len(switch_one.all_link_data() + switch_two.all_link_data()) == 1 - def test_link_options(self, session, tmpdir, ip_prefixes): + def test_link_options( + self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes + ): """ Test xml client methods for a ptp network. @@ -345,7 +355,9 @@ class TestXml: assert link_options.delay == link.delay assert link_options.dup == link.dup - def test_link_options_ptp(self, session, tmpdir, ip_prefixes): + def test_link_options_ptp( + self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes + ): """ Test xml client methods for a ptp network. @@ -412,7 +424,9 @@ class TestXml: assert link_options.delay == link.delay assert link_options.dup == link.dup - def test_link_options_bidirectional(self, session, tmpdir, ip_prefixes): + def test_link_options_bidirectional( + self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes + ): """ Test xml client methods for a ptp network. From 2e7802524991750f446f464cc9905a1108b7442e Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 25 May 2020 00:16:58 -0700 Subject: [PATCH 087/929] started type hinting on class variables for nodes/base --- daemon/core/nodes/base.py | 77 +++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index e1267530..662815ef 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -6,6 +6,7 @@ import logging import os import shutil import threading +from threading import RLock from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type import netaddr @@ -16,7 +17,7 @@ from core.constants import MOUNT_BIN, VNODED_BIN from core.emulator.data import LinkData, NodeData from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes from core.errors import CoreCommandError, CoreError -from core.nodes import client +from core.nodes.client import VnodeClient from core.nodes.interface import CoreInterface, TunTap, Veth from core.nodes.netclient import LinuxNetClient, get_net_client @@ -24,7 +25,9 @@ if TYPE_CHECKING: from core.emulator.distributed import DistributedServer from core.emulator.session import Session from core.configservice.base import ConfigService + from core.services.coreservices import CoreService + CoreServices = List[CoreService] ConfigServiceType = Type[ConfigService] _DEFAULT_MTU = 1500 @@ -35,7 +38,7 @@ class NodeBase: Base class for CORE nodes (nodes and networks) """ - apitype = None + apitype: Optional[NodeTypes] = None # TODO: appears start has no usage, verify and remove def __init__( @@ -57,25 +60,25 @@ class NodeBase: will run on, default is None for localhost """ - self.session = session + self.session: "Session" = session if _id is None: _id = session.get_node_id() - self.id = _id + self.id: int = _id if name is None: name = f"o{self.id}" - self.name = name - self.server = server - self.type = None - self.services = None - self._netif = {} - self.ifindex = 0 - self.canvas = None - self.icon = None - self.opaque = None - self.position = Position() - self.up = False + self.name: str = name + self.server: "DistributedServer" = server + self.type: Optional[str] = None + self.services: CoreServices = [] + self._netif: Dict[int, CoreInterface] = {} + self.ifindex: int = 0 + self.canvas: Optional[int] = None + self.icon: Optional[str] = None + self.opaque: Optional[str] = None + self.position: Position = Position() + self.up: bool = False use_ovs = session.options.get_config("ovs") == "True" - self.net_client = get_net_client(use_ovs, self.host_cmd) + self.net_client: LinuxNetClient = get_net_client(use_ovs, self.host_cmd) def startup(self) -> None: """ @@ -207,9 +210,7 @@ class NodeBase: server = None if self.server is not None: server = self.server.name - services = None - if self.services is not None: - services = [service.name for service in self.services] + services = [service.name for service in self.services] return NodeData( message_type=message_type, id=self.id, @@ -266,10 +267,9 @@ class CoreNodeBase(NodeBase): will run on, default is None for localhost """ super().__init__(session, _id, name, start, server) - self.services = [] - self.config_services = {} - self.nodedir = None - self.tmpnodedir = False + self.config_services: Dict[str, "ConfigService"] = {} + self.nodedir: Optional[str] = None + self.tmpnodedir: bool = False def add_config_service(self, service_class: "ConfigServiceType") -> None: """ @@ -298,7 +298,7 @@ class CoreNodeBase(NodeBase): def start_config_services(self) -> None: """ - Determins startup paths and starts configuration services, based on their + Determines startup paths and starts configuration services, based on their dependency chains. :return: nothing @@ -330,7 +330,6 @@ class CoreNodeBase(NodeBase): preserve = self.session.options.get_config("preservedir") == "1" if preserve: return - if self.tmpnodedir: self.host_cmd(f"rm -rf {self.nodedir}") @@ -503,16 +502,16 @@ class CoreNode(CoreNodeBase): will run on, default is None for localhost """ super().__init__(session, _id, name, start, server) - self.nodedir = nodedir - self.ctrlchnlname = os.path.abspath( + self.nodedir: Optional[str] = nodedir + self.ctrlchnlname: str = os.path.abspath( os.path.join(self.session.session_dir, self.name) ) - self.client = None - self.pid = None - self.lock = threading.RLock() - self._mounts = [] + self.client: Optional[VnodeClient] = None + self.pid: Optional[int] = None + self.lock: RLock = RLock() + self._mounts: List[Tuple[str, str]] = [] use_ovs = session.options.get_config("ovs") == "True" - self.node_net_client = self.create_node_net_client(use_ovs) + self.node_net_client: LinuxNetClient = self.create_node_net_client(use_ovs) if start: self.startup() @@ -567,7 +566,7 @@ class CoreNode(CoreNodeBase): logging.debug("node(%s) pid: %s", self.name, self.pid) # create vnode client - self.client = client.VnodeClient(self.name, self.ctrlchnlname) + self.client = VnodeClient(self.name, self.ctrlchnlname) # bring up the loopback interface logging.debug("bringing up loopback interface") @@ -1201,12 +1200,12 @@ class Position: :param y: y position :param z: z position """ - self.x = x - self.y = y - self.z = z - self.lon = None - self.lat = None - self.alt = None + self.x: float = x + self.y: float = y + self.z: float = z + self.lon: Optional[float] = None + self.lat: Optional[float] = None + self.alt: Optional[float] = None def set(self, x: float = None, y: float = None, z: float = None) -> bool: """ From f95a8113c9ba0601a9e14c43d4d27593e1f9b174 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 25 May 2020 11:17:28 -0700 Subject: [PATCH 088/929] added type hinting to nodes/network.py class variables --- daemon/core/emulator/session.py | 5 +- daemon/core/nodes/network.py | 105 +++++++++++++++----------------- 2 files changed, 52 insertions(+), 58 deletions(-) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index de7b1fe8..135c58dd 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -1759,10 +1759,9 @@ class Session: server_interface, ) control_net = self.create_node( - _class=CtrlNet, + CtrlNet, + prefix, _id=_id, - prefix=prefix, - assign_address=True, updown_script=updown_script, serverintf=server_interface, ) diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 5f6c635c..973346ad 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -21,7 +21,7 @@ from core.nodes.netclient import get_net_client if TYPE_CHECKING: from core.emulator.distributed import DistributedServer from core.emulator.session import Session - from core.location.mobility import WirelessModel + from core.location.mobility import WirelessModel, WayPointMobility WirelessModelType = Type[WirelessModel] @@ -36,26 +36,26 @@ class EbtablesQueue: """ # update rate is every 300ms - rate = 0.3 + rate: float = 0.3 # ebtables - atomic_file = "/tmp/pycore.ebtables.atomic" + atomic_file: str = "/tmp/pycore.ebtables.atomic" def __init__(self) -> None: """ Initialize the helper class, but don't start the update thread until a WLAN is instantiated. """ - self.doupdateloop = False - self.updatethread = None + self.doupdateloop: bool = False + self.updatethread: Optional[threading.Thread] = None # this lock protects cmds and updates lists - self.updatelock = threading.Lock() + self.updatelock: threading.Lock = threading.Lock() # list of pending ebtables commands - self.cmds = [] + self.cmds: List[str] = [] # list of WLANs requiring update - self.updates = [] + self.updates: List["CoreNetwork"] = [] # timestamps of last WLAN update; this keeps track of WLANs that are # using this queue - self.last_update_time = {} + self.last_update_time: Dict["CoreNetwork", float] = {} def startupdateloop(self, wlan: "CoreNetwork") -> None: """ @@ -65,10 +65,8 @@ class EbtablesQueue: """ with self.updatelock: self.last_update_time[wlan] = time.monotonic() - if self.doupdateloop: return - self.doupdateloop = True self.updatethread = threading.Thread(target=self.updateloop, daemon=True) self.updatethread.start() @@ -86,10 +84,8 @@ class EbtablesQueue: logging.exception( "error deleting last update time for wlan, ignored before: %s", wlan ) - if len(self.last_update_time) > 0: return - self.doupdateloop = False if self.updatethread: self.updatethread.join() @@ -233,7 +229,7 @@ class EbtablesQueue: # a global object because all WLANs share the same queue # cannot have multiple threads invoking the ebtables commnd -ebq = EbtablesQueue() +ebq: EbtablesQueue = EbtablesQueue() def ebtablescmds(call: Callable[..., str], cmds: List[str]) -> None: @@ -254,7 +250,7 @@ class CoreNetwork(CoreNetworkBase): Provides linux bridge network functionality for core nodes. """ - policy = "DROP" + policy: str = "DROP" def __init__( self, @@ -281,10 +277,10 @@ class CoreNetwork(CoreNetworkBase): name = str(self.id) if policy is not None: self.policy = policy - self.name = name + self.name: Optional[str] = name sessionid = self.session.short_session_id() - self.brname = f"b.{self.id}.{sessionid}" - self.has_ebtables_chain = False + self.brname: str = f"b.{self.id}.{sessionid}" + self.has_ebtables_chain: bool = False if start: self.startup() ebq.startupdateloop(self) @@ -633,17 +629,16 @@ class GreTapBridge(CoreNetwork): will run on, default is None for localhost """ CoreNetwork.__init__(self, session, _id, name, False, server, policy) - self.grekey = key - if self.grekey is None: - self.grekey = self.session.id ^ self.id - self.localnum = None - self.remotenum = None - self.remoteip = remoteip - self.localip = localip - self.ttl = ttl - if remoteip is None: - self.gretap = None - else: + if key is None: + key = self.session.id ^ self.id + self.grekey: int = key + self.localnum: Optional[int] = None + self.remotenum: Optional[int] = None + self.remoteip: Optional[str] = remoteip + self.localip: Optional[str] = localip + self.ttl: int = ttl + self.gretap: Optional[GreTap] = None + if remoteip is not None: self.gretap = GreTap( node=self, session=session, @@ -718,10 +713,10 @@ class CtrlNet(CoreNetwork): Control network functionality. """ - policy = "ACCEPT" + policy: str = "ACCEPT" # base control interface index - CTRLIF_IDX_BASE = 99 - DEFAULT_PREFIX_LIST = [ + CTRLIF_IDX_BASE: int = 99 + DEFAULT_PREFIX_LIST: List[str] = [ "172.16.0.0/24 172.16.1.0/24 172.16.2.0/24 172.16.3.0/24 172.16.4.0/24", "172.17.0.0/24 172.17.1.0/24 172.17.2.0/24 172.17.3.0/24 172.17.4.0/24", "172.18.0.0/24 172.18.1.0/24 172.18.2.0/24 172.18.3.0/24 172.18.4.0/24", @@ -731,15 +726,15 @@ class CtrlNet(CoreNetwork): def __init__( self, session: "Session", + prefix: str, _id: int = None, name: str = None, - prefix: str = None, hostid: int = None, start: bool = True, server: "DistributedServer" = None, assign_address: bool = True, updown_script: str = None, - serverintf: CoreInterface = None, + serverintf: str = None, ) -> None: """ Creates a CtrlNet instance. @@ -757,11 +752,11 @@ class CtrlNet(CoreNetwork): :param serverintf: server interface :return: """ - self.prefix = netaddr.IPNetwork(prefix).cidr - self.hostid = hostid - self.assign_address = assign_address - self.updown_script = updown_script - self.serverintf = serverintf + self.prefix: netaddr.IPNetwork = netaddr.IPNetwork(prefix).cidr + self.hostid: Optional[int] = hostid + self.assign_address: bool = assign_address + self.updown_script: Optional[str] = updown_script + self.serverintf: Optional[str] = serverintf super().__init__(session, _id, name, start, server) def add_addresses(self, index: int) -> None: @@ -858,7 +853,7 @@ class PtpNet(CoreNetwork): Peer to peer network node. """ - policy = "ACCEPT" + policy: str = "ACCEPT" def attach(self, netif: CoreInterface) -> None: """ @@ -988,9 +983,9 @@ class SwitchNode(CoreNetwork): Provides switch functionality within a core node. """ - apitype = NodeTypes.SWITCH - policy = "ACCEPT" - type = "lanswitch" + apitype: NodeTypes = NodeTypes.SWITCH + policy: str = "ACCEPT" + type: str = "lanswitch" class HubNode(CoreNetwork): @@ -999,9 +994,9 @@ class HubNode(CoreNetwork): ports by turning off MAC address learning. """ - apitype = NodeTypes.HUB - policy = "ACCEPT" - type = "hub" + apitype: NodeTypes = NodeTypes.HUB + policy: str = "ACCEPT" + type: str = "hub" def startup(self) -> None: """ @@ -1018,10 +1013,10 @@ class WlanNode(CoreNetwork): Provides wireless lan functionality within a core node. """ - apitype = NodeTypes.WIRELESS_LAN - linktype = LinkTypes.WIRED - policy = "DROP" - type = "wlan" + apitype: NodeTypes = NodeTypes.WIRELESS_LAN + linktype: LinkTypes = LinkTypes.WIRED + policy: str = "DROP" + type: str = "wlan" def __init__( self, @@ -1045,8 +1040,8 @@ class WlanNode(CoreNetwork): """ super().__init__(session, _id, name, start, server, policy) # wireless and mobility models (BasicRangeModel, Ns2WaypointMobility) - self.model = None - self.mobility = None + self.model: Optional[WirelessModel] = None + self.mobility: Optional[WayPointMobility] = None def startup(self) -> None: """ @@ -1122,6 +1117,6 @@ class TunnelNode(GreTapBridge): Provides tunnel functionality in a core node. """ - apitype = NodeTypes.TUNNEL - policy = "ACCEPT" - type = "tunnel" + apitype: NodeTypes = NodeTypes.TUNNEL + policy: str = "ACCEPT" + type: str = "tunnel" From 8fed201fd8641202b85082c9645d6ed7d1574d46 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 25 May 2020 11:33:59 -0700 Subject: [PATCH 089/929] added type hints to class variables in nodes/physical.py --- daemon/core/nodes/physical.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index b6ae8e8d..fac361e7 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -5,7 +5,7 @@ PhysicalNode class for including real systems in the emulated network. import logging import os import threading -from typing import IO, TYPE_CHECKING, List, Optional +from typing import IO, TYPE_CHECKING, List, Optional, Tuple from core import utils from core.constants import MOUNT_BIN, UMOUNT_BIN @@ -23,7 +23,7 @@ if TYPE_CHECKING: class PhysicalNode(CoreNodeBase): def __init__( self, - session, + session: "Session", _id: int = None, name: str = None, nodedir: str = None, @@ -33,10 +33,10 @@ class PhysicalNode(CoreNodeBase): super().__init__(session, _id, name, start, server) if not self.server: raise CoreError("physical nodes must be assigned to a remote server") - self.nodedir = nodedir - self.up = start - self.lock = threading.RLock() - self._mounts = [] + self.nodedir: Optional[str] = nodedir + self.up: bool = start + self.lock: threading.RLock = threading.RLock() + self._mounts: List[Tuple[str, str]] = [] if start: self.startup() @@ -112,7 +112,7 @@ class PhysicalNode(CoreNodeBase): logging.exception("trying to delete unknown address: %s", addr) if self.up: - self.net_client.delete_address(interface.name, str(addr)) + self.net_client.delete_address(interface.name, addr) def adoptnetif( self, netif: CoreInterface, ifindex: int, hwaddr: str, addrlist: List[str] @@ -256,8 +256,8 @@ class Rj45Node(CoreNodeBase, CoreInterface): network. """ - apitype = NodeTypes.RJ45 - type = "rj45" + apitype: NodeTypes = NodeTypes.RJ45 + type: str = "rj45" def __init__( self, @@ -281,11 +281,11 @@ class Rj45Node(CoreNodeBase, CoreInterface): """ CoreNodeBase.__init__(self, session, _id, name, start, server) CoreInterface.__init__(self, session, self, name, name, mtu, server) - self.lock = threading.RLock() - self.ifindex = None - self.transport_type = "raw" - self.old_up = False - self.old_addrs = [] + self.lock: threading.RLock = threading.RLock() + self.ifindex: Optional[int] = None + self.transport_type: str = "raw" + self.old_up: bool = False + self.old_addrs: List[str] = [] if start: self.startup() From 7e4ef0b280052adea9f09ac0f9833dccd364d0fa Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 25 May 2020 23:18:20 -0700 Subject: [PATCH 090/929] changes to make rj45 maintain the interface information, instead of trying to be 2 classes at once --- daemon/core/emulator/session.py | 2 +- daemon/core/nodes/netclient.py | 14 ++++- daemon/core/nodes/physical.py | 101 ++++++++++---------------------- daemon/core/services/frr.py | 2 +- daemon/core/services/quagga.py | 2 +- 5 files changed, 47 insertions(+), 74 deletions(-) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 135c58dd..9193196c 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -353,7 +353,7 @@ class Session: node_two.name, ) start = self.state.should_start() - net_one = self.create_node(_class=PtpNet, start=start) + net_one = self.create_node(PtpNet, start=start) # node to network if node_one and net_one: diff --git a/daemon/core/nodes/netclient.py b/daemon/core/nodes/netclient.py index 5062dead..51ac075e 100644 --- a/daemon/core/nodes/netclient.py +++ b/daemon/core/nodes/netclient.py @@ -71,13 +71,22 @@ class LinuxNetClient: def device_show(self, device: str) -> str: """ - Show information for a device. + Show link information for a device. :param device: device to get information for :return: device information """ return self.run(f"{IP_BIN} link show {device}") + def address_show(self, device: str) -> str: + """ + Show address information for a device. + + :param device: device name + :return: address information + """ + return self.run(f"{IP_BIN} address show {device}") + def get_mac(self, device: str) -> str: """ Retrieve MAC address for a given device. @@ -114,7 +123,8 @@ class LinuxNetClient: :return: nothing """ self.run( - f"[ -e /sys/class/net/{device} ] && {IP_BIN} -6 address flush dev {device} || true", + f"[ -e /sys/class/net/{device} ] && " + f"{IP_BIN} address flush dev {device} || true", shell=True, ) diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index fac361e7..2fc743fa 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -250,7 +250,7 @@ class PhysicalNode(CoreNodeBase): return self.host_cmd(args, wait=wait) -class Rj45Node(CoreNodeBase, CoreInterface): +class Rj45Node(CoreNodeBase): """ RJ45Node is a physical interface on the host linked to the emulated network. @@ -279,13 +279,13 @@ class Rj45Node(CoreNodeBase, CoreInterface): :param server: remote server node will run on, default is None for localhost """ - CoreNodeBase.__init__(self, session, _id, name, start, server) - CoreInterface.__init__(self, session, self, name, name, mtu, server) + super().__init__(session, _id, name, start, server) + self.interface = CoreInterface(session, self, name, name, mtu, server) + self.interface.transport_type = "raw" self.lock: threading.RLock = threading.RLock() self.ifindex: Optional[int] = None - self.transport_type: str = "raw" self.old_up: bool = False - self.old_addrs: List[str] = [] + self.old_addrs: List[Tuple[str, Optional[str]]] = [] if start: self.startup() @@ -298,7 +298,7 @@ class Rj45Node(CoreNodeBase, CoreInterface): """ # interface will also be marked up during net.attach() self.savestate() - self.net_client.device_up(self.localname) + self.net_client.device_up(self.interface.localname) self.up = True def shutdown(self) -> None: @@ -310,38 +310,16 @@ class Rj45Node(CoreNodeBase, CoreInterface): """ if not self.up: return - + localname = self.interface.localname + self.net_client.device_down(localname) + self.net_client.device_flush(localname) try: - self.net_client.device_down(self.localname) - self.net_client.device_flush(self.localname) - self.net_client.delete_tc(self.localname) + self.net_client.delete_tc(localname) except CoreCommandError: - logging.exception("error shutting down") - + pass self.up = False self.restorestate() - # TODO: issue in that both classes inherited from provide the same method with - # different signatures - def attachnet(self, net: CoreNetworkBase) -> None: - """ - Attach a network. - - :param net: network to attach - :return: nothing - """ - CoreInterface.attachnet(self, net) - - # TODO: issue in that both classes inherited from provide the same method with - # different signatures - def detachnet(self) -> None: - """ - Detach a network. - - :return: nothing - """ - CoreInterface.detachnet(self) - def newnetif( self, net: CoreNetworkBase = None, @@ -366,22 +344,15 @@ class Rj45Node(CoreNodeBase, CoreInterface): with self.lock: if ifindex is None: ifindex = 0 - - if self.net is not None: + if self.interface.net is not None: raise ValueError("RJ45 nodes support at most 1 network interface") - - self._netif[ifindex] = self - # PyCoreNetIf.node is self - self.node = self + self._netif[ifindex] = self.interface self.ifindex = ifindex - if net is not None: - self.attachnet(net) - + self.interface.attachnet(net) if addrlist: for addr in utils.make_tuple(addrlist): self.addaddr(addr) - return ifindex def delnetif(self, ifindex: int) -> None: @@ -393,9 +364,7 @@ class Rj45Node(CoreNodeBase, CoreInterface): """ if ifindex is None: ifindex = 0 - self._netif.pop(ifindex) - if ifindex == self.ifindex: self.shutdown() else: @@ -413,15 +382,12 @@ class Rj45Node(CoreNodeBase, CoreInterface): :param net: network to retrieve :return: a network interface """ - if net is not None and net == self.net: - return self - + if net is not None and net == self.interface.net: + return self.interface if ifindex is None: ifindex = 0 - if ifindex == self.ifindex: - return self - + return self.interface return None def getifindex(self, netif: CoreInterface) -> Optional[int]: @@ -432,7 +398,7 @@ class Rj45Node(CoreNodeBase, CoreInterface): index for :return: interface index, None otherwise """ - if netif != self: + if netif != self.interface: return None return self.ifindex @@ -447,7 +413,7 @@ class Rj45Node(CoreNodeBase, CoreInterface): addr = utils.validate_ip(addr) if self.up: self.net_client.create_address(self.name, addr) - CoreInterface.addaddr(self, addr) + self.interface.addaddr(addr) def deladdr(self, addr: str) -> None: """ @@ -458,8 +424,8 @@ class Rj45Node(CoreNodeBase, CoreInterface): :raises CoreCommandError: when there is a command exception """ if self.up: - self.net_client.delete_address(self.name, str(addr)) - CoreInterface.deladdr(self, addr) + self.net_client.delete_address(self.name, addr) + self.interface.deladdr(addr) def savestate(self) -> None: """ @@ -470,14 +436,14 @@ class Rj45Node(CoreNodeBase, CoreInterface): :raises CoreCommandError: when there is a command exception """ self.old_up = False - self.old_addrs = [] - output = self.net_client.device_show(self.localname) + self.old_addrs: List[Tuple[str, Optional[str]]] = [] + localname = self.interface.localname + output = self.net_client.address_show(localname) for line in output.split("\n"): items = line.split() if len(items) < 2: continue - - if items[1] == f"{self.localname}:": + if items[1] == f"{localname}:": flags = items[2][1:-1].split(",") if "UP" in flags: self.old_up = True @@ -487,6 +453,7 @@ class Rj45Node(CoreNodeBase, CoreInterface): if items[1][:4] == "fe80": continue self.old_addrs.append((items[1], None)) + logging.info("saved rj45 state: addrs(%s) up(%s)", self.old_addrs, self.old_up) def restorestate(self) -> None: """ @@ -495,16 +462,12 @@ class Rj45Node(CoreNodeBase, CoreInterface): :return: nothing :raises CoreCommandError: when there is a command exception """ + localname = self.interface.localname + logging.info("restoring rj45 state: %s", localname) for addr in self.old_addrs: - if addr[1] is None: - self.net_client.create_address(self.localname, addr[0]) - else: - self.net_client.create_address( - self.localname, addr[0], broadcast=addr[1] - ) - + self.net_client.create_address(localname, addr[0], addr[1]) if self.old_up: - self.net_client.device_up(self.localname) + self.net_client.device_up(localname) def setposition(self, x: float = None, y: float = None, z: float = None) -> None: """ @@ -515,8 +478,8 @@ class Rj45Node(CoreNodeBase, CoreInterface): :param z: z position :return: True if position changed, False otherwise """ - CoreNodeBase.setposition(self, x, y, z) - CoreInterface.setposition(self) + super().setposition(x, y, z) + self.interface.setposition() def termcmdstring(self, sh: str) -> str: """ diff --git a/daemon/core/services/frr.py b/daemon/core/services/frr.py index 799c03a5..9a344339 100644 --- a/daemon/core/services/frr.py +++ b/daemon/core/services/frr.py @@ -354,7 +354,7 @@ class FrrService(CoreService): for peerifc in ifc.net.netifs(): if peerifc == ifc: continue - if isinstance(peerifc, Rj45Node): + if isinstance(peerifc.node, Rj45Node): return True return False diff --git a/daemon/core/services/quagga.py b/daemon/core/services/quagga.py index 331e23da..a62cbc5c 100644 --- a/daemon/core/services/quagga.py +++ b/daemon/core/services/quagga.py @@ -272,7 +272,7 @@ class QuaggaService(CoreService): for peerifc in ifc.net.netifs(): if peerifc == ifc: continue - if isinstance(peerifc, Rj45Node): + if isinstance(peerifc.node, Rj45Node): return True return False From 4ab415e37d9adac06e57d7a3b534dc5b17b6d469 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 28 May 2020 16:12:11 -0700 Subject: [PATCH 091/929] grpc: updated node events to contain geo data when present --- daemon/core/api/grpc/events.py | 2 ++ daemon/tests/test_grpc.py | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/daemon/core/api/grpc/events.py b/daemon/core/api/grpc/events.py index a53ad971..837860e3 100644 --- a/daemon/core/api/grpc/events.py +++ b/daemon/core/api/grpc/events.py @@ -23,11 +23,13 @@ def handle_node_event(event: NodeData) -> core_pb2.NodeEvent: :return: node event that contains node id, name, model, position, and services """ position = core_pb2.Position(x=event.x_position, y=event.y_position) + geo = core_pb2.Geo(lat=event.latitude, lon=event.longitude, alt=event.altitude) node_proto = core_pb2.Node( id=event.id, name=event.name, model=event.model, position=position, + geo=geo, services=event.services, ) return core_pb2.NodeEvent(node=node_proto, source=event.source) diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 5e55f346..47cfe744 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -1002,11 +1002,18 @@ class TestGrpc: client = CoreGrpcClient() session = grpc_server.coreemu.create_session() node = session.add_node(CoreNode) + node.position.lat = 10.0 + node.position.lon = 20.0 + node.position.alt = 5.0 queue = Queue() def handle_event(event_data): assert event_data.session_id == session.id assert event_data.HasField("node_event") + event_node = event_data.node_event.node + assert event_node.geo.lat == node.position.lat + assert event_node.geo.lon == node.position.lon + assert event_node.geo.alt == node.position.alt queue.put(event_data) # then From 183ffda570331a6b1bd55c04edf49dc889700e4d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 29 May 2020 11:48:00 -0700 Subject: [PATCH 092/929] daemon: changes to support providing emane wireless links in all_link_data, which makes it accessible over grpc --- daemon/core/emane/emanemanager.py | 35 ++++++++++++++++++++++++++++--- daemon/core/emane/linkmonitor.py | 25 +++++----------------- daemon/core/emane/nodes.py | 32 +++++++++++++++++++++++++++- 3 files changed, 68 insertions(+), 24 deletions(-) diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 16680e0e..d5c787f5 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -6,7 +6,7 @@ import logging import os import threading from collections import OrderedDict -from typing import TYPE_CHECKING, Dict, List, Set, Tuple, Type +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type from core import utils from core.config import ConfigGroup, Configuration, ModelManager @@ -19,7 +19,13 @@ from core.emane.linkmonitor import EmaneLinkMonitor from core.emane.nodes import EmaneNet from core.emane.rfpipe import EmaneRfPipeModel from core.emane.tdma import EmaneTdmaModel -from core.emulator.enumerations import ConfigDataTypes, RegisterTlvs +from core.emulator.data import LinkData +from core.emulator.enumerations import ( + ConfigDataTypes, + LinkTypes, + MessageFlags, + RegisterTlvs, +) from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNode, NodeBase from core.nodes.interface import CoreInterface @@ -458,7 +464,7 @@ class EmaneManager(ModelManager): model_class = self.models[model_name] emane_node.setmodel(model_class, config) - def nemlookup(self, nemid) -> Tuple[EmaneNet, CoreInterface]: + def nemlookup(self, nemid) -> Tuple[Optional[EmaneNet], Optional[CoreInterface]]: """ Look for the given numerical NEM ID and return the first matching EMANE network and NEM interface. @@ -476,6 +482,29 @@ class EmaneManager(ModelManager): return emane_node, netif + def get_nem_link( + self, nem1: int, nem2: int, flags: MessageFlags = MessageFlags.NONE + ) -> Optional[LinkData]: + emane1, netif = self.nemlookup(nem1) + if not emane1 or not netif: + logging.error("invalid nem: %s", nem1) + return None + node1 = netif.node + emane2, netif = self.nemlookup(nem2) + if not emane2 or not netif: + logging.error("invalid nem: %s", nem2) + return None + node2 = netif.node + color = self.session.get_link_color(emane1.id) + return LinkData( + message_type=flags, + node1_id=node1.id, + node2_id=node2.id, + network_id=emane1.id, + link_type=LinkTypes.WIRELESS, + color=color, + ) + def numnems(self) -> int: """ Return the number of NEMs emulated locally. diff --git a/daemon/core/emane/linkmonitor.py b/daemon/core/emane/linkmonitor.py index 7eb903fd..861c108c 100644 --- a/daemon/core/emane/linkmonitor.py +++ b/daemon/core/emane/linkmonitor.py @@ -285,26 +285,11 @@ class EmaneLinkMonitor: def send_link(self, message_type: MessageFlags, link_id: Tuple[int, int]) -> None: nem_one, nem_two = link_id - emane_one, netif = self.emane_manager.nemlookup(nem_one) - if not emane_one or not netif: - logging.error("invalid nem: %s", nem_one) - return - node_one = netif.node - emane_two, netif = self.emane_manager.nemlookup(nem_two) - if not emane_two or not netif: - logging.error("invalid nem: %s", nem_two) - return - node_two = netif.node - logging.debug( - "%s emane link from %s(%s) to %s(%s)", - message_type.name, - node_one.name, - nem_one, - node_two.name, - nem_two, - ) - label = self.get_link_label(link_id) - self.send_message(message_type, label, node_one.id, node_two.id, emane_one.id) + link = self.emane_manager.get_nem_link(nem_one, nem_two, message_type) + if link: + label = self.get_link_label(link_id) + link.label = label + self.emane_manager.session.broadcast_link(link) def send_message( self, diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index d5f243cb..3032cda7 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -6,8 +6,9 @@ share the same MAC+PHY model. import logging from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type +from core.emulator.data import LinkData from core.emulator.distributed import DistributedServer -from core.emulator.enumerations import LinkTypes, NodeTypes, RegisterTlvs +from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes, RegisterTlvs from core.nodes.base import CoreNetworkBase from core.nodes.interface import CoreInterface @@ -236,3 +237,32 @@ class EmaneNet(CoreNetworkBase): nemid, lon, lat, alt = position event.append(nemid, latitude=lat, longitude=lon, altitude=alt) self.session.emane.service.publish(0, event) + + def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: + logging.info("gathering emane links: %s", self.id) + links = super().all_link_data(flags) + # gather current emane links + nem_ids = set(self.nemidmap.values()) + logging.info("known nems: %s", nem_ids) + emane_manager = self.session.emane + emane_links = emane_manager.link_monitor.links + considered = set() + for link_key in emane_links: + considered_key = tuple(sorted(link_key)) + if considered_key in considered: + continue + considered.add(considered_key) + logging.info("considering emane link: %s", considered_key) + nem1, nem2 = considered_key + # ignore links not related to this node + if nem1 not in nem_ids and nem2 not in nem_ids: + logging.info("ignore emane link not within network: %s", (nem1, nem2)) + continue + # ignore incomplete links + if (nem2, nem1) not in emane_links: + logging.info("ignore emane link not complete: %s", (nem1, nem2)) + continue + link = emane_manager.get_nem_link(nem1, nem2) + if link: + links.append(link) + return links From b88df84d6210886dfc4e0c98dc08cd448133ba28 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 29 May 2020 14:42:38 -0700 Subject: [PATCH 093/929] removed logs from emane network all_link_data --- daemon/core/emane/nodes.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index 3032cda7..d8a58806 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -239,11 +239,9 @@ class EmaneNet(CoreNetworkBase): self.session.emane.service.publish(0, event) def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: - logging.info("gathering emane links: %s", self.id) links = super().all_link_data(flags) # gather current emane links nem_ids = set(self.nemidmap.values()) - logging.info("known nems: %s", nem_ids) emane_manager = self.session.emane emane_links = emane_manager.link_monitor.links considered = set() @@ -252,15 +250,12 @@ class EmaneNet(CoreNetworkBase): if considered_key in considered: continue considered.add(considered_key) - logging.info("considering emane link: %s", considered_key) nem1, nem2 = considered_key # ignore links not related to this node if nem1 not in nem_ids and nem2 not in nem_ids: - logging.info("ignore emane link not within network: %s", (nem1, nem2)) continue # ignore incomplete links if (nem2, nem1) not in emane_links: - logging.info("ignore emane link not complete: %s", (nem1, nem2)) continue link = emane_manager.get_nem_link(nem1, nem2) if link: From 10fd844397a827b2f0ce7ca618956d0f5e6bad05 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 29 May 2020 21:41:58 -0700 Subject: [PATCH 094/929] further type hinting for tests --- daemon/tests/emane/test_emane.py | 11 ++++++++--- daemon/tests/test_core.py | 7 +++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/daemon/tests/emane/test_emane.py b/daemon/tests/emane/test_emane.py index b3499337..328aa94b 100644 --- a/daemon/tests/emane/test_emane.py +++ b/daemon/tests/emane/test_emane.py @@ -2,17 +2,20 @@ Unit tests for testing CORE EMANE networks. """ import os +from tempfile import TemporaryFile from xml.etree import ElementTree import pytest from core.emane.bypass import EmaneBypassModel from core.emane.commeffect import EmaneCommEffectModel +from core.emane.emanemodel import EmaneModel from core.emane.ieee80211abg import EmaneIeee80211abgModel from core.emane.nodes import EmaneNet from core.emane.rfpipe import EmaneRfPipeModel from core.emane.tdma import EmaneTdmaModel -from core.emulator.emudata import NodeOptions +from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.session import Session from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNode @@ -38,7 +41,7 @@ def ping(from_node, to_node, ip_prefixes, count=3): class TestEmane: @pytest.mark.parametrize("model", _EMANE_MODELS) - def test_models(self, session, model, ip_prefixes): + def test_models(self, session: Session, model: EmaneModel, ip_prefixes: IpPrefixes): """ Test emane models within a basic network. @@ -81,7 +84,9 @@ class TestEmane: status = ping(node_one, node_two, ip_prefixes, count=5) assert not status - def test_xml_emane(self, session, tmpdir, ip_prefixes): + def test_xml_emane( + self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes + ): """ Test xml client methods for emane. diff --git a/daemon/tests/test_core.py b/daemon/tests/test_core.py index b9e0c1df..1c40393e 100644 --- a/daemon/tests/test_core.py +++ b/daemon/tests/test_core.py @@ -4,6 +4,7 @@ Unit tests for testing basic CORE networks. import os import threading +from typing import Type import pytest @@ -12,7 +13,7 @@ from core.emulator.enumerations import MessageFlags from core.emulator.session import Session from core.errors import CoreCommandError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility -from core.nodes.base import CoreNode +from core.nodes.base import CoreNode, NodeBase from core.nodes.network import HubNode, PtpNet, SwitchNode, WlanNode _PATH = os.path.abspath(os.path.dirname(__file__)) @@ -32,7 +33,9 @@ def ping(from_node, to_node, ip_prefixes): class TestCore: @pytest.mark.parametrize("net_type", _WIRED) - def test_wired_ping(self, session, net_type, ip_prefixes): + def test_wired_ping( + self, session: Session, net_type: Type[NodeBase], ip_prefixes: IpPrefixes + ): """ Test ptp node network. From 8ad3f7961ab3331797dbeba6d35e8336e2e5847b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 29 May 2020 22:07:50 -0700 Subject: [PATCH 095/929] renamed netclient create_interface to set_interface_master to better describe its purpose --- daemon/core/emulator/distributed.py | 4 ++-- daemon/core/nodes/netclient.py | 4 ++-- daemon/core/nodes/network.py | 7 +++---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/daemon/core/emulator/distributed.py b/daemon/core/emulator/distributed.py index 4e7fcdde..5f188cb0 100644 --- a/daemon/core/emulator/distributed.py +++ b/daemon/core/emulator/distributed.py @@ -212,7 +212,7 @@ class DistributedController: "local tunnel node(%s) to remote(%s) key(%s)", node.name, host, key ) local_tap = GreTap(session=self.session, remoteip=host, key=key) - local_tap.net_client.create_interface(node.brname, local_tap.localname) + local_tap.net_client.set_interface_master(node.brname, local_tap.localname) # server to local logging.info( @@ -221,7 +221,7 @@ class DistributedController: remote_tap = GreTap( session=self.session, remoteip=self.address, key=key, server=server ) - remote_tap.net_client.create_interface(node.brname, remote_tap.localname) + remote_tap.net_client.set_interface_master(node.brname, remote_tap.localname) # save tunnels for shutdown tunnel = (local_tap, remote_tap) diff --git a/daemon/core/nodes/netclient.py b/daemon/core/nodes/netclient.py index 51ac075e..091938de 100644 --- a/daemon/core/nodes/netclient.py +++ b/daemon/core/nodes/netclient.py @@ -251,7 +251,7 @@ class LinuxNetClient: self.device_down(name) self.run(f"{IP_BIN} link delete {name} type bridge") - def create_interface(self, bridge_name: str, interface_name: str) -> None: + def set_interface_master(self, bridge_name: str, interface_name: str) -> None: """ Assign interface master to a Linux bridge. @@ -330,7 +330,7 @@ class OvsNetClient(LinuxNetClient): self.device_down(name) self.run(f"{OVS_BIN} del-br {name}") - def create_interface(self, bridge_name: str, interface_name: str) -> None: + def set_interface_master(self, bridge_name: str, interface_name: str) -> None: """ Create an interface associated with a network bridge. diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 973346ad..f0b2fefa 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -360,7 +360,7 @@ class CoreNetwork(CoreNetworkBase): :return: nothing """ if self.up: - netif.net_client.create_interface(self.brname, netif.localname) + netif.net_client.set_interface_master(self.brname, netif.localname) super().attach(netif) def detach(self, netif: CoreInterface) -> None: @@ -557,8 +557,7 @@ class CoreNetwork(CoreNetworkBase): netif = Veth(self.session, None, name, localname, start=self.up) self.attach(netif) if net.up and net.brname: - # this is similar to net.attach() but uses netif.name instead of localname - netif.net_client.create_interface(net.brname, netif.name) + netif.net_client.set_interface_master(net.brname, netif.name) i = net.newifindex() net._netif[i] = netif with net._linked_lock: @@ -807,7 +806,7 @@ class CtrlNet(CoreNetwork): self.host_cmd(f"{self.updown_script} {self.brname} startup") if self.serverintf: - self.net_client.create_interface(self.brname, self.serverintf) + self.net_client.set_interface_master(self.brname, self.serverintf) def shutdown(self) -> None: """ From b034ba6cc3e9b43096bfaa37c50c565280d7b13a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 29 May 2020 22:48:00 -0700 Subject: [PATCH 096/929] turned transport type usages of raw and virtual across the board to an enumerated type --- daemon/core/emane/commeffect.py | 7 +++-- daemon/core/emane/emanemanager.py | 5 +-- daemon/core/emane/emanemodel.py | 8 ++--- daemon/core/emane/nodes.py | 10 ++++-- daemon/core/emulator/enumerations.py | 5 +++ daemon/core/nodes/interface.py | 46 +++++++++++++--------------- daemon/core/nodes/physical.py | 4 +-- daemon/core/xml/emanexml.py | 25 +++++++-------- 8 files changed, 61 insertions(+), 49 deletions(-) diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index f98f2454..99fdb9b1 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -11,6 +11,7 @@ from lxml import etree from core.config import ConfigGroup, Configuration from core.emane import emanemanifest, emanemodel from core.emane.nodes import EmaneNet +from core.emulator.enumerations import TransportType from core.nodes.interface import CoreInterface from core.xml import emanexml @@ -79,9 +80,9 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): # create and write nem document nem_element = etree.Element("nem", name=f"{self.name} NEM", type="unstructured") - transport_type = "virtual" - if interface and interface.transport_type == "raw": - transport_type = "raw" + transport_type = TransportType.VIRTUAL + if interface and interface.transport_type == TransportType.RAW: + transport_type = TransportType.RAW transport_file = emanexml.transport_file_name(self.id, transport_type) etree.SubElement(nem_element, "transport", definition=transport_file) diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index d5c787f5..438fde00 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -30,6 +30,7 @@ from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNode, NodeBase from core.nodes.interface import CoreInterface from core.nodes.network import CtrlNet +from core.nodes.physical import Rj45Node from core.xml import emanexml if TYPE_CHECKING: @@ -596,7 +597,7 @@ class EmaneManager(ModelManager): run_emane_on_host = False for node in self.getnodes(): - if hasattr(node, "transport_type") and node.transport_type == "raw": + if isinstance(node, Rj45Node): run_emane_on_host = True continue path = self.session.session_dir @@ -655,7 +656,7 @@ class EmaneManager(ModelManager): kill_transortd = "killall -q emanetransportd" stop_emane_on_host = False for node in self.getnodes(): - if hasattr(node, "transport_type") and node.transport_type == "raw": + if isinstance(node, Rj45Node): stop_emane_on_host = True continue diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 4104d3d5..3a21643b 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -8,7 +8,7 @@ from typing import Dict, List from core.config import ConfigGroup, Configuration from core.emane import emanemanifest from core.emane.nodes import EmaneNet -from core.emulator.enumerations import ConfigDataTypes +from core.emulator.enumerations import ConfigDataTypes, TransportType from core.errors import CoreError from core.location.mobility import WirelessModel from core.nodes.interface import CoreInterface @@ -111,9 +111,9 @@ class EmaneModel(WirelessModel): server = interface.node.server # check if this is external - transport_type = "virtual" - if interface and interface.transport_type == "raw": - transport_type = "raw" + transport_type = TransportType.VIRTUAL + if interface and interface.transport_type == TransportType.RAW: + transport_type = TransportType.RAW transport_name = emanexml.transport_file_name(self.id, transport_type) # create nem xml file diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index d8a58806..be398329 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -8,7 +8,13 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type from core.emulator.data import LinkData from core.emulator.distributed import DistributedServer -from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes, RegisterTlvs +from core.emulator.enumerations import ( + LinkTypes, + MessageFlags, + NodeTypes, + RegisterTlvs, + TransportType, +) from core.nodes.base import CoreNetworkBase from core.nodes.interface import CoreInterface @@ -173,7 +179,7 @@ class EmaneNet(CoreNetworkBase): emanetransportd terminates. """ for netif in self.netifs(): - if "virtual" in netif.transport_type.lower(): + if netif.transport_type == TransportType.VIRTUAL: netif.shutdown() netif.poshook = None diff --git a/daemon/core/emulator/enumerations.py b/daemon/core/emulator/enumerations.py index 2c6e14db..f210c992 100644 --- a/daemon/core/emulator/enumerations.py +++ b/daemon/core/emulator/enumerations.py @@ -117,3 +117,8 @@ class ExceptionLevels(Enum): ERROR = 2 WARNING = 3 NOTICE = 4 + + +class TransportType(Enum): + RAW = "raw" + VIRTUAL = "virtual" diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index 97b494b7..16c242e9 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -4,12 +4,12 @@ virtual ethernet classes that implement the interfaces available under Linux. import logging import time -from typing import TYPE_CHECKING, Callable, Dict, List, Tuple +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple from core import utils -from core.emulator.enumerations import MessageFlags +from core.emulator.enumerations import MessageFlags, TransportType from core.errors import CoreCommandError -from core.nodes.netclient import get_net_client +from core.nodes.netclient import LinuxNetClient, get_net_client if TYPE_CHECKING: from core.emulator.distributed import DistributedServer @@ -42,32 +42,30 @@ class CoreInterface: :param server: remote server node will run on, default is None for localhost """ - self.session = session - self.node = node - self.name = name - self.localname = localname - self.up = False - if not isinstance(mtu, int): - raise ValueError - self.mtu = mtu - self.net = None - self.othernet = None + self.session: "Session" = session + self.node: "CoreNode" = node + self.name: str = name + self.localname: str = localname + self.up: bool = False + self.mtu: int = mtu + self.net: Optional[CoreNetworkBase] = None + self.othernet: Optional[CoreNetworkBase] = None self._params = {} - self.addrlist = [] - self.hwaddr = None + self.addrlist: List[str] = [] + self.hwaddr: Optional[str] = None # placeholder position hook - self.poshook = lambda x: None + self.poshook: Callable[[CoreInterface], None] = lambda x: None # used with EMANE - self.transport_type = None + self.transport_type: Optional[TransportType] = None # node interface index - self.netindex = None + self.netindex: Optional[int] = None # net interface index - self.netifi = None + self.netifi: Optional[int] = None # index used to find flow data - self.flow_id = None - self.server = server + self.flow_id: Optional[int] = None + self.server: Optional["DistributedServer"] = server use_ovs = session.options.get_config("ovs") == "True" - self.net_client = get_net_client(use_ovs, self.host_cmd) + self.net_client: LinuxNetClient = get_net_client(use_ovs, self.host_cmd) def host_cmd( self, @@ -330,7 +328,7 @@ class TunTap(CoreInterface): :param start: start flag """ super().__init__(session, node, name, localname, mtu, server) - self.transport_type = "virtual" + self.transport_type = TransportType.VIRTUAL if start: self.startup() @@ -516,7 +514,7 @@ class GreTap(CoreInterface): sessionid = session.short_session_id() localname = f"gt.{self.id}.{sessionid}" super().__init__(session, node, name, localname, mtu, server) - self.transport_type = "raw" + self.transport_type = TransportType.RAW if not start: return if remoteip is None: diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 2fc743fa..e5db8a80 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -10,7 +10,7 @@ from typing import IO, TYPE_CHECKING, List, Optional, Tuple from core import utils from core.constants import MOUNT_BIN, UMOUNT_BIN from core.emulator.distributed import DistributedServer -from core.emulator.enumerations import NodeTypes +from core.emulator.enumerations import NodeTypes, TransportType from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNetworkBase, CoreNodeBase from core.nodes.interface import CoreInterface, Veth @@ -281,7 +281,7 @@ class Rj45Node(CoreNodeBase): """ super().__init__(session, _id, name, start, server) self.interface = CoreInterface(session, self, name, name, mtu, server) - self.interface.transport_type = "raw" + self.interface.transport_type = TransportType.RAW self.lock: threading.RLock = threading.RLock() self.ifindex: Optional[int] = None self.old_up: bool = False diff --git a/daemon/core/xml/emanexml.py b/daemon/core/xml/emanexml.py index 2c5cc9c0..2589edd9 100644 --- a/daemon/core/xml/emanexml.py +++ b/daemon/core/xml/emanexml.py @@ -9,6 +9,7 @@ from core import utils from core.config import Configuration from core.emane.nodes import EmaneNet from core.emulator.distributed import DistributedServer +from core.emulator.enumerations import TransportType from core.nodes.interface import CoreInterface from core.nodes.network import CtrlNet from core.xml import corexml @@ -182,7 +183,7 @@ def build_node_platform_xml( transport_type = netif.transport_type if not transport_type: logging.info("warning: %s interface type unsupported!", netif.name) - transport_type = "raw" + transport_type = TransportType.RAW transport_file = transport_file_name(node.id, transport_type) transport_element = etree.SubElement( nem_element, "transport", definition=transport_file @@ -196,7 +197,7 @@ def build_node_platform_xml( # merging code key = netif.node.id - if netif.transport_type == "raw": + if netif.transport_type == TransportType.RAW: key = "host" otadev = control_net.brname eventdev = control_net.brname @@ -276,8 +277,8 @@ def build_xml_files(emane_manager: "EmaneManager", node: EmaneNet) -> None: # build XML for specific interface (NEM) configs need_virtual = False need_raw = False - vtype = "virtual" - rtype = "raw" + vtype = TransportType.VIRTUAL + rtype = TransportType.RAW for netif in node.netifs(): # check for interface specific emane configuration and write xml files @@ -286,7 +287,7 @@ def build_xml_files(emane_manager: "EmaneManager", node: EmaneNet) -> None: node.model.build_xml_files(config, netif) # check transport type needed for interface - if "virtual" in netif.transport_type: + if netif.transport_type == TransportType.VIRTUAL: need_virtual = True vtype = netif.transport_type else: @@ -301,7 +302,7 @@ def build_xml_files(emane_manager: "EmaneManager", node: EmaneNet) -> None: def build_transport_xml( - emane_manager: "EmaneManager", node: EmaneNet, transport_type: str + emane_manager: "EmaneManager", node: EmaneNet, transport_type: TransportType ) -> None: """ Build transport xml file for node and transport type. @@ -314,8 +315,8 @@ def build_transport_xml( """ transport_element = etree.Element( "transport", - name=f"{transport_type.capitalize()} Transport", - library=f"trans{transport_type.lower()}", + name=f"{transport_type.value.capitalize()} Transport", + library=f"trans{transport_type.value.lower()}", ) # add bitrate @@ -325,7 +326,7 @@ def build_transport_xml( config = emane_manager.get_configs(node.id, node.model.name) flowcontrol = config.get("flowcontrolenable", "0") == "1" - if "virtual" in transport_type.lower(): + if transport_type == TransportType.VIRTUAL: device_path = "/dev/net/tun_flowctl" if not os.path.exists(device_path): device_path = "/dev/net/tun" @@ -482,7 +483,7 @@ def create_event_service_xml( create_file(event_element, "emaneeventmsgsvc", file_path, server) -def transport_file_name(node_id: int, transport_type: str) -> str: +def transport_file_name(node_id: int, transport_type: TransportType) -> str: """ Create name for a transport xml file. @@ -490,7 +491,7 @@ def transport_file_name(node_id: int, transport_type: str) -> str: :param transport_type: transport type to generate transport file :return: """ - return f"n{node_id}trans{transport_type}.xml" + return f"n{node_id}trans{transport_type.value}.xml" def _basename(emane_model: "EmaneModel", interface: CoreInterface = None) -> str: @@ -521,7 +522,7 @@ def nem_file_name(emane_model: "EmaneModel", interface: CoreInterface = None) -> """ basename = _basename(emane_model, interface) append = "" - if interface and interface.transport_type == "raw": + if interface and interface.transport_type == TransportType.RAW: append = "_raw" return f"{basename}nem{append}.xml" From 73a556708446ba51da4174f060417b2c177cfe1d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 29 May 2020 23:22:21 -0700 Subject: [PATCH 097/929] added network policy enum to avoid string usage --- daemon/core/emulator/enumerations.py | 5 ++++ daemon/core/nodes/network.py | 40 ++++++++++++++++------------ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/daemon/core/emulator/enumerations.py b/daemon/core/emulator/enumerations.py index 2c6e14db..4c72d56e 100644 --- a/daemon/core/emulator/enumerations.py +++ b/daemon/core/emulator/enumerations.py @@ -117,3 +117,8 @@ class ExceptionLevels(Enum): ERROR = 2 WARNING = 3 NOTICE = 4 + + +class NetworkPolicy(Enum): + ACCEPT = "ACCEPT" + DROP = "DROP" diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index f0b2fefa..b08d87d4 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -12,7 +12,13 @@ import netaddr from core import utils from core.constants import EBTABLES_BIN, TC_BIN from core.emulator.data import LinkData, NodeData -from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes, RegisterTlvs +from core.emulator.enumerations import ( + LinkTypes, + MessageFlags, + NetworkPolicy, + NodeTypes, + RegisterTlvs, +) from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNetworkBase from core.nodes.interface import CoreInterface, GreTap, Veth @@ -204,21 +210,21 @@ class EbtablesQueue: wlan.has_ebtables_chain = True self.cmds.extend( [ - f"-N {wlan.brname} -P {wlan.policy}", + f"-N {wlan.brname} -P {wlan.policy.value}", f"-A FORWARD --logical-in {wlan.brname} -j {wlan.brname}", ] ) # rebuild the chain for netif1, v in wlan._linked.items(): for netif2, linked in v.items(): - if wlan.policy == "DROP" and linked: + if wlan.policy == NetworkPolicy.DROP and linked: self.cmds.extend( [ f"-A {wlan.brname} -i {netif1.localname} -o {netif2.localname} -j ACCEPT", f"-A {wlan.brname} -o {netif1.localname} -i {netif2.localname} -j ACCEPT", ] ) - elif wlan.policy == "ACCEPT" and not linked: + elif wlan.policy == NetworkPolicy.ACCEPT and not linked: self.cmds.extend( [ f"-A {wlan.brname} -i {netif1.localname} -o {netif2.localname} -j DROP", @@ -250,7 +256,7 @@ class CoreNetwork(CoreNetworkBase): Provides linux bridge network functionality for core nodes. """ - policy: str = "DROP" + policy: NetworkPolicy = NetworkPolicy.DROP def __init__( self, @@ -259,7 +265,7 @@ class CoreNetwork(CoreNetworkBase): name: str = None, start: bool = True, server: "DistributedServer" = None, - policy: str = None, + policy: NetworkPolicy = None, ) -> None: """ Creates a LxBrNet instance. @@ -392,12 +398,12 @@ class CoreNetwork(CoreNetworkBase): try: linked = self._linked[netif1][netif2] except KeyError: - if self.policy == "ACCEPT": + if self.policy == NetworkPolicy.ACCEPT: linked = True - elif self.policy == "DROP": + elif self.policy == NetworkPolicy.DROP: linked = False else: - raise Exception(f"unknown policy: {self.policy}") + raise Exception(f"unknown policy: {self.policy.value}") self._linked[netif1][netif2] = linked return linked @@ -605,7 +611,7 @@ class GreTapBridge(CoreNetwork): remoteip: str = None, _id: int = None, name: str = None, - policy: str = "ACCEPT", + policy: NetworkPolicy = NetworkPolicy.ACCEPT, localip: str = None, ttl: int = 255, key: int = None, @@ -712,7 +718,7 @@ class CtrlNet(CoreNetwork): Control network functionality. """ - policy: str = "ACCEPT" + policy: NetworkPolicy = NetworkPolicy.ACCEPT # base control interface index CTRLIF_IDX_BASE: int = 99 DEFAULT_PREFIX_LIST: List[str] = [ @@ -852,7 +858,7 @@ class PtpNet(CoreNetwork): Peer to peer network node. """ - policy: str = "ACCEPT" + policy: NetworkPolicy = NetworkPolicy.ACCEPT def attach(self, netif: CoreInterface) -> None: """ @@ -983,7 +989,7 @@ class SwitchNode(CoreNetwork): """ apitype: NodeTypes = NodeTypes.SWITCH - policy: str = "ACCEPT" + policy: NetworkPolicy = NetworkPolicy.ACCEPT type: str = "lanswitch" @@ -994,7 +1000,7 @@ class HubNode(CoreNetwork): """ apitype: NodeTypes = NodeTypes.HUB - policy: str = "ACCEPT" + policy: NetworkPolicy = NetworkPolicy.ACCEPT type: str = "hub" def startup(self) -> None: @@ -1014,7 +1020,7 @@ class WlanNode(CoreNetwork): apitype: NodeTypes = NodeTypes.WIRELESS_LAN linktype: LinkTypes = LinkTypes.WIRED - policy: str = "DROP" + policy: NetworkPolicy = NetworkPolicy.DROP type: str = "wlan" def __init__( @@ -1024,7 +1030,7 @@ class WlanNode(CoreNetwork): name: str = None, start: bool = True, server: "DistributedServer" = None, - policy: str = None, + policy: NetworkPolicy = None, ) -> None: """ Create a WlanNode instance. @@ -1117,5 +1123,5 @@ class TunnelNode(GreTapBridge): """ apitype: NodeTypes = NodeTypes.TUNNEL - policy: str = "ACCEPT" + policy: NetworkPolicy = NetworkPolicy.ACCEPT type: str = "tunnel" From c6a06baf295985dc729aff73c7f1af73d4553cf5 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 30 May 2020 14:24:38 -0700 Subject: [PATCH 098/929] add geo to grpc calls getting node values, updated emane position hook to set lon/lat/alt values --- daemon/core/api/grpc/grpcutils.py | 4 ++++ daemon/core/emane/nodes.py | 1 + 2 files changed, 5 insertions(+) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 6281ec67..8a69c40f 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -235,6 +235,9 @@ def get_node_proto(session: Session, node: NodeBase) -> core_pb2.Node: position = core_pb2.Position( x=node.position.x, y=node.position.y, z=node.position.z ) + geo = core_pb2.Geo( + lat=node.position.lat, lon=node.position.lon, alt=node.position.alt + ) services = getattr(node, "services", []) if services is None: services = [] @@ -255,6 +258,7 @@ def get_node_proto(session: Session, node: NodeBase) -> core_pb2.Node: model=model, type=node_type.value, position=position, + geo=geo, services=services, icon=node.icon, image=image, diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index be398329..bbe59b95 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -202,6 +202,7 @@ class EmaneNet(CoreNetworkBase): lat, lon, alt = self.session.location.getgeo(x, y, z) if node.position.alt is not None: alt = node.position.alt + node.position.set_geo(lon, lat, alt) # altitude must be an integer or warning is printed alt = int(round(alt)) return nemid, lon, lat, alt From e323f8965e2bca3f619e8977ba4c6d4170a4284c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 30 May 2020 21:36:44 -0700 Subject: [PATCH 099/929] removed docs link to example service and embedded example into docs --- docs/services.md | 120 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/docs/services.md b/docs/services.md index d2911d81..9f47ae48 100644 --- a/docs/services.md +++ b/docs/services.md @@ -145,7 +145,7 @@ ideas for a service before adding a new service type. ### Creating New Services -1. Modify the [Example Service File](../daemon/examples/myservices/sample.py) +1. Modify the example service shown below to do what you want. It could generate config/script files, mount per-node directories, start processes/scripts, etc. sample.py is a Python file that defines one or more classes to be imported. You can create multiple Python @@ -174,3 +174,121 @@ ideas for a service before adding a new service type. If you have created a new service type that may be useful to others, please consider contributing it to the CORE project. + +#### Example Custom Service + +Below is the skeleton for a custom service with some documentation. Most +people would likely only setup the required class variables **(name/group)**. +Then define the **configs** (files they want to generate) and implement the +**generate_confifs** function to dynamically create the files wanted. Finally +the **startup** commands would be supplied, which typically tends to be +running the shell files generated. + +```python +from core.services.coreservices import CoreService, ServiceMode + + +class MyService(CoreService): + """ + Custom CORE Service + + :var str name: name used as a unique ID for this service and is required, no spaces + :var str group: allows you to group services within the GUI under a common name + :var tuple executables: executables this service depends on to function, if executable is + not on the path, service will not be loaded + :var tuple dependencies: services that this service depends on for startup, tuple of service names + :var tuple dirs: directories that this service will create within a node + :var tuple configs: files that this service will generate, without a full path this file goes in + the node's directory e.g. /tmp/pycore.12345/n1.conf/myfile + :var tuple startup: commands used to start this service, any non-zero exit code will cause a failure + :var tuple validate: commands used to validate that a service was started, any non-zero exit code + will cause a failure + :var ServiceMode validation_mode: validation mode, used to determine startup success. + NON_BLOCKING - runs startup commands, and validates success with validation commands + BLOCKING - runs startup commands, and validates success with the startup commands themselves + TIMER - runs startup commands, and validates success by waiting for "validation_timer" alone + :var int validation_timer: time in seconds for a service to wait for validation, before determining + success in TIMER/NON_BLOCKING modes. + :var float validation_validation_period: period in seconds to wait before retrying validation, + only used in NON_BLOCKING mode + :var tuple shutdown: shutdown commands to stop this service + """ + + name = "MyService" + group = "Utility" + executables = () + dependencies = () + dirs = () + configs = ("myservice1.sh", "myservice2.sh") + startup = tuple(f"sh {x}" for x in configs) + validate = () + validation_mode = ServiceMode.NON_BLOCKING + validation_timer = 5 + validation_period = 0.5 + shutdown = () + + @classmethod + def on_load(cls): + """ + Provides a way to run some arbitrary logic when the service is loaded, possibly to help facilitate + dynamic settings for the environment. + + :return: nothing + """ + pass + + @classmethod + def get_configs(cls, node): + """ + Provides a way to dynamically generate the config files from the node a service will run. + Defaults to the class definition and can be left out entirely if not needed. + + :param node: core node that the service is being ran on + :return: tuple of config files to create + """ + return cls.configs + + @classmethod + def generate_config(cls, node, filename): + """ + Returns a string representation for a file, given the node the service is starting on the config filename + that this information will be used for. This must be defined, if "configs" are defined. + + :param node: core node that the service is being ran on + :param str filename: configuration file to generate + :return: configuration file content + :rtype: str + """ + cfg = "#!/bin/sh\n" + + if filename == cls.configs[0]: + cfg += "# auto-generated by MyService (sample.py)\n" + for ifc in node.netifs(): + cfg += f'echo "Node {node.name} has interface {ifc.name}"\n' + elif filename == cls.configs[1]: + cfg += "echo hello" + + return cfg + + @classmethod + def get_startup(cls, node): + """ + Provides a way to dynamically generate the startup commands from the node a service will run. + Defaults to the class definition and can be left out entirely if not needed. + + :param node: core node that the service is being ran on + :return: tuple of startup commands to run + """ + return cls.startup + + @classmethod + def get_validate(cls, node): + """ + Provides a way to dynamically generate the validate commands from the node a service will run. + Defaults to the class definition and can be left out entirely if not needed. + + :param node: core node that the service is being ran on + :return: tuple of commands to validate service startup with + """ + return cls.validate +``` From 0d2bcccf3e0077d31140bee7c0faa82444e4ee4f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 2 Jun 2020 14:48:57 -0700 Subject: [PATCH 100/929] added initial files to support transitioning to using poetry/invoke to provide an environment for core --- daemon/poetry.lock | 1061 +++++++++++++++++++++++++++++++++++++++++ daemon/pyproject.toml | 33 ++ tasks.py | 14 + 3 files changed, 1108 insertions(+) create mode 100644 daemon/poetry.lock create mode 100644 daemon/pyproject.toml create mode 100644 tasks.py diff --git a/daemon/poetry.lock b/daemon/poetry.lock new file mode 100644 index 00000000..c5e1ebb6 --- /dev/null +++ b/daemon/poetry.lock @@ -0,0 +1,1061 @@ +[[package]] +category = "dev" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +name = "appdirs" +optional = false +python-versions = "*" +version = "1.4.4" + +[[package]] +category = "dev" +description = "Atomic file writes." +marker = "sys_platform == \"win32\"" +name = "atomicwrites" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.4.0" + +[[package]] +category = "dev" +description = "Classes Without Boilerplate" +name = "attrs" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "19.3.0" + +[package.extras] +azure-pipelines = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "pytest-azurepipelines"] +dev = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "sphinx", "pre-commit"] +docs = ["sphinx", "zope.interface"] +tests = ["coverage", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] + +[[package]] +category = "main" +description = "Modern password hashing for your software and your servers" +name = "bcrypt" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "3.1.7" + +[package.dependencies] +cffi = ">=1.1" +six = ">=1.4.1" + +[package.extras] +tests = ["pytest (>=3.2.1,<3.3.0 || >3.3.0)"] + +[[package]] +category = "dev" +description = "The uncompromising code formatter." +name = "black" +optional = false +python-versions = ">=3.6" +version = "19.3b0" + +[package.dependencies] +appdirs = "*" +attrs = ">=18.1.0" +click = ">=6.5" +toml = ">=0.9.4" + +[package.extras] +d = ["aiohttp (>=3.3.2)", "aiohttp-cors"] + +[[package]] +category = "main" +description = "Foreign Function Interface for Python calling C code." +name = "cffi" +optional = false +python-versions = "*" +version = "1.14.0" + +[package.dependencies] +pycparser = "*" + +[[package]] +category = "dev" +description = "Validate configuration and produce human readable error messages." +name = "cfgv" +optional = false +python-versions = ">=3.6" +version = "3.0.0" + +[[package]] +category = "dev" +description = "Composable command line interface toolkit" +name = "click" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "7.1.2" + +[[package]] +category = "dev" +description = "Cross-platform colored terminal text." +marker = "sys_platform == \"win32\"" +name = "colorama" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +version = "0.4.3" + +[[package]] +category = "main" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +name = "cryptography" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" +version = "2.9.2" + +[package.dependencies] +cffi = ">=1.8,<1.11.3 || >1.11.3" +six = ">=1.4.1" + +[package.extras] +docs = ["sphinx (>=1.6.5,<1.8.0 || >1.8.0)", "sphinx-rtd-theme"] +docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +idna = ["idna (>=2.1)"] +pep8test = ["flake8", "flake8-import-order", "pep8-naming"] +test = ["pytest (>=3.6.0,<3.9.0 || >3.9.0,<3.9.1 || >3.9.1,<3.9.2 || >3.9.2)", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,<3.79.2 || >3.79.2)"] + +[[package]] +category = "main" +description = "A backport of the dataclasses module for Python 3.6" +marker = "python_version == \"3.6\"" +name = "dataclasses" +optional = false +python-versions = ">=3.6, <3.7" +version = "0.7" + +[[package]] +category = "dev" +description = "Distribution utilities" +name = "distlib" +optional = false +python-versions = "*" +version = "0.3.0" + +[[package]] +category = "main" +description = "High level SSH command execution" +name = "fabric" +optional = false +python-versions = "*" +version = "2.5.0" + +[package.dependencies] +invoke = ">=1.3,<2.0" +paramiko = ">=2.4" + +[package.extras] +pytest = ["mock (>=2.0.0,<3.0)", "pytest (>=3.2.5,<4.0)"] +testing = ["mock (>=2.0.0,<3.0)"] + +[[package]] +category = "dev" +description = "A platform independent file lock." +name = "filelock" +optional = false +python-versions = "*" +version = "3.0.12" + +[[package]] +category = "dev" +description = "the modular source code checker: pep8 pyflakes and co" +name = "flake8" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "3.8.2" + +[package.dependencies] +mccabe = ">=0.6.0,<0.7.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" + +[[package]] +category = "main" +description = "HTTP/2-based RPC framework" +name = "grpcio" +optional = false +python-versions = "*" +version = "1.29.0" + +[package.dependencies] +six = ">=1.5.2" + +[[package]] +category = "dev" +description = "Protobuf code generator for gRPC" +name = "grpcio-tools" +optional = false +python-versions = "*" +version = "1.29.0" + +[package.dependencies] +grpcio = ">=1.29.0" +protobuf = ">=3.5.0.post1" + +[[package]] +category = "dev" +description = "File identification library for Python" +name = "identify" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "1.4.18" + +[package.extras] +license = ["editdistance"] + +[[package]] +category = "dev" +description = "Read metadata from Python packages" +marker = "python_version < \"3.8\"" +name = "importlib-metadata" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.6.0" + +[package.dependencies] +zipp = ">=0.5" + +[package.extras] +docs = ["sphinx", "rst.linker"] +testing = ["packaging", "importlib-resources"] + +[[package]] +category = "dev" +description = "Read resources from Python packages" +marker = "python_version < \"3.7\"" +name = "importlib-resources" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.5.0" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" + +[package.dependencies.zipp] +python = "<3.8" +version = ">=0.4" + +[package.extras] +docs = ["sphinx", "rst.linker", "jaraco.packaging"] + +[[package]] +category = "main" +description = "Pythonic task execution" +name = "invoke" +optional = false +python-versions = "*" +version = "1.4.1" + +[[package]] +category = "dev" +description = "A Python utility / library to sort Python imports." +name = "isort" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "4.3.21" + +[package.extras] +pipfile = ["pipreqs", "requirementslib"] +pyproject = ["toml"] +requirements = ["pipreqs", "pip-api"] +xdg_home = ["appdirs (>=1.4.0)"] + +[[package]] +category = "main" +description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." +name = "lxml" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*" +version = "4.5.1" + +[package.extras] +cssselect = ["cssselect (>=0.7)"] +html5 = ["html5lib"] +htmlsoup = ["beautifulsoup4"] +source = ["Cython (>=0.29.7)"] + +[[package]] +category = "main" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +name = "mako" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.1.3" + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["babel"] +lingua = ["lingua"] + +[[package]] +category = "main" +description = "Safely add untrusted strings to HTML/XML markup." +name = "markupsafe" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*" +version = "1.1.1" + +[[package]] +category = "dev" +description = "McCabe checker, plugin for flake8" +name = "mccabe" +optional = false +python-versions = "*" +version = "0.6.1" + +[[package]] +category = "dev" +description = "Rolling backport of unittest.mock for all Pythons" +name = "mock" +optional = false +python-versions = ">=3.6" +version = "4.0.2" + +[package.extras] +build = ["twine", "wheel", "blurb"] +docs = ["sphinx"] +test = ["pytest", "pytest-cov"] + +[[package]] +category = "dev" +description = "More routines for operating on iterables, beyond itertools" +name = "more-itertools" +optional = false +python-versions = ">=3.5" +version = "8.3.0" + +[[package]] +category = "main" +description = "A network address manipulation library for Python" +name = "netaddr" +optional = false +python-versions = "*" +version = "0.7.19" + +[[package]] +category = "dev" +description = "Node.js virtual environment builder" +name = "nodeenv" +optional = false +python-versions = "*" +version = "1.4.0" + +[[package]] +category = "dev" +description = "Core utilities for Python packages" +name = "packaging" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "20.4" + +[package.dependencies] +pyparsing = ">=2.0.2" +six = "*" + +[[package]] +category = "main" +description = "SSH2 protocol library" +name = "paramiko" +optional = false +python-versions = "*" +version = "2.7.1" + +[package.dependencies] +bcrypt = ">=3.1.3" +cryptography = ">=2.5" +pynacl = ">=1.0.1" + +[package.extras] +all = ["pyasn1 (>=0.1.7)", "pynacl (>=1.0.1)", "bcrypt (>=3.1.3)", "invoke (>=1.3)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"] +ed25519 = ["pynacl (>=1.0.1)", "bcrypt (>=3.1.3)"] +gssapi = ["pyasn1 (>=0.1.7)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"] +invoke = ["invoke (>=1.3)"] + +[[package]] +category = "main" +description = "Python Imaging Library (Fork)" +name = "pillow" +optional = false +python-versions = ">=3.5" +version = "7.1.2" + +[[package]] +category = "dev" +description = "plugin and hook calling mechanisms for python" +name = "pluggy" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "0.13.1" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +category = "dev" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +name = "pre-commit" +optional = false +python-versions = ">=3.6" +version = "2.1.1" + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +toml = "*" +virtualenv = ">=15.2" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" + +[package.dependencies.importlib-resources] +python = "<3.7" +version = "*" + +[[package]] +category = "main" +description = "Protocol Buffers" +name = "protobuf" +optional = false +python-versions = "*" +version = "3.12.2" + +[package.dependencies] +setuptools = "*" +six = ">=1.9" + +[[package]] +category = "dev" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +name = "py" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.8.1" + +[[package]] +category = "dev" +description = "Python style guide checker" +name = "pycodestyle" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.6.0" + +[[package]] +category = "main" +description = "C parser in Python" +name = "pycparser" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.20" + +[[package]] +category = "dev" +description = "passive checker of Python programs" +name = "pyflakes" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "2.2.0" + +[[package]] +category = "main" +description = "Python binding to the Networking and Cryptography (NaCl) library" +name = "pynacl" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +version = "1.4.0" + +[package.dependencies] +cffi = ">=1.4.1" +six = "*" + +[package.extras] +docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"] +tests = ["pytest (>=3.2.1,<3.3.0 || >3.3.0)", "hypothesis (>=3.27.0)"] + +[[package]] +category = "dev" +description = "Python parsing module" +name = "pyparsing" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" +version = "2.4.7" + +[[package]] +category = "main" +description = "Python interface to PROJ (cartographic projections and coordinate transformations library)" +name = "pyproj" +optional = false +python-versions = ">=3.5" +version = "2.6.1.post1" + +[[package]] +category = "dev" +description = "pytest: simple powerful testing with Python" +name = "pytest" +optional = false +python-versions = ">=3.5" +version = "5.4.3" + +[package.dependencies] +atomicwrites = ">=1.0" +attrs = ">=17.4.0" +colorama = "*" +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.5.0" +wcwidth = "*" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12" + +[package.extras] +checkqa-mypy = ["mypy (v0.761)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +category = "main" +description = "YAML parser and emitter for Python" +name = "pyyaml" +optional = false +python-versions = "*" +version = "5.3.1" + +[[package]] +category = "main" +description = "Python 2 and 3 compatibility utilities" +name = "six" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +version = "1.15.0" + +[[package]] +category = "dev" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.1" + +[[package]] +category = "dev" +description = "Virtual Python Environment builder" +name = "virtualenv" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "20.0.21" + +[package.dependencies] +appdirs = ">=1.4.3,<2" +distlib = ">=0.3.0,<1" +filelock = ">=3.0.0,<4" +six = ">=1.9.0,<2" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12,<2" + +[package.dependencies.importlib-resources] +python = "<3.7" +version = ">=1.0,<2" + +[package.extras] +docs = ["sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)", "proselint (>=0.10.2)"] +testing = ["pytest (>=4)", "coverage (>=5)", "coverage-enable-subprocess (>=1)", "pytest-xdist (>=1.31.0)", "pytest-mock (>=2)", "pytest-env (>=0.6.2)", "pytest-randomly (>=1)", "pytest-timeout", "packaging (>=20.0)", "xonsh (>=0.9.16)"] + +[[package]] +category = "dev" +description = "Measures the displayed width of unicode strings in a terminal" +name = "wcwidth" +optional = false +python-versions = "*" +version = "0.2.3" + +[[package]] +category = "dev" +description = "Backport of pathlib-compatible object wrapper for zip files" +marker = "python_version < \"3.8\"" +name = "zipp" +optional = false +python-versions = ">=3.6" +version = "3.1.0" + +[package.extras] +docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] +testing = ["jaraco.itertools", "func-timeout"] + +[metadata] +content-hash = "ff2407f8ca447047101b8e0c8656027d07d2f15e51b3a950f2c2d789f929da6b" +python-versions = "^3.6" + +[metadata.files] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] +atomicwrites = [ + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, +] +attrs = [ + {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, + {file = "attrs-19.3.0.tar.gz", hash = "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"}, +] +bcrypt = [ + {file = "bcrypt-3.1.7-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7"}, + {file = "bcrypt-3.1.7-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:69361315039878c0680be456640f8705d76cb4a3a3fe1e057e0f261b74be4b31"}, + {file = "bcrypt-3.1.7-cp27-cp27m-win32.whl", hash = "sha256:5432dd7b34107ae8ed6c10a71b4397f1c853bd39a4d6ffa7e35f40584cffd161"}, + {file = "bcrypt-3.1.7-cp27-cp27m-win_amd64.whl", hash = "sha256:9fe92406c857409b70a38729dbdf6578caf9228de0aef5bc44f859ffe971a39e"}, + {file = "bcrypt-3.1.7-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:763669a367869786bb4c8fcf731f4175775a5b43f070f50f46f0b59da45375d0"}, + {file = "bcrypt-3.1.7-cp34-abi3-macosx_10_6_intel.whl", hash = "sha256:a190f2a5dbbdbff4b74e3103cef44344bc30e61255beb27310e2aec407766052"}, + {file = "bcrypt-3.1.7-cp34-abi3-manylinux1_x86_64.whl", hash = "sha256:c9457fa5c121e94a58d6505cadca8bed1c64444b83b3204928a866ca2e599105"}, + {file = "bcrypt-3.1.7-cp34-cp34m-win32.whl", hash = "sha256:8b10acde4e1919d6015e1df86d4c217d3b5b01bb7744c36113ea43d529e1c3de"}, + {file = "bcrypt-3.1.7-cp34-cp34m-win_amd64.whl", hash = "sha256:cb93f6b2ab0f6853550b74e051d297c27a638719753eb9ff66d1e4072be67133"}, + {file = "bcrypt-3.1.7-cp35-cp35m-win32.whl", hash = "sha256:6fe49a60b25b584e2f4ef175b29d3a83ba63b3a4df1b4c0605b826668d1b6be5"}, + {file = "bcrypt-3.1.7-cp35-cp35m-win_amd64.whl", hash = "sha256:a595c12c618119255c90deb4b046e1ca3bcfad64667c43d1166f2b04bc72db09"}, + {file = "bcrypt-3.1.7-cp36-cp36m-win32.whl", hash = "sha256:74a015102e877d0ccd02cdeaa18b32aa7273746914a6c5d0456dd442cb65b99c"}, + {file = "bcrypt-3.1.7-cp36-cp36m-win_amd64.whl", hash = "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89"}, + {file = "bcrypt-3.1.7-cp37-cp37m-win32.whl", hash = "sha256:19a4b72a6ae5bb467fea018b825f0a7d917789bcfe893e53f15c92805d187294"}, + {file = "bcrypt-3.1.7-cp37-cp37m-win_amd64.whl", hash = "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc"}, + {file = "bcrypt-3.1.7-cp38-cp38-win32.whl", hash = "sha256:ce4e4f0deb51d38b1611a27f330426154f2980e66582dc5f438aad38b5f24fc1"}, + {file = "bcrypt-3.1.7-cp38-cp38-win_amd64.whl", hash = "sha256:6305557019906466fc42dbc53b46da004e72fd7a551c044a827e572c82191752"}, + {file = "bcrypt-3.1.7.tar.gz", hash = "sha256:0b0069c752ec14172c5f78208f1863d7ad6755a6fae6fe76ec2c80d13be41e42"}, +] +black = [ + {file = "black-19.3b0-py36-none-any.whl", hash = "sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf"}, + {file = "black-19.3b0.tar.gz", hash = "sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c"}, +] +cffi = [ + {file = "cffi-1.14.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384"}, + {file = "cffi-1.14.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30"}, + {file = "cffi-1.14.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c"}, + {file = "cffi-1.14.0-cp27-cp27m-win32.whl", hash = "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78"}, + {file = "cffi-1.14.0-cp27-cp27m-win_amd64.whl", hash = "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793"}, + {file = "cffi-1.14.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e"}, + {file = "cffi-1.14.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a"}, + {file = "cffi-1.14.0-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff"}, + {file = "cffi-1.14.0-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f"}, + {file = "cffi-1.14.0-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa"}, + {file = "cffi-1.14.0-cp35-cp35m-win32.whl", hash = "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5"}, + {file = "cffi-1.14.0-cp35-cp35m-win_amd64.whl", hash = "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4"}, + {file = "cffi-1.14.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d"}, + {file = "cffi-1.14.0-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc"}, + {file = "cffi-1.14.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac"}, + {file = "cffi-1.14.0-cp36-cp36m-win32.whl", hash = "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f"}, + {file = "cffi-1.14.0-cp36-cp36m-win_amd64.whl", hash = "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b"}, + {file = "cffi-1.14.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3"}, + {file = "cffi-1.14.0-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66"}, + {file = "cffi-1.14.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0"}, + {file = "cffi-1.14.0-cp37-cp37m-win32.whl", hash = "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f"}, + {file = "cffi-1.14.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26"}, + {file = "cffi-1.14.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd"}, + {file = "cffi-1.14.0-cp38-cp38-manylinux1_i686.whl", hash = "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55"}, + {file = "cffi-1.14.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2"}, + {file = "cffi-1.14.0-cp38-cp38-win32.whl", hash = "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8"}, + {file = "cffi-1.14.0-cp38-cp38-win_amd64.whl", hash = "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b"}, + {file = "cffi-1.14.0.tar.gz", hash = "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6"}, +] +cfgv = [ + {file = "cfgv-3.0.0-py2.py3-none-any.whl", hash = "sha256:f22b426ed59cd2ab2b54ff96608d846c33dfb8766a67f0b4a6ce130ce244414f"}, + {file = "cfgv-3.0.0.tar.gz", hash = "sha256:04b093b14ddf9fd4d17c53ebfd55582d27b76ed30050193c14e560770c5360eb"}, +] +click = [ + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, +] +colorama = [ + {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, + {file = "colorama-0.4.3.tar.gz", hash = "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1"}, +] +cryptography = [ + {file = "cryptography-2.9.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:daf54a4b07d67ad437ff239c8a4080cfd1cc7213df57d33c97de7b4738048d5e"}, + {file = "cryptography-2.9.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:3b3eba865ea2754738616f87292b7f29448aec342a7c720956f8083d252bf28b"}, + {file = "cryptography-2.9.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:c447cf087cf2dbddc1add6987bbe2f767ed5317adb2d08af940db517dd704365"}, + {file = "cryptography-2.9.2-cp27-cp27m-win32.whl", hash = "sha256:f118a95c7480f5be0df8afeb9a11bd199aa20afab7a96bcf20409b411a3a85f0"}, + {file = "cryptography-2.9.2-cp27-cp27m-win_amd64.whl", hash = "sha256:c4fd17d92e9d55b84707f4fd09992081ba872d1a0c610c109c18e062e06a2e55"}, + {file = "cryptography-2.9.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:d0d5aeaedd29be304848f1c5059074a740fa9f6f26b84c5b63e8b29e73dfc270"}, + {file = "cryptography-2.9.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:1e4014639d3d73fbc5ceff206049c5a9a849cefd106a49fa7aaaa25cc0ce35cf"}, + {file = "cryptography-2.9.2-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:96c080ae7118c10fcbe6229ab43eb8b090fccd31a09ef55f83f690d1ef619a1d"}, + {file = "cryptography-2.9.2-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:e993468c859d084d5579e2ebee101de8f5a27ce8e2159959b6673b418fd8c785"}, + {file = "cryptography-2.9.2-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:88c881dd5a147e08d1bdcf2315c04972381d026cdb803325c03fe2b4a8ed858b"}, + {file = "cryptography-2.9.2-cp35-cp35m-win32.whl", hash = "sha256:651448cd2e3a6bc2bb76c3663785133c40d5e1a8c1a9c5429e4354201c6024ae"}, + {file = "cryptography-2.9.2-cp35-cp35m-win_amd64.whl", hash = "sha256:726086c17f94747cedbee6efa77e99ae170caebeb1116353c6cf0ab67ea6829b"}, + {file = "cryptography-2.9.2-cp36-cp36m-win32.whl", hash = "sha256:091d31c42f444c6f519485ed528d8b451d1a0c7bf30e8ca583a0cac44b8a0df6"}, + {file = "cryptography-2.9.2-cp36-cp36m-win_amd64.whl", hash = "sha256:bb1f0281887d89617b4c68e8db9a2c42b9efebf2702a3c5bf70599421a8623e3"}, + {file = "cryptography-2.9.2-cp37-cp37m-win32.whl", hash = "sha256:18452582a3c85b96014b45686af264563e3e5d99d226589f057ace56196ec78b"}, + {file = "cryptography-2.9.2-cp37-cp37m-win_amd64.whl", hash = "sha256:22e91636a51170df0ae4dcbd250d318fd28c9f491c4e50b625a49964b24fe46e"}, + {file = "cryptography-2.9.2-cp38-cp38-win32.whl", hash = "sha256:844a76bc04472e5135b909da6aed84360f522ff5dfa47f93e3dd2a0b84a89fa0"}, + {file = "cryptography-2.9.2-cp38-cp38-win_amd64.whl", hash = "sha256:1dfa985f62b137909496e7fc182dac687206d8d089dd03eaeb28ae16eec8e7d5"}, + {file = "cryptography-2.9.2.tar.gz", hash = "sha256:a0c30272fb4ddda5f5ffc1089d7405b7a71b0b0f51993cb4e5dbb4590b2fc229"}, +] +dataclasses = [ + {file = "dataclasses-0.7-py3-none-any.whl", hash = "sha256:3459118f7ede7c8bea0fe795bff7c6c2ce287d01dd226202f7c9ebc0610a7836"}, + {file = "dataclasses-0.7.tar.gz", hash = "sha256:494a6dcae3b8bcf80848eea2ef64c0cc5cd307ffc263e17cdf42f3e5420808e6"}, +] +distlib = [ + {file = "distlib-0.3.0.zip", hash = "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21"}, +] +fabric = [ + {file = "fabric-2.5.0-py2.py3-none-any.whl", hash = "sha256:160331934ea60036604928e792fa8e9f813266b098ef5562aa82b88527740389"}, + {file = "fabric-2.5.0.tar.gz", hash = "sha256:24842d7d51556adcabd885ac3cf5e1df73fc622a1708bf3667bf5927576cdfa6"}, +] +filelock = [ + {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, + {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, +] +flake8 = [ + {file = "flake8-3.8.2-py2.py3-none-any.whl", hash = "sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5"}, + {file = "flake8-3.8.2.tar.gz", hash = "sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634"}, +] +grpcio = [ + {file = "grpcio-1.29.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e90f3d11185c36593186e5ff1f581acc6ddfa4190f145b0366e579de1f52803b"}, + {file = "grpcio-1.29.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:5024b26e17a1bfc9390fb3b8077bf886eee02970af780fd23072970ef08cefe8"}, + {file = "grpcio-1.29.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:23bc395a32c2465564cb242e48bdd2fdbe5a4aebf307649a800da1b971ee7f29"}, + {file = "grpcio-1.29.0-cp27-cp27m-win32.whl", hash = "sha256:886d48c32960b39e059494637eb0157a694956248d03b0de814447c188b74799"}, + {file = "grpcio-1.29.0-cp27-cp27m-win_amd64.whl", hash = "sha256:da0ca9b1089d00e39a8b83deec799a4e5c37ec1b44d804495424acde50531868"}, + {file = "grpcio-1.29.0-cp27-cp27mu-linux_armv7l.whl", hash = "sha256:ebf0ccb782027ef9e213e03b6d00bbd8dabd80959db7d468c0738e6d94b5204c"}, + {file = "grpcio-1.29.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2637ce96b7c954d2b71060f50eb4c72f81668f1b2faa6cbdc74677e405978901"}, + {file = "grpcio-1.29.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:75b2247307a7ecaf6abc9eb2bd04af8f88816c111b87bf0044d7924396e9549c"}, + {file = "grpcio-1.29.0-cp35-cp35m-linux_armv7l.whl", hash = "sha256:7bf3cb1e0f4a9c89f7b748583b994bdce183103d89d5ff486da48a7668a052c7"}, + {file = "grpcio-1.29.0-cp35-cp35m-macosx_10_7_intel.whl", hash = "sha256:a6dddb177b3cfa0cfe299fb9e07d6a3382cc79466bef48fe9c4326d5c5b1dcb8"}, + {file = "grpcio-1.29.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:b49f243936b0f6ae8eb6adf88a1e54e736f1c6724a1bff6b591d105d708263ad"}, + {file = "grpcio-1.29.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:9cfb4b71cc3c8757f137d47000f9d90d4bd818733f9ab4f78bd447e052a4cb9a"}, + {file = "grpcio-1.29.0-cp35-cp35m-win32.whl", hash = "sha256:10cdc8946a7c2284bbc8e16d346eaa2beeaae86ea598f345df86d4ef7dfedb84"}, + {file = "grpcio-1.29.0-cp35-cp35m-win_amd64.whl", hash = "sha256:806c9759f5589b3761561187408e0313a35c5c53f075c7590effab8d27d67dfe"}, + {file = "grpcio-1.29.0-cp36-cp36m-linux_armv7l.whl", hash = "sha256:57c8cc2ae8cb94c3a89671af7e1380a4cdfcd6bab7ba303f4461ec32ded250ae"}, + {file = "grpcio-1.29.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:97b72bf2242a351a89184134adbb0ae3b422e6893c6c712bc7669e2eab21501b"}, + {file = "grpcio-1.29.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:517538a54afdd67162ea2af1ac3326c0752c5d13e6ddadbc4885f6a28e91ab28"}, + {file = "grpcio-1.29.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:eede3039c3998e2cc0f6713f4ac70f235bd32967c9b958a17bf937aceebc12c3"}, + {file = "grpcio-1.29.0-cp36-cp36m-win32.whl", hash = "sha256:54e4658c09084b09cd83a5ea3a8bce78e4031ff1010bb8908c399a22a76a6f08"}, + {file = "grpcio-1.29.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7e02a7c40304eecee203f809a982732bd37fad4e798acad98fe73c66e44ff2db"}, + {file = "grpcio-1.29.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ff7931241351521b8df01d7448800ce0d59364321d8d82c49b826d455678ff08"}, + {file = "grpcio-1.29.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:5fd9ffe938e9225c654c60eb21ff011108cc27302db85200413807e0eda99a4a"}, + {file = "grpcio-1.29.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:9ef0370bcf629ece4e7e37796e4604e2514b920669be2911fc3f9c163a73a57b"}, + {file = "grpcio-1.29.0-cp37-cp37m-win32.whl", hash = "sha256:3d8c510b6eabce5192ce126003d74d7751c7218d3e2ad39fcf02400d7ec43abe"}, + {file = "grpcio-1.29.0-cp37-cp37m-win_amd64.whl", hash = "sha256:81bbf78a399e0ee516c81ddad8601f12af3fc9b30f2e4b2fbd64efd327304a4d"}, + {file = "grpcio-1.29.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:80e9f9f6265149ca7c84e1c8c31c2cf3e2869c45776fbe8880a3133a11d6d290"}, + {file = "grpcio-1.29.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:524ae8d3da61b856cf08abb3d0947df05402919e4be1f88328e0c1004031f72e"}, + {file = "grpcio-1.29.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c3a0ef12ee86f6e72db50e01c3dba7735a76d8c30104b9b0f7fd9d65ceb9d93f"}, + {file = "grpcio-1.29.0-cp38-cp38-win32.whl", hash = "sha256:97fcbdf1f12e0079d26db73da11ee35a09adc870b1e72fbff0211f6a8003a4e8"}, + {file = "grpcio-1.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:b85f355fc24b68a6c52f2750e7141110d1fcd07dfdc9b282de0000550fe0511b"}, + {file = "grpcio-1.29.0.tar.gz", hash = "sha256:a97ea91e31863c9a3879684b5fb3c6ab4b17c5431787548fc9f52b9483ea9c25"}, +] +grpcio-tools = [ + {file = "grpcio-tools-1.29.0.tar.gz", hash = "sha256:0f681c1ebd5472b804baa391b16dc59d92b065903999566f4776bfbd010bcec9"}, + {file = "grpcio_tools-1.29.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b504e844e6f3610f279e0fba719052a73d5acc858a82d5a1151155b3c2304478"}, + {file = "grpcio_tools-1.29.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:c52bcc2e5e9d93b805e6f292e543cbabeb9a751dc9d4d451c39d4c30ee311142"}, + {file = "grpcio_tools-1.29.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:5beffd530b496866b8e8dc811e942815a6e637669350c1341b5972bb692465cc"}, + {file = "grpcio_tools-1.29.0-cp27-cp27m-win32.whl", hash = "sha256:49dcf4c11ba2766d065c90a61eb1cefc55d5d094f93c1f66a4d98bfcbc5f740c"}, + {file = "grpcio_tools-1.29.0-cp27-cp27m-win_amd64.whl", hash = "sha256:bab2a3d627f114091a758d8a7ae48af54bff717f84bb34538fed5114982e73a5"}, + {file = "grpcio_tools-1.29.0-cp27-cp27mu-linux_armv7l.whl", hash = "sha256:2a1f27a21d09e864cdfcff22265af86d9a548ea9a775e5d6a27d7abb71c3b5aa"}, + {file = "grpcio_tools-1.29.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:56aade8ed52a6cca74a4703279aaae4aa2e2b87d0ccb5778f95d31267e74fc6b"}, + {file = "grpcio_tools-1.29.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:78075ee7459001cf5c81b1f2e3f047b63d35ed018b9e15e3abeda59b70af0a4e"}, + {file = "grpcio_tools-1.29.0-cp35-cp35m-linux_armv7l.whl", hash = "sha256:1626cd01a484f29cc9b33c3902851490149d40a550b92a382978571ca7e712cf"}, + {file = "grpcio_tools-1.29.0-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:2f1d80e3988d86477633fb39442a2310513d02fcc48881b359257a4be3cfd336"}, + {file = "grpcio_tools-1.29.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:8ffdcb1cbbc1bdfe249eb08c9fc6557b4f83b9f6145b5914bfd2973013d6dc1f"}, + {file = "grpcio_tools-1.29.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:7e52c8ed5e0157ff85493f93540e3c897c7d97be03afc73230d1022ba7b80528"}, + {file = "grpcio_tools-1.29.0-cp35-cp35m-win32.whl", hash = "sha256:f464d2efe04a46a17cf9493d67e6839aa535bb8a904cc6a2b588f1b156c9265d"}, + {file = "grpcio_tools-1.29.0-cp35-cp35m-win_amd64.whl", hash = "sha256:9de112c090ab67e90b8c36eee5876278c8d037bf7c55052848886c1e8a2dd1c2"}, + {file = "grpcio_tools-1.29.0-cp36-cp36m-linux_armv7l.whl", hash = "sha256:38ab9e8afdf34289eab85ce2343c451c36837bf2521b927b30d9a845304abf4c"}, + {file = "grpcio_tools-1.29.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1038b3d6cfd7206caf7c0a54ed06896e2aeb0a7d213a40d9000a70595e2fca21"}, + {file = "grpcio_tools-1.29.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:2a681ebfde0d83b70117cac745a97a3e5dc258fd817c1c1dd2bf99579b663a28"}, + {file = "grpcio_tools-1.29.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:47d13ddbbc2bd0e21a6109f74e731049b1d8738b5d0124580efca3721fe77fd2"}, + {file = "grpcio_tools-1.29.0-cp36-cp36m-win32.whl", hash = "sha256:fb9c46b8a0ee1a5990f29d891d6023cb92fdab9aed408194667df04f72e9caf6"}, + {file = "grpcio_tools-1.29.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f672a606a59145bacc58cf4c4bb407f107abe1289f607c09e9224c99e897ed1a"}, + {file = "grpcio_tools-1.29.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1a606f2f5b23822e2e5271bf0df98c140ceed154ea6bf5c04ea85a37a0317771"}, + {file = "grpcio_tools-1.29.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d89a43d14fb3043c1876e78d7ad5018c762b0ce51c199c588fa9142442546005"}, + {file = "grpcio_tools-1.29.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:faf845f71fcb6cb5088429c676ae644116d56e5de41c639be4d7399bf71b9637"}, + {file = "grpcio_tools-1.29.0-cp37-cp37m-win32.whl", hash = "sha256:05f214bc904c8e4ebf0240993a868895ff96184172243c0c61b323f6f029863d"}, + {file = "grpcio_tools-1.29.0-cp37-cp37m-win_amd64.whl", hash = "sha256:afcb030067ba1b6c371a7bfd1ffd77375534144000d47d245ca77ebbd195901d"}, + {file = "grpcio_tools-1.29.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b55346fa75df4b1581627022a2c79cfeb58cdaebf719cdbf63ff8ae6d7d7704b"}, + {file = "grpcio_tools-1.29.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:22d91ceb853f6846bcc23f15d8a936574eeb9fc7e8941bb8a1a5f8fcf4f566b2"}, + {file = "grpcio_tools-1.29.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6eddefcd10f261d2aef6c122fb0651a53fcaee86e47d407492c9acf57107c91a"}, + {file = "grpcio_tools-1.29.0-cp38-cp38-win32.whl", hash = "sha256:658e131e983f4c3bec2e096c3cc048e6420acad2b19fad82328c481088ce0d1a"}, + {file = "grpcio_tools-1.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:7c52f68e864f60ed51ea59a3fd18d0989720bbf2e32d47b4096eba7b0b7f7086"}, +] +identify = [ + {file = "identify-1.4.18-py2.py3-none-any.whl", hash = "sha256:9f53e80371f2ac7c969eefda8efaabd4f77c6300f5f8fc4b634744a0db8fe5cc"}, + {file = "identify-1.4.18.tar.gz", hash = "sha256:de4e1de6c23f52b71c8a54ff558219f3783ff011b432f29360d84a8a31ba561c"}, +] +importlib-metadata = [ + {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"}, + {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"}, +] +importlib-resources = [ + {file = "importlib_resources-1.5.0-py2.py3-none-any.whl", hash = "sha256:85dc0b9b325ff78c8bef2e4ff42616094e16b98ebd5e3b50fe7e2f0bbcdcde49"}, + {file = "importlib_resources-1.5.0.tar.gz", hash = "sha256:6f87df66833e1942667108628ec48900e02a4ab4ad850e25fbf07cb17cf734ca"}, +] +invoke = [ + {file = "invoke-1.4.1-py2-none-any.whl", hash = "sha256:93e12876d88130c8e0d7fd6618dd5387d6b36da55ad541481dfa5e001656f134"}, + {file = "invoke-1.4.1-py3-none-any.whl", hash = "sha256:87b3ef9d72a1667e104f89b159eaf8a514dbf2f3576885b2bbdefe74c3fb2132"}, + {file = "invoke-1.4.1.tar.gz", hash = "sha256:de3f23bfe669e3db1085789fd859eb8ca8e0c5d9c20811e2407fa042e8a5e15d"}, +] +isort = [ + {file = "isort-4.3.21-py2.py3-none-any.whl", hash = "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"}, + {file = "isort-4.3.21.tar.gz", hash = "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1"}, +] +lxml = [ + {file = "lxml-4.5.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ee2be8b8f72a2772e72ab926a3bccebf47bb727bda41ae070dc91d1fb759b726"}, + {file = "lxml-4.5.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:fadd2a63a2bfd7fb604508e553d1cf68eca250b2fbdbd81213b5f6f2fbf23529"}, + {file = "lxml-4.5.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:4f282737d187ae723b2633856085c31ae5d4d432968b7f3f478a48a54835f5c4"}, + {file = "lxml-4.5.1-cp27-cp27m-win32.whl", hash = "sha256:7fd88cb91a470b383aafad554c3fe1ccf6dfb2456ff0e84b95335d582a799804"}, + {file = "lxml-4.5.1-cp27-cp27m-win_amd64.whl", hash = "sha256:0790ddca3f825dd914978c94c2545dbea5f56f008b050e835403714babe62a5f"}, + {file = "lxml-4.5.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:9144ce36ca0824b29ebc2e02ca186e54040ebb224292072250467190fb613b96"}, + {file = "lxml-4.5.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:a636346c6c0e1092ffc202d97ec1843a75937d8c98aaf6771348ad6422e44bb0"}, + {file = "lxml-4.5.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:f95d28193c3863132b1f55c1056036bf580b5a488d908f7d22a04ace8935a3a9"}, + {file = "lxml-4.5.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b26719890c79a1dae7d53acac5f089d66fd8cc68a81f4e4bd355e45470dc25e1"}, + {file = "lxml-4.5.1-cp35-cp35m-win32.whl", hash = "sha256:a9e3b8011388e7e373565daa5e92f6c9cb844790dc18e43073212bb3e76f7007"}, + {file = "lxml-4.5.1-cp35-cp35m-win_amd64.whl", hash = "sha256:2754d4406438c83144f9ffd3628bbe2dcc6d62b20dbc5c1ec4bc4385e5d44b42"}, + {file = "lxml-4.5.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:925baf6ff1ef2c45169f548cc85204433e061360bfa7d01e1be7ae38bef73194"}, + {file = "lxml-4.5.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a87dbee7ad9dce3aaefada2081843caf08a44a8f52e03e0a4cc5819f8398f2f4"}, + {file = "lxml-4.5.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:51bb4edeb36d24ec97eb3e6a6007be128b720114f9a875d6b370317d62ac80b9"}, + {file = "lxml-4.5.1-cp36-cp36m-win32.whl", hash = "sha256:c79e5debbe092e3c93ca4aee44c9a7631bdd407b2871cb541b979fd350bbbc29"}, + {file = "lxml-4.5.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b7462cdab6fffcda853338e1741ce99706cdf880d921b5a769202ea7b94e8528"}, + {file = "lxml-4.5.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:06748c7192eab0f48e3d35a7adae609a329c6257495d5e53878003660dc0fec6"}, + {file = "lxml-4.5.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:1aa7a6197c1cdd65d974f3e4953764eee3d9c7b67e3966616b41fab7f8f516b7"}, + {file = "lxml-4.5.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:afb53edf1046599991fb4a7d03e601ab5f5422a5435c47ee6ba91ec3b61416a6"}, + {file = "lxml-4.5.1-cp37-cp37m-win32.whl", hash = "sha256:2d1ddce96cf15f1254a68dba6935e6e0f1fe39247de631c115e84dd404a6f031"}, + {file = "lxml-4.5.1-cp37-cp37m-win_amd64.whl", hash = "sha256:22c6d34fdb0e65d5f782a4d1a1edb52e0a8365858dafb1c08cb1d16546cf0786"}, + {file = "lxml-4.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c47a8a5d00060122ca5908909478abce7bbf62d812e3fc35c6c802df8fb01fe7"}, + {file = "lxml-4.5.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:b77975465234ff49fdad871c08aa747aae06f5e5be62866595057c43f8d2f62c"}, + {file = "lxml-4.5.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2b02c106709466a93ed424454ce4c970791c486d5fcdf52b0d822a7e29789626"}, + {file = "lxml-4.5.1-cp38-cp38-win32.whl", hash = "sha256:7eee37c1b9815e6505847aa5e68f192e8a1b730c5c7ead39ff317fde9ce29448"}, + {file = "lxml-4.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:d8d40e0121ca1606aa9e78c28a3a7d88a05c06b3ca61630242cded87d8ce55fa"}, + {file = "lxml-4.5.1.tar.gz", hash = "sha256:27ee0faf8077c7c1a589573b1450743011117f1aa1a91d5ae776bbc5ca6070f2"}, +] +mako = [ + {file = "Mako-1.1.3-py2.py3-none-any.whl", hash = "sha256:93729a258e4ff0747c876bd9e20df1b9758028946e976324ccd2d68245c7b6a9"}, + {file = "Mako-1.1.3.tar.gz", hash = "sha256:8195c8c1400ceb53496064314c6736719c6f25e7479cd24c77be3d9361cddc27"}, +] +markupsafe = [ + {file = "MarkupSafe-1.1.1-cp27-cp27m-macosx_10_6_intel.whl", hash = "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win32.whl", hash = "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b"}, + {file = "MarkupSafe-1.1.1-cp27-cp27m-win_amd64.whl", hash = "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f"}, + {file = "MarkupSafe-1.1.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win32.whl", hash = "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21"}, + {file = "MarkupSafe-1.1.1-cp34-cp34m-win_amd64.whl", hash = "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win32.whl", hash = "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1"}, + {file = "MarkupSafe-1.1.1-cp35-cp35m-win_amd64.whl", hash = "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win32.whl", hash = "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66"}, + {file = "MarkupSafe-1.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win32.whl", hash = "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2"}, + {file = "MarkupSafe-1.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win32.whl", hash = "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b"}, + {file = "MarkupSafe-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"}, + {file = "MarkupSafe-1.1.1.tar.gz", hash = "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b"}, +] +mccabe = [ + {file = "mccabe-0.6.1-py2.py3-none-any.whl", hash = "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42"}, + {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, +] +mock = [ + {file = "mock-4.0.2-py3-none-any.whl", hash = "sha256:3f9b2c0196c60d21838f307f5825a7b86b678cedc58ab9e50a8988187b4d81e0"}, + {file = "mock-4.0.2.tar.gz", hash = "sha256:dd33eb70232b6118298d516bbcecd26704689c386594f0f3c4f13867b2c56f72"}, +] +more-itertools = [ + {file = "more-itertools-8.3.0.tar.gz", hash = "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be"}, + {file = "more_itertools-8.3.0-py3-none-any.whl", hash = "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982"}, +] +netaddr = [ + {file = "netaddr-0.7.19-py2.py3-none-any.whl", hash = "sha256:56b3558bd71f3f6999e4c52e349f38660e54a7a8a9943335f73dfc96883e08ca"}, + {file = "netaddr-0.7.19.tar.gz", hash = "sha256:38aeec7cdd035081d3a4c306394b19d677623bf76fa0913f6695127c7753aefd"}, +] +nodeenv = [ + {file = "nodeenv-1.4.0-py2.py3-none-any.whl", hash = "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc"}, +] +packaging = [ + {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, + {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, +] +paramiko = [ + {file = "paramiko-2.7.1-py2.py3-none-any.whl", hash = "sha256:9c980875fa4d2cb751604664e9a2d0f69096643f5be4db1b99599fe114a97b2f"}, + {file = "paramiko-2.7.1.tar.gz", hash = "sha256:920492895db8013f6cc0179293147f830b8c7b21fdfc839b6bad760c27459d9f"}, +] +pillow = [ + {file = "Pillow-7.1.2-cp35-cp35m-macosx_10_10_intel.whl", hash = "sha256:ae2b270f9a0b8822b98655cb3a59cdb1bd54a34807c6c56b76dd2e786c3b7db3"}, + {file = "Pillow-7.1.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:d23e2aa9b969cf9c26edfb4b56307792b8b374202810bd949effd1c6e11ebd6d"}, + {file = "Pillow-7.1.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:b532bcc2f008e96fd9241177ec580829dee817b090532f43e54074ecffdcd97f"}, + {file = "Pillow-7.1.2-cp35-cp35m-win32.whl", hash = "sha256:12e4bad6bddd8546a2f9771485c7e3d2b546b458ae8ff79621214119ac244523"}, + {file = "Pillow-7.1.2-cp35-cp35m-win_amd64.whl", hash = "sha256:9744350687459234867cbebfe9df8f35ef9e1538f3e729adbd8fde0761adb705"}, + {file = "Pillow-7.1.2-cp36-cp36m-macosx_10_10_x86_64.whl", hash = "sha256:f54be399340aa602066adb63a86a6a5d4f395adfdd9da2b9a0162ea808c7b276"}, + {file = "Pillow-7.1.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:1f694e28c169655c50bb89a3fa07f3b854d71eb47f50783621de813979ba87f3"}, + {file = "Pillow-7.1.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f784aad988f12c80aacfa5b381ec21fd3f38f851720f652b9f33facc5101cf4d"}, + {file = "Pillow-7.1.2-cp36-cp36m-win32.whl", hash = "sha256:b37bb3bd35edf53125b0ff257822afa6962649995cbdfde2791ddb62b239f891"}, + {file = "Pillow-7.1.2-cp36-cp36m-win_amd64.whl", hash = "sha256:b67a6c47ed963c709ed24566daa3f95a18f07d3831334da570c71da53d97d088"}, + {file = "Pillow-7.1.2-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:eaa83729eab9c60884f362ada982d3a06beaa6cc8b084cf9f76cae7739481dfa"}, + {file = "Pillow-7.1.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f46e0e024346e1474083c729d50de909974237c72daca05393ee32389dabe457"}, + {file = "Pillow-7.1.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:0e2a3bceb0fd4e0cb17192ae506d5f082b309ffe5fc370a5667959c9b2f85fa3"}, + {file = "Pillow-7.1.2-cp37-cp37m-win32.whl", hash = "sha256:ccc9ad2460eb5bee5642eaf75a0438d7f8887d484490d5117b98edd7f33118b7"}, + {file = "Pillow-7.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b943e71c2065ade6fef223358e56c167fc6ce31c50bc7a02dd5c17ee4338e8ac"}, + {file = "Pillow-7.1.2-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:04766c4930c174b46fd72d450674612ab44cca977ebbcc2dde722c6933290107"}, + {file = "Pillow-7.1.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:f455efb7a98557412dc6f8e463c1faf1f1911ec2432059fa3e582b6000fc90e2"}, + {file = "Pillow-7.1.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:ee94fce8d003ac9fd206496f2707efe9eadcb278d94c271f129ab36aa7181344"}, + {file = "Pillow-7.1.2-cp38-cp38-win32.whl", hash = "sha256:4b02b9c27fad2054932e89f39703646d0c543f21d3cc5b8e05434215121c28cd"}, + {file = "Pillow-7.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:3d25dd8d688f7318dca6d8cd4f962a360ee40346c15893ae3b95c061cdbc4079"}, + {file = "Pillow-7.1.2-pp373-pypy36_pp73-win32.whl", hash = "sha256:0f01e63c34f0e1e2580cc0b24e86a5ccbbfa8830909a52ee17624c4193224cd9"}, + {file = "Pillow-7.1.2-py3.8-macosx-10.9-x86_64.egg", hash = "sha256:70e3e0d99a0dcda66283a185f80697a9b08806963c6149c8e6c5f452b2aa59c0"}, + {file = "Pillow-7.1.2.tar.gz", hash = "sha256:a0b49960110bc6ff5fead46013bcb8825d101026d466f3a4de3476defe0fb0dd"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +pre-commit = [ + {file = "pre_commit-2.1.1-py2.py3-none-any.whl", hash = "sha256:09ebe467f43ce24377f8c2f200fe3cd2570d328eb2ce0568c8e96ce19da45fa6"}, + {file = "pre_commit-2.1.1.tar.gz", hash = "sha256:f8d555e31e2051892c7f7b3ad9f620bd2c09271d87e9eedb2ad831737d6211eb"}, +] +protobuf = [ + {file = "protobuf-3.12.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e1464a4a2cf12f58f662c8e6421772c07947266293fb701cb39cd9c1e183f63c"}, + {file = "protobuf-3.12.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:6f349adabf1c004aba53f7b4633459f8ca8a09654bf7e69b509c95a454755776"}, + {file = "protobuf-3.12.2-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:be04fe14ceed7f8641e30f36077c1a654ff6f17d0c7a5283b699d057d150d82a"}, + {file = "protobuf-3.12.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f4b73736108a416c76c17a8a09bc73af3d91edaa26c682aaa460ef91a47168d3"}, + {file = "protobuf-3.12.2-cp35-cp35m-win32.whl", hash = "sha256:5524c7020eb1fb7319472cb75c4c3206ef18b34d6034d2ee420a60f99cddeb07"}, + {file = "protobuf-3.12.2-cp35-cp35m-win_amd64.whl", hash = "sha256:bff02030bab8b969f4de597543e55bd05e968567acb25c0a87495a31eb09e925"}, + {file = "protobuf-3.12.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:c9ca9f76805e5a637605f171f6c4772fc4a81eced4e2f708f79c75166a2c99ea"}, + {file = "protobuf-3.12.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:304e08440c4a41a0f3592d2a38934aad6919d692bb0edfb355548786728f9a5e"}, + {file = "protobuf-3.12.2-cp36-cp36m-win32.whl", hash = "sha256:b5a114ea9b7fc90c2cc4867a866512672a47f66b154c6d7ee7e48ddb68b68122"}, + {file = "protobuf-3.12.2-cp36-cp36m-win_amd64.whl", hash = "sha256:85b94d2653b0fdf6d879e39d51018bf5ccd86c81c04e18a98e9888694b98226f"}, + {file = "protobuf-3.12.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a7ab28a8f1f043c58d157bceb64f80e4d2f7f1b934bc7ff5e7f7a55a337ea8b0"}, + {file = "protobuf-3.12.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:eafe9fa19fcefef424ee089fb01ac7177ff3691af7cc2ae8791ae523eb6ca907"}, + {file = "protobuf-3.12.2-cp37-cp37m-win32.whl", hash = "sha256:612bc97e42b22af10ba25e4140963fbaa4c5181487d163f4eb55b0b15b3dfcd2"}, + {file = "protobuf-3.12.2-cp37-cp37m-win_amd64.whl", hash = "sha256:e72736dd822748b0721f41f9aaaf6a5b6d5cfc78f6c8690263aef8bba4457f0e"}, + {file = "protobuf-3.12.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:87535dc2d2ef007b9d44e309d2b8ea27a03d2fa09556a72364d706fcb7090828"}, + {file = "protobuf-3.12.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:50b5fee674878b14baea73b4568dc478c46a31dd50157a5b5d2f71138243b1a9"}, + {file = "protobuf-3.12.2-py2.py3-none-any.whl", hash = "sha256:a96f8fc625e9ff568838e556f6f6ae8eca8b4837cdfb3f90efcb7c00e342a2eb"}, + {file = "protobuf-3.12.2.tar.gz", hash = "sha256:49ef8ab4c27812a89a76fa894fe7a08f42f2147078392c0dee51d4a444ef6df5"}, +] +py = [ + {file = "py-1.8.1-py2.py3-none-any.whl", hash = "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"}, + {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, +] +pycodestyle = [ + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, +] +pycparser = [ + {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, + {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, +] +pyflakes = [ + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, +] +pynacl = [ + {file = "PyNaCl-1.4.0-cp27-cp27m-macosx_10_10_x86_64.whl", hash = "sha256:ea6841bc3a76fa4942ce00f3bda7d436fda21e2d91602b9e21b7ca9ecab8f3ff"}, + {file = "PyNaCl-1.4.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:d452a6746f0a7e11121e64625109bc4468fc3100452817001dbe018bb8b08514"}, + {file = "PyNaCl-1.4.0-cp27-cp27m-win32.whl", hash = "sha256:2fe0fc5a2480361dcaf4e6e7cea00e078fcda07ba45f811b167e3f99e8cff574"}, + {file = "PyNaCl-1.4.0-cp27-cp27m-win_amd64.whl", hash = "sha256:f8851ab9041756003119368c1e6cd0b9c631f46d686b3904b18c0139f4419f80"}, + {file = "PyNaCl-1.4.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:7757ae33dae81c300487591c68790dfb5145c7d03324000433d9a2c141f82af7"}, + {file = "PyNaCl-1.4.0-cp35-abi3-macosx_10_10_x86_64.whl", hash = "sha256:757250ddb3bff1eecd7e41e65f7f833a8405fede0194319f87899690624f2122"}, + {file = "PyNaCl-1.4.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:30f9b96db44e09b3304f9ea95079b1b7316b2b4f3744fe3aaecccd95d547063d"}, + {file = "PyNaCl-1.4.0-cp35-cp35m-win32.whl", hash = "sha256:06cbb4d9b2c4bd3c8dc0d267416aaed79906e7b33f114ddbf0911969794b1cc4"}, + {file = "PyNaCl-1.4.0-cp35-cp35m-win_amd64.whl", hash = "sha256:511d269ee845037b95c9781aa702f90ccc36036f95d0f31373a6a79bd8242e25"}, + {file = "PyNaCl-1.4.0-cp36-cp36m-win32.whl", hash = "sha256:11335f09060af52c97137d4ac54285bcb7df0cef29014a1a4efe64ac065434c4"}, + {file = "PyNaCl-1.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cd401ccbc2a249a47a3a1724c2918fcd04be1f7b54eb2a5a71ff915db0ac51c6"}, + {file = "PyNaCl-1.4.0-cp37-cp37m-win32.whl", hash = "sha256:8122ba5f2a2169ca5da936b2e5a511740ffb73979381b4229d9188f6dcb22f1f"}, + {file = "PyNaCl-1.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:537a7ccbea22905a0ab36ea58577b39d1fa9b1884869d173b5cf111f006f689f"}, + {file = "PyNaCl-1.4.0-cp38-cp38-win32.whl", hash = "sha256:9c4a7ea4fb81536c1b1f5cc44d54a296f96ae78c1ebd2311bd0b60be45a48d96"}, + {file = "PyNaCl-1.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:7c6092102219f59ff29788860ccb021e80fffd953920c4a8653889c029b2d420"}, + {file = "PyNaCl-1.4.0.tar.gz", hash = "sha256:54e9a2c849c742006516ad56a88f5c74bf2ce92c9f67435187c3c5953b346505"}, +] +pyparsing = [ + {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, + {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, +] +pyproj = [ + {file = "pyproj-2.6.1.post1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:457ad3856014ac26af1d86def6dc8cf69c1fa377b6e2fd6e97912d51cf66bdbe"}, + {file = "pyproj-2.6.1.post1-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:6f3f36440ea61f5f6da4e6beb365dddcbe159815450001d9fb753545affa45ff"}, + {file = "pyproj-2.6.1.post1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6a212d0e5c7efa33d039f0c8b0a489e2204fcd28b56206567852ad7f5f2a653e"}, + {file = "pyproj-2.6.1.post1-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:451a3d1c563b672458029ebc04acbb3266cd8b3025268eb871a9176dc3638911"}, + {file = "pyproj-2.6.1.post1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e015f900b4b84e908f8035ab16ebf02d67389c1c216c17a2196fc2e515c00762"}, + {file = "pyproj-2.6.1.post1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:a13e5731b3a360ee7fbd1e9199ec9203fafcece8ebd0b1351f16d0a90cad6828"}, + {file = "pyproj-2.6.1.post1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:33c1c2968a4f4f87d517c4275a18b557e5c13907cf2609371fadea8463c3ba05"}, + {file = "pyproj-2.6.1.post1-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:3fef83a01c1e86dd9fa99d8214f749837cfafc34d9d6230b4b0a998fa7a68a1a"}, + {file = "pyproj-2.6.1.post1-cp36-cp36m-win32.whl", hash = "sha256:a6ac4861979cd05a0f5400fefa41d26c0269a5fb8237618aef7c998907db39e1"}, + {file = "pyproj-2.6.1.post1-cp36-cp36m-win_amd64.whl", hash = "sha256:cbf6ccf990860b06c5262ff97c4b78e1d07883981635cd53a6aa438a68d92945"}, + {file = "pyproj-2.6.1.post1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:adacb67a9f71fb54ca1b887a6ab20f32dd536fcdf2acec84a19e25ad768f7965"}, + {file = "pyproj-2.6.1.post1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:e50d5d20b87758acf8f13f39a3b3eb21d5ef32339d2bc8cdeb8092416e0051df"}, + {file = "pyproj-2.6.1.post1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2518d1606e2229b82318e704b40290e02a2a52d77b40cdcb2978973d6fc27b20"}, + {file = "pyproj-2.6.1.post1-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:33a5d1cfbb40a019422eb80709a0e270704390ecde7278fdc0b88f3647c56a39"}, + {file = "pyproj-2.6.1.post1-cp37-cp37m-win32.whl", hash = "sha256:daf2998e3f5bcdd579a18faf009f37f53538e9b7d0a252581a610297d31e8536"}, + {file = "pyproj-2.6.1.post1-cp37-cp37m-win_amd64.whl", hash = "sha256:a8b7c8accdc61dac8e91acab7c1f7b4590d1e102f2ee9b1f1e6399fad225958e"}, + {file = "pyproj-2.6.1.post1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9f097e8f341a162438918e908be86d105a28194ff6224633b2e9616c5031153f"}, + {file = "pyproj-2.6.1.post1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:d90a5d1fdd066b0e9b22409b0f5e81933469918fa04c2cf7f9a76ce84cb29dad"}, + {file = "pyproj-2.6.1.post1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:f5a8015c74ec8f6508aebf493b58ba20ccb4da8168bf05f0c2a37faccb518da9"}, + {file = "pyproj-2.6.1.post1-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:d87836be6b720fb4d9c112136aa47621b6ca09a554e645c1081561eb8e2fa1f4"}, + {file = "pyproj-2.6.1.post1-cp38-cp38-win32.whl", hash = "sha256:bc2f3a15d065e206d63edd2cc4739aa0a35c05338ee276ab1dc72f56f1944bda"}, + {file = "pyproj-2.6.1.post1-cp38-cp38-win_amd64.whl", hash = "sha256:93cbad7b699e8e80def7de80c350617f35e6a0b82862f8ce3c014657c25fdb3c"}, + {file = "pyproj-2.6.1.post1.tar.gz", hash = "sha256:4f5b02b4abbd41610397c635b275a8ee4a2b5bc72a75572b98ac6ae7befa471e"}, +] +pytest = [ + {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, + {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, +] +pyyaml = [ + {file = "PyYAML-5.3.1-cp27-cp27m-win32.whl", hash = "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f"}, + {file = "PyYAML-5.3.1-cp27-cp27m-win_amd64.whl", hash = "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win32.whl", hash = "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2"}, + {file = "PyYAML-5.3.1-cp35-cp35m-win_amd64.whl", hash = "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win32.whl", hash = "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2"}, + {file = "PyYAML-5.3.1-cp36-cp36m-win_amd64.whl", hash = "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win32.whl", hash = "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"}, + {file = "PyYAML-5.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf"}, + {file = "PyYAML-5.3.1-cp38-cp38-win32.whl", hash = "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97"}, + {file = "PyYAML-5.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee"}, + {file = "PyYAML-5.3.1.tar.gz", hash = "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d"}, +] +six = [ + {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, + {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, +] +toml = [ + {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, + {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, +] +virtualenv = [ + {file = "virtualenv-20.0.21-py2.py3-none-any.whl", hash = "sha256:a730548b27366c5e6cbdf6f97406d861cccece2e22275e8e1a757aeff5e00c70"}, + {file = "virtualenv-20.0.21.tar.gz", hash = "sha256:a116629d4e7f4d03433b8afa27f43deba09d48bc48f5ecefa4f015a178efb6cf"}, +] +wcwidth = [ + {file = "wcwidth-0.2.3-py2.py3-none-any.whl", hash = "sha256:980fbf4f3c196c0f329cdcd1e84c554d6a211f18e252e525a0cf4223154a41d6"}, + {file = "wcwidth-0.2.3.tar.gz", hash = "sha256:edbc2b718b4db6cdf393eefe3a420183947d6aa312505ce6754516f458ff8830"}, +] +zipp = [ + {file = "zipp-3.1.0-py3-none-any.whl", hash = "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b"}, + {file = "zipp-3.1.0.tar.gz", hash = "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96"}, +] diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml new file mode 100644 index 00000000..6df5f10e --- /dev/null +++ b/daemon/pyproject.toml @@ -0,0 +1,33 @@ +[tool.poetry] +name = "core" +version = "6.4.0" +description = "" +authors = [] + +[tool.poetry.dependencies] +python = "^3.6" +dataclasses = { version = "*", python = "3.6" } +fabric = "*" +grpcio = "*" +invoke = "*" +lxml = "*" +mako = "*" +netaddr = "*" +pillow = "*" +protobuf = "*" +pyproj = "*" +pyyaml = "*" + +[tool.poetry.dev-dependencies] +black = "==19.3b0" +flake8 = "*" +grpcio-tools = "*" +isort = "*" +mock = "*" +pre-commit = "*" +pytest = "*" + +[build-system] +requires = ["poetry>=0.12"] +build-backend = "poetry.masonry.api" + diff --git a/tasks.py b/tasks.py new file mode 100644 index 00000000..74e6af76 --- /dev/null +++ b/tasks.py @@ -0,0 +1,14 @@ +from invoke import task + + +@task +def core(c): + c.run( + "poetry run sudo python3 scripts/core-daemon " + "-f data/core.conf -l data/logging.conf" + ) + + +@task +def core_pygui(c): + c.run("poetry run python3 scripts/core-pygui") From 1884103cb4f54f475a8ab88ef5e31663a4bc3fdf Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 3 Jun 2020 08:47:36 -0700 Subject: [PATCH 101/929] grpc: added call to stream node movements using geo/xy and tests to validate usage, fixed potential exception when not setting session geo ref and using conversions --- daemon/core/api/grpc/client.py | 13 ++++- daemon/core/api/grpc/server.py | 34 +++++++++++++ daemon/core/location/geo.py | 4 +- daemon/proto/core/api/grpc/core.proto | 15 ++++++ daemon/tests/test_grpc.py | 70 ++++++++++++++++++++++++++- 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index a645c756..280b1cd8 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -5,7 +5,7 @@ gRpc client for interfacing with CORE, when gRPC mode is enabled. import logging import threading from contextlib import contextmanager -from typing import Any, Callable, Dict, Generator, List +from typing import Any, Callable, Dict, Generator, Iterable, List import grpc import netaddr @@ -571,6 +571,17 @@ class CoreGrpcClient: ) return self.stub.EditNode(request) + def move_nodes( + self, move_iterator: Iterable[core_pb2.MoveNodesRequest] + ) -> core_pb2.MoveNodesResponse: + """ + Stream node movements using the provided iterator. + + :param move_iterator: iterator for generating node movements + :return: move nodes response + """ + return self.stub.MoveNodes(move_iterator) + def delete_node(self, session_id: int, node_id: int) -> core_pb2.DeleteNodeResponse: """ Delete node from session. diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 16da7e6b..972153e7 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -692,6 +692,40 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): node_proto = grpcutils.get_node_proto(session, node) return core_pb2.GetNodeResponse(node=node_proto, interfaces=interfaces) + def MoveNodes( + self, request_iterator, context: ServicerContext + ) -> core_pb2.MoveNodesResponse: + """ + Stream node movements + + :param request_iterator: move nodes request iterator + :param context: context object + :return: move nodes response + """ + for request in request_iterator: + if not request.WhichOneof("move_type"): + raise CoreError("move nodes must provide a move type") + session = self.get_session(request.session_id, context) + node = self.get_node(session, request.node_id, context, NodeBase) + options = NodeOptions() + has_geo = request.HasField("geo") + if has_geo: + logging.info("has geo") + lat = request.geo.lat + lon = request.geo.lon + alt = request.geo.alt + options.set_location(lat, lon, alt) + else: + x = request.position.x + y = request.position.y + logging.info("has pos: %s,%s", x, y) + options.set_position(x, y) + session.edit_node(node.id, options) + source = request.source if request.source else None + if not has_geo: + session.broadcast_node(node, source=source) + return core_pb2.MoveNodesResponse() + def EditNode( self, request: core_pb2.EditNodeRequest, context: ServicerContext ) -> core_pb2.EditNodeResponse: diff --git a/daemon/core/location/geo.py b/daemon/core/location/geo.py index 1f78f329..4ff56dd6 100644 --- a/daemon/core/location/geo.py +++ b/daemon/core/location/geo.py @@ -31,7 +31,7 @@ class GeoLocation: CRS_WGS84, CRS_PROJ, always_xy=True ) self.to_geo = pyproj.Transformer.from_crs(CRS_PROJ, CRS_WGS84, always_xy=True) - self.refproj = (0.0, 0.0) + self.refproj = (0.0, 0.0, 0.0) self.refgeo = (0.0, 0.0, 0.0) self.refxyz = (0.0, 0.0, 0.0) self.refscale = 1.0 @@ -58,7 +58,7 @@ class GeoLocation: self.refxyz = (0.0, 0.0, 0.0) self.refgeo = (0.0, 0.0, 0.0) self.refscale = 1.0 - self.refproj = self.to_pixels.transform(self.refgeo[0], self.refgeo[1]) + self.refproj = self.to_pixels.transform(*self.refgeo) def pixels2meters(self, value: float) -> float: """ diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index b0ae6642..cdcd9686 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -61,6 +61,8 @@ service CoreApi { } rpc GetNodeTerminal (GetNodeTerminalRequest) returns (GetNodeTerminalResponse) { } + rpc MoveNodes (stream MoveNodesRequest) returns (MoveNodesResponse) { + } // link rpc rpc GetNodeLinks (GetNodeLinksRequest) returns (GetNodeLinksResponse) { @@ -446,6 +448,19 @@ message GetNodeTerminalResponse { string terminal = 1; } +message MoveNodesRequest { + int32 session_id = 1; + int32 node_id = 2; + string source = 3; + oneof move_type { + Position position = 4; + Geo geo = 5; + } +} + +message MoveNodesResponse { +} + message NodeCommandRequest { int32 session_id = 1; int32 node_id = 2; diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 47cfe744..128863b4 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -18,7 +18,7 @@ from core.api.tlv.dataconversion import ConfigShim from core.api.tlv.enumerations import ConfigFlags from core.emane.ieee80211abg import EmaneIeee80211abgModel from core.emane.nodes import EmaneNet -from core.emulator.data import EventData +from core.emulator.data import EventData, NodeData from core.emulator.emudata import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes, ExceptionLevels, NodeTypes from core.errors import CoreError @@ -1170,3 +1170,71 @@ class TestGrpc: # then queue.get(timeout=5) + + def test_move_nodes(self, grpc_server: CoreGrpcServer): + # given + client = CoreGrpcClient() + session = grpc_server.coreemu.create_session() + node = session.add_node(CoreNode) + x, y = 10.0, 15.0 + + def move_iter(): + yield core_pb2.MoveNodesRequest( + session_id=session.id, + node_id=node.id, + position=core_pb2.Position(x=x, y=y), + ) + + # then + with client.context_connect(): + client.move_nodes(move_iter()) + + # assert + assert node.position.x == x + assert node.position.y == y + + def test_move_nodes_geo(self, grpc_server: CoreGrpcServer): + # given + client = CoreGrpcClient() + session = grpc_server.coreemu.create_session() + node = session.add_node(CoreNode) + lon, lat, alt = 10.0, 15.0, 5.0 + queue = Queue() + + def node_handler(node_data: NodeData): + assert node_data.longitude == lon + assert node_data.latitude == lat + assert node_data.altitude == alt + queue.put(node_data) + + session.node_handlers.append(node_handler) + + def move_iter(): + yield core_pb2.MoveNodesRequest( + session_id=session.id, + node_id=node.id, + geo=core_pb2.Geo(lon=lon, lat=lat, alt=alt), + ) + + # then + with client.context_connect(): + client.move_nodes(move_iter()) + + # assert + assert node.position.lon == lon + assert node.position.lat == lat + assert node.position.alt == alt + assert queue.get(timeout=5) + + def test_move_nodes_exception(self, grpc_server: CoreGrpcServer): + # given + client = CoreGrpcClient() + grpc_server.coreemu.create_session() + + def move_iter(): + yield core_pb2.MoveNodesRequest() + + # then + with pytest.raises(grpc.RpcError): + with client.context_connect(): + client.move_nodes(move_iter()) From 3b0ca1638c2725e481e6b52cbfd9dec253892e33 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 3 Jun 2020 14:35:17 -0700 Subject: [PATCH 102/929] grpc: implemened initial support for streaming emane pathloss events --- daemon/core/api/grpc/client.py | 13 +++++++++++++ daemon/core/api/grpc/grpcutils.py | 24 +++++++++++++++++++++++- daemon/core/api/grpc/server.py | 18 +++++++++++++++++- daemon/core/emane/emanemanager.py | 18 +++++++++++++++++- daemon/proto/core/api/grpc/core.proto | 2 ++ daemon/proto/core/api/grpc/emane.proto | 13 +++++++++++++ 6 files changed, 85 insertions(+), 3 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 280b1cd8..6aaf7fac 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -31,6 +31,8 @@ from core.api.grpc.emane_pb2 import ( EmaneLinkRequest, EmaneLinkResponse, EmaneModelConfig, + EmanePathlossesRequest, + EmanePathlossesResponse, GetEmaneConfigRequest, GetEmaneConfigResponse, GetEmaneEventChannelRequest, @@ -1229,6 +1231,17 @@ class CoreGrpcClient: ) return self.stub.WlanLink(request) + def emane_pathlosses( + self, pathloss_iter: Iterable[EmanePathlossesRequest] + ) -> EmanePathlossesResponse: + """ + Stream EMANE pathloss events. + + :param pathloss_iter: iterator for sending EMANE pathloss events + :return: EMANE pathloss response + """ + return self.stub.EmanePathlosses(pathloss_iter) + def connect(self) -> None: """ Open connection to server, must be closed manually. diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 8a69c40f..b0c1e614 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -2,7 +2,9 @@ import logging import time from typing import Any, Dict, List, Tuple, Type +import grpc import netaddr +from grpc import ServicerContext from core import utils from core.api.grpc import common_pb2, core_pb2 @@ -13,7 +15,7 @@ from core.emulator.data import LinkData from core.emulator.emudata import InterfaceData, LinkOptions, NodeOptions from core.emulator.enumerations import LinkTypes, NodeTypes from core.emulator.session import Session -from core.nodes.base import NodeBase +from core.nodes.base import CoreNode, NodeBase from core.nodes.interface import CoreInterface from core.services.coreservices import CoreService @@ -478,3 +480,23 @@ def interface_to_proto(interface: CoreInterface) -> core_pb2.Interface: ip6=ip6, ip6mask=ip6mask, ) + + +def get_nem_id(node: CoreNode, netif_id: int, context: ServicerContext) -> int: + """ + Get nem id for a given node and interface id. + + :param node: node to get nem id for + :param netif_id: id of interface on node to get nem id for + :param context: request context + :return: nem id + """ + netif = node.netif(netif_id) + if not netif: + message = f"{node.name} missing interface {netif_id}" + context.abort(grpc.StatusCode.NOT_FOUND, message) + net = netif.net + if not isinstance(net, EmaneNet): + message = f"{node.name} interface {netif_id} is not an EMANE network" + context.abort(grpc.StatusCode.INVALID_ARGUMENT, message) + return net.getnemid(netif) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 972153e7..1d13ec63 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -6,7 +6,7 @@ import tempfile import threading import time from concurrent import futures -from typing import Type +from typing import Iterable, Type import grpc from grpc import ServicerContext @@ -39,6 +39,8 @@ from core.api.grpc.core_pb2 import ExecuteScriptResponse from core.api.grpc.emane_pb2 import ( EmaneLinkRequest, EmaneLinkResponse, + EmanePathlossesRequest, + EmanePathlossesResponse, GetEmaneConfigRequest, GetEmaneConfigResponse, GetEmaneEventChannelRequest, @@ -1751,3 +1753,17 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): wlan.model.sendlinkmsg(n1_netif, n2_netif, unlink=not request.linked) result = True return WlanLinkResponse(result=result) + + def EmanePathlosses( + self, + request_iterator: Iterable[EmanePathlossesRequest], + context: ServicerContext, + ) -> EmanePathlossesResponse: + for request in request_iterator: + session = self.get_session(request.session_id, context) + n1 = self.get_node(session, request.node_one, context, CoreNode) + nem1 = grpcutils.get_nem_id(n1, request.interface_one_id, context) + n2 = self.get_node(session, request.node_two, context, CoreNode) + nem2 = grpcutils.get_nem_id(n2, request.interface_two_id, context) + session.emane.publish_pathloss(nem1, nem2, request.rx_one, request.rx_two) + return EmanePathlossesResponse() diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 438fde00..12b477f0 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -37,7 +37,7 @@ if TYPE_CHECKING: from core.emulator.session import Session try: - from emane.events import EventService + from emane.events import EventService, PathlossEvent from emane.events import LocationEvent from emane.events.eventserviceexception import EventServiceException except ImportError: @@ -48,6 +48,7 @@ except ImportError: except ImportError: EventService = None LocationEvent = None + PathlossEvent = None EventServiceException = None logging.debug("compatible emane python bindings not installed") @@ -868,6 +869,21 @@ class EmaneManager(ModelManager): result = False return result + def publish_pathloss(self, nem1: int, nem2: int, rx1: float, rx2: float) -> None: + """ + Publish pathloss events between provided nems, using provided rx power. + :param nem1: interface one for pathloss + :param nem2: interface two for pathloss + :param rx1: received power from nem2 to nem1 + :param rx2: received power from nem1 to nem2 + :return: nothing + """ + event = PathlossEvent() + event.append(nem1, forward=rx1) + event.append(nem2, forward=rx2) + self.service.publish(nem1, event) + self.service.publish(nem2, event) + class EmaneGlobalModel: """ diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index cdcd9686..1d967d49 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -149,6 +149,8 @@ service CoreApi { } rpc GetEmaneEventChannel (emane.GetEmaneEventChannelRequest) returns (emane.GetEmaneEventChannelResponse) { } + rpc EmanePathlosses (stream emane.EmanePathlossesRequest) returns (emane.EmanePathlossesResponse) { + } // xml rpc rpc SaveXml (SaveXmlRequest) returns (SaveXmlResponse) { diff --git a/daemon/proto/core/api/grpc/emane.proto b/daemon/proto/core/api/grpc/emane.proto index 33cb1a2a..8c3ee4ca 100644 --- a/daemon/proto/core/api/grpc/emane.proto +++ b/daemon/proto/core/api/grpc/emane.proto @@ -90,3 +90,16 @@ message EmaneModelConfig { string model = 3; map config = 4; } + +message EmanePathlossesRequest { + int32 session_id = 1; + int32 node_one = 2; + float rx_one = 3; + int32 interface_one_id = 4; + int32 node_two = 5; + float rx_two = 6; + int32 interface_two_id = 7; +} + +message EmanePathlossesResponse { +} From 29d09c8397b71b54966566e1cf16b2d09e92c5cc Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 3 Jun 2020 14:58:29 -0700 Subject: [PATCH 103/929] updates to move_nodes and emane_pathlosses type hinting and naming --- daemon/core/api/grpc/client.py | 6 +++--- daemon/core/api/grpc/server.py | 4 +++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 6aaf7fac..1b353bd7 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -1232,15 +1232,15 @@ class CoreGrpcClient: return self.stub.WlanLink(request) def emane_pathlosses( - self, pathloss_iter: Iterable[EmanePathlossesRequest] + self, pathloss_iterator: Iterable[EmanePathlossesRequest] ) -> EmanePathlossesResponse: """ Stream EMANE pathloss events. - :param pathloss_iter: iterator for sending EMANE pathloss events + :param pathloss_iterator: iterator for sending EMANE pathloss events :return: EMANE pathloss response """ - return self.stub.EmanePathlosses(pathloss_iter) + return self.stub.EmanePathlosses(pathloss_iterator) def connect(self) -> None: """ diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 1d13ec63..fcf69d99 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -695,7 +695,9 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): return core_pb2.GetNodeResponse(node=node_proto, interfaces=interfaces) def MoveNodes( - self, request_iterator, context: ServicerContext + self, + request_iterator: Iterable[core_pb2.MoveNodesRequest], + context: ServicerContext, ) -> core_pb2.MoveNodesResponse: """ Stream node movements From 7b2dd59c81edeb10caa982c22e0bd297c3b7c7ec Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 4 Jun 2020 13:48:25 -0700 Subject: [PATCH 104/929] grpc: node_command improvements to include return code and options for wait and shell when running commands --- daemon/core/api/grpc/client.py | 15 +++++++++++++-- daemon/core/api/grpc/server.py | 6 ++++-- daemon/proto/core/api/grpc/core.proto | 3 +++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 1b353bd7..68310c67 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -597,7 +597,12 @@ class CoreGrpcClient: return self.stub.DeleteNode(request) def node_command( - self, session_id: int, node_id: int, command: str + self, + session_id: int, + node_id: int, + command: str, + wait: bool = True, + shell: bool = False, ) -> core_pb2.NodeCommandResponse: """ Send command to a node and get the output. @@ -605,11 +610,17 @@ class CoreGrpcClient: :param session_id: session id :param node_id: node id :param command: command to run on node + :param wait: wait for command to complete + :param shell: send shell command :return: response with command combined stdout/stderr :raises grpc.RpcError: when session or node doesn't exist """ request = core_pb2.NodeCommandRequest( - session_id=session_id, node_id=node_id, command=command + session_id=session_id, + node_id=node_id, + command=command, + wait=wait, + shell=shell, ) return self.stub.NodeCommand(request) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index fcf69d99..f85529e6 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -796,10 +796,12 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): session = self.get_session(request.session_id, context) node = self.get_node(session, request.node_id, context, CoreNode) try: - output = node.cmd(request.command) + output = node.cmd(request.command, request.wait, request.shell) + return_code = 0 except CoreCommandError as e: output = e.stderr - return core_pb2.NodeCommandResponse(output=output) + return_code = e.returncode + return core_pb2.NodeCommandResponse(output=output, return_code=return_code) def GetNodeTerminal( self, request: core_pb2.GetNodeTerminalRequest, context: ServicerContext diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 1d967d49..d602f9d3 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -467,10 +467,13 @@ message NodeCommandRequest { int32 session_id = 1; int32 node_id = 2; string command = 3; + bool wait = 4; + bool shell = 5; } message NodeCommandResponse { string output = 1; + int32 return_code = 2; } message GetNodeLinksRequest { From eaa05c34babf0e5bc7d87b229aa4dfd746134794 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 4 Jun 2020 21:14:11 -0700 Subject: [PATCH 105/929] avoid piping subprocess command output when not waiting for results --- daemon/core/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/daemon/core/utils.py b/daemon/core/utils.py index 8a988ede..c16d18b5 100644 --- a/daemon/core/utils.py +++ b/daemon/core/utils.py @@ -228,7 +228,8 @@ def cmd( if shell is False: args = shlex.split(args) try: - p = Popen(args, stdout=PIPE, stderr=PIPE, env=env, cwd=cwd, shell=shell) + output = PIPE if wait else DEVNULL + p = Popen(args, stdout=output, stderr=output, env=env, cwd=cwd, shell=shell) if wait: stdout, stderr = p.communicate() stdout = stdout.decode("utf-8").strip() From 9a5fc94ba22406e12dd84ebb72acd95ff47820fc Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 5 Jun 2020 08:44:19 -0700 Subject: [PATCH 106/929] improvements for grpc docs and upates to grpc client pydocs --- daemon/core/api/grpc/client.py | 97 ++++++++++++++++++++++++++++++---- 1 file changed, 86 insertions(+), 11 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 68310c67..cabd6dda 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -1,5 +1,5 @@ """ -gRpc client for interfacing with CORE, when gRPC mode is enabled. +gRpc client for interfacing with CORE. """ import logging @@ -289,6 +289,7 @@ class CoreGrpcClient: :param session_id: id of session :return: stop session response + :raises grpc.RpcError: when session doesn't exist """ request = core_pb2.StopSessionRequest(session_id=session_id) return self.stub.StopSession(request) @@ -581,6 +582,7 @@ class CoreGrpcClient: :param move_iterator: iterator for generating node movements :return: move nodes response + :raises grpc.RpcError: when session or nodes do not exist """ return self.stub.MoveNodes(move_iterator) @@ -1122,9 +1124,9 @@ class CoreGrpcClient: def get_emane_model_configs(self, session_id: int) -> GetEmaneModelConfigsResponse: """ - Get all emane model configurations for a session. + Get all EMANE model configurations for a session. - :param session_id: session id + :param session_id: session to get emane model configs :return: response with a dictionary of node/interface ids to configurations :raises grpc.RpcError: when session doesn't exist """ @@ -1135,9 +1137,10 @@ class CoreGrpcClient: """ Save the current scenario to an XML file. - :param session_id: session id + :param session_id: session to save xml file for :param file_path: local path to save scenario XML file to :return: nothing + :raises grpc.RpcError: when session doesn't exist """ request = core_pb2.SaveXmlRequest(session_id=session_id) response = self.stub.SaveXml(request) @@ -1163,11 +1166,12 @@ class CoreGrpcClient: """ Helps broadcast wireless link/unlink between EMANE nodes. - :param session_id: session id - :param nem_one: - :param nem_two: + :param session_id: session to emane link + :param nem_one: first nem for emane link + :param nem_two: second nem for emane link :param linked: True to link, False to unlink - :return: core_pb2.EmaneLinkResponse + :return: get emane link response + :raises grpc.RpcError: when session or nodes related to nems do not exist """ request = EmaneLinkRequest( session_id=session_id, nem_one=nem_one, nem_two=nem_two, linked=linked @@ -1179,30 +1183,57 @@ class CoreGrpcClient: Retrieves a list of interfaces available on the host machine that are not a part of a CORE session. - :return: core_pb2.GetInterfacesResponse + :return: get interfaces response """ request = core_pb2.GetInterfacesRequest() return self.stub.GetInterfaces(request) def get_config_services(self) -> GetConfigServicesResponse: + """ + Retrieve all known config services. + + :return: get config services response + """ request = GetConfigServicesRequest() return self.stub.GetConfigServices(request) def get_config_service_defaults( self, name: str ) -> GetConfigServiceDefaultsResponse: + """ + Retrieves config service default values. + + :param name: name of service to get defaults for + :return: get config service defaults + """ request = GetConfigServiceDefaultsRequest(name=name) return self.stub.GetConfigServiceDefaults(request) def get_node_config_service_configs( self, session_id: int ) -> GetNodeConfigServiceConfigsResponse: + """ + Retrieves all node config service configurations for a session. + + :param session_id: session to get config service configurations for + :return: get node config service configs response + :raises grpc.RpcError: when session doesn't exist + """ request = GetNodeConfigServiceConfigsRequest(session_id=session_id) return self.stub.GetNodeConfigServiceConfigs(request) def get_node_config_service( self, session_id: int, node_id: int, name: str ) -> GetNodeConfigServiceResponse: + """ + Retrieves information for a specific config service on a node. + + :param session_id: session node belongs to + :param node_id: id of node to get service information from + :param name: name of service + :return: get node config service response + :raises grpc.RpcError: when session or node doesn't exist + """ request = GetNodeConfigServiceRequest( session_id=session_id, node_id=node_id, name=name ) @@ -1211,28 +1242,70 @@ class CoreGrpcClient: def get_node_config_services( self, session_id: int, node_id: int ) -> GetNodeConfigServicesResponse: + """ + Retrieves the config services currently assigned to a node. + + :param session_id: session node belongs to + :param node_id: id of node to get config services for + :return: get node config services response + :raises grpc.RpcError: when session or node doesn't exist + """ request = GetNodeConfigServicesRequest(session_id=session_id, node_id=node_id) return self.stub.GetNodeConfigServices(request) def set_node_config_service( self, session_id: int, node_id: int, name: str, config: Dict[str, str] ) -> SetNodeConfigServiceResponse: + """ + Assigns a config service to a node with the provided configuration. + + :param session_id: session node belongs to + :param node_id: id of node to assign config service to + :param name: name of service + :param config: service configuration + :return: set node config service response + :raises grpc.RpcError: when session or node doesn't exist + """ request = SetNodeConfigServiceRequest( session_id=session_id, node_id=node_id, name=name, config=config ) return self.stub.SetNodeConfigService(request) def get_emane_event_channel(self, session_id: int) -> GetEmaneEventChannelResponse: + """ + Retrieves the current emane event channel being used for a session. + + :param session_id: session to get emane event channel for + :return: emane event channel response + :raises grpc.RpcError: when session doesn't exist + """ request = GetEmaneEventChannelRequest(session_id=session_id) return self.stub.GetEmaneEventChannel(request) def execute_script(self, script: str) -> ExecuteScriptResponse: + """ + Executes a python script given context of the current CoreEmu object. + + :param script: script to execute + :return: execute script response + """ request = ExecuteScriptRequest(script=script) return self.stub.ExecuteScript(request) def wlan_link( self, session_id: int, wlan: int, node_one: int, node_two: int, linked: bool ) -> WlanLinkResponse: + """ + Links/unlinks nodes on the same WLAN. + + :param session_id: session id containing wlan and nodes + :param wlan: wlan nodes must belong to + :param node_one: first node of pair to link/unlink + :param node_two: second node of pair to link/unlin + :param linked: True to link, False to unlink + :return: wlan link response + :raises grpc.RpcError: when session or one of the nodes do not exist + """ request = WlanLinkRequest( session_id=session_id, wlan=wlan, @@ -1248,8 +1321,10 @@ class CoreGrpcClient: """ Stream EMANE pathloss events. - :param pathloss_iterator: iterator for sending EMANE pathloss events - :return: EMANE pathloss response + :param pathloss_iterator: iterator for sending emane pathloss events + :return: emane pathloss response + :raises grpc.RpcError: when a pathloss event session or one of the nodes do not + exist """ return self.stub.EmanePathlosses(pathloss_iterator) From 75d5bced9cd5b8c5bb6f044b93e2b992fc7464c2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 5 Jun 2020 11:20:23 -0700 Subject: [PATCH 107/929] grpc doc improvements, grpc examples additions, small tweak to grpc client for setting emane models not requiring a config when using default configuration --- daemon/core/api/grpc/client.py | 2 +- daemon/examples/grpc/distributed_switch.py | 21 +++--- daemon/examples/grpc/emane80211.py | 74 +++++++++++++++++++ daemon/examples/grpc/large.py | 54 -------------- daemon/examples/grpc/switch.py | 57 +++++++++------ daemon/examples/grpc/wlan.py | 82 ++++++++++++++++++++++ docs/grpc.md | 78 ++++++-------------- 7 files changed, 220 insertions(+), 148 deletions(-) create mode 100644 daemon/examples/grpc/emane80211.py delete mode 100644 daemon/examples/grpc/large.py create mode 100644 daemon/examples/grpc/wlan.py diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index cabd6dda..c1d0e2fd 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -1100,7 +1100,7 @@ class CoreGrpcClient: session_id: int, node_id: int, model: str, - config: Dict[str, str], + config: Dict[str, str] = None, interface_id: int = -1, ) -> SetEmaneModelConfigResponse: """ diff --git a/daemon/examples/grpc/distributed_switch.py b/daemon/examples/grpc/distributed_switch.py index 9cc35f72..0477efdd 100644 --- a/daemon/examples/grpc/distributed_switch.py +++ b/daemon/examples/grpc/distributed_switch.py @@ -1,7 +1,8 @@ import argparse import logging -from core.api.grpc import client, core_pb2 +from core.api.grpc import client +from core.api.grpc.core_pb2 import Node, NodeType, Position, SessionState def log_event(event): @@ -26,13 +27,11 @@ def main(args): core.events(session_id, log_event) # change session state - response = core.set_session_state( - session_id, core_pb2.SessionState.CONFIGURATION - ) + response = core.set_session_state(session_id, SessionState.CONFIGURATION) logging.info("set session state: %s", response) # create switch node - switch = core_pb2.Node(type=core_pb2.NodeType.SWITCH) + switch = Node(type=NodeType.SWITCH) response = core.add_node(session_id, switch) logging.info("created switch: %s", response) switch_id = response.node_id @@ -41,8 +40,8 @@ def main(args): interface_helper = client.InterfaceHelper(ip4_prefix="10.83.0.0/16") # create node one - position = core_pb2.Position(x=100, y=50) - node = core_pb2.Node(position=position) + position = Position(x=100, y=50) + node = Node(position=position) response = core.add_node(session_id, node) logging.info("created node one: %s", response) node_one_id = response.node_id @@ -53,8 +52,8 @@ def main(args): logging.info("created link from node one to switch: %s", response) # create node two - position = core_pb2.Position(x=200, y=50) - node = core_pb2.Node(position=position, server=server_name) + position = Position(x=200, y=50) + node = Node(position=position, server=server_name) response = core.add_node(session_id, node) logging.info("created node two: %s", response) node_two_id = response.node_id @@ -65,9 +64,7 @@ def main(args): logging.info("created link from node two to switch: %s", response) # change session state - response = core.set_session_state( - session_id, core_pb2.SessionState.INSTANTIATION - ) + response = core.set_session_state(session_id, SessionState.INSTANTIATION) logging.info("set session state: %s", response) diff --git a/daemon/examples/grpc/emane80211.py b/daemon/examples/grpc/emane80211.py new file mode 100644 index 00000000..5656268c --- /dev/null +++ b/daemon/examples/grpc/emane80211.py @@ -0,0 +1,74 @@ +""" +Example using gRPC API to create a simple EMANE 80211 network. +""" + +import logging + +from core.api.grpc import client +from core.api.grpc.core_pb2 import Node, NodeType, Position, SessionState +from core.emane.ieee80211abg import EmaneIeee80211abgModel + + +def log_event(event): + logging.info("event: %s", event) + + +def main(): + # helper to create interface addresses + interface_helper = client.InterfaceHelper(ip4_prefix="10.83.0.0/24") + + # create grpc client and start connection context, which auto closes connection + core = client.CoreGrpcClient() + with core.context_connect(): + # create session + response = core.create_session() + logging.info("created session: %s", response) + + # handle events session may broadcast + session_id = response.session_id + core.events(session_id, log_event) + + # change session state to configuration so that nodes get started when added + response = core.set_session_state(session_id, SessionState.CONFIGURATION) + logging.info("set session state: %s", response) + + # create emane node + position = Position(x=200, y=200) + emane = Node(type=NodeType.EMANE, position=position) + response = core.add_node(session_id, emane) + logging.info("created emane: %s", response) + emane_id = response.node_id + + # an emane model must be configured for use, by the emane node + core.set_emane_model_config(session_id, emane_id, EmaneIeee80211abgModel.name) + + # create node one + position = Position(x=100, y=100) + node1 = Node(type=NodeType.DEFAULT, position=position) + response = core.add_node(session_id, node1) + logging.info("created node: %s", response) + node1_id = response.node_id + + # create node two + position = Position(x=300, y=100) + node2 = Node(type=NodeType.DEFAULT, position=position) + response = core.add_node(session_id, node2) + logging.info("created node: %s", response) + node2_id = response.node_id + + # links nodes to switch + interface_one = interface_helper.create_interface(node1_id, 0) + response = core.add_link(session_id, node1_id, emane_id, interface_one) + logging.info("created link: %s", response) + interface_one = interface_helper.create_interface(node2_id, 0) + response = core.add_link(session_id, node2_id, emane_id, interface_one) + logging.info("created link: %s", response) + + # change session state + response = core.set_session_state(session_id, SessionState.INSTANTIATION) + logging.info("set session state: %s", response) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + main() diff --git a/daemon/examples/grpc/large.py b/daemon/examples/grpc/large.py deleted file mode 100644 index ef1e6cc4..00000000 --- a/daemon/examples/grpc/large.py +++ /dev/null @@ -1,54 +0,0 @@ -import logging - -from core.api.grpc import client, core_pb2 - - -def log_event(event): - logging.info("event: %s", event) - - -def main(): - core = client.CoreGrpcClient() - - with core.context_connect(): - # create session - response = core.create_session() - session_id = response.session_id - logging.info("created session: %s", response) - - # create nodes for session - nodes = [] - position = core_pb2.Position(x=50, y=100) - switch = core_pb2.Node(id=1, type=core_pb2.NodeType.SWITCH, position=position) - nodes.append(switch) - for i in range(2, 50): - position = core_pb2.Position(x=50 + 50 * i, y=50) - node = core_pb2.Node(id=i, position=position, model="PC") - nodes.append(node) - - # create links - interface_helper = client.InterfaceHelper(ip4_prefix="10.83.0.0/16") - links = [] - for node in nodes: - interface_one = interface_helper.create_interface(node.id, 0) - link = core_pb2.Link( - type=core_pb2.LinkType.WIRED, - node_one_id=node.id, - node_two_id=switch.id, - interface_one=interface_one, - ) - links.append(link) - - # start session - response = core.start_session(session_id, nodes, links) - logging.info("started session: %s", response) - - input("press enter to shutdown session") - - response = core.stop_session(session_id) - logging.info("stop sessionL %s", response) - - -if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) - main() diff --git a/daemon/examples/grpc/switch.py b/daemon/examples/grpc/switch.py index 48aa63bc..3ab0e0ba 100644 --- a/daemon/examples/grpc/switch.py +++ b/daemon/examples/grpc/switch.py @@ -1,6 +1,11 @@ +""" +Example using gRPC API to create a simple switch network. +""" + import logging -from core.api.grpc import client, core_pb2 +from core.api.grpc import client +from core.api.grpc.core_pb2 import Node, NodeType, Position, SessionState def log_event(event): @@ -8,8 +13,11 @@ def log_event(event): def main(): - core = client.CoreGrpcClient() + # helper to create interface addresses + interface_helper = client.InterfaceHelper(ip4_prefix="10.83.0.0/24") + # create grpc client and start connection context, which auto closes connection + core = client.CoreGrpcClient() with core.context_connect(): # create session response = core.create_session() @@ -19,38 +27,41 @@ def main(): session_id = response.session_id core.events(session_id, log_event) - # change session state - response = core.set_session_state( - session_id, core_pb2.SessionState.CONFIGURATION - ) + # change session state to configuration so that nodes get started when added + response = core.set_session_state(session_id, SessionState.CONFIGURATION) logging.info("set session state: %s", response) # create switch node - switch = core_pb2.Node(type=core_pb2.NodeType.SWITCH) + position = Position(x=200, y=200) + switch = Node(type=NodeType.SWITCH, position=position) response = core.add_node(session_id, switch) logging.info("created switch: %s", response) switch_id = response.node_id - # helper to create interfaces - interface_helper = client.InterfaceHelper(ip4_prefix="10.83.0.0/16") + # create node one + position = Position(x=100, y=100) + node1 = Node(type=NodeType.DEFAULT, position=position) + response = core.add_node(session_id, node1) + logging.info("created node: %s", response) + node1_id = response.node_id - for i in range(2): - # create node - position = core_pb2.Position(x=50 + 50 * i, y=50) - node = core_pb2.Node(position=position) - response = core.add_node(session_id, node) - logging.info("created node: %s", response) - node_id = response.node_id + # create node two + position = Position(x=300, y=100) + node2 = Node(type=NodeType.DEFAULT, position=position) + response = core.add_node(session_id, node2) + logging.info("created node: %s", response) + node2_id = response.node_id - # create link - interface_one = interface_helper.create_interface(node_id, 0) - response = core.add_link(session_id, node_id, switch_id, interface_one) - logging.info("created link: %s", response) + # links nodes to switch + interface_one = interface_helper.create_interface(node1_id, 0) + response = core.add_link(session_id, node1_id, switch_id, interface_one) + logging.info("created link: %s", response) + interface_one = interface_helper.create_interface(node2_id, 0) + response = core.add_link(session_id, node2_id, switch_id, interface_one) + logging.info("created link: %s", response) # change session state - response = core.set_session_state( - session_id, core_pb2.SessionState.INSTANTIATION - ) + response = core.set_session_state(session_id, SessionState.INSTANTIATION) logging.info("set session state: %s", response) diff --git a/daemon/examples/grpc/wlan.py b/daemon/examples/grpc/wlan.py new file mode 100644 index 00000000..6118ae4c --- /dev/null +++ b/daemon/examples/grpc/wlan.py @@ -0,0 +1,82 @@ +""" +Example using gRPC API to create a simple wlan network. +""" + +import logging + +from core.api.grpc import client +from core.api.grpc.core_pb2 import Node, NodeType, Position, SessionState + + +def log_event(event): + logging.info("event: %s", event) + + +def main(): + # helper to create interface addresses + interface_helper = client.InterfaceHelper(ip4_prefix="10.83.0.0/24") + + # create grpc client and start connection context, which auto closes connection + core = client.CoreGrpcClient() + with core.context_connect(): + # create session + response = core.create_session() + logging.info("created session: %s", response) + + # handle events session may broadcast + session_id = response.session_id + core.events(session_id, log_event) + + # change session state to configuration so that nodes get started when added + response = core.set_session_state(session_id, SessionState.CONFIGURATION) + logging.info("set session state: %s", response) + + # create wlan node + position = Position(x=200, y=200) + wlan = Node(type=NodeType.WIRELESS_LAN, position=position) + response = core.add_node(session_id, wlan) + logging.info("created wlan: %s", response) + wlan_id = response.node_id + + # change/configure wlan if desired + # NOTE: error = loss, and named this way for legacy purposes for now + config = { + "bandwidth": "54000000", + "range": "500", + "jitter": "0", + "delay": "5000", + "error": "0", + } + response = core.set_wlan_config(session_id, wlan_id, config) + logging.info("set wlan config: %s", response) + + # create node one + position = Position(x=100, y=100) + node1 = Node(type=NodeType.DEFAULT, position=position) + response = core.add_node(session_id, node1) + logging.info("created node: %s", response) + node1_id = response.node_id + + # create node two + position = Position(x=300, y=100) + node2 = Node(type=NodeType.DEFAULT, position=position) + response = core.add_node(session_id, node2) + logging.info("created node: %s", response) + node2_id = response.node_id + + # links nodes to switch + interface_one = interface_helper.create_interface(node1_id, 0) + response = core.add_link(session_id, node1_id, wlan_id, interface_one) + logging.info("created link: %s", response) + interface_one = interface_helper.create_interface(node2_id, 0) + response = core.add_link(session_id, node2_id, wlan_id, interface_one) + logging.info("created link: %s", response) + + # change session state + response = core.set_session_state(session_id, SessionState.INSTANTIATION) + logging.info("set session state: %s", response) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + main() diff --git a/docs/grpc.md b/docs/grpc.md index 2430f3ae..69cf4aed 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -1,72 +1,34 @@ # Using the gRPC API -gRPC is the main API for interfacing with CORE. +[gRPC](https://grpc.io/) is the main API for interfacing with CORE and used by +the python GUI for driving all functionality. + +Currently we are providing a python client that wraps the generated files for +leveraging the API, but proto files noted below can also be leveraged to generate +bindings for other languages as well. ## HTTP Proxy -Since gRPC is HTTP2 based, proxy configurations can cause issue. Clear out your -proxy when running if needed. +Since gRPC is HTTP2 based, proxy configurations can cause issue. You can either +properly account for this issue or clear out your proxy when running if needed. ## Python Client -A python client wrapper is provided at **core.api.grpc.client.CoreGrpcClient**. +A python client wrapper is provided at +[CoreGrpcClient](../daemon/core/api/grpc/client.py) to help provide some +conveniences when using the API. -Below is a small example using it. +## Proto Files -```python -import logging +Proto files are used to define the API and protobuf messages that are used for +interfaces with this API. -from core.api.grpc import client, core_pb2 +They can be found [here](../daemon/proto/core/api/grpc) to see the specifics of +what is going on and response message values that would be returned. +## Examples -def log_event(event): - logging.info("event: %s", event) +Example usage of this API can be found [here](../daemon/examples/grpc). These +examples will create a session using the gRPC API when the core-daemon is running. - -def main(): - core = client.CoreGrpcClient() - - with core.context_connect(): - # create session - response = core.create_session() - logging.info("created session: %s", response) - - # handle events session may broadcast - session_id = response.session_id - core.events(session_id, log_event) - - # change session state - response = core.set_session_state(session_id, core_pb2.SessionState.CONFIGURATION) - logging.info("set session state: %s", response) - - # create switch node - switch = core_pb2.Node(type=core_pb2.NodeType.SWITCH) - response = core.add_node(session_id, switch) - logging.info("created switch: %s", response) - switch_id = response.node_id - - # helper to create interfaces - interface_helper = client.InterfaceHelper(ip4_prefix="10.83.0.0/16") - - for i in range(2): - # create node - position = core_pb2.Position(x=50 + 50 * i, y=50) - node = core_pb2.Node(position=position) - response = core.add_node(session_id, node) - logging.info("created node: %s", response) - node_id = response.node_id - - # create link - interface_one = interface_helper.create_interface(node_id, 0) - response = core.add_link(session_id, node_id, switch_id, interface_one) - logging.info("created link: %s", response) - - # change session state - response = core.set_session_state(session_id, core_pb2.SessionState.INSTANTIATION) - logging.info("set session state: %s", response) - - -if __name__ == "__main__": - logging.basicConfig(level=logging.DEBUG) - main() -``` +You can then switch to and attach to these sessions using either of the CORE GUIs. From bf1bc511e28b429355ef2a5d20a704f4aaf714be Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 5 Jun 2020 14:34:19 -0700 Subject: [PATCH 108/929] removed configuration option for number of for corehandler threads as it cannot properly deal with anything more than 1, updated man pages to current 6.4 versions for now --- daemon/core/api/tlv/corehandlers.py | 12 +++--------- daemon/data/core.conf | 1 - daemon/scripts/core-daemon | 11 +---------- daemon/scripts/core-imn-to-xml | 2 +- daemon/scripts/core-manage | 2 +- daemon/scripts/core-pygui | 2 +- daemon/scripts/core-route-monitor | 2 +- daemon/scripts/core-service-update | 2 +- daemon/scripts/coresendmsg | 2 +- daemon/tests/conftest.py | 6 +++--- man/core-cleanup.1 | 6 +++--- man/core-daemon.1 | 24 +++++++++++------------- man/core-gui.1 | 6 +++--- man/core-manage.1 | 6 +++--- man/coresendmsg.1 | 10 +++++----- man/netns.1 | 6 +++--- man/vcmd.1 | 6 +++--- man/vnoded.1 | 6 +++--- 18 files changed, 47 insertions(+), 65 deletions(-) diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 1a22cedd..7e2cd040 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -79,15 +79,9 @@ class CoreHandler(socketserver.BaseRequestHandler): self._sessions_lock = threading.Lock() self.handler_threads = [] - num_threads = int(server.config["numthreads"]) - if num_threads < 1: - raise ValueError(f"invalid number of threads: {num_threads}") - - logging.debug("launching core server handler threads: %s", num_threads) - for _ in range(num_threads): - thread = threading.Thread(target=self.handler_thread) - self.handler_threads.append(thread) - thread.start() + thread = threading.Thread(target=self.handler_thread, daemon=True) + thread.start() + self.handler_threads.append(thread) self.session = None self.coreemu = server.coreemu diff --git a/daemon/data/core.conf b/daemon/data/core.conf index 13b50785..5ff0be7f 100644 --- a/daemon/data/core.conf +++ b/daemon/data/core.conf @@ -4,7 +4,6 @@ listenaddr = localhost port = 4038 grpcaddress = localhost grpcport = 50051 -numthreads = 1 quagga_bin_search = "/usr/local/bin /usr/bin /usr/lib/quagga" quagga_sbin_search = "/usr/local/sbin /usr/sbin /usr/lib/quagga" frr_bin_search = "/usr/local/bin /usr/bin /usr/lib/frr" diff --git a/daemon/scripts/core-daemon b/daemon/scripts/core-daemon index 9f738467..a95e59fa 100755 --- a/daemon/scripts/core-daemon +++ b/daemon/scripts/core-daemon @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 """ core-daemon: the CORE daemon is a server process that receives CORE API messages and instantiates emulated nodes and networks within the kernel. Various @@ -93,12 +93,10 @@ def get_merged_config(filename): # these are the defaults used in the config file default_log = os.path.join(constants.CORE_CONF_DIR, "logging.conf") default_grpc_port = "50051" - default_threads = "1" default_address = "localhost" defaults = { "port": str(CORE_API_PORT), "listenaddr": default_address, - "numthreads": default_threads, "grpcport": default_grpc_port, "grpcaddress": default_address, "logfile": default_log @@ -110,8 +108,6 @@ def get_merged_config(filename): help=f"read config from specified file; default = {filename}") parser.add_argument("-p", "--port", dest="port", type=int, help=f"port number to listen on; default = {CORE_API_PORT}") - parser.add_argument("-n", "--numthreads", dest="numthreads", type=int, - help=f"number of server threads; default = {default_threads}") parser.add_argument("--ovs", action="store_true", help="enable experimental ovs mode, default is false") parser.add_argument("--grpc-port", dest="grpcport", help=f"grpc port to listen on; default {default_grpc_port}") @@ -148,14 +144,9 @@ def main(): :return: nothing """ - # get a configuration merged from config file and command-line arguments cfg = get_merged_config(f"{CORE_CONF_DIR}/core.conf") - - # load logging configuration load_logging_config(cfg["logfile"]) - banner() - try: cored(cfg) except KeyboardInterrupt: diff --git a/daemon/scripts/core-imn-to-xml b/daemon/scripts/core-imn-to-xml index 725d7119..495093ed 100755 --- a/daemon/scripts/core-imn-to-xml +++ b/daemon/scripts/core-imn-to-xml @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import argparse import re import sys diff --git a/daemon/scripts/core-manage b/daemon/scripts/core-manage index 14e10e5b..5587c9ae 100755 --- a/daemon/scripts/core-manage +++ b/daemon/scripts/core-manage @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 """ core-manage: Helper tool to add, remove, or check for services, models, and node types in a CORE installation. diff --git a/daemon/scripts/core-pygui b/daemon/scripts/core-pygui index f30b531b..46860ce9 100755 --- a/daemon/scripts/core-pygui +++ b/daemon/scripts/core-pygui @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import argparse import logging from logging.handlers import TimedRotatingFileHandler diff --git a/daemon/scripts/core-route-monitor b/daemon/scripts/core-route-monitor index a9b48aff..b12e6205 100755 --- a/daemon/scripts/core-route-monitor +++ b/daemon/scripts/core-route-monitor @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import argparse import enum import select diff --git a/daemon/scripts/core-service-update b/daemon/scripts/core-service-update index 6d0be06c..d0ca863f 100755 --- a/daemon/scripts/core-service-update +++ b/daemon/scripts/core-service-update @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import argparse import re from io import TextIOWrapper diff --git a/daemon/scripts/coresendmsg b/daemon/scripts/coresendmsg index ae89ecb1..13e20b5c 100755 --- a/daemon/scripts/coresendmsg +++ b/daemon/scripts/coresendmsg @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 """ coresendmsg: utility for generating CORE messages """ diff --git a/daemon/tests/conftest.py b/daemon/tests/conftest.py index 0c021b25..9d54d9c2 100644 --- a/daemon/tests/conftest.py +++ b/daemon/tests/conftest.py @@ -44,8 +44,8 @@ class PatchManager: class MockServer: - def __init__(self, config, coreemu): - self.config = config + def __init__(self, coreemu): + self.config = {} self.coreemu = coreemu @@ -108,7 +108,7 @@ def module_grpc(global_coreemu): def module_coretlv(patcher, global_coreemu, global_session): request_mock = MagicMock() request_mock.fileno = MagicMock(return_value=1) - server = MockServer({"numthreads": "1"}, global_coreemu) + server = MockServer(global_coreemu) request_handler = CoreHandler(request_mock, "", server) request_handler.session = global_session request_handler.add_session_handlers() diff --git a/man/core-cleanup.1 b/man/core-cleanup.1 index 64aa18fb..0f56c14c 100644 --- a/man/core-cleanup.1 +++ b/man/core-cleanup.1 @@ -1,7 +1,7 @@ -.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.3. -.TH CORE-CLEANUP "1" "June 2019" "CORE" "User Commands" +.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.6. +.TH CORE-CLEANUP "1" "June 2020" "CORE" "User Commands" .SH NAME -core-cleanup \- manual page for core-cleanup 5.3.0 +core-cleanup \- manual page for core-cleanup 6.4.0 .SH DESCRIPTION usage: ../daemon/scripts/core\-cleanup [\-d [\-l]] .IP diff --git a/man/core-daemon.1 b/man/core-daemon.1 index c8061e0d..01799894 100644 --- a/man/core-daemon.1 +++ b/man/core-daemon.1 @@ -1,14 +1,14 @@ -.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.3. -.TH CORE-DAEMON "1" "June 2019" "CORE" "User Commands" +.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.6. +.TH CORE-DAEMON "1" "June 2020" "CORE" "User Commands" .SH NAME -core-daemon \- manual page for core-daemon 5.3.0 +core-daemon \- manual page for core-daemon 6.4.0 .SH DESCRIPTION -usage: core\-daemon [\-h] [\-f CONFIGFILE] [\-p PORT] [\-n NUMTHREADS] [\-\-ovs] +usage: core\-daemon [\-h] [\-f CONFIGFILE] [\-p PORT] [\-\-ovs] .IP -[\-\-grpc] [\-\-grpc\-port GRPCPORT] -[\-\-grpc\-address GRPCADDRESS] +[\-\-grpc\-port GRPCPORT] [\-\-grpc\-address GRPCADDRESS] +[\-l LOGFILE] .PP -CORE daemon v.5.3.0 instantiates Linux network namespace nodes. +CORE daemon v.6.4.0 instantiates Linux network namespace nodes. .SS "optional arguments:" .TP \fB\-h\fR, \fB\-\-help\fR @@ -21,17 +21,15 @@ read config from specified file; default = \fB\-p\fR PORT, \fB\-\-port\fR PORT port number to listen on; default = 4038 .TP -\fB\-n\fR NUMTHREADS, \fB\-\-numthreads\fR NUMTHREADS -number of server threads; default = 1 -.TP \fB\-\-ovs\fR enable experimental ovs mode, default is false .TP -\fB\-\-grpc\fR -enable grpc api, default is false -.TP \fB\-\-grpc\-port\fR GRPCPORT grpc port to listen on; default 50051 .TP \fB\-\-grpc\-address\fR GRPCADDRESS grpc address to listen on; default localhost +.TP +\fB\-l\fR LOGFILE, \fB\-\-logfile\fR LOGFILE +core logging configuration; default +\fI\,/etc/core/logging.conf\/\fP diff --git a/man/core-gui.1 b/man/core-gui.1 index 5792e0c6..2a7b95ac 100644 --- a/man/core-gui.1 +++ b/man/core-gui.1 @@ -1,7 +1,7 @@ -.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.3. -.TH CORE-GUI "1" "June 2019" "CORE" "User Commands" +.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.6. +.TH CORE-GUI "1" "June 2020" "CORE" "User Commands" .SH NAME -core-gui \- manual page for core-gui version 5.3.0 (20190607) +core-gui \- manual page for core-gui version 6.4.0 (20200513) .SH SYNOPSIS .B core-gui [\fI\,-h|-v\/\fR] [\fI\,-b|-c \/\fR] [\fI\,-s\/\fR] [\fI\,-a address\/\fR] [\fI\,-p port\/\fR] diff --git a/man/core-manage.1 b/man/core-manage.1 index 7e7c6f42..594a5f64 100644 --- a/man/core-manage.1 +++ b/man/core-manage.1 @@ -1,7 +1,7 @@ -.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.3. -.TH CORE-MANAGE "1" "June 2019" "CORE" "User Commands" +.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.6. +.TH CORE-MANAGE "1" "June 2020" "CORE" "User Commands" .SH NAME -core-manage \- manual page for core-manage 5.3.0 +core-manage \- manual page for core-manage 6.4.0 .SH SYNOPSIS .B core-manage [\fI\,-h\/\fR] [\fI\,options\/\fR] \fI\, \/\fR diff --git a/man/coresendmsg.1 b/man/coresendmsg.1 index 9a42e29a..8815b189 100644 --- a/man/coresendmsg.1 +++ b/man/coresendmsg.1 @@ -1,17 +1,17 @@ -.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.3. -.TH CORESENDMSG "1" "June 2019" "CORE" "User Commands" +.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.6. +.TH CORESENDMSG "1" "June 2020" "CORE" "User Commands" .SH NAME -coresendmsg \- manual page for coresendmsg 5.3.0 +coresendmsg \- manual page for coresendmsg 6.4.0 .SH SYNOPSIS .B coresendmsg [\fI\,-h|-H\/\fR] [\fI\,options\/\fR] [\fI\,message-type\/\fR] [\fI\,flags=flags\/\fR] [\fI\,message-TLVs\/\fR] .SH DESCRIPTION .SS "Supported message types:" .IP -['NODE', 'LINK', 'EXECUTE', 'REGISTER', 'CONFIG', 'FILE', 'INTERFACE', 'EVENT', 'SESSION', 'EXCEPTION'] +node link execute register config file interface event session exception .SS "Supported message flags (flags=f1,f2,...):" .IP -['ADD', 'DELETE', 'CRI', 'LOCAL', 'STRING', 'TEXT', 'TTY'] +none add delete cri local string text tty .SH OPTIONS .TP \fB\-h\fR, \fB\-\-help\fR diff --git a/man/netns.1 b/man/netns.1 index 5cb6e312..abc62ee2 100644 --- a/man/netns.1 +++ b/man/netns.1 @@ -1,7 +1,7 @@ -.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.3. -.TH NETNS "1" "June 2019" "CORE" "User Commands" +.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.6. +.TH NETNS "1" "June 2020" "CORE" "User Commands" .SH NAME -netns \- manual page for netns version 5.3.0 +netns \- manual page for netns version 6.4.0 .SH SYNOPSIS .B netns [\fI\,-h|-V\/\fR] [\fI\,-w\/\fR] \fI\,-- command \/\fR[\fI\,args\/\fR...] diff --git a/man/vcmd.1 b/man/vcmd.1 index 79f14f50..f5438978 100644 --- a/man/vcmd.1 +++ b/man/vcmd.1 @@ -1,7 +1,7 @@ -.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.3. -.TH VCMD "1" "June 2019" "CORE" "User Commands" +.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.6. +.TH VCMD "1" "June 2020" "CORE" "User Commands" .SH NAME -vcmd \- manual page for vcmd version 5.3.0 +vcmd \- manual page for vcmd version 6.4.0 .SH SYNOPSIS .B vcmd [\fI\,-h|-V\/\fR] [\fI\,-v\/\fR] [\fI\,-q|-i|-I\/\fR] \fI\,-c -- command args\/\fR... diff --git a/man/vnoded.1 b/man/vnoded.1 index 841b00e9..11874e39 100644 --- a/man/vnoded.1 +++ b/man/vnoded.1 @@ -1,7 +1,7 @@ -.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.3. -.TH VNODED "1" "June 2019" "CORE" "User Commands" +.\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.47.6. +.TH VNODED "1" "June 2020" "CORE" "User Commands" .SH NAME -vnoded \- manual page for vnoded version 5.3.0 +vnoded \- manual page for vnoded version 6.4.0 .SH SYNOPSIS .B vnoded [\fI\,-h|-V\/\fR] [\fI\,-v\/\fR] [\fI\,-n\/\fR] [\fI\,-C \/\fR] [\fI\,-l \/\fR] [\fI\,-p \/\fR] \fI\,-c \/\fR From 7ffbf457be6020c9934f434c11000db3b813cc2c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 8 Jun 2020 09:55:05 -0700 Subject: [PATCH 109/929] update to netclient existing bridge check to avoid using the -j flag, which requires version 4.7+ vs 4.5+ that we currently expect --- daemon/core/nodes/netclient.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/daemon/core/nodes/netclient.py b/daemon/core/nodes/netclient.py index 091938de..29a70d18 100644 --- a/daemon/core/nodes/netclient.py +++ b/daemon/core/nodes/netclient.py @@ -1,7 +1,6 @@ """ Clients for dealing with bridge/interface commands. """ -import json from typing import Callable import netaddr @@ -279,12 +278,13 @@ class LinuxNetClient: :param _id: node id to check bridges for :return: True if there are existing bridges, False otherwise """ - output = self.run(f"{IP_BIN} -j link show type bridge") - bridges = json.loads(output) - for bridge in bridges: - name = bridge.get("ifname") - if not name: + output = self.run(f"{IP_BIN} -o link show type bridge") + lines = output.split("\n") + for line in lines: + values = line.split(":") + if not len(values) >= 2: continue + name = values[1] fields = name.split(".") if len(fields) != 3: continue From 199c4618f5e40e77e0bb42677b0b883c60a136d1 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 8 Jun 2020 10:08:26 -0700 Subject: [PATCH 110/929] removed comments about rj45 removing addresses and setting to promiscuous, as that is not true and misleading --- docs/gui.md | 6 +----- docs/pygui.md | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/docs/gui.md b/docs/gui.md index 8f6f9057..85bbb6cd 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -371,11 +371,7 @@ be entered into the text box. > **NOTE:** When you press the Start button to instantiate your topology, the interface assigned to the RJ45 will be connected to the CORE topology. The - interface can no longer be used by the system. For example, if there was an - IP address assigned to the physical interface before execution, the address - will be removed and control given over to CORE. No IP address is needed; the - interface is put into promiscuous mode so it will receive all packets and - send them into the emulated world. + interface can no longer be used by the system. Multiple RJ45 nodes can be used within CORE and assigned to the same physical interface if 802.1x VLANs are used. This allows for more RJ45 nodes than diff --git a/docs/pygui.md b/docs/pygui.md index 4ed3fe09..f3e2c592 100644 --- a/docs/pygui.md +++ b/docs/pygui.md @@ -348,11 +348,7 @@ be entered into the text box. > **NOTE:** When you press the Start button to instantiate your topology, the interface assigned to the RJ45 will be connected to the CORE topology. The - interface can no longer be used by the system. For example, if there was an - IP address assigned to the physical interface before execution, the address - will be removed and control given over to CORE. No IP address is needed; the - interface is put into promiscuous mode so it will receive all packets and - send them into the emulated world. + interface can no longer be used by the system. Multiple RJ45 nodes can be used within CORE and assigned to the same physical interface if 802.1x VLANs are used. This allows for more RJ45 nodes than From 6ddf1ac9a460018515895245761873fc88abdb65 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 00:56:34 -0700 Subject: [PATCH 111/929] removed IdGen class, added simple function to find next valid node id --- daemon/core/emulator/emudata.py | 9 --------- daemon/core/emulator/session.py | 21 ++++++++++++++------- 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index 4e3ebf8a..26e09fe8 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -10,15 +10,6 @@ from core.nodes.interface import CoreInterface from core.nodes.physical import PhysicalNode -class IdGen: - def __init__(self, _id: int = 0) -> None: - self.id = _id - - def next(self) -> int: - self.id += 1 - return self.id - - def link_config( node: Union[CoreNetworkBase, PhysicalNode], interface: CoreInterface, diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 9193196c..24fe05bd 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -20,7 +20,6 @@ from core.emane.nodes import EmaneNet from core.emulator.data import ConfigData, EventData, ExceptionData, FileData, LinkData from core.emulator.distributed import DistributedController from core.emulator.emudata import ( - IdGen, InterfaceData, LinkOptions, NodeOptions, @@ -111,7 +110,6 @@ class Session: self.link_colors = {} # dict of nodes: all nodes and nets - self.node_id_gen = IdGen() self.nodes = {} self._nodes_lock = threading.Lock() @@ -649,6 +647,19 @@ class Session: if node_two: node_two.lock.release() + def _next_node_id(self) -> int: + """ + Find the next valid node id, starting from 1. + + :return: next node id + """ + _id = 1 + while True: + if _id not in self.nodes: + break + _id += 1 + return _id + def add_node( self, _class: Type[NT], _id: int = None, options: NodeOptions = None ) -> NT: @@ -669,10 +680,7 @@ class Session: # determine node id if not _id: - while True: - _id = self.node_id_gen.next() - if _id not in self.nodes: - break + _id = self._next_node_id() # generate name if not provided if not options: @@ -1399,7 +1407,6 @@ class Session: self.sdt.delete_node(node.id) funcs.append((node.shutdown, [], {})) utils.threadpool(funcs) - self.node_id_gen.id = 0 def write_nodes(self) -> None: """ From 18044f947411938e3f8a20f4422b521f982e1a91 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 08:48:18 -0700 Subject: [PATCH 112/929] daemon: cleaned up InterfaceData class, it now leverages dataclass, removed extra bloat and no longer requires parameters as they are optional --- daemon/core/api/grpc/grpcutils.py | 16 +++-- daemon/core/api/tlv/corehandlers.py | 4 +- daemon/core/emulator/emudata.py | 90 ++++++----------------------- daemon/tests/emane/test_emane.py | 4 +- daemon/tests/test_core.py | 2 +- 5 files changed, 31 insertions(+), 85 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index b0c1e614..cf3f2a95 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -59,19 +59,17 @@ def link_interface(interface_proto: core_pb2.Interface) -> InterfaceData: """ interface = None if interface_proto: - name = interface_proto.name - if name == "": - name = None - mac = interface_proto.mac - if mac == "": - mac = None + name = interface_proto.name if interface_proto.name else None + mac = interface_proto.mac if interface_proto.mac else None + ip4 = interface_proto.ip4 if interface_proto.ip4 else None + ip6 = interface_proto.ip6 if interface_proto.ip6 else None interface = InterfaceData( - _id=interface_proto.id, + id=interface_proto.id, name=name, mac=mac, - ip4=interface_proto.ip4, + ip4=ip4, ip4_mask=interface_proto.ip4mask, - ip6=interface_proto.ip6, + ip6=ip6, ip6_mask=interface_proto.ip6mask, ) return interface diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 7e2cd040..a79d7d6d 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -749,7 +749,7 @@ class CoreHandler(socketserver.BaseRequestHandler): node_two_id = message.get_tlv(LinkTlvs.N2_NUMBER.value) interface_one = InterfaceData( - _id=message.get_tlv(LinkTlvs.INTERFACE1_NUMBER.value), + id=message.get_tlv(LinkTlvs.INTERFACE1_NUMBER.value), name=message.get_tlv(LinkTlvs.INTERFACE1_NAME.value), mac=message.get_tlv(LinkTlvs.INTERFACE1_MAC.value), ip4=message.get_tlv(LinkTlvs.INTERFACE1_IP4.value), @@ -758,7 +758,7 @@ class CoreHandler(socketserver.BaseRequestHandler): ip6_mask=message.get_tlv(LinkTlvs.INTERFACE1_IP6_MASK.value), ) interface_two = InterfaceData( - _id=message.get_tlv(LinkTlvs.INTERFACE2_NUMBER.value), + id=message.get_tlv(LinkTlvs.INTERFACE2_NUMBER.value), name=message.get_tlv(LinkTlvs.INTERFACE2_NAME.value), mac=message.get_tlv(LinkTlvs.INTERFACE2_MAC.value), ip4=message.get_tlv(LinkTlvs.INTERFACE2_IP4.value), diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index 26e09fe8..3c33ce65 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -1,4 +1,5 @@ -from typing import List, Optional, Union +from dataclasses import dataclass +from typing import List, Union import netaddr @@ -122,87 +123,32 @@ class LinkOptions: self.opaque = None +@dataclass class InterfaceData: """ Convenience class for storing interface data. """ - def __init__( - self, - _id: int, - name: str, - mac: str, - ip4: str, - ip4_mask: int, - ip6: str, - ip6_mask: int, - ) -> None: - """ - Creates an InterfaceData object. - - :param _id: interface id - :param name: name for interface - :param mac: mac address - :param ip4: ipv4 address - :param ip4_mask: ipv4 bit mask - :param ip6: ipv6 address - :param ip6_mask: ipv6 bit mask - """ - self.id = _id - self.name = name - self.mac = mac - self.ip4 = ip4 - self.ip4_mask = ip4_mask - self.ip6 = ip6 - self.ip6_mask = ip6_mask - - def has_ip4(self) -> bool: - """ - Determines if interface has an ip4 address. - - :return: True if has ip4, False otherwise - """ - return all([self.ip4, self.ip4_mask]) - - def has_ip6(self) -> bool: - """ - Determines if interface has an ip6 address. - - :return: True if has ip6, False otherwise - """ - return all([self.ip6, self.ip6_mask]) - - def ip4_address(self) -> Optional[str]: - """ - Retrieve a string representation of the ip4 address and netmask. - - :return: ip4 string or None - """ - if self.has_ip4(): - return f"{self.ip4}/{self.ip4_mask}" - else: - return None - - def ip6_address(self) -> Optional[str]: - """ - Retrieve a string representation of the ip6 address and netmask. - - :return: ip4 string or None - """ - if self.has_ip6(): - return f"{self.ip6}/{self.ip6_mask}" - else: - return None + id: int = None + name: str = None + mac: str = None + ip4: str = None + ip4_mask: int = None + ip6: str = None + ip6_mask: int = None def get_addresses(self) -> List[str]: """ - Returns a list of ip4 and ip6 address when present. + Returns a list of ip4 and ip6 addresses when present. :return: list of addresses """ - ip4 = self.ip4_address() - ip6 = self.ip6_address() - return [i for i in [ip4, ip6] if i] + addresses = [] + if self.ip4 and self.ip4_mask: + addresses.append(f"{self.ip4}/{self.ip4_mask}") + if self.ip6 and self.ip6_mask: + addresses.append(f"{self.ip6}/{self.ip6_mask}") + return addresses class IpPrefixes: @@ -285,7 +231,7 @@ class IpPrefixes: mac = utils.random_mac() return InterfaceData( - _id=inteface_id, + id=inteface_id, name=name, ip4=ip4, ip4_mask=ip4_mask, diff --git a/daemon/tests/emane/test_emane.py b/daemon/tests/emane/test_emane.py index 328aa94b..62fe15e1 100644 --- a/daemon/tests/emane/test_emane.py +++ b/daemon/tests/emane/test_emane.py @@ -29,7 +29,9 @@ _EMANE_MODELS = [ _DIR = os.path.dirname(os.path.abspath(__file__)) -def ping(from_node, to_node, ip_prefixes, count=3): +def ping( + from_node: CoreNode, to_node: CoreNode, ip_prefixes: IpPrefixes, count: int = 3 +): address = ip_prefixes.ip4_address(to_node) try: from_node.cmd(f"ping -c {count} {address}") diff --git a/daemon/tests/test_core.py b/daemon/tests/test_core.py index 1c40393e..88a40906 100644 --- a/daemon/tests/test_core.py +++ b/daemon/tests/test_core.py @@ -21,7 +21,7 @@ _MOBILITY_FILE = os.path.join(_PATH, "mobility.scen") _WIRED = [PtpNet, HubNode, SwitchNode] -def ping(from_node, to_node, ip_prefixes): +def ping(from_node: CoreNode, to_node: CoreNode, ip_prefixes: IpPrefixes): address = ip_prefixes.ip4_address(to_node) try: from_node.cmd(f"ping -c 1 {address}") From b5e53e573ac48295a3017832ac8d300e0754c3aa Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 09:12:31 -0700 Subject: [PATCH 113/929] daemon: LinkOptions now leverage dataclass and has type hinting, improve test_gui type hinting --- daemon/core/api/grpc/grpcutils.py | 10 ++------ daemon/core/api/tlv/corehandlers.py | 2 +- daemon/core/emulator/emudata.py | 40 ++++++++++++----------------- daemon/tests/test_gui.py | 14 +++++----- 4 files changed, 27 insertions(+), 39 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index cf3f2a95..7797c86e 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -86,13 +86,8 @@ def add_link_data( """ interface_one = link_interface(link_proto.interface_one) interface_two = link_interface(link_proto.interface_two) - - link_type = None - link_type_value = link_proto.type - if link_type_value is not None: - link_type = LinkTypes(link_type_value) - - options = LinkOptions(_type=link_type) + link_type = LinkTypes(link_proto.type) + options = LinkOptions(type=link_type) options_data = link_proto.options if options_data: options.delay = options_data.delay @@ -106,7 +101,6 @@ def add_link_data( options.unidirectional = options_data.unidirectional options.key = options_data.key options.opaque = options_data.opaque - return interface_one, interface_two, options diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index a79d7d6d..f3e1fbaa 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -772,7 +772,7 @@ class CoreHandler(socketserver.BaseRequestHandler): if link_type_value is not None: link_type = LinkTypes(link_type_value) - link_options = LinkOptions(_type=link_type) + link_options = LinkOptions(type=link_type) link_options.delay = message.get_tlv(LinkTlvs.DELAY.value) link_options.bandwidth = message.get_tlv(LinkTlvs.BANDWIDTH.value) link_options.session = message.get_tlv(LinkTlvs.SESSION.value) diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index 3c33ce65..f47da004 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -93,34 +93,28 @@ class NodeOptions: self.alt = alt +@dataclass class LinkOptions: """ Options for creating and updating links within core. """ - def __init__(self, _type: LinkTypes = LinkTypes.WIRED) -> None: - """ - Create a LinkOptions object. - - :param _type: type of link, defaults to - wired - """ - self.type = _type - self.session = None - self.delay = None - self.bandwidth = None - self.per = None - self.dup = None - self.jitter = None - self.mer = None - self.burst = None - self.mburst = None - self.gui_attributes = None - self.unidirectional = None - self.emulation_id = None - self.network_id = None - self.key = None - self.opaque = None + type: LinkTypes = LinkTypes.WIRED + session: int = None + delay: int = None + bandwidth: int = None + per: float = None + dup: int = None + jitter: int = None + mer: int = None + burst: int = None + mburst: int = None + gui_attributes: str = None + unidirectional: bool = None + emulation_id: int = None + network_id: int = None + key: int = None + opaque: str = None @dataclass diff --git a/daemon/tests/test_gui.py b/daemon/tests/test_gui.py index 89dcd7ab..800a8e62 100644 --- a/daemon/tests/test_gui.py +++ b/daemon/tests/test_gui.py @@ -511,7 +511,7 @@ class TestGui: EventTypes.DEFINITION_STATE, ], ) - def test_event_state(self, coretlv, state): + def test_event_state(self, coretlv: CoreHandler, state: EventTypes): message = coreapi.CoreEventMessage.create(0, [(EventTlvs.TYPE, state.value)]) coretlv.handle_message(message) @@ -536,7 +536,7 @@ class TestGui: coretlv.session.add_event.assert_called_once() - def test_event_save_xml(self, coretlv, tmpdir): + def test_event_save_xml(self, coretlv: CoreHandler, tmpdir): xml_file = tmpdir.join("coretlv.session.xml") file_path = xml_file.strpath coretlv.session.add_node(CoreNode) @@ -549,7 +549,7 @@ class TestGui: assert os.path.exists(file_path) - def test_event_open_xml(self, coretlv, tmpdir): + def test_event_open_xml(self, coretlv: CoreHandler, tmpdir): xml_file = tmpdir.join("coretlv.session.xml") file_path = xml_file.strpath node = coretlv.session.add_node(CoreNode) @@ -573,7 +573,7 @@ class TestGui: EventTypes.RECONFIGURE, ], ) - def test_event_service(self, coretlv, state): + def test_event_service(self, coretlv: CoreHandler, state: EventTypes): coretlv.session.broadcast_event = mock.MagicMock() node = coretlv.session.add_node(CoreNode) message = coreapi.CoreEventMessage.create( @@ -599,7 +599,7 @@ class TestGui: EventTypes.RECONFIGURE, ], ) - def test_event_mobility(self, coretlv, state): + def test_event_mobility(self, coretlv: CoreHandler, state: EventTypes): message = coreapi.CoreEventMessage.create( 0, [(EventTlvs.TYPE, state.value), (EventTlvs.NAME, "mobility:ns2script")] ) @@ -610,7 +610,7 @@ class TestGui: message = coreapi.CoreRegMessage.create(0, [(RegisterTlvs.GUI, "gui")]) coretlv.handle_message(message) - def test_register_xml(self, coretlv, tmpdir): + def test_register_xml(self, coretlv: CoreHandler, tmpdir): xml_file = tmpdir.join("coretlv.session.xml") file_path = xml_file.strpath node = coretlv.session.add_node(CoreNode) @@ -625,7 +625,7 @@ class TestGui: assert coretlv.coreemu.sessions[1].get_node(node.id, CoreNode) - def test_register_python(self, coretlv, tmpdir): + def test_register_python(self, coretlv: CoreHandler, tmpdir): xml_file = tmpdir.join("test.py") file_path = xml_file.strpath with open(file_path, "w") as f: From 7d2034df71d59a1c9ef3764e7590b5101233ee64 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 10:45:18 -0700 Subject: [PATCH 114/929] daemon: updated NodeOptions to leverage dataclass --- daemon/core/api/grpc/grpcutils.py | 16 ++++++----- daemon/core/api/grpc/server.py | 3 +- daemon/core/emulator/emudata.py | 46 +++++++++++++------------------ daemon/core/xml/corexml.py | 6 ++-- daemon/tests/test_distributed.py | 6 ++-- daemon/tests/test_grpc.py | 9 ++---- docs/scripting.md | 8 ++---- 7 files changed, 39 insertions(+), 55 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 7797c86e..5c6f3a80 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -31,17 +31,19 @@ def add_node_data(node_proto: core_pb2.Node) -> Tuple[NodeTypes, int, NodeOption """ _id = node_proto.id _type = NodeTypes(node_proto.type) - options = NodeOptions(name=node_proto.name, model=node_proto.model) - options.icon = node_proto.icon - options.opaque = node_proto.opaque - options.image = node_proto.image - options.services = node_proto.services - options.config_services = node_proto.config_services + options = NodeOptions( + name=node_proto.name, + model=node_proto.model, + icon=node_proto.icon, + opaque=node_proto.opaque, + image=node_proto.image, + services=node_proto.services, + config_services=node_proto.config_services, + ) if node_proto.emane: options.emane = node_proto.emane if node_proto.server: options.server = node_proto.server - position = node_proto.position options.set_position(position.x, position.y) if node_proto.HasField("geo"): diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index f85529e6..03cef387 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -743,8 +743,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): logging.debug("edit node: %s", request) session = self.get_session(request.session_id, context) node = self.get_node(session, request.node_id, context, NodeBase) - options = NodeOptions() - options.icon = request.icon + options = NodeOptions(icon=request.icon) if request.HasField("position"): x = request.position.x y = request.position.y diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index f47da004..7a9daf4f 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass -from typing import List, Union +from dataclasses import dataclass, field +from typing import List, Optional, Union import netaddr @@ -37,36 +37,28 @@ def link_config( ) +@dataclass class NodeOptions: """ Options for creating and updating nodes within core. """ - def __init__(self, name: str = None, model: str = "PC", image: str = None) -> None: - """ - Create a NodeOptions object. - - :param name: name of node, defaults to node class name postfix with its id - :param model: defines services for default and physical nodes, defaults to - "router" - :param image: image to use for docker nodes - """ - self.name = name - self.model = model - self.canvas = None - self.icon = None - self.opaque = None - self.services = [] - self.config_services = [] - self.x = None - self.y = None - self.lat = None - self.lon = None - self.alt = None - self.emulation_id = None - self.server = None - self.image = image - self.emane = None + name: str = None + model: Optional[str] = "PC" + canvas: int = None + icon: str = None + opaque: str = None + services: List[str] = field(default_factory=list) + config_services: List[str] = field(default_factory=list) + x: float = None + y: float = None + lat: float = None + lon: float = None + alt: float = None + emulation_id: int = None + server: str = None + image: str = None + emane: str = None def set_position(self, x: float, y: float) -> None: """ diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index efbf85c8..33005c97 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -832,8 +832,7 @@ class CoreXmlReader: icon = device_element.get("icon") clazz = device_element.get("class") image = device_element.get("image") - options = NodeOptions(name, model, image) - options.icon = icon + options = NodeOptions(name=name, model=model, image=image, icon=icon) node_type = NodeTypes.DEFAULT if clazz == "docker": @@ -874,8 +873,7 @@ class CoreXmlReader: node_type = NodeTypes[network_element.get("type")] _class = self.session.get_node_class(node_type) icon = network_element.get("icon") - options = NodeOptions(name) - options.icon = icon + options = NodeOptions(name=name, icon=icon) position_element = network_element.find("position") if position_element is not None: diff --git a/daemon/tests/test_distributed.py b/daemon/tests/test_distributed.py index 86ddaf99..0f4b1731 100644 --- a/daemon/tests/test_distributed.py +++ b/daemon/tests/test_distributed.py @@ -12,8 +12,7 @@ class TestDistributed: # when session.distributed.add_server(server_name, host) - options = NodeOptions() - options.server = server_name + options = NodeOptions(server=server_name) node = session.add_node(CoreNode, options=options) session.instantiate() @@ -30,8 +29,7 @@ class TestDistributed: # when session.distributed.add_server(server_name, host) - options = NodeOptions() - options.server = server_name + options = NodeOptions(server=server_name) node = session.add_node(HubNode, options=options) session.instantiate() diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 128863b4..c0686d71 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -710,8 +710,7 @@ class TestGrpc: client = CoreGrpcClient() session = grpc_server.coreemu.create_session() session.set_location(47.57917, -122.13232, 2.00000, 1.0) - options = NodeOptions() - options.emane = EmaneIeee80211abgModel.name + options = NodeOptions(emane=EmaneIeee80211abgModel.name) emane_network = session.add_node(EmaneNet, options=options) session.emane.set_model(emane_network, EmaneIeee80211abgModel) config_key = "platform_id_start" @@ -737,8 +736,7 @@ class TestGrpc: client = CoreGrpcClient() session = grpc_server.coreemu.create_session() session.set_location(47.57917, -122.13232, 2.00000, 1.0) - options = NodeOptions() - options.emane = EmaneIeee80211abgModel.name + options = NodeOptions(emane=EmaneIeee80211abgModel.name) emane_network = session.add_node(EmaneNet, options=options) session.emane.set_model(emane_network, EmaneIeee80211abgModel) config_key = "bandwidth" @@ -765,8 +763,7 @@ class TestGrpc: client = CoreGrpcClient() session = grpc_server.coreemu.create_session() session.set_location(47.57917, -122.13232, 2.00000, 1.0) - options = NodeOptions() - options.emane = EmaneIeee80211abgModel.name + options = NodeOptions(emane=EmaneIeee80211abgModel.name) emane_network = session.add_node(EmaneNet, options=options) session.emane.set_model(emane_network, EmaneIeee80211abgModel) diff --git a/docs/scripting.md b/docs/scripting.md index 7c8205c3..8c1a705c 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -136,8 +136,7 @@ coreemu = CoreEmu() session = coreemu.create_session() # create node with custom services -options = NodeOptions() -options.services = ["ServiceName"] +options = NodeOptions(services=["ServiceName"]) node = session.add_node(options=options) # set custom file data @@ -157,7 +156,6 @@ options = NodeOptions() options.set_position(80, 50) emane_network = session.add_node(EmaneNet, options=options) -# set custom emane model config -config = {} -session.emane.set_model(emane_network, EmaneIeee80211abgModel, config) +# set custom emane model config defaults +session.emane.set_model(emane_network, EmaneIeee80211abgModel) ``` From 3691c6029f33724c670422680767f7999d7fb6ce Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 10:48:50 -0700 Subject: [PATCH 115/929] updated corexml InterfaceData instantiation to use named params --- daemon/core/xml/corexml.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 33005c97..cb25e717 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -66,7 +66,15 @@ def create_interface_data(interface_element: etree.Element) -> InterfaceData: ip4_mask = get_int(interface_element, "ip4_mask") ip6 = interface_element.get("ip6") ip6_mask = get_int(interface_element, "ip6_mask") - return InterfaceData(interface_id, name, mac, ip4, ip4_mask, ip6, ip6_mask) + return InterfaceData( + id=interface_id, + name=name, + mac=mac, + ip4=ip4, + ip4_mask=ip4_mask, + ip6=ip6, + ip6_mask=ip6_mask, + ) def create_emane_config(session: "Session") -> etree.Element: From 3be15a131604b87a9661c952c891bce7c2e48513 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 12:42:15 -0700 Subject: [PATCH 116/929] daemon: update CoreNode.newnetif to require parameters, CoreNode.newnetif now depends on being provided InterfaceData --- daemon/core/emulator/emudata.py | 39 +++++++----------------- daemon/core/emulator/session.py | 49 +++++++++++------------------- daemon/core/nodes/base.py | 42 ++++++++------------------ daemon/core/nodes/physical.py | 53 ++++++++++----------------------- daemon/tests/test_nodes.py | 18 +++++++---- 5 files changed, 71 insertions(+), 130 deletions(-) diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index 7a9daf4f..b950e58c 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -1,18 +1,22 @@ from dataclasses import dataclass, field -from typing import List, Optional, Union +from typing import TYPE_CHECKING, List, Optional, Union import netaddr from core import utils from core.api.grpc.core_pb2 import LinkOptions from core.emulator.enumerations import LinkTypes -from core.nodes.base import CoreNetworkBase, CoreNode from core.nodes.interface import CoreInterface -from core.nodes.physical import PhysicalNode + +if TYPE_CHECKING: + from core.nodes.base import CoreNetworkBase, CoreNode + from core.nodes.physical import PhysicalNode + + LinkConfigNode = Union[CoreNetworkBase, PhysicalNode] def link_config( - node: Union[CoreNetworkBase, PhysicalNode], + node: "LinkConfigNode", interface: CoreInterface, link_options: LinkOptions, interface_two: CoreInterface = None, @@ -160,7 +164,7 @@ class IpPrefixes: if ip6_prefix: self.ip6 = netaddr.IPNetwork(ip6_prefix) - def ip4_address(self, node: CoreNode) -> str: + def ip4_address(self, node: "CoreNode") -> str: """ Convenience method to return the IP4 address for a node. @@ -171,7 +175,7 @@ class IpPrefixes: raise ValueError("ip4 prefixes have not been set") return str(self.ip4[node.id]) - def ip6_address(self, node: CoreNode) -> str: + def ip6_address(self, node: "CoreNode") -> str: """ Convenience method to return the IP6 address for a node. @@ -183,7 +187,7 @@ class IpPrefixes: return str(self.ip6[node.id]) def create_interface( - self, node: CoreNode, name: str = None, mac: str = None + self, node: "CoreNode", name: str = None, mac: str = None ) -> InterfaceData: """ Creates interface data for linking nodes, using the nodes unique id for @@ -225,24 +229,3 @@ class IpPrefixes: ip6_mask=ip6_mask, mac=mac, ) - - -def create_interface( - node: CoreNode, network: CoreNetworkBase, interface_data: InterfaceData -): - """ - Create an interface for a node on a network using provided interface data. - - :param node: node to create interface for - :param network: network to associate interface with - :param interface_data: interface data - :return: created interface - """ - node.newnetif( - network, - addrlist=interface_data.get_addresses(), - hwaddr=interface_data.mac, - ifindex=interface_data.id, - ifname=interface_data.name, - ) - return node.netif(interface_data.id) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 24fe05bd..d258ce9f 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -19,13 +19,7 @@ from core.emane.emanemanager import EmaneManager from core.emane.nodes import EmaneNet from core.emulator.data import ConfigData, EventData, ExceptionData, FileData, LinkData from core.emulator.distributed import DistributedController -from core.emulator.emudata import ( - InterfaceData, - LinkOptions, - NodeOptions, - create_interface, - link_config, -) +from core.emulator.emudata import InterfaceData, LinkOptions, NodeOptions, link_config from core.emulator.enumerations import ( EventTypes, ExceptionLevels, @@ -360,11 +354,11 @@ class Session: node_one.name, net_one.name, ) - interface = create_interface(node_one, net_one, interface_one) - node_one_interface = interface + ifindex = node_one.newnetif(net_one, interface_one) + node_one_interface = node_one.netif(ifindex) wireless_net = isinstance(net_one, (EmaneNet, WlanNode)) if not wireless_net: - link_config(net_one, interface, link_options) + link_config(net_one, node_one_interface, link_options) # network to node if node_two and net_one: @@ -373,11 +367,11 @@ class Session: node_two.name, net_one.name, ) - interface = create_interface(node_two, net_one, interface_two) - node_two_interface = interface + ifindex = node_two.newnetif(net_one, interface_two) + node_two_interface = node_two.netif(ifindex) wireless_net = isinstance(net_one, (EmaneNet, WlanNode)) if not link_options.unidirectional and not wireless_net: - link_config(net_one, interface, link_options) + link_config(net_one, node_two_interface, link_options) # network to network if net_one and net_two: @@ -1797,35 +1791,28 @@ class Session: control_net = self.add_remove_control_net(net_index, remove, conf_required) if not control_net: return - if not node: return - # ctrl# already exists if node.netif(control_net.CTRLIF_IDX_BASE + net_index): return - - control_ip = node.id - try: - address = control_net.prefix[control_ip] - prefix = control_net.prefix.prefixlen - addrlist = [f"{address}/{prefix}"] + ip4 = control_net.prefix[node.id] + ip4_mask = control_net.prefix.prefixlen + interface = InterfaceData( + id=control_net.CTRLIF_IDX_BASE + net_index, + name=f"ctrl{net_index}", + mac=utils.random_mac(), + ip4=ip4, + ip4_mask=ip4_mask, + ) + ifindex = node.newnetif(control_net, interface) + node.netif(ifindex).control = True except ValueError: msg = f"Control interface not added to node {node.id}. " msg += f"Invalid control network prefix ({control_net.prefix}). " msg += "A longer prefix length may be required for this many nodes." logging.exception(msg) - return - - interface1 = node.newnetif( - net=control_net, - ifindex=control_net.CTRLIF_IDX_BASE + net_index, - ifname=f"ctrl{net_index}", - hwaddr=utils.random_mac(), - addrlist=addrlist, - ) - node.netif(interface1).control = True def update_control_interface_hosts( self, net_index: int = 0, remove: bool = False diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 662815ef..e88912ac 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -15,6 +15,7 @@ from core import utils from core.configservice.dependencies import ConfigServiceDependencies from core.constants import MOUNT_BIN, VNODED_BIN from core.emulator.data import LinkData, NodeData +from core.emulator.emudata import InterfaceData from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes from core.errors import CoreCommandError, CoreError from core.nodes.client import VnodeClient @@ -845,53 +846,36 @@ class CoreNode(CoreNodeBase): interface_name = self.ifname(ifindex) self.node_net_client.device_up(interface_name) - def newnetif( - self, - net: "CoreNetworkBase" = None, - addrlist: List[str] = None, - hwaddr: str = None, - ifindex: int = None, - ifname: str = None, - ) -> int: + def newnetif(self, net: "CoreNetworkBase", interface: InterfaceData) -> int: """ Create a new network interface. :param net: network to associate with - :param addrlist: addresses to add on the interface - :param hwaddr: hardware address to set for interface - :param ifindex: index of interface to create - :param ifname: name for interface + :param interface: interface data for new interface :return: interface index """ - if not addrlist: - addrlist = [] - + addresses = interface.get_addresses() with self.lock: # TODO: emane specific code - if net is not None and net.is_emane is True: - ifindex = self.newtuntap(ifindex, ifname) + if net.is_emane is True: + ifindex = self.newtuntap(interface.id, interface.name) # TUN/TAP is not ready for addressing yet; the device may # take some time to appear, and installing it into a # namespace after it has been bound removes addressing; # save addresses with the interface now self.attachnet(ifindex, net) netif = self.netif(ifindex) - netif.sethwaddr(hwaddr) - for address in utils.make_tuple(addrlist): + netif.sethwaddr(interface.mac) + for address in addresses: netif.addaddr(address) return ifindex else: - ifindex = self.newveth(ifindex, ifname) - - if net is not None: - self.attachnet(ifindex, net) - - if hwaddr: - self.sethwaddr(ifindex, hwaddr) - - for address in utils.make_tuple(addrlist): + ifindex = self.newveth(interface.id, interface.name) + self.attachnet(ifindex, net) + if interface.mac: + self.sethwaddr(ifindex, interface.mac) + for address in addresses: self.addaddr(ifindex, address) - self.ifup(ifindex) return ifindex diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index e5db8a80..ec531505 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -10,10 +10,11 @@ from typing import IO, TYPE_CHECKING, List, Optional, Tuple from core import utils from core.constants import MOUNT_BIN, UMOUNT_BIN from core.emulator.distributed import DistributedServer +from core.emulator.emudata import InterfaceData from core.emulator.enumerations import NodeTypes, TransportType from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNetworkBase, CoreNodeBase -from core.nodes.interface import CoreInterface, Veth +from core.nodes.interface import CoreInterface from core.nodes.network import CoreNetwork, GreTap if TYPE_CHECKING: @@ -168,37 +169,25 @@ class PhysicalNode(CoreNodeBase): self.ifindex += 1 return ifindex - def newnetif( - self, - net: Veth = None, - addrlist: List[str] = None, - hwaddr: str = None, - ifindex: int = None, - ifname: str = None, - ) -> int: + def newnetif(self, net: CoreNetworkBase, interface: InterfaceData) -> int: logging.info("creating interface") - if not addrlist: - addrlist = [] - - if self.up and net is None: - raise NotImplementedError - + addresses = interface.get_addresses() + ifindex = interface.id if ifindex is None: ifindex = self.newifindex() - - if ifname is None: - ifname = f"gt{ifindex}" - + name = interface.name + if name is None: + name = f"gt{ifindex}" if self.up: # this is reached when this node is linked to a network node # tunnel to net not built yet, so build it now and adopt it _, remote_tap = self.session.distributed.create_gre_tunnel(net, self.server) - self.adoptnetif(remote_tap, ifindex, hwaddr, addrlist) + self.adoptnetif(remote_tap, ifindex, interface.mac, addresses) return ifindex else: # this is reached when configuring services (self.up=False) - netif = GreTap(node=self, name=ifname, session=self.session, start=False) - self.adoptnetif(netif, ifindex, hwaddr, addrlist) + netif = GreTap(node=self, name=name, session=self.session, start=False) + self.adoptnetif(netif, ifindex, interface.mac, addresses) return ifindex def privatedir(self, path: str) -> None: @@ -320,28 +309,19 @@ class Rj45Node(CoreNodeBase): self.up = False self.restorestate() - def newnetif( - self, - net: CoreNetworkBase = None, - addrlist: List[str] = None, - hwaddr: str = None, - ifindex: int = None, - ifname: str = None, - ) -> int: + def newnetif(self, net: CoreNetworkBase, interface: InterfaceData) -> int: """ This is called when linking with another node. Since this node represents an interface, we do not create another object here, but attach ourselves to the given network. :param net: new network instance - :param addrlist: address list - :param hwaddr: hardware address - :param ifindex: interface index - :param ifname: interface name + :param interface: interface data for new interface :return: interface index :raises ValueError: when an interface has already been created, one max """ with self.lock: + ifindex = interface.id if ifindex is None: ifindex = 0 if self.interface.net is not None: @@ -350,9 +330,8 @@ class Rj45Node(CoreNodeBase): self.ifindex = ifindex if net is not None: self.interface.attachnet(net) - if addrlist: - for addr in utils.make_tuple(addrlist): - self.addaddr(addr) + for addr in interface.get_addresses(): + self.addaddr(addr) return ifindex def delnetif(self, ifindex: int) -> None: diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index 65b17949..26e78367 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -1,6 +1,6 @@ import pytest -from core.emulator.emudata import NodeOptions +from core.emulator.emudata import InterfaceData, NodeOptions from core.emulator.session import Session from core.errors import CoreError from core.nodes.base import CoreNode @@ -52,7 +52,9 @@ class TestNodes: def test_node_sethwaddr(self, session: Session): # given node = session.add_node(CoreNode) - index = node.newnetif() + switch = session.add_node(SwitchNode) + interface_data = InterfaceData() + index = node.newnetif(switch, interface_data) interface = node.netif(index) mac = "aa:aa:aa:ff:ff:ff" @@ -65,7 +67,9 @@ class TestNodes: def test_node_sethwaddr_exception(self, session: Session): # given node = session.add_node(CoreNode) - index = node.newnetif() + switch = session.add_node(SwitchNode) + interface_data = InterfaceData() + index = node.newnetif(switch, interface_data) node.netif(index) mac = "aa:aa:aa:ff:ff:fff" @@ -76,7 +80,9 @@ class TestNodes: def test_node_addaddr(self, session: Session): # given node = session.add_node(CoreNode) - index = node.newnetif() + switch = session.add_node(SwitchNode) + interface_data = InterfaceData() + index = node.newnetif(switch, interface_data) interface = node.netif(index) addr = "192.168.0.1/24" @@ -89,7 +95,9 @@ class TestNodes: def test_node_addaddr_exception(self, session): # given node = session.add_node(CoreNode) - index = node.newnetif() + switch = session.add_node(SwitchNode) + interface_data = InterfaceData() + index = node.newnetif(switch, interface_data) node.netif(index) addr = "256.168.0.1/24" From 2965273f58a3782d75f5d10700c0e5fbe50d0ea6 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 13:41:31 -0700 Subject: [PATCH 117/929] daemon: CoreNetworkBase.linkconfig now takes a LinkOptions object, removed usage of emudata.link_config --- daemon/core/emane/commeffect.py | 23 ++++++++------------- daemon/core/emane/emanemodel.py | 16 +++------------ daemon/core/emane/nodes.py | 12 +++-------- daemon/core/emulator/emudata.py | 35 ++------------------------------ daemon/core/emulator/session.py | 34 +++++++++++++++---------------- daemon/core/location/mobility.py | 9 ++++++-- daemon/core/nodes/base.py | 17 +++------------- daemon/core/nodes/network.py | 21 ++++++++----------- daemon/core/nodes/physical.py | 13 +++--------- 9 files changed, 53 insertions(+), 127 deletions(-) diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index 99fdb9b1..b7060e96 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -11,6 +11,7 @@ from lxml import etree from core.config import ConfigGroup, Configuration from core.emane import emanemanifest, emanemodel from core.emane.nodes import EmaneNet +from core.emulator.emudata import LinkOptions from core.emulator.enumerations import TransportType from core.nodes.interface import CoreInterface from core.xml import emanexml @@ -114,14 +115,7 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): emanexml.create_file(shim_element, "shim", shim_file) def linkconfig( - self, - netif: CoreInterface, - bw: float = None, - delay: float = None, - loss: float = None, - duplicate: float = None, - jitter: float = None, - netif2: CoreInterface = None, + self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None ) -> None: """ Generate CommEffect events when a Link Message is received having @@ -142,15 +136,14 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): emane_node = self.session.get_node(self.id, EmaneNet) nemid = emane_node.getnemid(netif) nemid2 = emane_node.getnemid(netif2) - mbw = bw logging.info("sending comm effect event") event.append( nemid, - latency=convert_none(delay), - jitter=convert_none(jitter), - loss=convert_none(loss), - duplicate=convert_none(duplicate), - unicast=int(convert_none(bw)), - broadcast=int(convert_none(mbw)), + latency=convert_none(options.delay), + jitter=convert_none(options.jitter), + loss=convert_none(options.per), + duplicate=convert_none(options.dup), + unicast=int(convert_none(options.bandwidth)), + broadcast=int(convert_none(options.bandwidth)), ) service.publish(nemid2, event) diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 3a21643b..f42caa14 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -8,6 +8,7 @@ from typing import Dict, List from core.config import ConfigGroup, Configuration from core.emane import emanemanifest from core.emane.nodes import EmaneNet +from core.emulator.emudata import LinkOptions from core.emulator.enumerations import ConfigDataTypes, TransportType from core.errors import CoreError from core.location.mobility import WirelessModel @@ -155,24 +156,13 @@ class EmaneModel(WirelessModel): logging.exception("error during update") def linkconfig( - self, - netif: CoreInterface, - bw: float = None, - delay: float = None, - loss: float = None, - duplicate: float = None, - jitter: float = None, - netif2: CoreInterface = None, + self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None ) -> None: """ Invoked when a Link Message is received. Default is unimplemented. :param netif: interface one - :param bw: bandwidth to set to - :param delay: packet delay to set to - :param loss: packet loss to set to - :param duplicate: duplicate percentage to set to - :param jitter: jitter to set to + :param options: options for configuring link :param netif2: interface two :return: nothing """ diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index bbe59b95..f4de8f47 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type from core.emulator.data import LinkData from core.emulator.distributed import DistributedServer +from core.emulator.emudata import LinkOptions from core.emulator.enumerations import ( LinkTypes, MessageFlags, @@ -60,21 +61,14 @@ class EmaneNet(CoreNetworkBase): self.mobility = None def linkconfig( - self, - netif: CoreInterface, - bw: float = None, - delay: float = None, - loss: float = None, - duplicate: float = None, - jitter: float = None, - netif2: CoreInterface = None, + self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None ) -> None: """ The CommEffect model supports link configuration. """ if not self.model: return - self.model.linkconfig(netif, bw, delay, loss, duplicate, jitter, netif2) + self.model.linkconfig(netif, options, netif2) def config(self, conf: str) -> None: self.conf = conf diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index b950e58c..3ccf11cc 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -1,44 +1,13 @@ from dataclasses import dataclass, field -from typing import TYPE_CHECKING, List, Optional, Union +from typing import TYPE_CHECKING, List, Optional import netaddr from core import utils -from core.api.grpc.core_pb2 import LinkOptions from core.emulator.enumerations import LinkTypes -from core.nodes.interface import CoreInterface if TYPE_CHECKING: - from core.nodes.base import CoreNetworkBase, CoreNode - from core.nodes.physical import PhysicalNode - - LinkConfigNode = Union[CoreNetworkBase, PhysicalNode] - - -def link_config( - node: "LinkConfigNode", - interface: CoreInterface, - link_options: LinkOptions, - interface_two: CoreInterface = None, -) -> None: - """ - Convenience method for configuring a link, - - :param node: network to configure link for - :param interface: interface to configure - :param link_options: data to configure link with - :param interface_two: other interface associated, default is None - :return: nothing - """ - node.linkconfig( - interface, - link_options.bandwidth, - link_options.delay, - link_options.per, - link_options.dup, - link_options.jitter, - interface_two, - ) + from core.nodes.base import CoreNode @dataclass diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index d258ce9f..59bd3cf3 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -19,7 +19,7 @@ from core.emane.emanemanager import EmaneManager from core.emane.nodes import EmaneNet from core.emulator.data import ConfigData, EventData, ExceptionData, FileData, LinkData from core.emulator.distributed import DistributedController -from core.emulator.emudata import InterfaceData, LinkOptions, NodeOptions, link_config +from core.emulator.emudata import InterfaceData, LinkOptions, NodeOptions from core.emulator.enumerations import ( EventTypes, ExceptionLevels, @@ -358,7 +358,7 @@ class Session: node_one_interface = node_one.netif(ifindex) wireless_net = isinstance(net_one, (EmaneNet, WlanNode)) if not wireless_net: - link_config(net_one, node_one_interface, link_options) + net_one.linkconfig(node_one_interface, link_options) # network to node if node_two and net_one: @@ -371,7 +371,7 @@ class Session: node_two_interface = node_two.netif(ifindex) wireless_net = isinstance(net_one, (EmaneNet, WlanNode)) if not link_options.unidirectional and not wireless_net: - link_config(net_one, node_two_interface, link_options) + net_one.linkconfig(node_two_interface, link_options) # network to network if net_one and net_two: @@ -382,18 +382,16 @@ class Session: ) interface = net_one.linknet(net_two) node_one_interface = interface - link_config(net_one, interface, link_options) - + net_one.linkconfig(interface, link_options) if not link_options.unidirectional: interface.swapparams("_params_up") - link_config(net_two, interface, link_options) + net_two.linkconfig(interface, link_options) interface.swapparams("_params_up") # a tunnel node was found for the nodes addresses = [] if not node_one and all([net_one, interface_one]): addresses.extend(interface_one.get_addresses()) - if not node_two and all([net_two, interface_two]): addresses.extend(interface_two.get_addresses()) @@ -418,14 +416,14 @@ class Session: node_one.adoptnetif( tunnel, interface_one.id, interface_one.mac, addresses ) - link_config(node_one, tunnel, link_options) + node_one.linkconfig(tunnel, link_options) elif node_two and isinstance(node_two, PhysicalNode): logging.info("adding link for physical node: %s", node_two.name) addresses = interface_two.get_addresses() node_two.adoptnetif( tunnel, interface_two.id, interface_two.mac, addresses ) - link_config(node_two, tunnel, link_options) + node_two.linkconfig(tunnel, link_options) finally: if node_one: node_one.lock.release() @@ -596,28 +594,28 @@ class Session: if upstream: interface.swapparams("_params_up") - link_config(net_one, interface, link_options) + net_one.linkconfig(interface, link_options) interface.swapparams("_params_up") else: - link_config(net_one, interface, link_options) + net_one.linkconfig(interface, link_options) if not link_options.unidirectional: if upstream: - link_config(net_two, interface, link_options) + net_two.linkconfig(interface, link_options) else: interface.swapparams("_params_up") - link_config(net_two, interface, link_options) + net_two.linkconfig(interface, link_options) interface.swapparams("_params_up") else: raise CoreError("modify link for unknown nodes") elif not node_one: # node1 = layer 2node, node2 = layer3 node interface = node_two.netif(interface_two_id) - link_config(net_one, interface, link_options) + net_one.linkconfig(interface, link_options) elif not node_two: # node2 = layer 2node, node1 = layer3 node interface = node_one.netif(interface_one_id) - link_config(net_one, interface, link_options) + net_one.linkconfig(interface, link_options) else: common_networks = node_one.commonnets(node_two) if not common_networks: @@ -630,10 +628,10 @@ class Session: ): continue - link_config(net_one, interface_one, link_options, interface_two) + net_one.linkconfig(interface_one, link_options, interface_two) if not link_options.unidirectional: - link_config( - net_one, interface_two, link_options, interface_one + net_one.linkconfig( + interface_two, link_options, interface_one ) finally: if node_one: diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index 5041f144..3ca46418 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, Dict, List, Tuple from core import utils from core.config import ConfigGroup, ConfigurableOptions, Configuration, ModelManager from core.emulator.data import EventData, LinkData +from core.emulator.emudata import LinkOptions from core.emulator.enumerations import ( ConfigDataTypes, EventTypes, @@ -334,9 +335,13 @@ class BasicRangeModel(WirelessModel): """ with self._netifslock: for netif in self._netifs: - self.wlan.linkconfig( - netif, self.bw, self.delay, self.loss, jitter=self.jitter + options = LinkOptions( + bandwidth=self.bw, + delay=self.delay, + per=self.loss, + jitter=self.jitter, ) + self.wlan.linkconfig(netif, options) def get_position(self, netif: CoreInterface) -> Tuple[float, float, float]: """ diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index e88912ac..0c76d6a2 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -15,7 +15,7 @@ from core import utils from core.configservice.dependencies import ConfigServiceDependencies from core.constants import MOUNT_BIN, VNODED_BIN from core.emulator.data import LinkData, NodeData -from core.emulator.emudata import InterfaceData +from core.emulator.emudata import InterfaceData, LinkOptions from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes from core.errors import CoreCommandError, CoreError from core.nodes.client import VnodeClient @@ -1147,24 +1147,13 @@ class CoreNetworkBase(NodeBase): return all_links def linkconfig( - self, - netif: CoreInterface, - bw: float = None, - delay: float = None, - loss: float = None, - duplicate: float = None, - jitter: float = None, - netif2: float = None, + self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None ) -> None: """ Configure link parameters by applying tc queuing disciplines on the interface. :param netif: interface one - :param bw: bandwidth to set to - :param delay: packet delay to set to - :param loss: packet loss to set to - :param duplicate: duplicate percentage to set to - :param jitter: jitter to set to + :param options: options for configuring link :param netif2: interface two :return: nothing """ diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index b08d87d4..095fbe9b 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -12,6 +12,7 @@ import netaddr from core import utils from core.constants import EBTABLES_BIN, TC_BIN from core.emulator.data import LinkData, NodeData +from core.emulator.emudata import LinkOptions from core.emulator.enumerations import ( LinkTypes, MessageFlags, @@ -441,24 +442,13 @@ class CoreNetwork(CoreNetworkBase): ebq.ebchange(self) def linkconfig( - self, - netif: CoreInterface, - bw: float = None, - delay: float = None, - loss: float = None, - duplicate: float = None, - jitter: float = None, - netif2: float = None, + self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None ) -> None: """ Configure link parameters by applying tc queuing disciplines on the interface. :param netif: interface one - :param bw: bandwidth to set to - :param delay: packet delay to set to - :param loss: packet loss to set to - :param duplicate: duplicate percentage to set to - :param jitter: jitter to set to + :param options: options for configuring link :param netif2: interface two :return: nothing """ @@ -466,6 +456,7 @@ class CoreNetwork(CoreNetworkBase): tc = f"{TC_BIN} qdisc replace dev {devname}" parent = "root" changed = False + bw = options.bandwidth if netif.setparam("bw", bw): # from tc-tbf(8): minimum value for burst is rate / kernel_hz burst = max(2 * netif.mtu, int(bw / 1000)) @@ -489,13 +480,17 @@ class CoreNetwork(CoreNetworkBase): if netif.getparam("has_tbf"): parent = "parent 1:1" netem = "netem" + delay = options.delay changed = max(changed, netif.setparam("delay", delay)) + loss = options.per if loss is not None: loss = float(loss) changed = max(changed, netif.setparam("loss", loss)) + duplicate = options.dup if duplicate is not None: duplicate = int(duplicate) changed = max(changed, netif.setparam("duplicate", duplicate)) + jitter = options.jitter changed = max(changed, netif.setparam("jitter", jitter)) if not changed: return diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index ec531505..018ca60d 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -10,7 +10,7 @@ from typing import IO, TYPE_CHECKING, List, Optional, Tuple from core import utils from core.constants import MOUNT_BIN, UMOUNT_BIN from core.emulator.distributed import DistributedServer -from core.emulator.emudata import InterfaceData +from core.emulator.emudata import InterfaceData, LinkOptions from core.emulator.enumerations import NodeTypes, TransportType from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNetworkBase, CoreNodeBase @@ -144,21 +144,14 @@ class PhysicalNode(CoreNodeBase): self.net_client.device_up(netif.localname) def linkconfig( - self, - netif: CoreInterface, - bw: float = None, - delay: float = None, - loss: float = None, - duplicate: float = None, - jitter: float = None, - netif2: CoreInterface = None, + self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None ) -> None: """ Apply tc queing disciplines using linkconfig. """ linux_bridge = CoreNetwork(session=self.session, start=False) linux_bridge.up = True - linux_bridge.linkconfig(netif, bw, delay, loss, duplicate, jitter, netif2) + linux_bridge.linkconfig(netif, options, netif2) del linux_bridge def newifindex(self) -> int: From 21da67069803f323f1cb1e57290a278a9d53fc09 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 13:46:26 -0700 Subject: [PATCH 118/929] daemon: renamed link_options to options in both session.add_link and session.update_link --- daemon/core/api/grpc/server.py | 2 +- daemon/core/emulator/session.py | 60 ++++++++++++++++----------------- daemon/tests/test_links.py | 2 +- daemon/tests/test_xml.py | 4 +-- 4 files changed, 32 insertions(+), 36 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 03cef387..7d7f7c80 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -854,7 +854,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): node_two_id = request.link.node_two_id interface_one, interface_two, options = grpcutils.add_link_data(request.link) node_one_interface, node_two_interface = session.add_link( - node_one_id, node_two_id, interface_one, interface_two, link_options=options + node_one_id, node_two_id, interface_one, interface_two, options=options ) interface_one_proto = None interface_two_proto = None diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 59bd3cf3..a1e71612 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -299,7 +299,7 @@ class Session: node_two_id: int, interface_one: InterfaceData = None, interface_two: InterfaceData = None, - link_options: LinkOptions = None, + options: LinkOptions = None, ) -> Tuple[CoreInterface, CoreInterface]: """ Add a link between nodes. @@ -310,12 +310,12 @@ class Session: data, defaults to none :param interface_two: node two interface data, defaults to none - :param link_options: data for creating link, + :param options: data for creating link, defaults to no options :return: tuple of created core interfaces, depending on link """ - if not link_options: - link_options = LinkOptions() + if not options: + options = LinkOptions() # get node objects identified by link data node_one, node_two, net_one, net_two, tunnel = self._link_nodes( @@ -332,7 +332,7 @@ class Session: try: # wireless link - if link_options.type == LinkTypes.WIRELESS: + if options.type == LinkTypes.WIRELESS: objects = [node_one, node_two, net_one, net_two] self._link_wireless(objects, connect=True) # wired link @@ -358,7 +358,7 @@ class Session: node_one_interface = node_one.netif(ifindex) wireless_net = isinstance(net_one, (EmaneNet, WlanNode)) if not wireless_net: - net_one.linkconfig(node_one_interface, link_options) + net_one.linkconfig(node_one_interface, options) # network to node if node_two and net_one: @@ -370,8 +370,8 @@ class Session: ifindex = node_two.newnetif(net_one, interface_two) node_two_interface = node_two.netif(ifindex) wireless_net = isinstance(net_one, (EmaneNet, WlanNode)) - if not link_options.unidirectional and not wireless_net: - net_one.linkconfig(node_two_interface, link_options) + if not options.unidirectional and not wireless_net: + net_one.linkconfig(node_two_interface, options) # network to network if net_one and net_two: @@ -382,10 +382,10 @@ class Session: ) interface = net_one.linknet(net_two) node_one_interface = interface - net_one.linkconfig(interface, link_options) - if not link_options.unidirectional: + net_one.linkconfig(interface, options) + if not options.unidirectional: interface.swapparams("_params_up") - net_two.linkconfig(interface, link_options) + net_two.linkconfig(interface, options) interface.swapparams("_params_up") # a tunnel node was found for the nodes @@ -396,7 +396,7 @@ class Session: addresses.extend(interface_two.get_addresses()) # tunnel node logic - key = link_options.key + key = options.key if key and isinstance(net_one, TunnelNode): logging.info("setting tunnel key for: %s", net_one.name) net_one.setkey(key) @@ -416,14 +416,14 @@ class Session: node_one.adoptnetif( tunnel, interface_one.id, interface_one.mac, addresses ) - node_one.linkconfig(tunnel, link_options) + node_one.linkconfig(tunnel, options) elif node_two and isinstance(node_two, PhysicalNode): logging.info("adding link for physical node: %s", node_two.name) addresses = interface_two.get_addresses() node_two.adoptnetif( tunnel, interface_two.id, interface_two.mac, addresses ) - node_two.linkconfig(tunnel, link_options) + node_two.linkconfig(tunnel, options) finally: if node_one: node_one.lock.release() @@ -547,7 +547,7 @@ class Session: node_two_id: int, interface_one_id: int = None, interface_two_id: int = None, - link_options: LinkOptions = None, + options: LinkOptions = None, ) -> None: """ Update link information between nodes. @@ -556,13 +556,13 @@ class Session: :param node_two_id: node two id :param interface_one_id: interface id for node one :param interface_two_id: interface id for node two - :param link_options: data to update link with + :param options: data to update link with :return: nothing :raises core.CoreError: when updating a wireless type link, when there is a unknown link between networks """ - if not link_options: - link_options = LinkOptions() + if not options: + options = LinkOptions() # get node objects identified by link data node_one, node_two, net_one, net_two, _tunnel = self._link_nodes( @@ -576,7 +576,7 @@ class Session: try: # wireless link - if link_options.type == LinkTypes.WIRELESS: + if options.type == LinkTypes.WIRELESS: raise CoreError("cannot update wireless link") else: if not node_one and not node_two: @@ -594,28 +594,28 @@ class Session: if upstream: interface.swapparams("_params_up") - net_one.linkconfig(interface, link_options) + net_one.linkconfig(interface, options) interface.swapparams("_params_up") else: - net_one.linkconfig(interface, link_options) + net_one.linkconfig(interface, options) - if not link_options.unidirectional: + if not options.unidirectional: if upstream: - net_two.linkconfig(interface, link_options) + net_two.linkconfig(interface, options) else: interface.swapparams("_params_up") - net_two.linkconfig(interface, link_options) + net_two.linkconfig(interface, options) interface.swapparams("_params_up") else: raise CoreError("modify link for unknown nodes") elif not node_one: # node1 = layer 2node, node2 = layer3 node interface = node_two.netif(interface_two_id) - net_one.linkconfig(interface, link_options) + net_one.linkconfig(interface, options) elif not node_two: # node2 = layer 2node, node1 = layer3 node interface = node_one.netif(interface_one_id) - net_one.linkconfig(interface, link_options) + net_one.linkconfig(interface, options) else: common_networks = node_one.commonnets(node_two) if not common_networks: @@ -628,11 +628,9 @@ class Session: ): continue - net_one.linkconfig(interface_one, link_options, interface_two) - if not link_options.unidirectional: - net_one.linkconfig( - interface_two, link_options, interface_one - ) + net_one.linkconfig(interface_one, options, interface_two) + if not options.unidirectional: + net_one.linkconfig(interface_two, options, interface_one) finally: if node_one: node_one.lock.release() diff --git a/daemon/tests/test_links.py b/daemon/tests/test_links.py index 94b2e53f..9736537e 100644 --- a/daemon/tests/test_links.py +++ b/daemon/tests/test_links.py @@ -105,7 +105,7 @@ class TestLinks: node_one.id, node_two.id, interface_one_id=interface_one_data.id, - link_options=link_options, + options=link_options, ) # then diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index 70117fb8..c40a9ef3 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -309,9 +309,7 @@ class TestXml: link_options.jitter = 10 link_options.delay = 30 link_options.dup = 5 - session.add_link( - node_one.id, switch.id, interface_one, link_options=link_options - ) + session.add_link(node_one.id, switch.id, interface_one, options=link_options) # instantiate session session.instantiate() From d71d84fae7da24cabe7a94aee2b60a84786959b0 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 18:40:50 -0700 Subject: [PATCH 119/929] daemon: updated IpPrefixes and InterfaceHelper to remove duplicate code --- daemon/core/api/grpc/client.py | 77 ++++--------------------------- daemon/core/emulator/emudata.py | 78 +++++++++++++++++--------------- daemon/tests/emane/test_emane.py | 2 +- daemon/tests/test_core.py | 2 +- 4 files changed, 53 insertions(+), 106 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index c1d0e2fd..64b8d29f 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -8,9 +8,7 @@ from contextlib import contextmanager from typing import Any, Callable, Dict, Generator, Iterable, List import grpc -import netaddr -from core import utils from core.api.grpc import configservices_pb2, core_pb2, core_pb2_grpc from core.api.grpc.configservices_pb2 import ( GetConfigServiceDefaultsRequest, @@ -94,6 +92,7 @@ from core.api.grpc.wlan_pb2 import ( WlanLinkRequest, WlanLinkResponse, ) +from core.emulator.emudata import IpPrefixes class InterfaceHelper: @@ -109,78 +108,20 @@ class InterfaceHelper: :param ip6_prefix: ip6 prefix to use for generation :raises ValueError: when both ip4 and ip6 prefixes have not been provided """ - if not ip4_prefix and not ip6_prefix: - raise ValueError("ip4 or ip6 must be provided") - - self.ip4 = None - if ip4_prefix: - self.ip4 = netaddr.IPNetwork(ip4_prefix) - self.ip6 = None - if ip6_prefix: - self.ip6 = netaddr.IPNetwork(ip6_prefix) - - def ip4_address(self, node_id: int) -> str: - """ - Convenience method to return the IP4 address for a node. - - :param node_id: node id to get IP4 address for - :return: IP4 address or None - """ - if not self.ip4: - raise ValueError("ip4 prefixes have not been set") - return str(self.ip4[node_id]) - - def ip6_address(self, node_id: int) -> str: - """ - Convenience method to return the IP6 address for a node. - - :param node_id: node id to get IP6 address for - :return: IP4 address or None - """ - if not self.ip6: - raise ValueError("ip6 prefixes have not been set") - return str(self.ip6[node_id]) + self.prefixes = IpPrefixes(ip4_prefix, ip6_prefix) def create_interface( self, node_id: int, interface_id: int, name: str = None, mac: str = None ) -> core_pb2.Interface: - """ - Creates interface data for linking nodes, using the nodes unique id for - generation, along with a random mac address, unless provided. - - :param node_id: node id to create interface for - :param interface_id: interface id for interface - :param name: name to set for interface, default is eth{id} - :param mac: mac address to use for this interface, default is random - generation - :return: new interface data for the provided node - """ - # generate ip4 data - ip4 = None - ip4_mask = None - if self.ip4: - ip4 = self.ip4_address(node_id) - ip4_mask = self.ip4.prefixlen - - # generate ip6 data - ip6 = None - ip6_mask = None - if self.ip6: - ip6 = self.ip6_address(node_id) - ip6_mask = self.ip6.prefixlen - - # random mac - if not mac: - mac = utils.random_mac() - + interface_data = self.prefixes.gen_interface(node_id, name, mac) return core_pb2.Interface( id=interface_id, - name=name, - ip4=ip4, - ip4mask=ip4_mask, - ip6=ip6, - ip6mask=ip6_mask, - mac=str(mac), + name=interface_data.name, + ip4=interface_data.ip4, + ip4mask=interface_data.ip4_mask, + ip6=interface_data.ip6, + ip6mask=interface_data.ip6_mask, + mac=interface_data.mac, ) diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index 3ccf11cc..b6dbd57c 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -133,27 +133,60 @@ class IpPrefixes: if ip6_prefix: self.ip6 = netaddr.IPNetwork(ip6_prefix) - def ip4_address(self, node: "CoreNode") -> str: + def ip4_address(self, node_id: int) -> str: """ Convenience method to return the IP4 address for a node. - :param node: node to get IP4 address for + :param node_id: node id to get IP4 address for :return: IP4 address or None """ if not self.ip4: raise ValueError("ip4 prefixes have not been set") - return str(self.ip4[node.id]) + return str(self.ip4[node_id]) - def ip6_address(self, node: "CoreNode") -> str: + def ip6_address(self, node_id: int) -> str: """ Convenience method to return the IP6 address for a node. - :param node: node to get IP6 address for + :param node_id: node id to get IP6 address for :return: IP4 address or None """ if not self.ip6: raise ValueError("ip6 prefixes have not been set") - return str(self.ip6[node.id]) + return str(self.ip6[node_id]) + + def gen_interface(self, node_id: int, name: str = None, mac: str = None): + """ + Creates interface data for linking nodes, using the nodes unique id for + generation, along with a random mac address, unless provided. + + :param node_id: node id to create an interface for + :param name: name to set for interface, default is eth{id} + :param mac: mac address to use for this interface, default is random + generation + :return: new interface data for the provided node + """ + # generate ip4 data + ip4 = None + ip4_mask = None + if self.ip4: + ip4 = self.ip4_address(node_id) + ip4_mask = self.ip4.prefixlen + + # generate ip6 data + ip6 = None + ip6_mask = None + if self.ip6: + ip6 = self.ip6_address(node_id) + ip6_mask = self.ip6.prefixlen + + # random mac + if not mac: + mac = utils.random_mac() + + return InterfaceData( + name=name, ip4=ip4, ip4_mask=ip4_mask, ip6=ip6, ip6_mask=ip6_mask, mac=mac + ) def create_interface( self, node: "CoreNode", name: str = None, mac: str = None @@ -168,33 +201,6 @@ class IpPrefixes: generation :return: new interface data for the provided node """ - # interface id - inteface_id = node.newifindex() - - # generate ip4 data - ip4 = None - ip4_mask = None - if self.ip4: - ip4 = self.ip4_address(node) - ip4_mask = self.ip4.prefixlen - - # generate ip6 data - ip6 = None - ip6_mask = None - if self.ip6: - ip6 = self.ip6_address(node) - ip6_mask = self.ip6.prefixlen - - # random mac - if not mac: - mac = utils.random_mac() - - return InterfaceData( - id=inteface_id, - name=name, - ip4=ip4, - ip4_mask=ip4_mask, - ip6=ip6, - ip6_mask=ip6_mask, - mac=mac, - ) + interface = self.gen_interface(node.id, name, mac) + interface.id = node.newifindex() + return interface diff --git a/daemon/tests/emane/test_emane.py b/daemon/tests/emane/test_emane.py index 62fe15e1..2d90ebcc 100644 --- a/daemon/tests/emane/test_emane.py +++ b/daemon/tests/emane/test_emane.py @@ -32,7 +32,7 @@ _DIR = os.path.dirname(os.path.abspath(__file__)) def ping( from_node: CoreNode, to_node: CoreNode, ip_prefixes: IpPrefixes, count: int = 3 ): - address = ip_prefixes.ip4_address(to_node) + address = ip_prefixes.ip4_address(to_node.id) try: from_node.cmd(f"ping -c {count} {address}") status = 0 diff --git a/daemon/tests/test_core.py b/daemon/tests/test_core.py index 88a40906..68515a41 100644 --- a/daemon/tests/test_core.py +++ b/daemon/tests/test_core.py @@ -22,7 +22,7 @@ _WIRED = [PtpNet, HubNode, SwitchNode] def ping(from_node: CoreNode, to_node: CoreNode, ip_prefixes: IpPrefixes): - address = ip_prefixes.ip4_address(to_node) + address = ip_prefixes.ip4_address(to_node.id) try: from_node.cmd(f"ping -c 1 {address}") status = 0 From f73c617ecfc81ce4b393e39b631a591e144ca725 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 18:53:42 -0700 Subject: [PATCH 120/929] daemon: removed utils.make_tuple and last remaining usage --- daemon/core/nodes/physical.py | 7 +------ daemon/core/utils.py | 13 ------------- 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 018ca60d..ee00c705 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -126,20 +126,15 @@ class PhysicalNode(CoreNodeBase): netif.name = f"gt{ifindex}" netif.node = self self.addnetif(netif, ifindex) - # use a more reasonable name, e.g. "gt0" instead of "gt.56286.150" if self.up: self.net_client.device_down(netif.localname) self.net_client.device_name(netif.localname, netif.name) - netif.localname = netif.name - if hwaddr: self.sethwaddr(ifindex, hwaddr) - - for addr in utils.make_tuple(addrlist): + for addr in addrlist: self.addaddr(ifindex, addr) - if self.up: self.net_client.device_up(netif.localname) diff --git a/daemon/core/utils.py b/daemon/core/utils.py index c16d18b5..3b1ea46a 100644 --- a/daemon/core/utils.py +++ b/daemon/core/utils.py @@ -158,19 +158,6 @@ def which(command: str, required: bool) -> str: return found_path -def make_tuple(obj: Generic[T]) -> Tuple[T]: - """ - Create a tuple from an object, or return the object itself. - - :param obj: object to convert to a tuple - :return: converted tuple or the object itself - """ - if hasattr(obj, "__iter__"): - return tuple(obj) - else: - return (obj,) - - def make_tuple_fromstr(s: str, value_type: Callable[[str], T]) -> Tuple[T]: """ Create a tuple from a string. From 4cc9d3debfc17461a87abfe2bfd6e9da1e8fe67f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 18:59:14 -0700 Subject: [PATCH 121/929] added pydoc for grpc client InterfaceHelper --- daemon/core/api/grpc/client.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 64b8d29f..0361a69b 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -113,6 +113,15 @@ class InterfaceHelper: def create_interface( self, node_id: int, interface_id: int, name: str = None, mac: str = None ) -> core_pb2.Interface: + """ + Create an interface protobuf object. + + :param node_id: node id to create interface for + :param interface_id: interface id + :param name: name of interface + :param mac: mac address for interface + :return: interface protobuf + """ interface_data = self.prefixes.gen_interface(node_id, name, mac) return core_pb2.Interface( id=interface_id, From a79ba1b8d32efd4416156a3cb4edb8b6348b10f8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 19:48:29 -0700 Subject: [PATCH 122/929] daemon: added type hints to CoreEmu --- daemon/core/emulator/coreemu.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/daemon/core/emulator/coreemu.py b/daemon/core/emulator/coreemu.py index 90f75427..6a7f8b80 100644 --- a/daemon/core/emulator/coreemu.py +++ b/daemon/core/emulator/coreemu.py @@ -3,7 +3,7 @@ import logging import os import signal import sys -from typing import Mapping, Type +from typing import Dict, List, Type import core.services from core import configservices @@ -36,7 +36,7 @@ class CoreEmu: Provides logic for creating and configuring CORE sessions and the nodes within them. """ - def __init__(self, config: Mapping[str, str] = None) -> None: + def __init__(self, config: Dict[str, str] = None) -> None: """ Create a CoreEmu object. @@ -48,17 +48,17 @@ class CoreEmu: # configuration if config is None: config = {} - self.config = config + self.config: Dict[str, str] = config # session management - self.sessions = {} + self.sessions: Dict[int, Session] = {} # load services - self.service_errors = [] + self.service_errors: List[str] = [] self.load_services() # config services - self.service_manager = ConfigServiceManager() + self.service_manager: ConfigServiceManager = ConfigServiceManager() config_services_path = os.path.abspath(os.path.dirname(configservices.__file__)) self.service_manager.load(config_services_path) custom_dir = self.config.get("custom_config_services_dir") From 32ad8a9b683bd7625b5f9aff57e2cedaa4a33bb3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 20:03:32 -0700 Subject: [PATCH 123/929] daemon: added type hinting to Session --- daemon/core/emulator/session.py | 70 ++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index a1e71612..a2b2670b 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -15,9 +15,17 @@ import time from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, TypeVar from core import constants, utils +from core.configservice.manager import ConfigServiceManager from core.emane.emanemanager import EmaneManager from core.emane.nodes import EmaneNet -from core.emulator.data import ConfigData, EventData, ExceptionData, FileData, LinkData +from core.emulator.data import ( + ConfigData, + EventData, + ExceptionData, + FileData, + LinkData, + NodeData, +) from core.emulator.distributed import DistributedController from core.emulator.emudata import InterfaceData, LinkOptions, NodeOptions from core.emulator.enumerations import ( @@ -89,62 +97,62 @@ class Session: :param config: session configuration :param mkdir: flag to determine if a directory should be made """ - self.id = _id + self.id: int = _id # define and create session directory when desired - self.session_dir = os.path.join(tempfile.gettempdir(), f"pycore.{self.id}") + self.session_dir: str = os.path.join(tempfile.gettempdir(), f"pycore.{self.id}") if mkdir: os.mkdir(self.session_dir) - self.name = None - self.file_name = None - self.thumbnail = None - self.user = None - self.event_loop = EventLoop() - self.link_colors = {} + self.name: Optional[str] = None + self.file_name: Optional[str] = None + self.thumbnail: Optional[str] = None + self.user: Optional[str] = None + self.event_loop: EventLoop = EventLoop() + self.link_colors: Dict[int, str] = {} # dict of nodes: all nodes and nets - self.nodes = {} + self.nodes: Dict[int, NodeBase] = {} self._nodes_lock = threading.Lock() - self.state = EventTypes.DEFINITION_STATE - self._state_time = time.monotonic() - self._state_file = os.path.join(self.session_dir, "state") + self.state: EventTypes = EventTypes.DEFINITION_STATE + self._state_time: float = time.monotonic() + self._state_file: str = os.path.join(self.session_dir, "state") # hooks handlers - self._hooks = {} - self._state_hooks = {} + self._hooks: Dict[EventTypes, Tuple[str, str]] = {} + self._state_hooks: Dict[EventTypes, Callable[[int], None]] = {} self.add_state_hook( state=EventTypes.RUNTIME_STATE, hook=self.runtime_state_hook ) # handlers for broadcasting information - self.event_handlers = [] - self.exception_handlers = [] - self.node_handlers = [] - self.link_handlers = [] - self.file_handlers = [] - self.config_handlers = [] - self.shutdown_handlers = [] + self.event_handlers: List[Callable[[EventData], None]] = [] + self.exception_handlers: List[Callable[[ExceptionData], None]] = [] + self.node_handlers: List[Callable[[NodeData], None]] = [] + self.link_handlers: List[Callable[[LinkData], None]] = [] + self.file_handlers: List[Callable[[FileData], None]] = [] + self.config_handlers: List[Callable[[ConfigData], None]] = [] + self.shutdown_handlers: List[Callable[[Session], None]] = [] # session options/metadata - self.options = SessionConfig() + self.options: SessionConfig = SessionConfig() if not config: config = {} for key in config: value = config[key] self.options.set_config(key, value) - self.metadata = {} + self.metadata: Dict[str, str] = {} # distributed support and logic - self.distributed = DistributedController(self) + self.distributed: DistributedController = DistributedController(self) # initialize session feature helpers - self.location = GeoLocation() - self.mobility = MobilityManager(session=self) - self.services = CoreServices(session=self) - self.emane = EmaneManager(session=self) - self.sdt = Sdt(session=self) + self.location: GeoLocation = GeoLocation() + self.mobility: MobilityManager = MobilityManager(self) + self.services: CoreServices = CoreServices(self) + self.emane: EmaneManager = EmaneManager(self) + self.sdt: Sdt = Sdt(self) # initialize default node services self.services.default_services = { @@ -156,7 +164,7 @@ class Session: } # config services - self.service_manager = None + self.service_manager: Optional[ConfigServiceManager] = None @classmethod def get_node_class(cls, _type: NodeTypes) -> Type[NodeBase]: From 452e0720f2232f748c26a04fe1631d349e08cab3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 9 Jun 2020 21:03:19 -0700 Subject: [PATCH 124/929] daemon: added type hinting to DistributedControll and removed bad logic looking for tunnels during add_link --- daemon/core/emulator/distributed.py | 32 +++++------------- daemon/core/emulator/session.py | 48 ++++----------------------- daemon/core/emulator/sessionconfig.py | 8 ++--- 3 files changed, 18 insertions(+), 70 deletions(-) diff --git a/daemon/core/emulator/distributed.py b/daemon/core/emulator/distributed.py index 5f188cb0..3753e1c2 100644 --- a/daemon/core/emulator/distributed.py +++ b/daemon/core/emulator/distributed.py @@ -37,10 +37,10 @@ class DistributedServer: :param name: convenience name to associate with host :param host: host to connect to """ - self.name = name - self.host = host - self.conn = Connection(host, user="root") - self.lock = threading.Lock() + self.name: str = name + self.host: str = host + self.conn: Connection = Connection(host, user="root") + self.lock: threading.Lock = threading.Lock() def remote_cmd( self, cmd: str, env: Dict[str, str] = None, cwd: str = None, wait: bool = True @@ -117,10 +117,10 @@ class DistributedController: :param session: session """ - self.session = session - self.servers = OrderedDict() - self.tunnels = {} - self.address = self.session.options.get_config( + self.session: "Session" = session + self.servers: Dict[str, DistributedServer] = OrderedDict() + self.tunnels: Dict[int, Tuple[GreTap, GreTap]] = {} + self.address: str = self.session.options.get_config( "distributed_address", default=None ) @@ -178,13 +178,10 @@ class DistributedController: """ for node_id in self.session.nodes: node = self.session.nodes[node_id] - if not isinstance(node, CoreNetwork): continue - if isinstance(node, CtrlNet) and node.serverintf is not None: continue - for name in self.servers: server = self.servers[name] self.create_gre_tunnel(node, server) @@ -195,7 +192,6 @@ class DistributedController: """ Create gre tunnel using a pair of gre taps between the local and remote server. - :param node: node to create gre tunnel for :param server: server to create tunnel for @@ -243,15 +239,3 @@ class DistributedController: (self.session.id << 16) ^ utils.hashkey(n1_id) ^ (utils.hashkey(n2_id) << 8) ) return key & 0xFFFFFFFF - - def get_tunnel(self, n1_id: int, n2_id: int) -> GreTap: - """ - Return the GreTap between two nodes if it exists. - - :param n1_id: node one id - :param n2_id: node two id - :return: gre tap between nodes or None - """ - key = self.tunnel_key(n1_id, n2_id) - logging.debug("checking for tunnel key(%s) in: %s", key, self.tunnels) - return self.tunnels.get(key) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index a2b2670b..45c17743 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -42,7 +42,7 @@ from core.location.geo import GeoLocation from core.location.mobility import BasicRangeModel, MobilityManager from core.nodes.base import CoreNetworkBase, CoreNode, CoreNodeBase, NodeBase from core.nodes.docker import DockerNode -from core.nodes.interface import CoreInterface, GreTap +from core.nodes.interface import CoreInterface from core.nodes.lxd import LxcNode from core.nodes.network import ( CtrlNet, @@ -200,7 +200,6 @@ class Session: Optional[CoreNode], Optional[CoreNetworkBase], Optional[CoreNetworkBase], - GreTap, ]: """ Convenience method for retrieving nodes within link data. @@ -221,23 +220,6 @@ class Session: node_one = self.get_node(node_one_id, NodeBase) node_two = self.get_node(node_two_id, NodeBase) - # both node ids are provided - tunnel = self.distributed.get_tunnel(node_one_id, node_two_id) - logging.debug("tunnel between nodes: %s", tunnel) - if isinstance(tunnel, GreTapBridge): - net_one = tunnel - if tunnel.remotenum == node_one_id: - node_one = None - else: - node_two = None - # physical node connected via gre tap tunnel - # TODO: double check this cases type - elif tunnel: - if tunnel.remotenum == node_one_id: - node_one = None - else: - node_two = None - if isinstance(node_one, CoreNetworkBase): if not net_one: net_one = node_one @@ -253,14 +235,13 @@ class Session: node_two = None logging.debug( - "link node types n1(%s) n2(%s) net1(%s) net2(%s) tunnel(%s)", + "link node types n1(%s) n2(%s) net1(%s) net2(%s)", node_one, node_two, net_one, net_two, - tunnel, ) - return node_one, node_two, net_one, net_two, tunnel + return node_one, node_two, net_one, net_two def _link_wireless(self, objects: Iterable[CoreNodeBase], connect: bool) -> None: """ @@ -326,7 +307,7 @@ class Session: options = LinkOptions() # get node objects identified by link data - node_one, node_two, net_one, net_two, tunnel = self._link_nodes( + node_one, node_two, net_one, net_two = self._link_nodes( node_one_id, node_two_id ) @@ -415,23 +396,6 @@ class Session: net_two.setkey(key) if addresses: net_two.addrconfig(addresses) - - # physical node connected with tunnel - if not net_one and not net_two and (node_one or node_two): - if node_one and isinstance(node_one, PhysicalNode): - logging.info("adding link for physical node: %s", node_one.name) - addresses = interface_one.get_addresses() - node_one.adoptnetif( - tunnel, interface_one.id, interface_one.mac, addresses - ) - node_one.linkconfig(tunnel, options) - elif node_two and isinstance(node_two, PhysicalNode): - logging.info("adding link for physical node: %s", node_two.name) - addresses = interface_two.get_addresses() - node_two.adoptnetif( - tunnel, interface_two.id, interface_two.mac, addresses - ) - node_two.linkconfig(tunnel, options) finally: if node_one: node_one.lock.release() @@ -461,7 +425,7 @@ class Session: :raises core.CoreError: when no common network is found for link being deleted """ # get node objects identified by link data - node_one, node_two, net_one, net_two, _tunnel = self._link_nodes( + node_one, node_two, net_one, net_two = self._link_nodes( node_one_id, node_two_id ) @@ -573,7 +537,7 @@ class Session: options = LinkOptions() # get node objects identified by link data - node_one, node_two, net_one, net_two, _tunnel = self._link_nodes( + node_one, node_two, net_one, net_two = self._link_nodes( node_one_id, node_two_id ) diff --git a/daemon/core/emulator/sessionconfig.py b/daemon/core/emulator/sessionconfig.py index ffeccdc4..e22e852e 100644 --- a/daemon/core/emulator/sessionconfig.py +++ b/daemon/core/emulator/sessionconfig.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, List from core.config import ConfigurableManager, ConfigurableOptions, Configuration from core.emulator.enumerations import ConfigDataTypes, RegisterTlvs @@ -10,8 +10,8 @@ class SessionConfig(ConfigurableManager, ConfigurableOptions): Provides session configuration. """ - name = "session" - options = [ + name: str = "session" + options: List[Configuration] = [ Configuration( _id="controlnet", _type=ConfigDataTypes.STRING, label="Control Network" ), @@ -57,7 +57,7 @@ class SessionConfig(ConfigurableManager, ConfigurableOptions): label="SDT3D URL", ), ] - config_type = RegisterTlvs.UTILITY + config_type: RegisterTlvs = RegisterTlvs.UTILITY def __init__(self) -> None: super().__init__() From 6ee9590bdc3629e61ee128d77238041b58ac3c65 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 10 Jun 2020 08:52:51 -0700 Subject: [PATCH 125/929] daemon: finished class variable type hinting for core.nodes --- daemon/core/nodes/client.py | 4 ++-- daemon/core/nodes/docker.py | 12 ++++++------ daemon/core/nodes/lxd.py | 12 ++++++------ daemon/core/nodes/netclient.py | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/daemon/core/nodes/client.py b/daemon/core/nodes/client.py index d7642863..c004b814 100644 --- a/daemon/core/nodes/client.py +++ b/daemon/core/nodes/client.py @@ -20,8 +20,8 @@ class VnodeClient: :param name: name for client :param ctrlchnlname: control channel name """ - self.name = name - self.ctrlchnlname = ctrlchnlname + self.name: str = name + self.ctrlchnlname: str = ctrlchnlname def _verify_connection(self) -> None: """ diff --git a/daemon/core/nodes/docker.py b/daemon/core/nodes/docker.py index 684e8452..fa4b8f8b 100644 --- a/daemon/core/nodes/docker.py +++ b/daemon/core/nodes/docker.py @@ -2,7 +2,7 @@ import json import logging import os from tempfile import NamedTemporaryFile -from typing import TYPE_CHECKING, Callable, Dict +from typing import TYPE_CHECKING, Callable, Dict, Optional from core import utils from core.emulator.distributed import DistributedServer @@ -17,10 +17,10 @@ if TYPE_CHECKING: class DockerClient: def __init__(self, name: str, image: str, run: Callable[..., str]) -> None: - self.name = name - self.image = image - self.run = run - self.pid = None + self.name: str = name + self.image: str = image + self.run: Callable[..., str] = run + self.pid: Optional[str] = None def create_container(self) -> str: self.run( @@ -95,7 +95,7 @@ class DockerNode(CoreNode): """ if image is None: image = "ubuntu" - self.image = image + self.image: str = image super().__init__(session, _id, name, nodedir, start, server) def create_node_net_client(self, use_ovs: bool) -> LinuxNetClient: diff --git a/daemon/core/nodes/lxd.py b/daemon/core/nodes/lxd.py index 3b4c88c0..af906f01 100644 --- a/daemon/core/nodes/lxd.py +++ b/daemon/core/nodes/lxd.py @@ -3,7 +3,7 @@ import logging import os import time from tempfile import NamedTemporaryFile -from typing import TYPE_CHECKING, Callable, Dict +from typing import TYPE_CHECKING, Callable, Dict, Optional from core import utils from core.emulator.distributed import DistributedServer @@ -18,10 +18,10 @@ if TYPE_CHECKING: class LxdClient: def __init__(self, name: str, image: str, run: Callable[..., str]) -> None: - self.name = name - self.image = image - self.run = run - self.pid = None + self.name: str = name + self.image: str = image + self.run: Callable[..., str] = run + self.pid: Optional[int] = None def create_container(self) -> int: self.run(f"lxc launch {self.image} {self.name}") @@ -92,7 +92,7 @@ class LxcNode(CoreNode): """ if image is None: image = "ubuntu" - self.image = image + self.image: str = image super().__init__(session, _id, name, nodedir, start, server) def alive(self) -> bool: diff --git a/daemon/core/nodes/netclient.py b/daemon/core/nodes/netclient.py index 29a70d18..25a10b99 100644 --- a/daemon/core/nodes/netclient.py +++ b/daemon/core/nodes/netclient.py @@ -19,7 +19,7 @@ class LinuxNetClient: :param run: function to run commands with """ - self.run = run + self.run: Callable[..., str] = run def set_hostname(self, name: str) -> None: """ From fd341bd69bd0874656b0e7f178e5cad0ba8e9fbb Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 10 Jun 2020 09:01:38 -0700 Subject: [PATCH 126/929] daemon: added class variable type hinting to core.plugins --- daemon/core/plugins/sdt.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/daemon/core/plugins/sdt.py b/daemon/core/plugins/sdt.py index 06c23de5..8b4ec39f 100644 --- a/daemon/core/plugins/sdt.py +++ b/daemon/core/plugins/sdt.py @@ -5,7 +5,7 @@ sdt.py: Scripted Display Tool (SDT3D) helper import logging import socket import threading -from typing import TYPE_CHECKING, Optional +from typing import IO, TYPE_CHECKING, Dict, Optional, Set, Tuple from urllib.parse import urlparse from core import constants @@ -42,11 +42,11 @@ class Sdt: when a node position or link has changed. """ - DEFAULT_SDT_URL = "tcp://127.0.0.1:50000/" + DEFAULT_SDT_URL: str = "tcp://127.0.0.1:50000/" # default altitude (in meters) for flyto view - DEFAULT_ALT = 2500 + DEFAULT_ALT: int = 2500 # TODO: read in user"s nodes.conf here; below are default node types from the GUI - DEFAULT_SPRITES = [ + DEFAULT_SPRITES: Dict[str, str] = [ ("router", "router.gif"), ("host", "host.gif"), ("PC", "pc.gif"), @@ -65,14 +65,14 @@ class Sdt: :param session: session this manager is tied to """ - self.session = session - self.lock = threading.Lock() - self.sock = None - self.connected = False - self.url = self.DEFAULT_SDT_URL - self.address = None - self.protocol = None - self.network_layers = set() + self.session: "Session" = session + self.lock: threading.Lock = threading.Lock() + self.sock: Optional[IO] = None + self.connected: bool = False + self.url: str = self.DEFAULT_SDT_URL + self.address: Optional[Tuple[Optional[str], Optional[int]]] = None + self.protocol: Optional[str] = None + self.network_layers: Set[str] = set() self.session.node_handlers.append(self.handle_node_update) self.session.link_handlers.append(self.handle_link_update) From 784c4d241976a0385c4ee96886f49db329b27b26 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 10 Jun 2020 10:24:44 -0700 Subject: [PATCH 127/929] daemon: added core.location class variable type hinting --- daemon/core/emane/emanemodel.py | 5 +- daemon/core/location/event.py | 81 ++++++++-------- daemon/core/location/geo.py | 19 ++-- daemon/core/location/mobility.py | 152 +++++++++++++++---------------- daemon/tests/test_mobility.py | 10 +- 5 files changed, 137 insertions(+), 130 deletions(-) diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index f42caa14..7b5ff417 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -12,6 +12,7 @@ from core.emulator.emudata import LinkOptions from core.emulator.enumerations import ConfigDataTypes, TransportType from core.errors import CoreError from core.location.mobility import WirelessModel +from core.nodes.base import CoreNode from core.nodes.interface import CoreInterface from core.xml import emanexml @@ -139,13 +140,13 @@ class EmaneModel(WirelessModel): """ logging.debug("emane model(%s) has no post setup tasks", self.name) - def update(self, moved: bool, moved_netifs: List[CoreInterface]) -> None: + def update(self, moved: List[CoreNode], moved_netifs: List[CoreInterface]) -> None: """ Invoked from MobilityModel when nodes are moved; this causes emane location events to be generated for the nodes in the moved list, making EmaneModels compatible with Ns2ScriptedMobility. - :param moved: were nodes moved + :param moved: moved nodes :param moved_netifs: interfaces that were moved :return: nothing """ diff --git a/daemon/core/location/event.py b/daemon/core/location/event.py index 8826c42b..7f8a33a1 100644 --- a/daemon/core/location/event.py +++ b/daemon/core/location/event.py @@ -6,7 +6,7 @@ import heapq import threading import time from functools import total_ordering -from typing import Any, Callable +from typing import Any, Callable, Dict, List, Optional, Tuple class Timer(threading.Thread): @@ -16,34 +16,33 @@ class Timer(threading.Thread): """ def __init__( - self, interval: float, function: Callable, args: Any = None, kwargs: Any = None + self, + interval: float, + func: Callable[..., None], + args: Tuple[Any] = None, + kwargs: Dict[Any, Any] = None, ) -> None: """ Create a Timer instance. :param interval: time interval - :param function: function to call when timer finishes + :param func: function to call when timer finishes :param args: function arguments :param kwargs: function keyword arguments """ super().__init__() - self.interval = interval - self.function = function - - self.finished = threading.Event() - self._running = threading.Lock() - + self.interval: float = interval + self.func: Callable[..., None] = func + self.finished: threading.Event = threading.Event() + self._running: threading.Lock = threading.Lock() # validate arguments were provided - if args: - self.args = args - else: - self.args = [] - + if args is None: + args = () + self.args: Tuple[Any] = args # validate keyword arguments were provided - if kwargs: - self.kwargs = kwargs - else: - self.kwargs = {} + if kwargs is None: + kwargs = {} + self.kwargs: Dict[Any, Any] = kwargs def cancel(self) -> bool: """ @@ -67,7 +66,7 @@ class Timer(threading.Thread): self.finished.wait(self.interval) with self._running: if not self.finished.is_set(): - self.function(*self.args, **self.kwargs) + self.func(*self.args, **self.kwargs) self.finished.set() @@ -78,7 +77,12 @@ class Event: """ def __init__( - self, eventnum: int, event_time: float, func: Callable, *args: Any, **kwds: Any + self, + eventnum: int, + event_time: float, + func: Callable[..., None], + *args: Any, + **kwds: Any ) -> None: """ Create an Event instance. @@ -89,12 +93,12 @@ class Event: :param args: function arguments :param kwds: function keyword arguments """ - self.eventnum = eventnum - self.time = event_time - self.func = func - self.args = args - self.kwds = kwds - self.canceled = False + self.eventnum: int = eventnum + self.time: float = event_time + self.func: Callable[..., None] = func + self.args: Tuple[Any] = args + self.kwds: Dict[Any, Any] = kwds + self.canceled: bool = False def __lt__(self, other: "Event") -> bool: result = self.time < other.time @@ -118,7 +122,6 @@ class Event: :return: nothing """ - # XXX not thread-safe self.canceled = True @@ -131,14 +134,14 @@ class EventLoop: """ Creates a EventLoop instance. """ - self.lock = threading.RLock() - self.queue = [] - self.eventnum = 0 - self.timer = None - self.running = False - self.start = None + self.lock: threading.RLock = threading.RLock() + self.queue: List[Event] = [] + self.eventnum: int = 0 + self.timer: Optional[Timer] = None + self.running: bool = False + self.start: Optional[float] = None - def __run_events(self) -> None: + def _run_events(self) -> None: """ Run events. @@ -161,9 +164,9 @@ class EventLoop: with self.lock: self.timer = None if schedule: - self.__schedule_event() + self._schedule_event() - def __schedule_event(self) -> None: + def _schedule_event(self) -> None: """ Schedule event. @@ -177,7 +180,7 @@ class EventLoop: delay = self.queue[0].time - time.monotonic() if self.timer: raise ValueError("timer was already set") - self.timer = Timer(delay, self.__run_events) + self.timer = Timer(delay, self._run_events) self.timer.daemon = True self.timer.start() @@ -194,7 +197,7 @@ class EventLoop: self.start = time.monotonic() for event in self.queue: event.time += self.start - self.__schedule_event() + self._schedule_event() def stop(self) -> None: """ @@ -242,5 +245,5 @@ class EventLoop: if self.timer is not None and self.timer.cancel(): self.timer = None if self.running and self.timer is None: - self.__schedule_event() + self._schedule_event() return event diff --git a/daemon/core/location/geo.py b/daemon/core/location/geo.py index 4ff56dd6..6c8eb651 100644 --- a/daemon/core/location/geo.py +++ b/daemon/core/location/geo.py @@ -6,6 +6,7 @@ import logging from typing import Tuple import pyproj +from pyproj import Transformer from core.emulator.enumerations import RegisterTlvs @@ -20,21 +21,23 @@ class GeoLocation: defined projections. """ - name = "location" - config_type = RegisterTlvs.UTILITY + name: str = "location" + config_type: RegisterTlvs = RegisterTlvs.UTILITY def __init__(self) -> None: """ Creates a GeoLocation instance. """ - self.to_pixels = pyproj.Transformer.from_crs( + self.to_pixels: Transformer = pyproj.Transformer.from_crs( CRS_WGS84, CRS_PROJ, always_xy=True ) - self.to_geo = pyproj.Transformer.from_crs(CRS_PROJ, CRS_WGS84, always_xy=True) - self.refproj = (0.0, 0.0, 0.0) - self.refgeo = (0.0, 0.0, 0.0) - self.refxyz = (0.0, 0.0, 0.0) - self.refscale = 1.0 + self.to_geo: Transformer = pyproj.Transformer.from_crs( + CRS_PROJ, CRS_WGS84, always_xy=True + ) + self.refproj: Tuple[float, float, float] = (0.0, 0.0, 0.0) + self.refgeo: Tuple[float, float, float] = (0.0, 0.0, 0.0) + self.refxyz: Tuple[float, float, float] = (0.0, 0.0, 0.0) + self.refscale: float = 1.0 def setrefgeo(self, lat: float, lon: float, alt: float) -> None: """ diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index 3ca46418..87cd7141 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -9,7 +9,7 @@ import os import threading import time from functools import total_ordering -from typing import TYPE_CHECKING, Dict, List, Tuple +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple from core import utils from core.config import ConfigGroup, ConfigurableOptions, Configuration, ModelManager @@ -23,7 +23,7 @@ from core.emulator.enumerations import ( RegisterTlvs, ) from core.errors import CoreError -from core.nodes.base import CoreNode, NodeBase +from core.nodes.base import CoreNode from core.nodes.interface import CoreInterface from core.nodes.network import WlanNode @@ -47,7 +47,7 @@ class MobilityManager(ModelManager): :param session: session this manager is tied to """ super().__init__() - self.session = session + self.session: "Session" = session self.models[BasicRangeModel.name] = BasicRangeModel self.models[Ns2ScriptedMobility.name] = Ns2ScriptedMobility @@ -178,7 +178,7 @@ class MobilityManager(ModelManager): self.session.broadcast_event(event_data) def updatewlans( - self, moved: List[NodeBase], moved_netifs: List[CoreInterface] + self, moved: List[CoreNode], moved_netifs: List[CoreInterface] ) -> None: """ A mobility script has caused nodes in the 'moved' list to move. @@ -204,21 +204,21 @@ class WirelessModel(ConfigurableOptions): Used for managing arbitrary configuration parameters. """ - config_type = RegisterTlvs.WIRELESS - bitmap = None - position_callback = None + config_type: RegisterTlvs = RegisterTlvs.WIRELESS + bitmap: str = None + position_callback: Callable[[CoreInterface], None] = None - def __init__(self, session: "Session", _id: int): + def __init__(self, session: "Session", _id: int) -> None: """ Create a WirelessModel instance. :param session: core session we are tied to :param _id: object id """ - self.session = session - self.id = _id + self.session: "Session" = session + self.id: int = _id - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List: + def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ May be used if the model can populate the GUI with wireless (green) link lines. @@ -228,11 +228,11 @@ class WirelessModel(ConfigurableOptions): """ return [] - def update(self, moved: bool, moved_netifs: List[CoreInterface]) -> None: + def update(self, moved: List[CoreNode], moved_netifs: List[CoreInterface]) -> None: """ Update this wireless model. - :param moved: flag is it was moved + :param moved: moved nodes :param moved_netifs: moved network interfaces :return: nothing """ @@ -256,8 +256,8 @@ class BasicRangeModel(WirelessModel): the GUI. """ - name = "basic_range" - options = [ + name: str = "basic_range" + options: List[Configuration] = [ Configuration( _id="range", _type=ConfigDataTypes.UINT32, @@ -299,15 +299,15 @@ class BasicRangeModel(WirelessModel): :param _id: object id """ super().__init__(session, _id) - self.session = session - self.wlan = session.get_node(_id, WlanNode) - self._netifs = {} - self._netifslock = threading.Lock() - self.range = 0 - self.bw = None - self.delay = None - self.loss = None - self.jitter = None + self.session: "Session" = session + self.wlan: WlanNode = session.get_node(_id, WlanNode) + self._netifs: Dict[CoreInterface, Tuple[float, float, float]] = {} + self._netifslock: threading.Lock = threading.Lock() + self.range: int = 0 + self.bw: Optional[int] = None + self.delay: Optional[int] = None + self.loss: Optional[float] = None + self.jitter: Optional[int] = None def _get_config(self, current_value: int, config: Dict[str, str], name: str) -> int: """ @@ -374,14 +374,14 @@ class BasicRangeModel(WirelessModel): position_callback = set_position - def update(self, moved: bool, moved_netifs: List[CoreInterface]) -> None: + def update(self, moved: List[CoreNode], moved_netifs: List[CoreInterface]) -> None: """ Node positions have changed without recalc. Update positions from node.position, then re-calculate links for those that have moved. Assumes bidirectional links, with one calculation per node pair, where one of the nodes has moved. - :param moved: flag is it was moved + :param moved: moved nodes :param moved_netifs: moved network interfaces :return: nothing """ @@ -535,29 +535,35 @@ class WayPoint: Maintains information regarding waypoints. """ - def __init__(self, time: float, nodenum: int, coords, speed: float): + def __init__( + self, + _time: float, + node_id: int, + coords: Tuple[float, float, float], + speed: float, + ) -> None: """ Creates a WayPoint instance. - :param time: waypoint time - :param nodenum: node id + :param _time: waypoint time + :param node_id: node id :param coords: waypoint coordinates :param speed: waypoint speed """ - self.time = time - self.nodenum = nodenum - self.coords = coords - self.speed = speed + self.time: float = _time + self.node_id: int = node_id + self.coords: Tuple[float, float, float] = coords + self.speed: float = speed def __eq__(self, other: "WayPoint") -> bool: - return (self.time, self.nodenum) == (other.time, other.nodenum) + return (self.time, self.node_id) == (other.time, other.node_id) def __ne__(self, other: "WayPoint") -> bool: return not self == other def __lt__(self, other: "WayPoint") -> bool: if self.time == other.time: - return self.nodenum < other.nodenum + return self.node_id < other.node_id else: return self.time < other.time @@ -567,12 +573,11 @@ class WayPointMobility(WirelessModel): Abstract class for mobility models that set node waypoints. """ - name = "waypoint" - config_type = RegisterTlvs.MOBILITY - - STATE_STOPPED = 0 - STATE_RUNNING = 1 - STATE_PAUSED = 2 + name: str = "waypoint" + config_type: RegisterTlvs = RegisterTlvs.MOBILITY + STATE_STOPPED: int = 0 + STATE_RUNNING: int = 1 + STATE_PAUSED: int = 2 def __init__(self, session: "Session", _id: int) -> None: """ @@ -583,20 +588,21 @@ class WayPointMobility(WirelessModel): :return: """ super().__init__(session=session, _id=_id) - self.state = self.STATE_STOPPED - self.queue = [] - self.queue_copy = [] - self.points = {} - self.initial = {} - self.lasttime = None - self.endtime = None - self.wlan = session.get_node(_id, WlanNode) + self.state: int = self.STATE_STOPPED + self.queue: List[WayPoint] = [] + self.queue_copy: List[WayPoint] = [] + self.points: Dict[int, WayPoint] = {} + self.initial: Dict[int, WayPoint] = {} + self.lasttime: Optional[float] = None + self.endtime: Optional[int] = None + self.timezero: float = 0.0 + self.wlan: WlanNode = session.get_node(_id, WlanNode) # these are really set in child class via confmatrix - self.loop = False - self.refresh_ms = 50 + self.loop: bool = False + self.refresh_ms: int = 50 # flag whether to stop scheduling when queue is empty # (ns-3 sets this to False as new waypoints may be added from trace) - self.empty_queue_stop = True + self.empty_queue_stop: bool = True def runround(self) -> None: """ @@ -684,16 +690,11 @@ class WayPointMobility(WirelessModel): self.setnodeposition(node, x2, y2, z2) del self.points[node.id] return True - # speed can be a velocity vector or speed value - if isinstance(speed, (float, int)): - # linear speed value - alpha = math.atan2(y2 - y1, x2 - x1) - sx = speed * math.cos(alpha) - sy = speed * math.sin(alpha) - else: - # velocity vector - sx = speed[0] - sy = speed[1] + + # linear speed value + alpha = math.atan2(y2 - y1, x2 - x1) + sx = speed * math.cos(alpha) + sy = speed * math.sin(alpha) # calculate dt * speed = distance moved dx = sx * dt @@ -776,7 +777,7 @@ class WayPointMobility(WirelessModel): if self.queue[0].time > now: break wp = heapq.heappop(self.queue) - self.points[wp.nodenum] = wp + self.points[wp.node_id] = wp def copywaypoints(self) -> None: """ @@ -876,8 +877,8 @@ class Ns2ScriptedMobility(WayPointMobility): BonnMotion. """ - name = "ns2script" - options = [ + name: str = "ns2script" + options: List[Configuration] = [ Configuration( _id="file", _type=ConfigDataTypes.STRING, label="mobility script file" ), @@ -923,7 +924,7 @@ class Ns2ScriptedMobility(WayPointMobility): ConfigGroup("ns-2 Mobility Script Parameters", 1, len(cls.configurations())) ] - def __init__(self, session: "Session", _id: int): + def __init__(self, session: "Session", _id: int) -> None: """ Creates a Ns2ScriptedMobility instance. @@ -931,17 +932,14 @@ class Ns2ScriptedMobility(WayPointMobility): :param _id: object id """ super().__init__(session, _id) - self._netifs = {} - self._netifslock = threading.Lock() - - self.file = None - self.refresh_ms = None - self.loop = None - self.autostart = None - self.nodemap = {} - self.script_start = None - self.script_pause = None - self.script_stop = None + self.file: Optional[str] = None + self.refresh_ms: Optional[int] = None + self.loop: Optional[bool] = None + self.autostart: Optional[str] = None + self.nodemap: Dict[int, int] = {} + self.script_start: Optional[str] = None + self.script_pause: Optional[str] = None + self.script_stop: Optional[str] = None def update_config(self, config: Dict[str, str]) -> None: self.file = config["file"] diff --git a/daemon/tests/test_mobility.py b/daemon/tests/test_mobility.py index e2e8f90e..aab7b30f 100644 --- a/daemon/tests/test_mobility.py +++ b/daemon/tests/test_mobility.py @@ -2,15 +2,17 @@ import pytest from core.location.mobility import WayPoint +POSITION = (0.0, 0.0, 0.0) + class TestMobility: @pytest.mark.parametrize( "wp1, wp2, expected", [ - (WayPoint(10.0, 1, [0, 0], 1.0), WayPoint(1.0, 2, [0, 0], 1.0), False), - (WayPoint(1.0, 1, [0, 0], 1.0), WayPoint(10.0, 2, [0, 0], 1.0), True), - (WayPoint(1.0, 1, [0, 0], 1.0), WayPoint(1.0, 2, [0, 0], 1.0), True), - (WayPoint(1.0, 2, [0, 0], 1.0), WayPoint(1.0, 1, [0, 0], 1.0), False), + (WayPoint(10.0, 1, POSITION, 1.0), WayPoint(1.0, 2, POSITION, 1.0), False), + (WayPoint(1.0, 1, POSITION, 1.0), WayPoint(10.0, 2, POSITION, 1.0), True), + (WayPoint(1.0, 1, POSITION, 1.0), WayPoint(1.0, 2, POSITION, 1.0), True), + (WayPoint(1.0, 2, POSITION, 1.0), WayPoint(1.0, 1, POSITION, 1.0), False), ], ) def test_waypoint_lessthan(self, wp1, wp2, expected): From a389dc6240ba744418152855cb093367c33c06dd Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 10 Jun 2020 10:31:13 -0700 Subject: [PATCH 128/929] daemon: improve type hinting for WayPoint --- daemon/core/location/mobility.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index 87cd7141..e9efa16b 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -539,7 +539,7 @@ class WayPoint: self, _time: float, node_id: int, - coords: Tuple[float, float, float], + coords: Tuple[float, float, Optional[float]], speed: float, ) -> None: """ @@ -552,7 +552,7 @@ class WayPoint: """ self.time: float = _time self.node_id: int = node_id - self.coords: Tuple[float, float, float] = coords + self.coords: Tuple[float, float, Optional[float]] = coords self.speed: float = speed def __eq__(self, other: "WayPoint") -> bool: @@ -737,7 +737,13 @@ class WayPointMobility(WirelessModel): self.session.mobility.updatewlans(moved, moved_netifs) def addwaypoint( - self, _time: float, nodenum: int, x: float, y: float, z: float, speed: float + self, + _time: float, + nodenum: int, + x: float, + y: float, + z: Optional[float], + speed: float, ) -> None: """ Waypoints are pushed to a heapq, sorted by time. From 39fd11efb304d93377fcb599f5ed82bf5d1574b0 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 10 Jun 2020 10:40:24 -0700 Subject: [PATCH 129/929] daemon: added missing type hint to core.nodes.interface.CoreInterface --- daemon/core/nodes/interface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index 16c242e9..e73e2989 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -50,7 +50,7 @@ class CoreInterface: self.mtu: int = mtu self.net: Optional[CoreNetworkBase] = None self.othernet: Optional[CoreNetworkBase] = None - self._params = {} + self._params: Dict[str, float] = {} self.addrlist: List[str] = [] self.hwaddr: Optional[str] = None # placeholder position hook From 9ed42cfba894c760b70a5711e46c55dec7ee5473 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 10 Jun 2020 11:04:33 -0700 Subject: [PATCH 130/929] pygui: avoid issue when joining opened xml that has a node with no ip4 address --- daemon/core/gui/interface.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 437bd37c..1973fe99 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -12,7 +12,9 @@ if TYPE_CHECKING: from core.gui.graph.node import CanvasNode -def get_index(interface: "core_pb2.Interface") -> int: +def get_index(interface: "core_pb2.Interface") -> Optional[int]: + if not interface.ip4: + return None net = netaddr.IPNetwork(f"{interface.ip4}/{interface.ip4mask}") ip_value = net.value cidr_value = net.cidr.value @@ -108,7 +110,8 @@ class InterfaceManager: self.used_subnets.pop(subnets.key(), None) else: index = get_index(interface) - subnets.used_indexes.discard(index) + if index is not None: + subnets.used_indexes.discard(index) self.current_subnets = None def joined(self, links: List["core_pb2.Link"]) -> None: @@ -123,6 +126,8 @@ class InterfaceManager: for interface in interfaces: subnets = self.get_subnets(interface) index = get_index(interface) + if index is None: + continue subnets.used_indexes.add(index) if subnets.key() not in self.used_subnets: self.used_subnets[subnets.key()] = subnets From ccf2646c00fd5641137d20f578ab448e97e99ee9 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 11 Jun 2020 13:59:29 -0700 Subject: [PATCH 131/929] daemon: refactored add_link,update_link,delete_link to have more specific logic, refactored CoreNodeBase to have newnetif and for it to return the interface created --- daemon/core/api/tlv/corehandlers.py | 2 +- daemon/core/emulator/session.py | 510 ++++++++++------------------ daemon/core/nodes/base.py | 37 +- daemon/core/nodes/network.py | 8 +- daemon/core/nodes/physical.py | 10 +- daemon/tests/test_nodes.py | 20 +- 6 files changed, 232 insertions(+), 355 deletions(-) diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index f3e1fbaa..5531e5af 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -767,7 +767,7 @@ class CoreHandler(socketserver.BaseRequestHandler): ip6_mask=message.get_tlv(LinkTlvs.INTERFACE2_IP6_MASK.value), ) - link_type = None + link_type = LinkTypes.WIRED link_type_value = message.get_tlv(LinkTlvs.TYPE.value) if link_type_value is not None: link_type = LinkTypes(link_type_value) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 45c17743..54486bfb 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -12,7 +12,7 @@ import subprocess import tempfile import threading import time -from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Type, TypeVar +from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar from core import constants, utils from core.configservice.manager import ConfigServiceManager @@ -193,76 +193,28 @@ class Session: raise CoreError(f"invalid node class: {_class}") return node_type - def _link_nodes( - self, node_one_id: int, node_two_id: int - ) -> Tuple[ - Optional[CoreNode], - Optional[CoreNode], - Optional[CoreNetworkBase], - Optional[CoreNetworkBase], - ]: - """ - Convenience method for retrieving nodes within link data. - - :param node_one_id: node one id - :param node_two_id: node two id - :return: nodes, network nodes if present, and tunnel if present - """ - logging.debug( - "link message between node1(%s) and node2(%s)", node_one_id, node_two_id - ) - - # values to fill - net_one = None - net_two = None - - # retrieve node one - node_one = self.get_node(node_one_id, NodeBase) - node_two = self.get_node(node_two_id, NodeBase) - - if isinstance(node_one, CoreNetworkBase): - if not net_one: - net_one = node_one - else: - net_two = node_one - node_one = None - - if isinstance(node_two, CoreNetworkBase): - if not net_one: - net_one = node_two - else: - net_two = node_two - node_two = None - - logging.debug( - "link node types n1(%s) n2(%s) net1(%s) net2(%s)", - node_one, - node_two, - net_one, - net_two, - ) - return node_one, node_two, net_one, net_two - - def _link_wireless(self, objects: Iterable[CoreNodeBase], connect: bool) -> None: + def _link_wireless( + self, node_one: CoreNodeBase, node_two: CoreNodeBase, connect: bool + ) -> None: """ Objects to deal with when connecting/disconnecting wireless links. - :param objects: possible objects to deal with + :param node_one: node one for wireless link + :param node_two: node two for wireless link :param connect: link interfaces if True, unlink otherwise :return: nothing :raises core.CoreError: when objects to link is less than 2, or no common networks are found """ - objects = [x for x in objects if x] - if len(objects) < 2: - raise CoreError(f"wireless link failure: {objects}") - logging.debug( - "handling wireless linking objects(%s) connect(%s)", objects, connect + logging.info( + "handling wireless linking node1(%s) node2(%s): %s", + node_one.name, + node_two.name, + connect, ) - common_networks = objects[0].commonnets(objects[1]) + common_networks = node_one.commonnets(node_one) if not common_networks: raise CoreError("no common network found for wireless link/unlink") - for common_network, interface_one, interface_two in common_networks: if not isinstance(common_network, (WlanNode, EmaneNet)): logging.info( @@ -270,13 +222,6 @@ class Session: common_network, ) continue - - logging.info( - "wireless linking connect(%s): %s - %s", - connect, - interface_one, - interface_two, - ) if connect: common_network.link(interface_one, interface_two) else: @@ -305,105 +250,70 @@ class Session: """ if not options: options = LinkOptions() + node1 = self.get_node(node_one_id, NodeBase) + node2 = self.get_node(node_two_id, NodeBase) + node1_interface = None + node2_interface = None - # get node objects identified by link data - node_one, node_two, net_one, net_two = self._link_nodes( - node_one_id, node_two_id - ) - - if node_one: - node_one.lock.acquire() - if node_two: - node_two.lock.acquire() - - node_one_interface = None - node_two_interface = None - - try: - # wireless link - if options.type == LinkTypes.WIRELESS: - objects = [node_one, node_two, net_one, net_two] - self._link_wireless(objects, connect=True) - # wired link + # wireless link + if options.type == LinkTypes.WIRELESS: + if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): + self._link_wireless(node1, node2, connect=True) else: - # 2 nodes being linked, ptp network - if all([node_one, node_two]) and not net_one: - logging.info( - "adding link for peer to peer nodes: %s - %s", - node_one.name, - node_two.name, - ) - start = self.state.should_start() - net_one = self.create_node(PtpNet, start=start) - - # node to network - if node_one and net_one: - logging.info( - "adding link from node to network: %s - %s", - node_one.name, - net_one.name, - ) - ifindex = node_one.newnetif(net_one, interface_one) - node_one_interface = node_one.netif(ifindex) - wireless_net = isinstance(net_one, (EmaneNet, WlanNode)) - if not wireless_net: - net_one.linkconfig(node_one_interface, options) - - # network to node - if node_two and net_one: - logging.info( - "adding link from network to node: %s - %s", - node_two.name, - net_one.name, - ) - ifindex = node_two.newnetif(net_one, interface_two) - node_two_interface = node_two.netif(ifindex) - wireless_net = isinstance(net_one, (EmaneNet, WlanNode)) - if not options.unidirectional and not wireless_net: - net_one.linkconfig(node_two_interface, options) - - # network to network - if net_one and net_two: - logging.info( - "adding link from network to network: %s - %s", - net_one.name, - net_two.name, - ) - interface = net_one.linknet(net_two) - node_one_interface = interface - net_one.linkconfig(interface, options) - if not options.unidirectional: - interface.swapparams("_params_up") - net_two.linkconfig(interface, options) - interface.swapparams("_params_up") - - # a tunnel node was found for the nodes - addresses = [] - if not node_one and all([net_one, interface_one]): - addresses.extend(interface_one.get_addresses()) - if not node_two and all([net_two, interface_two]): - addresses.extend(interface_two.get_addresses()) - - # tunnel node logic - key = options.key - if key and isinstance(net_one, TunnelNode): - logging.info("setting tunnel key for: %s", net_one.name) - net_one.setkey(key) - if addresses: - net_one.addrconfig(addresses) - if key and isinstance(net_two, TunnelNode): - logging.info("setting tunnel key for: %s", net_two.name) - net_two.setkey(key) - if addresses: - net_two.addrconfig(addresses) - finally: - if node_one: - node_one.lock.release() - if node_two: - node_two.lock.release() + raise CoreError( + f"cannot wireless link node1({type(node1)}) node2({type(node2)})" + ) + # wired link + else: + # peer to peer link + if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): + logging.info("linking ptp: %s - %s", node1.name, node2.name) + start = self.state.should_start() + ptp = self.create_node(PtpNet, start=start) + node1_interface = node1.newnetif(ptp, interface_one) + node2_interface = node2.newnetif(ptp, interface_two) + ptp.linkconfig(node1_interface, options) + if not options.unidirectional: + ptp.linkconfig(node2_interface, options) + # link node to net + elif isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNetworkBase): + node1_interface = node1.newnetif(node2, interface_one) + if not isinstance(node2, (EmaneNet, WlanNode)): + node2.linkconfig(node1_interface, options) + # link net to node + elif isinstance(node2, CoreNodeBase) and isinstance(node1, CoreNetworkBase): + node2_interface = node2.newnetif(node1, interface_two) + wireless_net = isinstance(node1, (EmaneNet, WlanNode)) + if not options.unidirectional and not wireless_net: + node1.linkconfig(node2_interface, options) + # network to network + elif isinstance(node1, CoreNetworkBase) and isinstance( + node2, CoreNetworkBase + ): + logging.info( + "linking network to network: %s - %s", node1.name, node2.name + ) + node1_interface = node1.linknet(node2) + node1.linkconfig(node1_interface, options) + if not options.unidirectional: + node1_interface.swapparams("_params_up") + node2.linkconfig(node1_interface, options) + node1_interface.swapparams("_params_up") + else: + raise CoreError( + f"cannot link node1({type(node1)}) node2({type(node2)})" + ) + # configure tunnel nodes + key = options.key + if isinstance(node1, TunnelNode): + logging.info("setting tunnel key for: %s", node1.name) + node1.setkey(key, interface_one) + if isinstance(node2, TunnelNode): + logging.info("setting tunnel key for: %s", node2.name) + node2.setkey(key, interface_two) self.sdt.add_link(node_one_id, node_two_id) - return node_one_interface, node_two_interface + return node1_interface, node2_interface def delete_link( self, @@ -424,93 +334,52 @@ class Session: :return: nothing :raises core.CoreError: when no common network is found for link being deleted """ - # get node objects identified by link data - node_one, node_two, net_one, net_two = self._link_nodes( - node_one_id, node_two_id + node1 = self.get_node(node_one_id, NodeBase) + node2 = self.get_node(node_two_id, NodeBase) + logging.info( + "deleting link(%s) node(%s):interface(%s) node(%s):interface(%s)", + link_type.name, + node1.name, + interface_one_id, + node2.name, + interface_two_id, ) - if node_one: - node_one.lock.acquire() - if node_two: - node_two.lock.acquire() - - try: - # wireless link - if link_type == LinkTypes.WIRELESS: - objects = [node_one, node_two, net_one, net_two] - self._link_wireless(objects, connect=False) - # wired link + # wireless link + if link_type == LinkTypes.WIRELESS: + if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): + self._link_wireless(node1, node2, connect=False) else: - if all([node_one, node_two]): - # TODO: fix this for the case where ifindex[1,2] are not specified - # a wired unlink event, delete the connecting bridge - interface_one = node_one.netif(interface_one_id) - interface_two = node_two.netif(interface_two_id) - - # get interfaces from common network, if no network node - # otherwise get interfaces between a node and network - if not interface_one and not interface_two: - common_networks = node_one.commonnets(node_two) - for ( - network, - common_interface_one, - common_interface_two, - ) in common_networks: - if (net_one and network == net_one) or not net_one: - interface_one = common_interface_one - interface_two = common_interface_two - break - - if all([interface_one, interface_two]) and any( - [interface_one.net, interface_two.net] - ): - if interface_one.net != interface_two.net and all( - [interface_one.up, interface_two.up] - ): - raise CoreError("no common network found") - - logging.info( - "deleting link node(%s):interface(%s) node(%s):interface(%s)", - node_one.name, - interface_one.name, - node_two.name, - interface_two.name, - ) - net_one = interface_one.net - interface_one.detachnet() - interface_two.detachnet() - if net_one.numnetif() == 0: - self.delete_node(net_one.id) - node_one.delnetif(interface_one.netindex) - node_two.delnetif(interface_two.netindex) - elif node_one and net_one: - interface = node_one.netif(interface_one_id) - if interface: - logging.info( - "deleting link node(%s):interface(%s) node(%s)", - node_one.name, - interface.name, - net_one.name, - ) - interface.detachnet() - node_one.delnetif(interface.netindex) - elif node_two and net_one: - interface = node_two.netif(interface_two_id) - if interface: - logging.info( - "deleting link node(%s):interface(%s) node(%s)", - node_two.name, - interface.name, - net_one.name, - ) - interface.detachnet() - node_two.delnetif(interface.netindex) - finally: - if node_one: - node_one.lock.release() - if node_two: - node_two.lock.release() - + raise CoreError( + "cannot delete wireless link " + f"node1({type(node1)}) node2({type(node2)})" + ) + # wired link + else: + if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): + interface1 = node1.netif(interface_one_id) + interface2 = node2.netif(interface_two_id) + if not interface1: + raise CoreError( + f"node({node1.name}) missing interface({interface_one_id})" + ) + if not interface2: + raise CoreError( + f"node({node2.name}) missing interface({interface_two_id})" + ) + if interface1.net != interface2.net: + raise CoreError( + f"node1({node1.name}) node2({node2.name}) " + "not connected to same net" + ) + ptp = interface1.net + node1.delnetif(interface_one_id) + node2.delnetif(interface_two_id) + self.delete_node(ptp.id) + elif isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNetworkBase): + node1.delnetif(interface_one_id) + elif isinstance(node2, CoreNodeBase) and isinstance(node1, CoreNetworkBase): + node2.delnetif(interface_two_id) self.sdt.delete_link(node_one_id, node_two_id) def update_link( @@ -530,84 +399,79 @@ class Session: :param interface_two_id: interface id for node two :param options: data to update link with :return: nothing - :raises core.CoreError: when updating a wireless type link, when there is a unknown - link between networks + :raises core.CoreError: when updating a wireless type link, when there is a + unknown link between networks """ if not options: options = LinkOptions() - - # get node objects identified by link data - node_one, node_two, net_one, net_two = self._link_nodes( - node_one_id, node_two_id + node1 = self.get_node(node_one_id, NodeBase) + node2 = self.get_node(node_two_id, NodeBase) + logging.info( + "update link(%s) node(%s):interface(%s) node(%s):interface(%s)", + options.type.name, + node1.name, + interface_one_id, + node2.name, + interface_two_id, ) - if node_one: - node_one.lock.acquire() - if node_two: - node_two.lock.acquire() - - try: - # wireless link - if options.type == LinkTypes.WIRELESS: - raise CoreError("cannot update wireless link") - else: - if not node_one and not node_two: - if net_one and net_two: - # modify link between nets - interface = net_one.getlinknetif(net_two) - upstream = False - - if not interface: - upstream = True - interface = net_two.getlinknetif(net_one) - - if not interface: - raise CoreError("modify unknown link between nets") - - if upstream: - interface.swapparams("_params_up") - net_one.linkconfig(interface, options) - interface.swapparams("_params_up") - else: - net_one.linkconfig(interface, options) - - if not options.unidirectional: - if upstream: - net_two.linkconfig(interface, options) - else: - interface.swapparams("_params_up") - net_two.linkconfig(interface, options) - interface.swapparams("_params_up") - else: - raise CoreError("modify link for unknown nodes") - elif not node_one: - # node1 = layer 2node, node2 = layer3 node - interface = node_two.netif(interface_two_id) - net_one.linkconfig(interface, options) - elif not node_two: - # node2 = layer 2node, node1 = layer3 node - interface = node_one.netif(interface_one_id) - net_one.linkconfig(interface, options) + # wireless link + if options.type == LinkTypes.WIRELESS: + raise CoreError("cannot update wireless link") + else: + if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): + interface1 = node1.netif(interface_one_id) + interface2 = node2.netif(interface_two_id) + if not interface1: + raise CoreError( + f"node({node1.name}) missing interface({interface_one_id})" + ) + if not interface2: + raise CoreError( + f"node({node2.name}) missing interface({interface_two_id})" + ) + if interface1.net != interface2.net: + raise CoreError( + f"node1({node1.name}) node2({node2.name}) " + "not connected to same net" + ) + ptp = interface1.net + ptp.linkconfig(interface1, options, interface2) + if not options.unidirectional: + ptp.linkconfig(interface2, options, interface1) + elif isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNetworkBase): + interface = node1.netif(interface_one_id) + node2.linkconfig(interface, options) + elif isinstance(node2, CoreNodeBase) and isinstance(node1, CoreNetworkBase): + interface = node2.netif(interface_two_id) + node1.linkconfig(interface, options) + elif isinstance(node1, CoreNetworkBase) and isinstance( + node2, CoreNetworkBase + ): + interface = node1.getlinknetif(node2) + upstream = False + if not interface: + upstream = True + interface = node2.getlinknetif(node1) + if not interface: + raise CoreError("modify unknown link between nets") + if upstream: + interface.swapparams("_params_up") + node1.linkconfig(interface, options) + interface.swapparams("_params_up") else: - common_networks = node_one.commonnets(node_two) - if not common_networks: - raise CoreError("no common network found") - - for net_one, interface_one, interface_two in common_networks: - if ( - interface_one_id is not None - and interface_one_id != node_one.getifindex(interface_one) - ): - continue - - net_one.linkconfig(interface_one, options, interface_two) - if not options.unidirectional: - net_one.linkconfig(interface_two, options, interface_one) - finally: - if node_one: - node_one.lock.release() - if node_two: - node_two.lock.release() + node1.linkconfig(interface, options) + if not options.unidirectional: + if upstream: + node2.linkconfig(interface, options) + else: + interface.swapparams("_params_up") + node2.linkconfig(interface, options) + interface.swapparams("_params_up") + else: + raise CoreError( + f"cannot update link node1({type(node1)}) node2({type(node2)})" + ) def _next_node_id(self) -> int: """ @@ -1345,17 +1209,15 @@ class Session: :return: True if node deleted, False otherwise """ # delete node and check for session shutdown if a node was removed - logging.info("deleting node(%s)", _id) node = None with self._nodes_lock: if _id in self.nodes: node = self.nodes.pop(_id) - + logging.info("deleted node(%s)", node.name) if node: node.shutdown() self.sdt.delete_node(_id) self.check_shutdown() - return node is not None def delete_nodes(self) -> None: @@ -1767,15 +1629,15 @@ class Session: try: ip4 = control_net.prefix[node.id] ip4_mask = control_net.prefix.prefixlen - interface = InterfaceData( + interface_data = InterfaceData( id=control_net.CTRLIF_IDX_BASE + net_index, name=f"ctrl{net_index}", mac=utils.random_mac(), ip4=ip4, ip4_mask=ip4_mask, ) - ifindex = node.newnetif(control_net, interface) - node.netif(ifindex).control = True + interface = node.newnetif(control_net, interface_data) + interface.control = True except ValueError: msg = f"Control interface not added to node {node.id}. " msg += f"Invalid control network prefix ({control_net.prefix}). " diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 0c76d6a2..498a9beb 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -355,10 +355,11 @@ class CoreNodeBase(NodeBase): :return: nothing """ if ifindex not in self._netif: - raise ValueError(f"ifindex {ifindex} does not exist") + raise CoreError(f"node({self.name}) ifindex({ifindex}) does not exist") netif = self._netif.pop(ifindex) + logging.info("node(%s) removing interface(%s)", self.name, netif.name) + netif.detachnet() netif.shutdown() - del netif def netif(self, ifindex: int) -> Optional[CoreInterface]: """ @@ -473,6 +474,18 @@ class CoreNodeBase(NodeBase): """ raise NotImplementedError + def newnetif( + self, net: "CoreNetworkBase", interface: InterfaceData + ) -> CoreInterface: + """ + Create a new network interface. + + :param net: network to associate with + :param interface: interface data for new interface + :return: interface index + """ + raise NotImplementedError + class CoreNode(CoreNodeBase): """ @@ -846,7 +859,9 @@ class CoreNode(CoreNodeBase): interface_name = self.ifname(ifindex) self.node_net_client.device_up(interface_name) - def newnetif(self, net: "CoreNetworkBase", interface: InterfaceData) -> int: + def newnetif( + self, net: "CoreNetworkBase", interface: InterfaceData + ) -> CoreInterface: """ Create a new network interface. @@ -868,16 +883,16 @@ class CoreNode(CoreNodeBase): netif.sethwaddr(interface.mac) for address in addresses: netif.addaddr(address) - return ifindex else: ifindex = self.newveth(interface.id, interface.name) - self.attachnet(ifindex, net) - if interface.mac: - self.sethwaddr(ifindex, interface.mac) - for address in addresses: - self.addaddr(ifindex, address) - self.ifup(ifindex) - return ifindex + self.attachnet(ifindex, net) + if interface.mac: + self.sethwaddr(ifindex, interface.mac) + for address in addresses: + self.addaddr(ifindex, address) + self.ifup(ifindex) + netif = self.netif(ifindex) + return netif def addfile(self, srcname: str, filename: str) -> None: """ diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 095fbe9b..6d6ad589 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -12,7 +12,7 @@ import netaddr from core import utils from core.constants import EBTABLES_BIN, TC_BIN from core.emulator.data import LinkData, NodeData -from core.emulator.emudata import LinkOptions +from core.emulator.emudata import InterfaceData, LinkOptions from core.emulator.enumerations import ( LinkTypes, MessageFlags, @@ -697,15 +697,19 @@ class GreTapBridge(CoreNetwork): ) self.attach(self.gretap) - def setkey(self, key: int) -> None: + def setkey(self, key: int, interface_data: InterfaceData) -> None: """ Set the GRE key used for the GreTap device. This needs to be set prior to instantiating the GreTap device (before addrconfig). :param key: gre key + :param interface_data: interface data for setting up tunnel key :return: nothing """ self.grekey = key + addresses = interface_data.get_addresses() + if addresses: + self.addrconfig(addresses) class CtrlNet(CoreNetwork): diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index ee00c705..6faa7824 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -157,7 +157,7 @@ class PhysicalNode(CoreNodeBase): self.ifindex += 1 return ifindex - def newnetif(self, net: CoreNetworkBase, interface: InterfaceData) -> int: + def newnetif(self, net: CoreNetworkBase, interface: InterfaceData) -> CoreInterface: logging.info("creating interface") addresses = interface.get_addresses() ifindex = interface.id @@ -171,12 +171,12 @@ class PhysicalNode(CoreNodeBase): # tunnel to net not built yet, so build it now and adopt it _, remote_tap = self.session.distributed.create_gre_tunnel(net, self.server) self.adoptnetif(remote_tap, ifindex, interface.mac, addresses) - return ifindex + return remote_tap else: # this is reached when configuring services (self.up=False) netif = GreTap(node=self, name=name, session=self.session, start=False) self.adoptnetif(netif, ifindex, interface.mac, addresses) - return ifindex + return netif def privatedir(self, path: str) -> None: if path[0] != "/": @@ -297,7 +297,7 @@ class Rj45Node(CoreNodeBase): self.up = False self.restorestate() - def newnetif(self, net: CoreNetworkBase, interface: InterfaceData) -> int: + def newnetif(self, net: CoreNetworkBase, interface: InterfaceData) -> CoreInterface: """ This is called when linking with another node. Since this node represents an interface, we do not create another object here, @@ -320,7 +320,7 @@ class Rj45Node(CoreNodeBase): self.interface.attachnet(net) for addr in interface.get_addresses(): self.addaddr(addr) - return ifindex + return self.interface def delnetif(self, ifindex: int) -> None: """ diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index 26e78367..0cbdb8ae 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -54,12 +54,11 @@ class TestNodes: node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) interface_data = InterfaceData() - index = node.newnetif(switch, interface_data) - interface = node.netif(index) + interface = node.newnetif(switch, interface_data) mac = "aa:aa:aa:ff:ff:ff" # when - node.sethwaddr(index, mac) + node.sethwaddr(interface.netindex, mac) # then assert interface.hwaddr == mac @@ -69,25 +68,23 @@ class TestNodes: node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) interface_data = InterfaceData() - index = node.newnetif(switch, interface_data) - node.netif(index) + interface = node.newnetif(switch, interface_data) mac = "aa:aa:aa:ff:ff:fff" # when with pytest.raises(CoreError): - node.sethwaddr(index, mac) + node.sethwaddr(interface.netindex, mac) def test_node_addaddr(self, session: Session): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) interface_data = InterfaceData() - index = node.newnetif(switch, interface_data) - interface = node.netif(index) + interface = node.newnetif(switch, interface_data) addr = "192.168.0.1/24" # when - node.addaddr(index, addr) + node.addaddr(interface.netindex, addr) # then assert interface.addrlist[0] == addr @@ -97,13 +94,12 @@ class TestNodes: node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) interface_data = InterfaceData() - index = node.newnetif(switch, interface_data) - node.netif(index) + interface = node.newnetif(switch, interface_data) addr = "256.168.0.1/24" # when with pytest.raises(CoreError): - node.addaddr(index, addr) + node.addaddr(interface.netindex, addr) @pytest.mark.parametrize("net_type", NET_TYPES) def test_net(self, session, net_type): From e325bcfc5549be738fae934790a87020958c6484 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 11 Jun 2020 14:41:05 -0700 Subject: [PATCH 132/929] bumped version for release --- configure.ac | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configure.ac b/configure.ac index 90b731a9..ae2d0c8d 100644 --- a/configure.ac +++ b/configure.ac @@ -2,7 +2,7 @@ # Process this file with autoconf to produce a configure script. # this defines the CORE version number, must be static for AC_INIT -AC_INIT(core, 6.4.0) +AC_INIT(core, 6.5.0) # autoconf and automake initialization AC_CONFIG_SRCDIR([netns/version.h.in]) From f2409d0604892d9727d08b6205d6e91386bf6088 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 11 Jun 2020 15:40:25 -0700 Subject: [PATCH 133/929] updated changelog for 6.5.0 --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f4557a3a..96f7b30a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,40 @@ +## 2020-06-11 CORE 6.5.0 +* Breaking Changes + * CoreNode.newnetif - both parameters are required and now takes an InterfaceData object as its second parameter + * CoreNetworkBase.linkconfig - now takes a LinkOptions parameter instead of a subset of some of the options (ie bandwidth, delay, etc) + * \#453 - Session.add_node and Session.get_node now requires the node class you expect to create/retrieve + * \#458 - rj45 cleanup to only inherit from one class +* Enhancements + * fixed issues with handling bad commands for TLV execute messages + * removed unused boot.sh from CoreNode types + * added linkconfig to CoreNetworkBase and cleaned up function signature + * emane position hook now saves geo position to node + * emane pathloss support + * core.emulator.emudata leveraged dataclass and type hinting + * \#459 - updated transport type usage to an enum + * \#460 - updated network policy type usage to an enum +* Python GUI Enhancements + * fixed throughput events do not work for joined sessions + * fixed exiting app with a toolbar picker showing + * fixed issue with creating interfaces and reusing subnets after deletion + * fixed issue with moving text shapes + * fixed scaling with custom node selected + * fixed toolbar state switching issues + * enable/disable toolbar when running stop/start + * marker config integrated into toolbar + * improved color picker layout + * shapes can now be moved while drawing shapes + * added observers to toolbar in run mode +* gRPC API + * node events will now have geo positional data + * node geo data is now returned in get_session and get_node calls + * \#451 - added wlan link api to allow direct linking/unlinking of wireless links between nodes + * \#462 - added streaming call for sending node position/geo changes + * \#463 - added streaming call for emane pathloss events +* Bugfixes + * \#454 - fixed issue creating docker nodes, but containers are now required to have networking tools + * \#466 - fixed issue in python gui when xml file is loading nodes with no ip4 addresses + ## 2020-05-11 CORE 6.4.0 * Enhancements * updates to core-route-monitor, allow specific session, configurable settings, and properly From c64094ac1c219c5dc9b0d56f07279c702b3115cf Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 11 Jun 2020 19:01:38 -0700 Subject: [PATCH 134/929] daemon: updated session.delete_link to have the interface ids default to none, since only one may need to be provided, updated link tests to account for more cases --- daemon/core/api/tlv/corehandlers.py | 1 - daemon/core/emulator/session.py | 4 +- daemon/tests/test_links.py | 136 +++++++++++++++++++++++++--- 3 files changed, 126 insertions(+), 15 deletions(-) diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 5531e5af..3adaed63 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -788,7 +788,6 @@ class CoreHandler(socketserver.BaseRequestHandler): link_options.network_id = message.get_tlv(LinkTlvs.NETWORK_ID.value) link_options.key = message.get_tlv(LinkTlvs.KEY.value) link_options.opaque = message.get_tlv(LinkTlvs.OPAQUE.value) - if message.flags & MessageFlags.ADD.value: self.session.add_link( node_one_id, node_two_id, interface_one, interface_two, link_options diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 54486bfb..854d5cc8 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -319,8 +319,8 @@ class Session: self, node_one_id: int, node_two_id: int, - interface_one_id: int, - interface_two_id: int, + interface_one_id: int = None, + interface_two_id: int = None, link_type: LinkTypes = LinkTypes.WIRED, ) -> None: """ diff --git a/daemon/tests/test_links.py b/daemon/tests/test_links.py index 9736537e..71942e4b 100644 --- a/daemon/tests/test_links.py +++ b/daemon/tests/test_links.py @@ -25,7 +25,7 @@ def create_ptp_network( class TestLinks: - def test_ptp(self, session: Session, ip_prefixes: IpPrefixes): + def test_add_ptp(self, session: Session, ip_prefixes: IpPrefixes): # given node_one = session.add_node(CoreNode) node_two = session.add_node(CoreNode) @@ -39,20 +39,20 @@ class TestLinks: assert node_one.netif(interface_one.id) assert node_two.netif(interface_two.id) - def test_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): + def test_add_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): # given node_one = session.add_node(CoreNode) node_two = session.add_node(SwitchNode) interface_one = ip_prefixes.create_interface(node_one) # when - session.add_link(node_one.id, node_two.id, interface_one) + session.add_link(node_one.id, node_two.id, interface_one=interface_one) # then assert node_two.all_link_data() assert node_one.netif(interface_one.id) - def test_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): + def test_add_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): # given node_one = session.add_node(SwitchNode) node_two = session.add_node(CoreNode) @@ -76,7 +76,7 @@ class TestLinks: # then assert node_one.all_link_data() - def test_link_update(self, session: Session, ip_prefixes: IpPrefixes): + def test_update_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): # given delay = 50 bandwidth = 5000000 @@ -95,12 +95,9 @@ class TestLinks: assert interface_one.getparam("jitter") != jitter # when - link_options = LinkOptions() - link_options.delay = delay - link_options.bandwidth = bandwidth - link_options.per = per - link_options.dup = dup - link_options.jitter = jitter + link_options = LinkOptions( + delay=delay, bandwidth=bandwidth, per=per, dup=dup, jitter=jitter + ) session.update_link( node_one.id, node_two.id, @@ -115,7 +112,94 @@ class TestLinks: assert interface_one.getparam("duplicate") == dup assert interface_one.getparam("jitter") == jitter - def test_link_delete(self, session: Session, ip_prefixes: IpPrefixes): + def test_update_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): + # given + delay = 50 + bandwidth = 5000000 + per = 25 + dup = 25 + jitter = 10 + node_one = session.add_node(SwitchNode) + node_two = session.add_node(CoreNode) + interface_two_data = ip_prefixes.create_interface(node_two) + session.add_link(node_one.id, node_two.id, interface_two=interface_two_data) + interface_two = node_two.netif(interface_two_data.id) + assert interface_two.getparam("delay") != delay + assert interface_two.getparam("bw") != bandwidth + assert interface_two.getparam("loss") != per + assert interface_two.getparam("duplicate") != dup + assert interface_two.getparam("jitter") != jitter + + # when + link_options = LinkOptions( + delay=delay, bandwidth=bandwidth, per=per, dup=dup, jitter=jitter + ) + session.update_link( + node_one.id, + node_two.id, + interface_two_id=interface_two_data.id, + options=link_options, + ) + + # then + assert interface_two.getparam("delay") == delay + assert interface_two.getparam("bw") == bandwidth + assert interface_two.getparam("loss") == per + assert interface_two.getparam("duplicate") == dup + assert interface_two.getparam("jitter") == jitter + + def test_update_ptp(self, session: Session, ip_prefixes: IpPrefixes): + # given + delay = 50 + bandwidth = 5000000 + per = 25 + dup = 25 + jitter = 10 + node_one = session.add_node(CoreNode) + node_two = session.add_node(CoreNode) + interface_one_data = ip_prefixes.create_interface(node_one) + interface_two_data = ip_prefixes.create_interface(node_two) + session.add_link( + node_one.id, node_two.id, interface_one_data, interface_two_data + ) + interface_one = node_one.netif(interface_one_data.id) + interface_two = node_two.netif(interface_two_data.id) + assert interface_one.getparam("delay") != delay + assert interface_one.getparam("bw") != bandwidth + assert interface_one.getparam("loss") != per + assert interface_one.getparam("duplicate") != dup + assert interface_one.getparam("jitter") != jitter + assert interface_two.getparam("delay") != delay + assert interface_two.getparam("bw") != bandwidth + assert interface_two.getparam("loss") != per + assert interface_two.getparam("duplicate") != dup + assert interface_two.getparam("jitter") != jitter + + # when + link_options = LinkOptions( + delay=delay, bandwidth=bandwidth, per=per, dup=dup, jitter=jitter + ) + session.update_link( + node_one.id, + node_two.id, + interface_one_data.id, + interface_two_data.id, + link_options, + ) + + # then + assert interface_one.getparam("delay") == delay + assert interface_one.getparam("bw") == bandwidth + assert interface_one.getparam("loss") == per + assert interface_one.getparam("duplicate") == dup + assert interface_one.getparam("jitter") == jitter + assert interface_two.getparam("delay") == delay + assert interface_two.getparam("bw") == bandwidth + assert interface_two.getparam("loss") == per + assert interface_two.getparam("duplicate") == dup + assert interface_two.getparam("jitter") == jitter + + def test_delete_ptp(self, session: Session, ip_prefixes: IpPrefixes): # given node_one = session.add_node(CoreNode) node_two = session.add_node(CoreNode) @@ -133,3 +217,31 @@ class TestLinks: # then assert not node_one.netif(interface_one.id) assert not node_two.netif(interface_two.id) + + def test_delete_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): + # given + node_one = session.add_node(CoreNode) + node_two = session.add_node(SwitchNode) + interface_one = ip_prefixes.create_interface(node_one) + session.add_link(node_one.id, node_two.id, interface_one) + assert node_one.netif(interface_one.id) + + # when + session.delete_link(node_one.id, node_two.id, interface_one_id=interface_one.id) + + # then + assert not node_one.netif(interface_one.id) + + def test_delete_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): + # given + node_one = session.add_node(SwitchNode) + node_two = session.add_node(CoreNode) + interface_two = ip_prefixes.create_interface(node_two) + session.add_link(node_one.id, node_two.id, interface_two=interface_two) + assert node_two.netif(interface_two.id) + + # when + session.delete_link(node_one.id, node_two.id, interface_two_id=interface_two.id) + + # then + assert not node_two.netif(interface_two.id) From 00cda5c55067db0f995817bcd0d4e04f1fe2eae8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 11 Jun 2020 19:08:50 -0700 Subject: [PATCH 135/929] fixed test_link name --- daemon/tests/test_links.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/tests/test_links.py b/daemon/tests/test_links.py index 71942e4b..9f693da1 100644 --- a/daemon/tests/test_links.py +++ b/daemon/tests/test_links.py @@ -65,7 +65,7 @@ class TestLinks: assert node_one.all_link_data() assert node_two.netif(interface_two.id) - def test_net_to_net(self, session): + def test_add_net_to_net(self, session): # given node_one = session.add_node(SwitchNode) node_two = session.add_node(SwitchNode) From e72e332babe9aa8502b903bac2f6a8bd0662f2c9 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 11 Jun 2020 19:12:51 -0700 Subject: [PATCH 136/929] daemon: removed need to use getaddr for CoreInterface.othernet as it now has a default of None --- daemon/core/nodes/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 498a9beb..4b8d513b 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -1040,7 +1040,7 @@ class CoreNetworkBase(NodeBase): :return: interface the provided network is linked to """ for netif in self.netifs(): - if getattr(netif, "othernet", None) == net: + if netif.othernet == net: return netif return None From cfaa9397ada5df0a5cde683376e0bf3de4bfe807 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 12 Jun 2020 08:34:02 -0700 Subject: [PATCH 137/929] daemon: added class variable type hinting to core.api.grpc --- daemon/core/api/grpc/client.py | 12 ++++++------ daemon/core/api/grpc/events.py | 6 +++--- daemon/core/api/grpc/grpcutils.py | 7 ++++--- daemon/core/api/grpc/server.py | 8 ++++---- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 0361a69b..1bc88069 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -5,7 +5,7 @@ gRpc client for interfacing with CORE. import logging import threading from contextlib import contextmanager -from typing import Any, Callable, Dict, Generator, Iterable, List +from typing import Any, Callable, Dict, Generator, Iterable, List, Optional import grpc @@ -108,7 +108,7 @@ class InterfaceHelper: :param ip6_prefix: ip6 prefix to use for generation :raises ValueError: when both ip4 and ip6 prefixes have not been provided """ - self.prefixes = IpPrefixes(ip4_prefix, ip6_prefix) + self.prefixes: IpPrefixes = IpPrefixes(ip4_prefix, ip6_prefix) def create_interface( self, node_id: int, interface_id: int, name: str = None, mac: str = None @@ -177,10 +177,10 @@ class CoreGrpcClient: :param address: grpc server address to connect to """ - self.address = address - self.stub = None - self.channel = None - self.proxy = proxy + self.address: str = address + self.stub: Optional[core_pb2_grpc.CoreApiStub] = None + self.channel: Optional[grpc.Channel] = None + self.proxy: bool = proxy def start_session( self, diff --git a/daemon/core/api/grpc/events.py b/daemon/core/api/grpc/events.py index 837860e3..82cf1eac 100644 --- a/daemon/core/api/grpc/events.py +++ b/daemon/core/api/grpc/events.py @@ -140,9 +140,9 @@ class EventStreamer: :param session: session to process events for :param event_types: types of events to process """ - self.session = session - self.event_types = event_types - self.queue = Queue() + self.session: Session = session + self.event_types: Iterable[core_pb2.EventType] = event_types + self.queue: Queue = Queue() self.add_handlers() def add_handlers(self) -> None: diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 5c6f3a80..73d19a2a 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -1,6 +1,6 @@ import logging import time -from typing import Any, Dict, List, Tuple, Type +from typing import Any, Dict, List, Tuple, Type, Union import grpc import netaddr @@ -190,7 +190,8 @@ def convert_value(value: Any) -> str: def get_config_options( - config: Dict[str, str], configurable_options: Type[ConfigurableOptions] + config: Dict[str, str], + configurable_options: Union[ConfigurableOptions, Type[ConfigurableOptions]], ) -> Dict[str, common_pb2.ConfigOption]: """ Retrieve configuration options in a form that is used by the grpc server. @@ -418,7 +419,7 @@ def service_configuration(session: Session, config: ServiceConfig) -> None: service.shutdown = tuple(config.shutdown) -def get_service_configuration(service: Type[CoreService]) -> NodeServiceData: +def get_service_configuration(service: CoreService) -> NodeServiceData: """ Convenience for converting a service to service data proto. diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 7d7f7c80..adddff14 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -6,7 +6,7 @@ import tempfile import threading import time from concurrent import futures -from typing import Iterable, Type +from typing import Iterable, Optional, Type import grpc from grpc import ServicerContext @@ -131,9 +131,9 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): def __init__(self, coreemu: CoreEmu) -> None: super().__init__() - self.coreemu = coreemu - self.running = True - self.server = None + self.coreemu: CoreEmu = coreemu + self.running: bool = True + self.server: Optional[grpc.Server] = None atexit.register(self._exit_handler) def _exit_handler(self) -> None: From ef3cf5697d0080b45a5bf5894c5e87f2b105c0ea Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 12 Jun 2020 08:54:06 -0700 Subject: [PATCH 138/929] daemon: added class variable type hinting for core.xml --- daemon/core/config.py | 7 ++++--- daemon/core/xml/corexml.py | 28 +++++++++++----------------- daemon/core/xml/corexmldeployment.py | 6 +++--- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/daemon/core/config.py b/daemon/core/config.py index 1f5bc3c0..d4ba6164 100644 --- a/daemon/core/config.py +++ b/daemon/core/config.py @@ -4,7 +4,7 @@ Common support for configurable CORE objects. import logging from collections import OrderedDict -from typing import TYPE_CHECKING, Dict, List, Tuple, Type, Union +from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Type, Union from core.emane.nodes import EmaneNet from core.emulator.enumerations import ConfigDataTypes @@ -136,7 +136,8 @@ class ConfigurableManager: """ Clears all configurations or configuration for a specific node. - :param node_id: node id to clear configurations for, default is None and clears all configurations + :param node_id: node id to clear configurations for, default is None and clears + all configurations :return: nothing """ if not node_id: @@ -222,7 +223,7 @@ class ConfigurableManager: result = node_configs.get(config_type) return result - def get_all_configs(self, node_id: int = _default_node) -> List[Dict[str, str]]: + def get_all_configs(self, node_id: int = _default_node) -> Dict[str, Any]: """ Retrieve all current configuration types for a node. diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index cb25e717..973eb77f 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -124,9 +124,9 @@ def add_configuration(parent: etree.Element, name: str, value: str) -> None: class NodeElement: def __init__(self, session: "Session", node: NodeBase, element_name: str) -> None: - self.session = session - self.node = node - self.element = etree.Element(element_name) + self.session: "Session" = session + self.node: NodeBase = node + self.element: etree.Element = etree.Element(element_name) add_attribute(self.element, "id", node.id) add_attribute(self.element, "name", node.name) add_attribute(self.element, "icon", node.icon) @@ -151,8 +151,8 @@ class NodeElement: class ServiceElement: def __init__(self, service: Type[CoreService]) -> None: - self.service = service - self.element = etree.Element("service") + self.service: Type[CoreService] = service + self.element: etree.Element = etree.Element("service") add_attribute(self.element, "name", service.name) self.add_directories() self.add_startup() @@ -268,10 +268,10 @@ class NetworkElement(NodeElement): class CoreXmlWriter: def __init__(self, session: "Session") -> None: - self.session = session - self.scenario = etree.Element("scenario") - self.networks = None - self.devices = None + self.session: "Session" = session + self.scenario: etree.Element = etree.Element("scenario") + self.networks: etree.SubElement = etree.SubElement(self.scenario, "networks") + self.devices: etree.SubElement = etree.SubElement(self.scenario, "devices") self.write_session() def write_session(self) -> None: @@ -362,13 +362,11 @@ class CoreXmlWriter: def write_emane_configs(self) -> None: emane_global_configuration = create_emane_config(self.session) self.scenario.append(emane_global_configuration) - emane_configurations = etree.Element("emane_configurations") for node_id in self.session.emane.nodes(): all_configs = self.session.emane.get_all_configs(node_id) if not all_configs: continue - for model_name in all_configs: config = all_configs[model_name] logging.debug( @@ -453,9 +451,6 @@ class CoreXmlWriter: self.scenario.append(node_types) def write_nodes(self) -> List[LinkData]: - self.networks = etree.SubElement(self.scenario, "networks") - self.devices = etree.SubElement(self.scenario, "devices") - links = [] for node_id in self.session.nodes: node = self.session.nodes[node_id] @@ -472,7 +467,6 @@ class CoreXmlWriter: # add known links links.extend(node.all_link_data()) - return links def write_network(self, node: NodeBase) -> None: @@ -597,8 +591,8 @@ class CoreXmlWriter: class CoreXmlReader: def __init__(self, session: "Session") -> None: - self.session = session - self.scenario = None + self.session: "Session" = session + self.scenario: Optional[etree.ElementTree] = None def read(self, file_name: str) -> None: xml_tree = etree.parse(file_name) diff --git a/daemon/core/xml/corexmldeployment.py b/daemon/core/xml/corexmldeployment.py index 5f340b69..04915bf1 100644 --- a/daemon/core/xml/corexmldeployment.py +++ b/daemon/core/xml/corexmldeployment.py @@ -101,9 +101,9 @@ def get_ipv4_addresses(hostname: str) -> List[Tuple[str, str]]: class CoreXmlDeployment: def __init__(self, session: "Session", scenario: etree.Element) -> None: - self.session = session - self.scenario = scenario - self.root = etree.SubElement( + self.session: "Session" = session + self.scenario: etree.Element = scenario + self.root: etree.SubElement = etree.SubElement( scenario, "container", id="TestBed", name="TestBed" ) self.add_deployment() From 6201875b782c266335f23518a4cfd8cfc30f5b42 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 12 Jun 2020 09:52:01 -0700 Subject: [PATCH 139/929] daemon: added class variable type hinting to core.emane --- daemon/core/emane/bypass.py | 13 ++++--- daemon/core/emane/commeffect.py | 16 ++++---- daemon/core/emane/emanemanager.py | 44 +++++++++++---------- daemon/core/emane/emanemanifest.py | 1 + daemon/core/emane/emanemodel.py | 26 +++++++------ daemon/core/emane/ieee80211abg.py | 6 +-- daemon/core/emane/linkmonitor.py | 61 ++++++++++++++++-------------- daemon/core/emane/nodes.py | 26 +++++++------ daemon/core/emane/rfpipe.py | 6 +-- daemon/core/emane/tdma.py | 13 ++++--- daemon/core/nodes/network.py | 4 +- 11 files changed, 116 insertions(+), 100 deletions(-) diff --git a/daemon/core/emane/bypass.py b/daemon/core/emane/bypass.py index 83f3b6e8..8aabc3f9 100644 --- a/daemon/core/emane/bypass.py +++ b/daemon/core/emane/bypass.py @@ -1,6 +1,7 @@ """ EMANE Bypass model for CORE """ +from typing import List, Set from core.config import Configuration from core.emane import emanemodel @@ -8,14 +9,14 @@ from core.emulator.enumerations import ConfigDataTypes class EmaneBypassModel(emanemodel.EmaneModel): - name = "emane_bypass" + name: str = "emane_bypass" # values to ignore, when writing xml files - config_ignore = {"none"} + config_ignore: Set[str] = {"none"} # mac definitions - mac_library = "bypassmaclayer" - mac_config = [ + mac_library: str = "bypassmaclayer" + mac_config: List[Configuration] = [ Configuration( _id="none", _type=ConfigDataTypes.BOOL, @@ -25,8 +26,8 @@ class EmaneBypassModel(emanemodel.EmaneModel): ] # phy definitions - phy_library = "bypassphylayer" - phy_config = [] + phy_library: str = "bypassphylayer" + phy_config: List[Configuration] = [] @classmethod def load(cls, emane_prefix: str) -> None: diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index b7060e96..71acb199 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -22,6 +22,7 @@ except ImportError: try: from emanesh.events.commeffectevent import CommEffectEvent except ImportError: + CommEffectEvent = None logging.debug("compatible emane python bindings not installed") @@ -38,16 +39,15 @@ def convert_none(x: float) -> int: class EmaneCommEffectModel(emanemodel.EmaneModel): - name = "emane_commeffect" - - shim_library = "commeffectshim" - shim_xml = "commeffectshim.xml" - shim_defaults = {} - config_shim = [] + name: str = "emane_commeffect" + shim_library: str = "commeffectshim" + shim_xml: str = "commeffectshim.xml" + shim_defaults: Dict[str, str] = {} + config_shim: List[Configuration] = [] # comm effect does not need the default phy and external configurations - phy_config = [] - external_config = [] + phy_config: List[Configuration] = [] + external_config: List[Configuration] = [] @classmethod def load(cls, emane_prefix: str) -> None: diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 12b477f0..146d186f 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -70,11 +70,13 @@ class EmaneManager(ModelManager): controlling the EMANE daemons. """ - name = "emane" - config_type = RegisterTlvs.EMULATION_SERVER - SUCCESS, NOT_NEEDED, NOT_READY = (0, 1, 2) - EVENTCFGVAR = "LIBEMANEEVENTSERVICECONFIG" - DEFAULT_LOG_LEVEL = 3 + name: str = "emane" + config_type: RegisterTlvs = RegisterTlvs.EMULATION_SERVER + SUCCESS: int = 0 + NOT_NEEDED: int = 1 + NOT_READY: int = 2 + EVENTCFGVAR: str = "LIBEMANEEVENTSERVICECONFIG" + DEFAULT_LOG_LEVEL: int = 3 def __init__(self, session: "Session") -> None: """ @@ -84,29 +86,29 @@ class EmaneManager(ModelManager): :return: nothing """ super().__init__() - self.session = session - self._emane_nets = {} - self._emane_node_lock = threading.Lock() + self.session: "Session" = session + self._emane_nets: Dict[int, EmaneNet] = {} + self._emane_node_lock: threading.Lock = threading.Lock() # port numbers are allocated from these counters - self.platformport = self.session.options.get_config_int( + self.platformport: int = self.session.options.get_config_int( "emane_platform_port", 8100 ) - self.transformport = self.session.options.get_config_int( + self.transformport: int = self.session.options.get_config_int( "emane_transform_port", 8200 ) - self.doeventloop = False - self.eventmonthread = None + self.doeventloop: bool = False + self.eventmonthread: Optional[threading.Thread] = None # model for global EMANE configuration options - self.emane_config = EmaneGlobalModel(session) + self.emane_config: EmaneGlobalModel = EmaneGlobalModel(session) self.set_configs(self.emane_config.default_values()) # link monitor - self.link_monitor = EmaneLinkMonitor(self) + self.link_monitor: EmaneLinkMonitor = EmaneLinkMonitor(self) - self.service = None - self.eventchannel = None - self.event_device = None + self.service: Optional[EventService] = None + self.eventchannel: Optional[Tuple[str, int, str]] = None + self.event_device: Optional[str] = None self.emane_check() def getifcconfig( @@ -890,12 +892,12 @@ class EmaneGlobalModel: Global EMANE configuration options. """ - name = "emane" - bitmap = None + name: str = "emane" + bitmap: Optional[str] = None def __init__(self, session: "Session") -> None: - self.session = session - self.core_config = [ + self.session: "Session" = session + self.core_config: List[Configuration] = [ Configuration( _id="platform_id_start", _type=ConfigDataTypes.INT32, diff --git a/daemon/core/emane/emanemanifest.py b/daemon/core/emane/emanemanifest.py index 914b4f83..41dc7beb 100644 --- a/daemon/core/emane/emanemanifest.py +++ b/daemon/core/emane/emanemanifest.py @@ -11,6 +11,7 @@ except ImportError: try: from emanesh import manifest except ImportError: + manifest = None logging.debug("compatible emane python bindings not installed") diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 7b5ff417..78d5ec5e 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -3,7 +3,7 @@ Defines Emane Models used within CORE. """ import logging import os -from typing import Dict, List +from typing import Dict, List, Optional, Set from core.config import ConfigGroup, Configuration from core.emane import emanemanifest @@ -25,19 +25,23 @@ class EmaneModel(WirelessModel): """ # default mac configuration settings - mac_library = None - mac_xml = None - mac_defaults = {} - mac_config = [] + mac_library: Optional[str] = None + mac_xml: Optional[str] = None + mac_defaults: Dict[str, str] = {} + mac_config: List[Configuration] = [] # default phy configuration settings, using the universal model - phy_library = None - phy_xml = "emanephy.xml" - phy_defaults = {"subid": "1", "propagationmodel": "2ray", "noisemode": "none"} - phy_config = [] + phy_library: Optional[str] = None + phy_xml: str = "emanephy.xml" + phy_defaults: Dict[str, str] = { + "subid": "1", + "propagationmodel": "2ray", + "noisemode": "none", + } + phy_config: List[Configuration] = [] # support for external configurations - external_config = [ + external_config: List[Configuration] = [ Configuration("external", ConfigDataTypes.BOOL, default="0"), Configuration( "platformendpoint", ConfigDataTypes.STRING, default="127.0.0.1:40001" @@ -47,7 +51,7 @@ class EmaneModel(WirelessModel): ), ] - config_ignore = set() + config_ignore: Set[str] = set() @classmethod def load(cls, emane_prefix: str) -> None: diff --git a/daemon/core/emane/ieee80211abg.py b/daemon/core/emane/ieee80211abg.py index ecfd3694..0d58ec9e 100644 --- a/daemon/core/emane/ieee80211abg.py +++ b/daemon/core/emane/ieee80211abg.py @@ -8,11 +8,11 @@ from core.emane import emanemodel class EmaneIeee80211abgModel(emanemodel.EmaneModel): # model name - name = "emane_ieee80211abg" + name: str = "emane_ieee80211abg" # mac configuration - mac_library = "ieee80211abgmaclayer" - mac_xml = "ieee80211abgmaclayer.xml" + mac_library: str = "ieee80211abgmaclayer" + mac_xml: str = "ieee80211abgmaclayer.xml" @classmethod def load(cls, emane_prefix: str) -> None: diff --git a/daemon/core/emane/linkmonitor.py b/daemon/core/emane/linkmonitor.py index 861c108c..b9fd9a2a 100644 --- a/daemon/core/emane/linkmonitor.py +++ b/daemon/core/emane/linkmonitor.py @@ -2,7 +2,7 @@ import logging import sched import threading import time -from typing import TYPE_CHECKING, Dict, List, Tuple +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple import netaddr from lxml import etree @@ -17,28 +17,29 @@ except ImportError: try: from emanesh import shell except ImportError: + shell = None logging.debug("compatible emane python bindings not installed") if TYPE_CHECKING: from core.emane.emanemanager import EmaneManager -DEFAULT_PORT = 47_000 -MAC_COMPONENT_INDEX = 1 -EMANE_RFPIPE = "rfpipemaclayer" -EMANE_80211 = "ieee80211abgmaclayer" -EMANE_TDMA = "tdmaeventschedulerradiomodel" -SINR_TABLE = "NeighborStatusTable" -NEM_SELF = 65535 +DEFAULT_PORT: int = 47_000 +MAC_COMPONENT_INDEX: int = 1 +EMANE_RFPIPE: str = "rfpipemaclayer" +EMANE_80211: str = "ieee80211abgmaclayer" +EMANE_TDMA: str = "tdmaeventschedulerradiomodel" +SINR_TABLE: str = "NeighborStatusTable" +NEM_SELF: int = 65535 class LossTable: def __init__(self, losses: Dict[float, float]) -> None: - self.losses = losses - self.sinrs = sorted(self.losses.keys()) - self.loss_lookup = {} + self.losses: Dict[float, float] = losses + self.sinrs: List[float] = sorted(self.losses.keys()) + self.loss_lookup: Dict[int, float] = {} for index, value in enumerate(self.sinrs): self.loss_lookup[index] = self.losses[value] - self.mac_id = None + self.mac_id: Optional[str] = None def get_loss(self, sinr: float) -> float: index = self._get_index(sinr) @@ -54,11 +55,11 @@ class LossTable: class EmaneLink: def __init__(self, from_nem: int, to_nem: int, sinr: float) -> None: - self.from_nem = from_nem - self.to_nem = to_nem - self.sinr = sinr - self.last_seen = None - self.updated = False + self.from_nem: int = from_nem + self.to_nem: int = to_nem + self.sinr: float = sinr + self.last_seen: Optional[float] = None + self.updated: bool = False self.touch() def update(self, sinr: float) -> None: @@ -78,9 +79,11 @@ class EmaneLink: class EmaneClient: def __init__(self, address: str) -> None: - self.address = address - self.client = shell.ControlPortClient(self.address, DEFAULT_PORT) - self.nems = {} + self.address: str = address + self.client: shell.ControlPortClient = shell.ControlPortClient( + self.address, DEFAULT_PORT + ) + self.nems: Dict[int, LossTable] = {} self.setup() def setup(self) -> None: @@ -174,15 +177,15 @@ class EmaneClient: class EmaneLinkMonitor: def __init__(self, emane_manager: "EmaneManager") -> None: - self.emane_manager = emane_manager - self.clients = [] - self.links = {} - self.complete_links = set() - self.loss_threshold = None - self.link_interval = None - self.link_timeout = None - self.scheduler = None - self.running = False + self.emane_manager: "EmaneManager" = emane_manager + self.clients: List[EmaneClient] = [] + self.links: Dict[Tuple[int, int], EmaneLink] = {} + self.complete_links: Set[Tuple[int, int]] = set() + self.loss_threshold: Optional[int] = None + self.link_interval: Optional[int] = None + self.link_timeout: Optional[int] = None + self.scheduler: Optional[sched.scheduler] = None + self.running: bool = False def start(self) -> None: self.loss_threshold = int(self.emane_manager.get_config("loss_threshold")) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index f4de8f47..e88cb194 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -16,13 +16,16 @@ from core.emulator.enumerations import ( RegisterTlvs, TransportType, ) +from core.errors import CoreError from core.nodes.base import CoreNetworkBase from core.nodes.interface import CoreInterface if TYPE_CHECKING: + from core.emane.emanemodel import EmaneModel from core.emulator.session import Session - from core.location.mobility import WirelessModel + from core.location.mobility import WirelessModel, WayPointMobility + OptionalEmaneModel = Optional[EmaneModel] WirelessModelType = Type[WirelessModel] try: @@ -31,6 +34,7 @@ except ImportError: try: from emanesh.events import LocationEvent except ImportError: + LocationEvent = None logging.debug("compatible emane python bindings not installed") @@ -41,10 +45,10 @@ class EmaneNet(CoreNetworkBase): Emane controller object that exists in a session. """ - apitype = NodeTypes.EMANE - linktype = LinkTypes.WIRED - type = "wlan" - is_emane = True + apitype: NodeTypes = NodeTypes.EMANE + linktype: LinkTypes = LinkTypes.WIRED + type: str = "wlan" + is_emane: bool = True def __init__( self, @@ -55,10 +59,10 @@ class EmaneNet(CoreNetworkBase): server: DistributedServer = None, ) -> None: super().__init__(session, _id, name, start, server) - self.conf = "" - self.nemidmap = {} - self.model = None - self.mobility = None + self.conf: str = "" + self.nemidmap: Dict[CoreInterface, int] = {} + self.model: "OptionalEmaneModel" = None + self.mobility: Optional[WayPointMobility] = None def linkconfig( self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None @@ -84,11 +88,11 @@ class EmaneNet(CoreNetworkBase): def updatemodel(self, config: Dict[str, str]) -> None: if not self.model: - raise ValueError("no model set to update for node(%s)", self.id) + raise CoreError(f"no model set to update for node({self.name})") logging.info( "node(%s) updating model(%s): %s", self.id, self.model.name, config ) - self.model.set_configs(config, node_id=self.id) + self.model.update_config(config) def setmodel(self, model: "WirelessModelType", config: Dict[str, str]) -> None: """ diff --git a/daemon/core/emane/rfpipe.py b/daemon/core/emane/rfpipe.py index 23790b3c..068ef800 100644 --- a/daemon/core/emane/rfpipe.py +++ b/daemon/core/emane/rfpipe.py @@ -8,11 +8,11 @@ from core.emane import emanemodel class EmaneRfPipeModel(emanemodel.EmaneModel): # model name - name = "emane_rfpipe" + name: str = "emane_rfpipe" # mac configuration - mac_library = "rfpipemaclayer" - mac_xml = "rfpipemaclayer.xml" + mac_library: str = "rfpipemaclayer" + mac_xml: str = "rfpipemaclayer.xml" @classmethod def load(cls, emane_prefix: str) -> None: diff --git a/daemon/core/emane/tdma.py b/daemon/core/emane/tdma.py index 17f5328f..ee80f3d7 100644 --- a/daemon/core/emane/tdma.py +++ b/daemon/core/emane/tdma.py @@ -4,6 +4,7 @@ tdma.py: EMANE TDMA model bindings for CORE import logging import os +from typing import Set from core import constants, utils from core.config import Configuration @@ -13,18 +14,18 @@ from core.emulator.enumerations import ConfigDataTypes class EmaneTdmaModel(emanemodel.EmaneModel): # model name - name = "emane_tdma" + name: str = "emane_tdma" # mac configuration - mac_library = "tdmaeventschedulerradiomodel" - mac_xml = "tdmaeventschedulerradiomodel.xml" + mac_library: str = "tdmaeventschedulerradiomodel" + mac_xml: str = "tdmaeventschedulerradiomodel.xml" # add custom schedule options and ignore it when writing emane xml - schedule_name = "schedule" - default_schedule = os.path.join( + schedule_name: str = "schedule" + default_schedule: str = os.path.join( constants.CORE_DATA_DIR, "examples", "tdma", "schedule.xml" ) - config_ignore = {schedule_name} + config_ignore: Set[str] = {schedule_name} @classmethod def load(cls, emane_prefix: str) -> None: diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 6d6ad589..235b43f2 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -1090,12 +1090,12 @@ class WlanNode(CoreNetwork): def update_mobility(self, config: Dict[str, str]) -> None: if not self.mobility: - raise ValueError(f"no mobility set to update for node({self.id})") + raise CoreError(f"no mobility set to update for node({self.name})") self.mobility.update_config(config) def updatemodel(self, config: Dict[str, str]) -> None: if not self.model: - raise ValueError(f"no model set to update for node({self.id})") + raise CoreError(f"no model set to update for node({self.name})") logging.debug( "node(%s) updating model(%s): %s", self.id, self.model.name, config ) From b28ef76d65a483c09bc19f28bec8e1c6635568d8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 12 Jun 2020 10:05:49 -0700 Subject: [PATCH 140/929] daemon: added class variable type hinting to core.config --- daemon/core/config.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/daemon/core/config.py b/daemon/core/config.py index d4ba6164..618e1273 100644 --- a/daemon/core/config.py +++ b/daemon/core/config.py @@ -4,7 +4,7 @@ Common support for configurable CORE objects. import logging from collections import OrderedDict -from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Type, Union +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Type, Union from core.emane.nodes import EmaneNet from core.emulator.enumerations import ConfigDataTypes @@ -29,9 +29,9 @@ class ConfigGroup: :param start: configurations start index for this group :param stop: configurations stop index for this group """ - self.name = name - self.start = start - self.stop = stop + self.name: str = name + self.start: int = start + self.stop: int = stop class Configuration: @@ -56,18 +56,21 @@ class Configuration: :param default: default value for configuration :param options: list options if this is a configuration with a combobox """ - self.id = _id - self.type = _type - self.default = default + self.id: str = _id + self.type: ConfigDataTypes = _type + self.default: str = default if not options: options = [] - self.options = options + self.options: List[str] = options if not label: label = _id - self.label = label + self.label: str = label def __str__(self): - return f"{self.__class__.__name__}(id={self.id}, type={self.type}, default={self.default}, options={self.options})" + return ( + f"{self.__class__.__name__}(id={self.id}, type={self.type}, " + f"default={self.default}, options={self.options})" + ) class ConfigurableOptions: @@ -75,9 +78,9 @@ class ConfigurableOptions: Provides a base for defining configuration options within CORE. """ - name = None - bitmap = None - options = [] + name: Optional[str] = None + bitmap: Optional[str] = None + options: List[Configuration] = [] @classmethod def configurations(cls) -> List[Configuration]: @@ -115,8 +118,8 @@ class ConfigurableManager: nodes. """ - _default_node = -1 - _default_type = _default_node + _default_node: int = -1 + _default_type: int = _default_node def __init__(self) -> None: """ @@ -243,8 +246,8 @@ class ModelManager(ConfigurableManager): Creates a ModelManager object. """ super().__init__() - self.models = {} - self.node_models = {} + self.models: Dict[str, Any] = {} + self.node_models: Dict[int, str] = {} def set_model_config( self, node_id: int, model_name: str, config: Dict[str, str] = None From 76305f72577b273050818148c3303c9c9102232c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 12 Jun 2020 12:49:53 -0700 Subject: [PATCH 141/929] converted usages of per to loss --- daemon/core/api/grpc/grpcutils.py | 4 ++-- daemon/core/api/grpc/server.py | 27 +++++++++++++------------- daemon/core/api/tlv/coreapi.py | 2 +- daemon/core/api/tlv/corehandlers.py | 10 +++++----- daemon/core/api/tlv/enumerations.py | 2 +- daemon/core/emane/commeffect.py | 2 +- daemon/core/emulator/data.py | 2 +- daemon/core/emulator/emudata.py | 2 +- daemon/core/gui/dialogs/linkconfig.py | 8 ++++---- daemon/core/location/mobility.py | 2 +- daemon/core/nodes/base.py | 4 ++-- daemon/core/nodes/network.py | 6 +++--- daemon/core/xml/corexml.py | 4 ++-- daemon/proto/core/api/grpc/core.proto | 2 +- daemon/tests/test_links.py | 28 +++++++++++++-------------- daemon/tests/test_xml.py | 16 +++++++-------- 16 files changed, 61 insertions(+), 60 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 73d19a2a..4acecad9 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -94,7 +94,7 @@ def add_link_data( if options_data: options.delay = options_data.delay options.bandwidth = options_data.bandwidth - options.per = options_data.per + options.loss = options_data.loss options.dup = options_data.dup options.jitter = options_data.jitter options.mer = options_data.mer @@ -343,7 +343,7 @@ def convert_link(link_data: LinkData) -> core_pb2.Link: key=link_data.key, mburst=link_data.mburst, mer=link_data.mer, - per=link_data.per, + loss=link_data.loss, bandwidth=link_data.bandwidth, burst=link_data.burst, delay=link_data.delay, diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index adddff14..9ea4e555 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -885,20 +885,21 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): interface_one_id = request.interface_one_id interface_two_id = request.interface_two_id options_data = request.options - link_options = LinkOptions() - link_options.delay = options_data.delay - link_options.bandwidth = options_data.bandwidth - link_options.per = options_data.per - link_options.dup = options_data.dup - link_options.jitter = options_data.jitter - link_options.mer = options_data.mer - link_options.burst = options_data.burst - link_options.mburst = options_data.mburst - link_options.unidirectional = options_data.unidirectional - link_options.key = options_data.key - link_options.opaque = options_data.opaque + options = LinkOptions( + delay=options_data.delay, + bandwidth=options_data.bandwidth, + loss=options_data.loss, + dup=options_data.dup, + jitter=options_data.jitter, + mer=options_data.mer, + burst=options_data.burst, + mburst=options_data.mburst, + unidirectional=options_data.unidirectional, + key=options_data.key, + opaque=options_data.opaque, + ) session.update_link( - node_one_id, node_two_id, interface_one_id, interface_two_id, link_options + node_one_id, node_two_id, interface_one_id, interface_two_id, options ) return core_pb2.EditLinkResponse(result=True) diff --git a/daemon/core/api/tlv/coreapi.py b/daemon/core/api/tlv/coreapi.py index df60e374..088a7631 100644 --- a/daemon/core/api/tlv/coreapi.py +++ b/daemon/core/api/tlv/coreapi.py @@ -495,7 +495,7 @@ class CoreLinkTlv(CoreTlv): LinkTlvs.N2_NUMBER.value: CoreTlvDataUint32, LinkTlvs.DELAY.value: CoreTlvDataUint64, LinkTlvs.BANDWIDTH.value: CoreTlvDataUint64, - LinkTlvs.PER.value: CoreTlvDataString, + LinkTlvs.LOSS.value: CoreTlvDataString, LinkTlvs.DUP.value: CoreTlvDataString, LinkTlvs.JITTER.value: CoreTlvDataUint64, LinkTlvs.MER.value: CoreTlvDataUint16, diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 3adaed63..d02c274d 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -334,9 +334,9 @@ class CoreHandler(socketserver.BaseRequestHandler): :return: nothing """ logging.debug("handling broadcast link: %s", link_data) - per = "" - if link_data.per is not None: - per = str(link_data.per) + loss = "" + if link_data.loss is not None: + loss = str(link_data.loss) dup = "" if link_data.dup is not None: dup = str(link_data.dup) @@ -348,7 +348,7 @@ class CoreHandler(socketserver.BaseRequestHandler): (LinkTlvs.N2_NUMBER, link_data.node2_id), (LinkTlvs.DELAY, link_data.delay), (LinkTlvs.BANDWIDTH, link_data.bandwidth), - (LinkTlvs.PER, per), + (LinkTlvs.LOSS, loss), (LinkTlvs.DUP, dup), (LinkTlvs.JITTER, link_data.jitter), (LinkTlvs.MER, link_data.mer), @@ -776,7 +776,7 @@ class CoreHandler(socketserver.BaseRequestHandler): link_options.delay = message.get_tlv(LinkTlvs.DELAY.value) link_options.bandwidth = message.get_tlv(LinkTlvs.BANDWIDTH.value) link_options.session = message.get_tlv(LinkTlvs.SESSION.value) - link_options.per = message.get_tlv(LinkTlvs.PER.value) + link_options.loss = message.get_tlv(LinkTlvs.LOSS.value) link_options.dup = message.get_tlv(LinkTlvs.DUP.value) link_options.jitter = message.get_tlv(LinkTlvs.JITTER.value) link_options.mer = message.get_tlv(LinkTlvs.MER.value) diff --git a/daemon/core/api/tlv/enumerations.py b/daemon/core/api/tlv/enumerations.py index ed06bbe7..0efb7c99 100644 --- a/daemon/core/api/tlv/enumerations.py +++ b/daemon/core/api/tlv/enumerations.py @@ -59,7 +59,7 @@ class LinkTlvs(Enum): N2_NUMBER = 0x02 DELAY = 0x03 BANDWIDTH = 0x04 - PER = 0x05 + LOSS = 0x05 DUP = 0x06 JITTER = 0x07 MER = 0x08 diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index 71acb199..21252b6f 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -141,7 +141,7 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): nemid, latency=convert_none(options.delay), jitter=convert_none(options.jitter), - loss=convert_none(options.per), + loss=convert_none(options.loss), duplicate=convert_none(options.dup), unicast=int(convert_none(options.bandwidth)), broadcast=int(convert_none(options.bandwidth)), diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index d3283974..819716e3 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -101,7 +101,7 @@ class LinkData: node2_id: int = None delay: float = None bandwidth: float = None - per: float = None + loss: float = None dup: float = None jitter: float = None mer: float = None diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index b6dbd57c..992b9cd2 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -68,7 +68,7 @@ class LinkOptions: session: int = None delay: int = None bandwidth: int = None - per: float = None + loss: float = None dup: int = None jitter: int = None mer: int = None diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index 92361ed4..c553bb94 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -223,7 +223,7 @@ class LinkConfigurationDialog(Dialog): duplicate = get_int(self.duplicate) loss = get_float(self.loss) options = core_pb2.LinkOptions( - bandwidth=bandwidth, jitter=jitter, delay=delay, dup=duplicate, per=loss + bandwidth=bandwidth, jitter=jitter, delay=delay, dup=duplicate, loss=loss ) link.options.CopyFrom(options) @@ -252,7 +252,7 @@ class LinkConfigurationDialog(Dialog): jitter=down_jitter, delay=down_delay, dup=down_duplicate, - per=down_loss, + loss=down_loss, unidirectional=True, ) self.edge.asymmetric_link = core_pb2.Link( @@ -317,12 +317,12 @@ class LinkConfigurationDialog(Dialog): self.bandwidth.set(str(link.options.bandwidth)) self.jitter.set(str(link.options.jitter)) self.duplicate.set(str(link.options.dup)) - self.loss.set(str(link.options.per)) + self.loss.set(str(link.options.loss)) self.delay.set(str(link.options.delay)) if not self.is_symmetric: asym_link = self.edge.asymmetric_link self.down_bandwidth.set(str(asym_link.options.bandwidth)) self.down_jitter.set(str(asym_link.options.jitter)) self.down_duplicate.set(str(asym_link.options.dup)) - self.down_loss.set(str(asym_link.options.per)) + self.down_loss.set(str(asym_link.options.loss)) self.down_delay.set(str(asym_link.options.delay)) diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index e9efa16b..43996ba3 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -338,7 +338,7 @@ class BasicRangeModel(WirelessModel): options = LinkOptions( bandwidth=self.bw, delay=self.delay, - per=self.loss, + loss=self.loss, jitter=self.jitter, ) self.wlan.linkconfig(netif, options) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 4b8d513b..2b3c7751 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -1134,7 +1134,7 @@ class CoreNetworkBase(NodeBase): bandwidth=netif.getparam("bw"), dup=netif.getparam("duplicate"), jitter=netif.getparam("jitter"), - per=netif.getparam("loss"), + loss=netif.getparam("loss"), ) all_links.append(link_data) @@ -1153,7 +1153,7 @@ class CoreNetworkBase(NodeBase): bandwidth=netif.getparam("bw"), dup=netif.getparam("duplicate"), jitter=netif.getparam("jitter"), - per=netif.getparam("loss"), + loss=netif.getparam("loss"), ) netif.swapparams("_params_up") diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 235b43f2..8ac1939e 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -482,7 +482,7 @@ class CoreNetwork(CoreNetworkBase): netem = "netem" delay = options.delay changed = max(changed, netif.setparam("delay", delay)) - loss = options.per + loss = options.loss if loss is not None: loss = float(loss) changed = max(changed, netif.setparam("loss", loss)) @@ -939,7 +939,7 @@ class PtpNet(CoreNetwork): unidirectional=unidirectional, delay=if1.getparam("delay"), bandwidth=if1.getparam("bw"), - per=if1.getparam("loss"), + loss=if1.getparam("loss"), dup=if1.getparam("duplicate"), jitter=if1.getparam("jitter"), interface1_id=if1.node.getifindex(if1), @@ -970,7 +970,7 @@ class PtpNet(CoreNetwork): node2_id=if1.node.id, delay=if2.getparam("delay"), bandwidth=if2.getparam("bw"), - per=if2.getparam("loss"), + loss=if2.getparam("loss"), dup=if2.getparam("duplicate"), jitter=if2.getparam("jitter"), unidirectional=1, diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 973eb77f..820f1cea 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -569,7 +569,7 @@ class CoreXmlWriter: options = etree.Element("options") add_attribute(options, "delay", link_data.delay) add_attribute(options, "bandwidth", link_data.bandwidth) - add_attribute(options, "per", link_data.per) + add_attribute(options, "per", link_data.loss) add_attribute(options, "dup", link_data.dup) add_attribute(options, "jitter", link_data.jitter) add_attribute(options, "mer", link_data.mer) @@ -957,7 +957,7 @@ class CoreXmlReader: link_options.mburst = get_int(options_element, "mburst") link_options.jitter = get_int(options_element, "jitter") link_options.key = get_int(options_element, "key") - link_options.per = get_float(options_element, "per") + link_options.loss = get_float(options_element, "per") link_options.unidirectional = get_int(options_element, "unidirectional") link_options.session = options_element.get("session") link_options.emulation_id = get_int(options_element, "emulation_id") diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index d602f9d3..c9c2d94b 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -719,7 +719,7 @@ message LinkOptions { int32 key = 3; int32 mburst = 4; int32 mer = 5; - float per = 6; + float loss = 6; int64 bandwidth = 7; int32 burst = 8; int64 delay = 9; diff --git a/daemon/tests/test_links.py b/daemon/tests/test_links.py index 9f693da1..9c4fd4f2 100644 --- a/daemon/tests/test_links.py +++ b/daemon/tests/test_links.py @@ -80,7 +80,7 @@ class TestLinks: # given delay = 50 bandwidth = 5000000 - per = 25 + loss = 25 dup = 25 jitter = 10 node_one = session.add_node(CoreNode) @@ -90,13 +90,13 @@ class TestLinks: interface_one = node_one.netif(interface_one_data.id) assert interface_one.getparam("delay") != delay assert interface_one.getparam("bw") != bandwidth - assert interface_one.getparam("loss") != per + assert interface_one.getparam("loss") != loss assert interface_one.getparam("duplicate") != dup assert interface_one.getparam("jitter") != jitter # when link_options = LinkOptions( - delay=delay, bandwidth=bandwidth, per=per, dup=dup, jitter=jitter + delay=delay, bandwidth=bandwidth, loss=loss, dup=dup, jitter=jitter ) session.update_link( node_one.id, @@ -108,7 +108,7 @@ class TestLinks: # then assert interface_one.getparam("delay") == delay assert interface_one.getparam("bw") == bandwidth - assert interface_one.getparam("loss") == per + assert interface_one.getparam("loss") == loss assert interface_one.getparam("duplicate") == dup assert interface_one.getparam("jitter") == jitter @@ -116,7 +116,7 @@ class TestLinks: # given delay = 50 bandwidth = 5000000 - per = 25 + loss = 25 dup = 25 jitter = 10 node_one = session.add_node(SwitchNode) @@ -126,13 +126,13 @@ class TestLinks: interface_two = node_two.netif(interface_two_data.id) assert interface_two.getparam("delay") != delay assert interface_two.getparam("bw") != bandwidth - assert interface_two.getparam("loss") != per + assert interface_two.getparam("loss") != loss assert interface_two.getparam("duplicate") != dup assert interface_two.getparam("jitter") != jitter # when link_options = LinkOptions( - delay=delay, bandwidth=bandwidth, per=per, dup=dup, jitter=jitter + delay=delay, bandwidth=bandwidth, loss=loss, dup=dup, jitter=jitter ) session.update_link( node_one.id, @@ -144,7 +144,7 @@ class TestLinks: # then assert interface_two.getparam("delay") == delay assert interface_two.getparam("bw") == bandwidth - assert interface_two.getparam("loss") == per + assert interface_two.getparam("loss") == loss assert interface_two.getparam("duplicate") == dup assert interface_two.getparam("jitter") == jitter @@ -152,7 +152,7 @@ class TestLinks: # given delay = 50 bandwidth = 5000000 - per = 25 + loss = 25 dup = 25 jitter = 10 node_one = session.add_node(CoreNode) @@ -166,18 +166,18 @@ class TestLinks: interface_two = node_two.netif(interface_two_data.id) assert interface_one.getparam("delay") != delay assert interface_one.getparam("bw") != bandwidth - assert interface_one.getparam("loss") != per + assert interface_one.getparam("loss") != loss assert interface_one.getparam("duplicate") != dup assert interface_one.getparam("jitter") != jitter assert interface_two.getparam("delay") != delay assert interface_two.getparam("bw") != bandwidth - assert interface_two.getparam("loss") != per + assert interface_two.getparam("loss") != loss assert interface_two.getparam("duplicate") != dup assert interface_two.getparam("jitter") != jitter # when link_options = LinkOptions( - delay=delay, bandwidth=bandwidth, per=per, dup=dup, jitter=jitter + delay=delay, bandwidth=bandwidth, loss=loss, dup=dup, jitter=jitter ) session.update_link( node_one.id, @@ -190,12 +190,12 @@ class TestLinks: # then assert interface_one.getparam("delay") == delay assert interface_one.getparam("bw") == bandwidth - assert interface_one.getparam("loss") == per + assert interface_one.getparam("loss") == loss assert interface_one.getparam("duplicate") == dup assert interface_one.getparam("jitter") == jitter assert interface_two.getparam("delay") == delay assert interface_two.getparam("bw") == bandwidth - assert interface_two.getparam("loss") == per + assert interface_two.getparam("loss") == loss assert interface_two.getparam("duplicate") == dup assert interface_two.getparam("jitter") == jitter diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index c40a9ef3..0345daed 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -304,7 +304,7 @@ class TestXml: # create link link_options = LinkOptions() - link_options.per = 10.5 + link_options.loss = 10.5 link_options.bandwidth = 50000 link_options.jitter = 10 link_options.delay = 30 @@ -347,7 +347,7 @@ class TestXml: node = session.nodes[node_id] links += node.all_link_data() link = links[0] - assert link_options.per == link.per + assert link_options.loss == link.loss assert link_options.bandwidth == link.bandwidth assert link_options.jitter == link.jitter assert link_options.delay == link.delay @@ -371,7 +371,7 @@ class TestXml: # create link link_options = LinkOptions() - link_options.per = 10.5 + link_options.loss = 10.5 link_options.bandwidth = 50000 link_options.jitter = 10 link_options.delay = 30 @@ -416,7 +416,7 @@ class TestXml: node = session.nodes[node_id] links += node.all_link_data() link = links[0] - assert link_options.per == link.per + assert link_options.loss == link.loss assert link_options.bandwidth == link.bandwidth assert link_options.jitter == link.jitter assert link_options.delay == link.delay @@ -443,7 +443,7 @@ class TestXml: link_options_one.unidirectional = 1 link_options_one.bandwidth = 5000 link_options_one.delay = 10 - link_options_one.per = 10.5 + link_options_one.loss = 10.5 link_options_one.dup = 5 link_options_one.jitter = 5 session.add_link( @@ -453,7 +453,7 @@ class TestXml: link_options_two.unidirectional = 1 link_options_two.bandwidth = 10000 link_options_two.delay = 20 - link_options_two.per = 10 + link_options_two.loss = 10 link_options_two.dup = 10 link_options_two.jitter = 10 session.update_link( @@ -504,11 +504,11 @@ class TestXml: link_two = links[1] assert link_options_one.bandwidth == link_one.bandwidth assert link_options_one.delay == link_one.delay - assert link_options_one.per == link_one.per + assert link_options_one.loss == link_one.loss assert link_options_one.dup == link_one.dup assert link_options_one.jitter == link_one.jitter assert link_options_two.bandwidth == link_two.bandwidth assert link_options_two.delay == link_two.delay - assert link_options_two.per == link_two.per + assert link_options_two.loss == link_two.loss assert link_options_two.dup == link_two.dup assert link_options_two.jitter == link_two.jitter From 876699e8efeb03daee471b0662d7d3eba8183917 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 12 Jun 2020 16:52:41 -0700 Subject: [PATCH 142/929] variable/grpc cleanup to rename everything using spelt out numbers instead of actual numbers --- daemon/core/api/grpc/client.py | 94 +++---- daemon/core/api/grpc/grpcutils.py | 38 +-- daemon/core/api/grpc/server.py | 116 ++++----- daemon/core/api/tlv/corehandlers.py | 54 ++-- daemon/core/emane/linkmonitor.py | 20 +- daemon/core/emulator/distributed.py | 12 +- daemon/core/emulator/session.py | 154 +++++------ daemon/core/gui/coreclient.py | 58 ++--- daemon/core/gui/dialogs/linkconfig.py | 48 ++-- daemon/core/gui/graph/edges.py | 14 +- daemon/core/gui/graph/graph.py | 78 +++--- daemon/core/gui/interface.py | 24 +- daemon/core/plugins/sdt.py | 56 ++-- daemon/core/xml/corexml.py | 56 ++-- daemon/examples/configservices/testing.py | 14 +- daemon/examples/docker/docker2core.py | 10 +- daemon/examples/docker/docker2docker.py | 10 +- daemon/examples/docker/switch.py | 12 +- daemon/examples/grpc/distributed_switch.py | 12 +- daemon/examples/grpc/emane80211.py | 8 +- daemon/examples/grpc/switch.py | 8 +- daemon/examples/grpc/wlan.py | 8 +- daemon/examples/lxd/lxd2core.py | 10 +- daemon/examples/lxd/lxd2lxd.py | 10 +- daemon/examples/lxd/switch.py | 18 +- daemon/examples/python/distributed_emane.py | 12 +- daemon/examples/python/distributed_lxd.py | 10 +- daemon/examples/python/distributed_ptp.py | 10 +- daemon/examples/python/distributed_switch.py | 12 +- daemon/examples/python/emane80211.py | 2 +- daemon/examples/python/switch.py | 2 +- daemon/examples/python/switch_inject.py | 2 +- daemon/examples/python/wlan.py | 2 +- daemon/proto/core/api/grpc/core.proto | 28 +- daemon/proto/core/api/grpc/emane.proto | 16 +- daemon/proto/core/api/grpc/wlan.proto | 4 +- daemon/tests/emane/test_emane.py | 37 +-- daemon/tests/test_conf.py | 16 +- daemon/tests/test_core.py | 64 ++--- daemon/tests/test_grpc.py | 54 ++-- daemon/tests/test_gui.py | 159 ++++++------ daemon/tests/test_links.py | 246 +++++++++--------- daemon/tests/test_services.py | 20 +- daemon/tests/test_xml.py | 258 +++++++++---------- docs/scripting.md | 2 +- 45 files changed, 932 insertions(+), 966 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 1bc88069..3a16d4fd 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -609,30 +609,30 @@ class CoreGrpcClient: def add_link( self, session_id: int, - node_one_id: int, - node_two_id: int, - interface_one: core_pb2.Interface = None, - interface_two: core_pb2.Interface = None, + node1_id: int, + node2_id: int, + interface1: core_pb2.Interface = None, + interface2: core_pb2.Interface = None, options: core_pb2.LinkOptions = None, ) -> core_pb2.AddLinkResponse: """ Add a link between nodes. :param session_id: session id - :param node_one_id: node one id - :param node_two_id: node two id - :param interface_one: node one interface data - :param interface_two: node two interface data + :param node1_id: node one id + :param node2_id: node two id + :param interface1: node one interface data + :param interface2: node two interface data :param options: options for link (jitter, bandwidth, etc) :return: response with result of success or failure :raises grpc.RpcError: when session or one of the nodes don't exist """ link = core_pb2.Link( - node_one_id=node_one_id, - node_two_id=node_two_id, + node1_id=node1_id, + node2_id=node2_id, type=core_pb2.LinkType.WIRED, - interface_one=interface_one, - interface_two=interface_two, + interface1=interface1, + interface2=interface2, options=options, ) request = core_pb2.AddLinkRequest(session_id=session_id, link=link) @@ -641,59 +641,59 @@ class CoreGrpcClient: def edit_link( self, session_id: int, - node_one_id: int, - node_two_id: int, + node1_id: int, + node2_id: int, options: core_pb2.LinkOptions, - interface_one_id: int = None, - interface_two_id: int = None, + interface1_id: int = None, + interface2_id: int = None, ) -> core_pb2.EditLinkResponse: """ Edit a link between nodes. :param session_id: session id - :param node_one_id: node one id - :param node_two_id: node two id + :param node1_id: node one id + :param node2_id: node two id :param options: options for link (jitter, bandwidth, etc) - :param interface_one_id: node one interface id - :param interface_two_id: node two interface id + :param interface1_id: node one interface id + :param interface2_id: node two interface id :return: response with result of success or failure :raises grpc.RpcError: when session or one of the nodes don't exist """ request = core_pb2.EditLinkRequest( session_id=session_id, - node_one_id=node_one_id, - node_two_id=node_two_id, + node1_id=node1_id, + node2_id=node2_id, options=options, - interface_one_id=interface_one_id, - interface_two_id=interface_two_id, + interface1_id=interface1_id, + interface2_id=interface2_id, ) return self.stub.EditLink(request) def delete_link( self, session_id: int, - node_one_id: int, - node_two_id: int, - interface_one_id: int = None, - interface_two_id: int = None, + node1_id: int, + node2_id: int, + interface1_id: int = None, + interface2_id: int = None, ) -> core_pb2.DeleteLinkResponse: """ Delete a link between nodes. :param session_id: session id - :param node_one_id: node one id - :param node_two_id: node two id - :param interface_one_id: node one interface id - :param interface_two_id: node two interface id + :param node1_id: node one id + :param node2_id: node two id + :param interface1_id: node one interface id + :param interface2_id: node two interface id :return: response with result of success or failure :raises grpc.RpcError: when session doesn't exist """ request = core_pb2.DeleteLinkRequest( session_id=session_id, - node_one_id=node_one_id, - node_two_id=node_two_id, - interface_one_id=interface_one_id, - interface_two_id=interface_two_id, + node1_id=node1_id, + node2_id=node2_id, + interface1_id=interface1_id, + interface2_id=interface2_id, ) return self.stub.DeleteLink(request) @@ -1111,20 +1111,20 @@ class CoreGrpcClient: return self.stub.OpenXml(request) def emane_link( - self, session_id: int, nem_one: int, nem_two: int, linked: bool + self, session_id: int, nem1: int, nem2: int, linked: bool ) -> EmaneLinkResponse: """ Helps broadcast wireless link/unlink between EMANE nodes. :param session_id: session to emane link - :param nem_one: first nem for emane link - :param nem_two: second nem for emane link + :param nem1: first nem for emane link + :param nem2: second nem for emane link :param linked: True to link, False to unlink :return: get emane link response :raises grpc.RpcError: when session or nodes related to nems do not exist """ request = EmaneLinkRequest( - session_id=session_id, nem_one=nem_one, nem_two=nem_two, linked=linked + session_id=session_id, nem1=nem1, nem2=nem2, linked=linked ) return self.stub.EmaneLink(request) @@ -1243,24 +1243,24 @@ class CoreGrpcClient: return self.stub.ExecuteScript(request) def wlan_link( - self, session_id: int, wlan: int, node_one: int, node_two: int, linked: bool + self, session_id: int, wlan_id: int, node1_id: int, node2_id: int, linked: bool ) -> WlanLinkResponse: """ Links/unlinks nodes on the same WLAN. :param session_id: session id containing wlan and nodes - :param wlan: wlan nodes must belong to - :param node_one: first node of pair to link/unlink - :param node_two: second node of pair to link/unlin + :param wlan_id: wlan nodes must belong to + :param node1_id: first node of pair to link/unlink + :param node2_id: second node of pair to link/unlin :param linked: True to link, False to unlink :return: wlan link response :raises grpc.RpcError: when session or one of the nodes do not exist """ request = WlanLinkRequest( session_id=session_id, - wlan=wlan, - node_one=node_one, - node_two=node_two, + wlan=wlan_id, + node1_id=node1_id, + node2_id=node2_id, linked=linked, ) return self.stub.WlanLink(request) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 73d19a2a..539face1 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -86,8 +86,8 @@ def add_link_data( :param link_proto: link proto :return: link interfaces and options """ - interface_one = link_interface(link_proto.interface_one) - interface_two = link_interface(link_proto.interface_two) + interface1_data = link_interface(link_proto.interface1) + interface2_data = link_interface(link_proto.interface2) link_type = LinkTypes(link_proto.type) options = LinkOptions(type=link_type) options_data = link_proto.options @@ -103,7 +103,7 @@ def add_link_data( options.unidirectional = options_data.unidirectional options.key = options_data.key options.opaque = options_data.opaque - return interface_one, interface_two, options + return interface1_data, interface2_data, options def create_nodes( @@ -141,10 +141,10 @@ def create_links( """ funcs = [] for link_proto in link_protos: - node_one_id = link_proto.node_one_id - node_two_id = link_proto.node_two_id - interface_one, interface_two, options = add_link_data(link_proto) - args = (node_one_id, node_two_id, interface_one, interface_two, options) + node1_id = link_proto.node1_id + node2_id = link_proto.node2_id + interface1, interface2, options = add_link_data(link_proto) + args = (node1_id, node2_id, interface1, interface2, options) funcs.append((session.add_link, args, {})) start = time.monotonic() results, exceptions = utils.threadpool(funcs) @@ -165,10 +165,10 @@ def edit_links( """ funcs = [] for link_proto in link_protos: - node_one_id = link_proto.node_one_id - node_two_id = link_proto.node_two_id - interface_one, interface_two, options = add_link_data(link_proto) - args = (node_one_id, node_two_id, interface_one.id, interface_two.id, options) + node1_id = link_proto.node1_id + node2_id = link_proto.node2_id + interface1, interface2, options = add_link_data(link_proto) + args = (node1_id, node2_id, interface1.id, interface2.id, options) funcs.append((session.update_link, args, {})) start = time.monotonic() results, exceptions = utils.threadpool(funcs) @@ -315,9 +315,9 @@ def convert_link(link_data: LinkData) -> core_pb2.Link: :param link_data: link to convert :return: core protobuf Link """ - interface_one = None + interface1 = None if link_data.interface1_id is not None: - interface_one = core_pb2.Interface( + interface1 = core_pb2.Interface( id=link_data.interface1_id, name=link_data.interface1_name, mac=convert_value(link_data.interface1_mac), @@ -326,9 +326,9 @@ def convert_link(link_data: LinkData) -> core_pb2.Link: ip6=convert_value(link_data.interface1_ip6), ip6mask=link_data.interface1_ip6_mask, ) - interface_two = None + interface2 = None if link_data.interface2_id is not None: - interface_two = core_pb2.Interface( + interface2 = core_pb2.Interface( id=link_data.interface2_id, name=link_data.interface2_name, mac=convert_value(link_data.interface2_mac), @@ -352,10 +352,10 @@ def convert_link(link_data: LinkData) -> core_pb2.Link: ) return core_pb2.Link( type=link_data.link_type.value, - node_one_id=link_data.node1_id, - node_two_id=link_data.node2_id, - interface_one=interface_one, - interface_two=interface_two, + node1_id=link_data.node1_id, + node2_id=link_data.node2_id, + interface1=interface1, + interface2=interface2, options=options, network_id=link_data.network_id, label=link_data.label, diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index adddff14..a0ddf806 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -845,27 +845,23 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): :return: add-link response """ logging.debug("add link: %s", request) - # validate session and nodes session = self.get_session(request.session_id, context) - self.get_node(session, request.link.node_one_id, context, NodeBase) - self.get_node(session, request.link.node_two_id, context, NodeBase) - - node_one_id = request.link.node_one_id - node_two_id = request.link.node_two_id - interface_one, interface_two, options = grpcutils.add_link_data(request.link) - node_one_interface, node_two_interface = session.add_link( - node_one_id, node_two_id, interface_one, interface_two, options=options + node1_id = request.link.node1_id + node2_id = request.link.node2_id + self.get_node(session, node1_id, context, NodeBase) + self.get_node(session, node2_id, context, NodeBase) + interface1, interface2, options = grpcutils.add_link_data(request.link) + node1_interface, node2_interface = session.add_link( + node1_id, node2_id, interface1, interface2, options=options ) - interface_one_proto = None - interface_two_proto = None - if node_one_interface: - interface_one_proto = grpcutils.interface_to_proto(node_one_interface) - if node_two_interface: - interface_two_proto = grpcutils.interface_to_proto(node_two_interface) + interface1_proto = None + interface2_proto = None + if node1_interface: + interface1_proto = grpcutils.interface_to_proto(node1_interface) + if node2_interface: + interface2_proto = grpcutils.interface_to_proto(node2_interface) return core_pb2.AddLinkResponse( - result=True, - interface_one=interface_one_proto, - interface_two=interface_two_proto, + result=True, interface1=interface1_proto, interface2=interface2_proto ) def EditLink( @@ -880,10 +876,10 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("edit link: %s", request) session = self.get_session(request.session_id, context) - node_one_id = request.node_one_id - node_two_id = request.node_two_id - interface_one_id = request.interface_one_id - interface_two_id = request.interface_two_id + node1_id = request.node1_id + node2_id = request.node2_id + interface1_id = request.interface1_id + interface2_id = request.interface2_id options_data = request.options link_options = LinkOptions() link_options.delay = options_data.delay @@ -898,7 +894,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): link_options.key = options_data.key link_options.opaque = options_data.opaque session.update_link( - node_one_id, node_two_id, interface_one_id, interface_two_id, link_options + node1_id, node2_id, interface1_id, interface2_id, link_options ) return core_pb2.EditLinkResponse(result=True) @@ -914,13 +910,11 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("delete link: %s", request) session = self.get_session(request.session_id, context) - node_one_id = request.node_one_id - node_two_id = request.node_two_id - interface_one_id = request.interface_one_id - interface_two_id = request.interface_two_id - session.delete_link( - node_one_id, node_two_id, interface_one_id, interface_two_id - ) + node1_id = request.node1_id + node2_id = request.node2_id + interface1_id = request.interface1_id + interface2_id = request.interface2_id + session.delete_link(node1_id, node2_id, interface1_id, interface2_id) return core_pb2.DeleteLinkResponse(result=True) def GetHooks( @@ -1519,30 +1513,30 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("emane link: %s", request) session = self.get_session(request.session_id, context) - nem_one = request.nem_one - emane_one, netif = session.emane.nemlookup(nem_one) - if not emane_one or not netif: - context.abort(grpc.StatusCode.NOT_FOUND, f"nem one {nem_one} not found") - node_one = netif.node + nem1 = request.nem1 + emane1, netif = session.emane.nemlookup(nem1) + if not emane1 or not netif: + context.abort(grpc.StatusCode.NOT_FOUND, f"nem one {nem1} not found") + node1 = netif.node - nem_two = request.nem_two - emane_two, netif = session.emane.nemlookup(nem_two) - if not emane_two or not netif: - context.abort(grpc.StatusCode.NOT_FOUND, f"nem two {nem_two} not found") - node_two = netif.node + nem2 = request.nem2 + emane2, netif = session.emane.nemlookup(nem2) + if not emane2 or not netif: + context.abort(grpc.StatusCode.NOT_FOUND, f"nem two {nem2} not found") + node2 = netif.node - if emane_one.id == emane_two.id: + if emane1.id == emane2.id: if request.linked: flag = MessageFlags.ADD else: flag = MessageFlags.DELETE - color = session.get_link_color(emane_one.id) + color = session.get_link_color(emane1.id) link = LinkData( message_type=flag, link_type=LinkTypes.WIRELESS, - node1_id=node_one.id, - node2_id=node_two.id, - network_id=emane_one.id, + node1_id=node1.id, + node2_id=node2.id, + network_id=emane1.id, color=color, ) session.broadcast_link(link) @@ -1739,21 +1733,23 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): grpc.StatusCode.NOT_FOUND, f"wlan node {request.wlan} does not using BasicRangeModel", ) - n1 = self.get_node(session, request.node_one, context, CoreNode) - n2 = self.get_node(session, request.node_two, context, CoreNode) - n1_netif, n2_netif = None, None - for net, netif1, netif2 in n1.commonnets(n2): + node1 = self.get_node(session, request.node1_id, context, CoreNode) + node2 = self.get_node(session, request.node2_id, context, CoreNode) + node1_interface, node2_interface = None, None + for net, interface1, interface2 in node1.commonnets(node2): if net == wlan: - n1_netif = netif1 - n2_netif = netif2 + node1_interface = interface1 + node2_interface = interface2 break result = False - if n1_netif and n2_netif: + if node1_interface and node2_interface: if request.linked: - wlan.link(n1_netif, n2_netif) + wlan.link(node1_interface, node2_interface) else: - wlan.unlink(n1_netif, n2_netif) - wlan.model.sendlinkmsg(n1_netif, n2_netif, unlink=not request.linked) + wlan.unlink(node1_interface, node2_interface) + wlan.model.sendlinkmsg( + node1_interface, node2_interface, unlink=not request.linked + ) result = True return WlanLinkResponse(result=result) @@ -1764,9 +1760,9 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): ) -> EmanePathlossesResponse: for request in request_iterator: session = self.get_session(request.session_id, context) - n1 = self.get_node(session, request.node_one, context, CoreNode) - nem1 = grpcutils.get_nem_id(n1, request.interface_one_id, context) - n2 = self.get_node(session, request.node_two, context, CoreNode) - nem2 = grpcutils.get_nem_id(n2, request.interface_two_id, context) - session.emane.publish_pathloss(nem1, nem2, request.rx_one, request.rx_two) + node1 = self.get_node(session, request.node1_id, context, CoreNode) + nem1 = grpcutils.get_nem_id(node1, request.interface1_id, context) + node2 = self.get_node(session, request.node2_id, context, CoreNode) + nem2 = grpcutils.get_nem_id(node2, request.interface2_id, context) + session.emane.publish_pathloss(nem1, nem2, request.rx1, request.rx2) return EmanePathlossesResponse() diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 3adaed63..e7a67b3e 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -745,10 +745,9 @@ class CoreHandler(socketserver.BaseRequestHandler): :param core.api.tlv.coreapi.CoreLinkMessage message: link message to handle :return: link message replies """ - node_one_id = message.get_tlv(LinkTlvs.N1_NUMBER.value) - node_two_id = message.get_tlv(LinkTlvs.N2_NUMBER.value) - - interface_one = InterfaceData( + node1_id = message.get_tlv(LinkTlvs.N1_NUMBER.value) + node2_id = message.get_tlv(LinkTlvs.N2_NUMBER.value) + interface1_data = InterfaceData( id=message.get_tlv(LinkTlvs.INTERFACE1_NUMBER.value), name=message.get_tlv(LinkTlvs.INTERFACE1_NAME.value), mac=message.get_tlv(LinkTlvs.INTERFACE1_MAC.value), @@ -757,7 +756,7 @@ class CoreHandler(socketserver.BaseRequestHandler): ip6=message.get_tlv(LinkTlvs.INTERFACE1_IP6.value), ip6_mask=message.get_tlv(LinkTlvs.INTERFACE1_IP6_MASK.value), ) - interface_two = InterfaceData( + interface2_data = InterfaceData( id=message.get_tlv(LinkTlvs.INTERFACE2_NUMBER.value), name=message.get_tlv(LinkTlvs.INTERFACE2_NAME.value), mac=message.get_tlv(LinkTlvs.INTERFACE2_MAC.value), @@ -766,45 +765,38 @@ class CoreHandler(socketserver.BaseRequestHandler): ip6=message.get_tlv(LinkTlvs.INTERFACE2_IP6.value), ip6_mask=message.get_tlv(LinkTlvs.INTERFACE2_IP6_MASK.value), ) - link_type = LinkTypes.WIRED link_type_value = message.get_tlv(LinkTlvs.TYPE.value) if link_type_value is not None: link_type = LinkTypes(link_type_value) - - link_options = LinkOptions(type=link_type) - link_options.delay = message.get_tlv(LinkTlvs.DELAY.value) - link_options.bandwidth = message.get_tlv(LinkTlvs.BANDWIDTH.value) - link_options.session = message.get_tlv(LinkTlvs.SESSION.value) - link_options.per = message.get_tlv(LinkTlvs.PER.value) - link_options.dup = message.get_tlv(LinkTlvs.DUP.value) - link_options.jitter = message.get_tlv(LinkTlvs.JITTER.value) - link_options.mer = message.get_tlv(LinkTlvs.MER.value) - link_options.burst = message.get_tlv(LinkTlvs.BURST.value) - link_options.mburst = message.get_tlv(LinkTlvs.MBURST.value) - link_options.gui_attributes = message.get_tlv(LinkTlvs.GUI_ATTRIBUTES.value) - link_options.unidirectional = message.get_tlv(LinkTlvs.UNIDIRECTIONAL.value) - link_options.emulation_id = message.get_tlv(LinkTlvs.EMULATION_ID.value) - link_options.network_id = message.get_tlv(LinkTlvs.NETWORK_ID.value) - link_options.key = message.get_tlv(LinkTlvs.KEY.value) - link_options.opaque = message.get_tlv(LinkTlvs.OPAQUE.value) + options = LinkOptions(type=link_type) + options.delay = message.get_tlv(LinkTlvs.DELAY.value) + options.bandwidth = message.get_tlv(LinkTlvs.BANDWIDTH.value) + options.session = message.get_tlv(LinkTlvs.SESSION.value) + options.per = message.get_tlv(LinkTlvs.PER.value) + options.dup = message.get_tlv(LinkTlvs.DUP.value) + options.jitter = message.get_tlv(LinkTlvs.JITTER.value) + options.mer = message.get_tlv(LinkTlvs.MER.value) + options.burst = message.get_tlv(LinkTlvs.BURST.value) + options.mburst = message.get_tlv(LinkTlvs.MBURST.value) + options.gui_attributes = message.get_tlv(LinkTlvs.GUI_ATTRIBUTES.value) + options.unidirectional = message.get_tlv(LinkTlvs.UNIDIRECTIONAL.value) + options.emulation_id = message.get_tlv(LinkTlvs.EMULATION_ID.value) + options.network_id = message.get_tlv(LinkTlvs.NETWORK_ID.value) + options.key = message.get_tlv(LinkTlvs.KEY.value) + options.opaque = message.get_tlv(LinkTlvs.OPAQUE.value) if message.flags & MessageFlags.ADD.value: self.session.add_link( - node_one_id, node_two_id, interface_one, interface_two, link_options + node1_id, node2_id, interface1_data, interface2_data, options ) elif message.flags & MessageFlags.DELETE.value: self.session.delete_link( - node_one_id, node_two_id, interface_one.id, interface_two.id + node1_id, node2_id, interface1_data.id, interface2_data.id ) else: self.session.update_link( - node_one_id, - node_two_id, - interface_one.id, - interface_two.id, - link_options, + node1_id, node2_id, interface1_data.id, interface2_data.id, options ) - return () def handle_execute_message(self, message): diff --git a/daemon/core/emane/linkmonitor.py b/daemon/core/emane/linkmonitor.py index b9fd9a2a..ca9f4493 100644 --- a/daemon/core/emane/linkmonitor.py +++ b/daemon/core/emane/linkmonitor.py @@ -269,11 +269,11 @@ class EmaneLinkMonitor: self.scheduler.enter(self.link_interval, 0, self.check_links) def get_complete_id(self, link_id: Tuple[int, int]) -> Tuple[int, int]: - value_one, value_two = link_id - if value_one < value_two: - return value_one, value_two + value1, value2 = link_id + if value1 < value2: + return value1, value2 else: - return value_two, value_one + return value2, value1 def is_complete_link(self, link_id: Tuple[int, int]) -> bool: reverse_id = link_id[1], link_id[0] @@ -287,8 +287,8 @@ class EmaneLinkMonitor: return f"{source_link.sinr:.1f} / {dest_link.sinr:.1f}" def send_link(self, message_type: MessageFlags, link_id: Tuple[int, int]) -> None: - nem_one, nem_two = link_id - link = self.emane_manager.get_nem_link(nem_one, nem_two, message_type) + nem1, nem2 = link_id + link = self.emane_manager.get_nem_link(nem1, nem2, message_type) if link: label = self.get_link_label(link_id) link.label = label @@ -298,16 +298,16 @@ class EmaneLinkMonitor: self, message_type: MessageFlags, label: str, - node_one: int, - node_two: int, + node1: int, + node2: int, emane_id: int, ) -> None: color = self.emane_manager.session.get_link_color(emane_id) link_data = LinkData( message_type=message_type, label=label, - node1_id=node_one, - node2_id=node_two, + node1_id=node1, + node2_id=node2, network_id=emane_id, link_type=LinkTypes.WIRELESS, color=color, diff --git a/daemon/core/emulator/distributed.py b/daemon/core/emulator/distributed.py index 3753e1c2..75081447 100644 --- a/daemon/core/emulator/distributed.py +++ b/daemon/core/emulator/distributed.py @@ -224,18 +224,20 @@ class DistributedController: self.tunnels[key] = tunnel return tunnel - def tunnel_key(self, n1_id: int, n2_id: int) -> int: + def tunnel_key(self, node1_id: int, node2_id: int) -> int: """ Compute a 32-bit key used to uniquely identify a GRE tunnel. The hash(n1num), hash(n2num) values are used, so node numbers may be None or string values (used for e.g. "ctrlnet"). - :param n1_id: node one id - :param n2_id: node two id + :param node1_id: node one id + :param node2_id: node two id :return: tunnel key for the node pair """ - logging.debug("creating tunnel key for: %s, %s", n1_id, n2_id) + logging.debug("creating tunnel key for: %s, %s", node1_id, node2_id) key = ( - (self.session.id << 16) ^ utils.hashkey(n1_id) ^ (utils.hashkey(n2_id) << 8) + (self.session.id << 16) + ^ utils.hashkey(node1_id) + ^ (utils.hashkey(node2_id) << 8) ) return key & 0xFFFFFFFF diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 854d5cc8..0a90b943 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -194,13 +194,13 @@ class Session: return node_type def _link_wireless( - self, node_one: CoreNodeBase, node_two: CoreNodeBase, connect: bool + self, node1: CoreNodeBase, node2: CoreNodeBase, connect: bool ) -> None: """ Objects to deal with when connecting/disconnecting wireless links. - :param node_one: node one for wireless link - :param node_two: node two for wireless link + :param node1: node one for wireless link + :param node2: node two for wireless link :param connect: link interfaces if True, unlink otherwise :return: nothing :raises core.CoreError: when objects to link is less than 2, or no common @@ -208,14 +208,14 @@ class Session: """ logging.info( "handling wireless linking node1(%s) node2(%s): %s", - node_one.name, - node_two.name, + node1.name, + node2.name, connect, ) - common_networks = node_one.commonnets(node_one) + common_networks = node1.commonnets(node1) if not common_networks: raise CoreError("no common network found for wireless link/unlink") - for common_network, interface_one, interface_two in common_networks: + for common_network, interface1, interface2 in common_networks: if not isinstance(common_network, (WlanNode, EmaneNet)): logging.info( "skipping common network that is not wireless/emane: %s", @@ -223,26 +223,26 @@ class Session: ) continue if connect: - common_network.link(interface_one, interface_two) + common_network.link(interface1, interface2) else: - common_network.unlink(interface_one, interface_two) + common_network.unlink(interface1, interface2) def add_link( self, - node_one_id: int, - node_two_id: int, - interface_one: InterfaceData = None, - interface_two: InterfaceData = None, + node1_id: int, + node2_id: int, + interface1_data: InterfaceData = None, + interface2_data: InterfaceData = None, options: LinkOptions = None, ) -> Tuple[CoreInterface, CoreInterface]: """ Add a link between nodes. - :param node_one_id: node one id - :param node_two_id: node two id - :param interface_one: node one interface + :param node1_id: node one id + :param node2_id: node two id + :param interface1_data: node one interface data, defaults to none - :param interface_two: node two interface + :param interface2_data: node two interface data, defaults to none :param options: data for creating link, defaults to no options @@ -250,10 +250,10 @@ class Session: """ if not options: options = LinkOptions() - node1 = self.get_node(node_one_id, NodeBase) - node2 = self.get_node(node_two_id, NodeBase) - node1_interface = None - node2_interface = None + node1 = self.get_node(node1_id, NodeBase) + node2 = self.get_node(node2_id, NodeBase) + interface1 = None + interface2 = None # wireless link if options.type == LinkTypes.WIRELESS: @@ -270,22 +270,22 @@ class Session: logging.info("linking ptp: %s - %s", node1.name, node2.name) start = self.state.should_start() ptp = self.create_node(PtpNet, start=start) - node1_interface = node1.newnetif(ptp, interface_one) - node2_interface = node2.newnetif(ptp, interface_two) - ptp.linkconfig(node1_interface, options) + interface1 = node1.newnetif(ptp, interface1_data) + interface2 = node2.newnetif(ptp, interface2_data) + ptp.linkconfig(interface1, options) if not options.unidirectional: - ptp.linkconfig(node2_interface, options) + ptp.linkconfig(interface2, options) # link node to net elif isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNetworkBase): - node1_interface = node1.newnetif(node2, interface_one) + interface1 = node1.newnetif(node2, interface1_data) if not isinstance(node2, (EmaneNet, WlanNode)): - node2.linkconfig(node1_interface, options) + node2.linkconfig(interface1, options) # link net to node elif isinstance(node2, CoreNodeBase) and isinstance(node1, CoreNetworkBase): - node2_interface = node2.newnetif(node1, interface_two) + interface2 = node2.newnetif(node1, interface2_data) wireless_net = isinstance(node1, (EmaneNet, WlanNode)) if not options.unidirectional and not wireless_net: - node1.linkconfig(node2_interface, options) + node1.linkconfig(interface2, options) # network to network elif isinstance(node1, CoreNetworkBase) and isinstance( node2, CoreNetworkBase @@ -293,12 +293,12 @@ class Session: logging.info( "linking network to network: %s - %s", node1.name, node2.name ) - node1_interface = node1.linknet(node2) - node1.linkconfig(node1_interface, options) + interface1 = node1.linknet(node2) + node1.linkconfig(interface1, options) if not options.unidirectional: - node1_interface.swapparams("_params_up") - node2.linkconfig(node1_interface, options) - node1_interface.swapparams("_params_up") + interface1.swapparams("_params_up") + node2.linkconfig(interface1, options) + interface1.swapparams("_params_up") else: raise CoreError( f"cannot link node1({type(node1)}) node2({type(node2)})" @@ -308,41 +308,41 @@ class Session: key = options.key if isinstance(node1, TunnelNode): logging.info("setting tunnel key for: %s", node1.name) - node1.setkey(key, interface_one) + node1.setkey(key, interface1_data) if isinstance(node2, TunnelNode): logging.info("setting tunnel key for: %s", node2.name) - node2.setkey(key, interface_two) - self.sdt.add_link(node_one_id, node_two_id) - return node1_interface, node2_interface + node2.setkey(key, interface2_data) + self.sdt.add_link(node1_id, node2_id) + return interface1, interface2 def delete_link( self, - node_one_id: int, - node_two_id: int, - interface_one_id: int = None, - interface_two_id: int = None, + node1_id: int, + node2_id: int, + interface1_id: int = None, + interface2_id: int = None, link_type: LinkTypes = LinkTypes.WIRED, ) -> None: """ Delete a link between nodes. - :param node_one_id: node one id - :param node_two_id: node two id - :param interface_one_id: interface id for node one - :param interface_two_id: interface id for node two + :param node1_id: node one id + :param node2_id: node two id + :param interface1_id: interface id for node one + :param interface2_id: interface id for node two :param link_type: link type to delete :return: nothing :raises core.CoreError: when no common network is found for link being deleted """ - node1 = self.get_node(node_one_id, NodeBase) - node2 = self.get_node(node_two_id, NodeBase) + node1 = self.get_node(node1_id, NodeBase) + node2 = self.get_node(node2_id, NodeBase) logging.info( "deleting link(%s) node(%s):interface(%s) node(%s):interface(%s)", link_type.name, node1.name, - interface_one_id, + interface1_id, node2.name, - interface_two_id, + interface2_id, ) # wireless link @@ -357,15 +357,15 @@ class Session: # wired link else: if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): - interface1 = node1.netif(interface_one_id) - interface2 = node2.netif(interface_two_id) + interface1 = node1.netif(interface1_id) + interface2 = node2.netif(interface2_id) if not interface1: raise CoreError( - f"node({node1.name}) missing interface({interface_one_id})" + f"node({node1.name}) missing interface({interface1_id})" ) if not interface2: raise CoreError( - f"node({node2.name}) missing interface({interface_two_id})" + f"node({node2.name}) missing interface({interface2_id})" ) if interface1.net != interface2.net: raise CoreError( @@ -373,30 +373,30 @@ class Session: "not connected to same net" ) ptp = interface1.net - node1.delnetif(interface_one_id) - node2.delnetif(interface_two_id) + node1.delnetif(interface1_id) + node2.delnetif(interface2_id) self.delete_node(ptp.id) elif isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNetworkBase): - node1.delnetif(interface_one_id) + node1.delnetif(interface1_id) elif isinstance(node2, CoreNodeBase) and isinstance(node1, CoreNetworkBase): - node2.delnetif(interface_two_id) - self.sdt.delete_link(node_one_id, node_two_id) + node2.delnetif(interface2_id) + self.sdt.delete_link(node1_id, node2_id) def update_link( self, - node_one_id: int, - node_two_id: int, - interface_one_id: int = None, - interface_two_id: int = None, + node1_id: int, + node2_id: int, + interface1_id: int = None, + interface2_id: int = None, options: LinkOptions = None, ) -> None: """ Update link information between nodes. - :param node_one_id: node one id - :param node_two_id: node two id - :param interface_one_id: interface id for node one - :param interface_two_id: interface id for node two + :param node1_id: node one id + :param node2_id: node two id + :param interface1_id: interface id for node one + :param interface2_id: interface id for node two :param options: data to update link with :return: nothing :raises core.CoreError: when updating a wireless type link, when there is a @@ -404,15 +404,15 @@ class Session: """ if not options: options = LinkOptions() - node1 = self.get_node(node_one_id, NodeBase) - node2 = self.get_node(node_two_id, NodeBase) + node1 = self.get_node(node1_id, NodeBase) + node2 = self.get_node(node2_id, NodeBase) logging.info( "update link(%s) node(%s):interface(%s) node(%s):interface(%s)", options.type.name, node1.name, - interface_one_id, + interface1_id, node2.name, - interface_two_id, + interface2_id, ) # wireless link @@ -420,15 +420,15 @@ class Session: raise CoreError("cannot update wireless link") else: if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): - interface1 = node1.netif(interface_one_id) - interface2 = node2.netif(interface_two_id) + interface1 = node1.netif(interface1_id) + interface2 = node2.netif(interface2_id) if not interface1: raise CoreError( - f"node({node1.name}) missing interface({interface_one_id})" + f"node({node1.name}) missing interface({interface1_id})" ) if not interface2: raise CoreError( - f"node({node2.name}) missing interface({interface_two_id})" + f"node({node2.name}) missing interface({interface2_id})" ) if interface1.net != interface2.net: raise CoreError( @@ -440,10 +440,10 @@ class Session: if not options.unidirectional: ptp.linkconfig(interface2, options, interface1) elif isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNetworkBase): - interface = node1.netif(interface_one_id) + interface = node1.netif(interface1_id) node2.linkconfig(interface, options) elif isinstance(node2, CoreNodeBase) and isinstance(node1, CoreNetworkBase): - interface = node2.netif(interface_two_id) + interface = node2.netif(interface2_id) node1.linkconfig(interface, options) elif isinstance(node1, CoreNetworkBase) and isinstance( node2, CoreNetworkBase diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 2b565e7f..5c1c52a0 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -164,25 +164,19 @@ class CoreClient: def handle_link_event(self, event: core_pb2.LinkEvent): logging.debug("Link event: %s", event) - node_one_id = event.link.node_one_id - node_two_id = event.link.node_two_id - if node_one_id == node_two_id: + node1_id = event.link.node1_id + node2_id = event.link.node2_id + if node1_id == node2_id: logging.warning("ignoring links with loops: %s", event) return - canvas_node_one = self.canvas_nodes[node_one_id] - canvas_node_two = self.canvas_nodes[node_two_id] + canvas_node1 = self.canvas_nodes[node1_id] + canvas_node2 = self.canvas_nodes[node2_id] if event.message_type == core_pb2.MessageType.ADD: - self.app.canvas.add_wireless_edge( - canvas_node_one, canvas_node_two, event.link - ) + self.app.canvas.add_wireless_edge(canvas_node1, canvas_node2, event.link) elif event.message_type == core_pb2.MessageType.DELETE: - self.app.canvas.delete_wireless_edge( - canvas_node_one, canvas_node_two, event.link - ) + self.app.canvas.delete_wireless_edge(canvas_node1, canvas_node2, event.link) elif event.message_type == core_pb2.MessageType.NONE: - self.app.canvas.update_wireless_edge( - canvas_node_one, canvas_node_two, event.link - ) + self.app.canvas.update_wireless_edge(canvas_node1, canvas_node2, event.link) else: logging.warning("unknown link event: %s", event) @@ -472,10 +466,10 @@ class CoreClient: for edge in self.links.values(): link = core_pb2.Link() link.CopyFrom(edge.link) - if link.HasField("interface_one") and not link.interface_one.mac: - link.interface_one.mac = self.interfaces_manager.next_mac() - if link.HasField("interface_two") and not link.interface_two.mac: - link.interface_two.mac = self.interfaces_manager.next_mac() + if link.HasField("interface1") and not link.interface1.mac: + link.interface1.mac = self.interfaces_manager.next_mac() + if link.HasField("interface2") and not link.interface2.mac: + link.interface2.mac = self.interfaces_manager.next_mac() links.append(link) wlan_configs = self.get_wlan_configs_proto() mobility_configs = self.get_mobility_configs_proto() @@ -693,10 +687,10 @@ class CoreClient: for link_proto in link_protos: response = self.client.add_link( self.session_id, - link_proto.node_one_id, - link_proto.node_two_id, - link_proto.interface_one, - link_proto.interface_two, + link_proto.node1_id, + link_proto.node2_id, + link_proto.interface1, + link_proto.interface2, link_proto.options, ) logging.debug("create link: %s", response) @@ -881,20 +875,20 @@ class CoreClient: link = core_pb2.Link( type=core_pb2.LinkType.WIRED, - node_one_id=src_node.id, - node_two_id=dst_node.id, - interface_one=src_interface, - interface_two=dst_interface, + node1_id=src_node.id, + node2_id=dst_node.id, + interface1=src_interface, + interface2=dst_interface, ) # assign after creating link proto, since interfaces are copied if src_interface: - interface_one = link.interface_one - edge.src_interface = interface_one - canvas_src_node.interfaces[interface_one.id] = interface_one + interface1 = link.interface1 + edge.src_interface = interface1 + canvas_src_node.interfaces[interface1.id] = interface1 if dst_interface: - interface_two = link.interface_two - edge.dst_interface = interface_two - canvas_dst_node.interfaces[interface_two.id] = interface_two + interface2 = link.interface2 + edge.dst_interface = interface2 + canvas_dst_node.interfaces[interface2.id] = interface2 edge.set_link(link) self.links[edge.token] = edge logging.info("Add link between %s and %s", src_node.name, dst_node.name) diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index 92361ed4..1c20e2e1 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -227,21 +227,21 @@ class LinkConfigurationDialog(Dialog): ) link.options.CopyFrom(options) - interface_one = None - if link.HasField("interface_one"): - interface_one = link.interface_one.id - interface_two = None - if link.HasField("interface_two"): - interface_two = link.interface_two.id + interface1_id = None + if link.HasField("interface1"): + interface1_id = link.interface1.id + interface2_id = None + if link.HasField("interface2"): + interface2_id = link.interface2.id if not self.is_symmetric: link.options.unidirectional = True - asym_interface_one = None - if interface_one: - asym_interface_one = core_pb2.Interface(id=interface_one) - asym_interface_two = None - if interface_two: - asym_interface_two = core_pb2.Interface(id=interface_two) + asym_interface1 = None + if interface1_id: + asym_interface1 = core_pb2.Interface(id=interface1_id) + asym_interface2 = None + if interface2_id: + asym_interface2 = core_pb2.Interface(id=interface2_id) down_bandwidth = get_int(self.down_bandwidth) down_jitter = get_int(self.down_jitter) down_delay = get_int(self.down_delay) @@ -256,10 +256,10 @@ class LinkConfigurationDialog(Dialog): unidirectional=True, ) self.edge.asymmetric_link = core_pb2.Link( - node_one_id=link.node_two_id, - node_two_id=link.node_one_id, - interface_one=asym_interface_one, - interface_two=asym_interface_two, + node1_id=link.node2_id, + node2_id=link.node1_id, + interface1=asym_interface1, + interface2=asym_interface2, options=options, ) else: @@ -270,20 +270,20 @@ class LinkConfigurationDialog(Dialog): session_id = self.app.core.session_id self.app.core.client.edit_link( session_id, - link.node_one_id, - link.node_two_id, + link.node1_id, + link.node2_id, link.options, - interface_one, - interface_two, + interface1_id, + interface2_id, ) if self.edge.asymmetric_link: self.app.core.client.edit_link( session_id, - link.node_two_id, - link.node_one_id, + link.node2_id, + link.node1_id, self.edge.asymmetric_link.options, - interface_one, - interface_two, + interface1_id, + interface2_id, ) self.destroy() diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index 00268c88..1d2264eb 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -296,13 +296,13 @@ class CanvasEdge(Edge): return label def create_node_labels(self) -> Tuple[str, str]: - label_one = None - if self.link.HasField("interface_one"): - label_one = self.interface_label(self.link.interface_one) - label_two = None - if self.link.HasField("interface_two"): - label_two = self.interface_label(self.link.interface_two) - return label_one, label_two + label1 = None + if self.link.HasField("interface1"): + label1 = self.interface_label(self.link.interface1) + label2 = None + if self.link.HasField("interface2"): + label2 = self.interface_label(self.link.interface2) + return label1, label2 def draw_labels(self) -> None: src_text, dst_text = self.create_node_labels() diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 3d6fd369..90dcd9f6 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -300,41 +300,39 @@ class CanvasGraph(tk.Canvas): # draw existing links for link in session.links: logging.debug("drawing link: %s", link) - canvas_node_one = self.core.canvas_nodes[link.node_one_id] - node_one = canvas_node_one.core_node - canvas_node_two = self.core.canvas_nodes[link.node_two_id] - node_two = canvas_node_two.core_node - token = create_edge_token(canvas_node_one.id, canvas_node_two.id) + canvas_node1 = self.core.canvas_nodes[link.node1_id] + node1 = canvas_node1.core_node + canvas_node2 = self.core.canvas_nodes[link.node2_id] + node2 = canvas_node2.core_node + token = create_edge_token(canvas_node1.id, canvas_node2.id) if link.type == core_pb2.LinkType.WIRELESS: - self.add_wireless_edge(canvas_node_one, canvas_node_two, link) + self.add_wireless_edge(canvas_node1, canvas_node2, link) else: if token not in self.edges: - src_pos = (node_one.position.x, node_one.position.y) - dst_pos = (node_two.position.x, node_two.position.y) - edge = CanvasEdge(self, canvas_node_one.id, src_pos, dst_pos) + src_pos = (node1.position.x, node1.position.y) + dst_pos = (node2.position.x, node2.position.y) + edge = CanvasEdge(self, canvas_node1.id, src_pos, dst_pos) edge.token = token - edge.dst = canvas_node_two.id + edge.dst = canvas_node2.id edge.set_link(link) edge.check_wireless() - canvas_node_one.edges.add(edge) - canvas_node_two.edges.add(edge) + canvas_node1.edges.add(edge) + canvas_node2.edges.add(edge) self.edges[edge.token] = edge self.core.links[edge.token] = edge - if link.HasField("interface_one"): - interface_one = link.interface_one + if link.HasField("interface1"): + interface1 = link.interface1 + self.core.interface_to_edge[(node1.id, interface1.id)] = token + canvas_node1.interfaces[interface1.id] = interface1 + edge.src_interface = interface1 + if link.HasField("interface2"): + interface2 = link.interface2 self.core.interface_to_edge[ - (node_one.id, interface_one.id) - ] = token - canvas_node_one.interfaces[interface_one.id] = interface_one - edge.src_interface = interface_one - if link.HasField("interface_two"): - interface_two = link.interface_two - self.core.interface_to_edge[ - (node_two.id, interface_two.id) + (node2.id, interface2.id) ] = edge.token - canvas_node_two.interfaces[interface_two.id] = interface_two - edge.dst_interface = interface_two + canvas_node2.interfaces[interface2.id] = interface2 + edge.dst_interface = interface2 elif link.options.unidirectional: edge = self.edges[token] edge.asymmetric_link = link @@ -965,26 +963,26 @@ class CanvasGraph(tk.Canvas): copy_link = copy_edge.link options = edge.link.options copy_link.options.CopyFrom(options) - interface_one = None - if copy_link.HasField("interface_one"): - interface_one = copy_link.interface_one.id - interface_two = None - if copy_link.HasField("interface_two"): - interface_two = copy_link.interface_two.id + interface1_id = None + if copy_link.HasField("interface1"): + interface1_id = copy_link.interface1.id + interface2_id = None + if copy_link.HasField("interface2"): + interface2_id = copy_link.interface2.id if not options.unidirectional: copy_edge.asymmetric_link = None else: - asym_interface_one = None - if interface_one: - asym_interface_one = core_pb2.Interface(id=interface_one) - asym_interface_two = None - if interface_two: - asym_interface_two = core_pb2.Interface(id=interface_two) + asym_interface1 = None + if interface1_id: + asym_interface1 = core_pb2.Interface(id=interface1_id) + asym_interface2 = None + if interface2_id: + asym_interface2 = core_pb2.Interface(id=interface2_id) copy_edge.asymmetric_link = core_pb2.Link( - node_one_id=copy_link.node_two_id, - node_two_id=copy_link.node_one_id, - interface_one=asym_interface_one, - interface_two=asym_interface_two, + node1_id=copy_link.node2_id, + node2_id=copy_link.node1_id, + interface1=asym_interface1, + interface2=asym_interface2, options=edge.asymmetric_link.options, ) self.itemconfig( diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 1973fe99..34270f56 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -89,21 +89,21 @@ class InterfaceManager: remaining_subnets = set() for edge in self.app.core.links.values(): link = edge.link - if link.HasField("interface_one"): - subnets = self.get_subnets(link.interface_one) + if link.HasField("interface1"): + subnets = self.get_subnets(link.interface1) remaining_subnets.add(subnets) - if link.HasField("interface_two"): - subnets = self.get_subnets(link.interface_two) + if link.HasField("interface2"): + subnets = self.get_subnets(link.interface2) remaining_subnets.add(subnets) # remove all subnets from used subnets when no longer present # or remove used indexes from subnet interfaces = [] for link in links: - if link.HasField("interface_one"): - interfaces.append(link.interface_one) - if link.HasField("interface_two"): - interfaces.append(link.interface_two) + if link.HasField("interface1"): + interfaces.append(link.interface1) + if link.HasField("interface2"): + interfaces.append(link.interface2) for interface in interfaces: subnets = self.get_subnets(interface) if subnets not in remaining_subnets: @@ -117,10 +117,10 @@ class InterfaceManager: def joined(self, links: List["core_pb2.Link"]) -> None: interfaces = [] for link in links: - if link.HasField("interface_one"): - interfaces.append(link.interface_one) - if link.HasField("interface_two"): - interfaces.append(link.interface_two) + if link.HasField("interface1"): + interfaces.append(link.interface1) + if link.HasField("interface2"): + interfaces.append(link.interface2) # add to used subnets and mark used indexes for interface in interfaces: diff --git a/daemon/core/plugins/sdt.py b/daemon/core/plugins/sdt.py index 8b4ec39f..062217cb 100644 --- a/daemon/core/plugins/sdt.py +++ b/daemon/core/plugins/sdt.py @@ -21,8 +21,8 @@ if TYPE_CHECKING: from core.emulator.session import Session -def get_link_id(node_one: int, node_two: int, network_id: int) -> str: - link_id = f"{node_one}-{node_two}" +def get_link_id(node1_id: int, node2_id: int, network_id: int) -> str: + link_id = f"{node1_id}-{node2_id}" if network_id is not None: link_id = f"{link_id}-{network_id}" return link_id @@ -351,27 +351,27 @@ class Sdt: return result def add_link( - self, node_one: int, node_two: int, network_id: int = None, label: str = None + self, node1_id: int, node2_id: int, network_id: int = None, label: str = None ) -> None: """ Handle adding a link in SDT. - :param node_one: node one id - :param node_two: node two id + :param node1_id: node one id + :param node2_id: node two id :param network_id: network link is associated with, None otherwise :param label: label for link :return: nothing """ - logging.debug("sdt add link: %s, %s, %s", node_one, node_two, network_id) + logging.debug("sdt add link: %s, %s, %s", node1_id, node2_id, network_id) if not self.connect(): return - if self.wireless_net_check(node_one) or self.wireless_net_check(node_two): + if self.wireless_net_check(node1_id) or self.wireless_net_check(node2_id): return color = DEFAULT_LINK_COLOR if network_id: color = self.session.get_link_color(network_id) line = f"{color},2" - link_id = get_link_id(node_one, node_two, network_id) + link_id = get_link_id(node1_id, node2_id, network_id) layer = LINK_LAYER if network_id: node = self.session.nodes.get(network_id) @@ -383,47 +383,47 @@ class Sdt: if label: link_label = f'linklabel on,"{label}"' self.cmd( - f"link {node_one},{node_two},{link_id} linkLayer {layer} line {line} " + f"link {node1_id},{node2_id},{link_id} linkLayer {layer} line {line} " f"{link_label}" ) - def delete_link(self, node_one: int, node_two: int, network_id: int = None) -> None: + def delete_link(self, node1_id: int, node2_id: int, network_id: int = None) -> None: """ Handle deleting a link in SDT. - :param node_one: node one id - :param node_two: node two id + :param node1_id: node one id + :param node2_id: node two id :param network_id: network link is associated with, None otherwise :return: nothing """ - logging.debug("sdt delete link: %s, %s, %s", node_one, node_two, network_id) + logging.debug("sdt delete link: %s, %s, %s", node1_id, node2_id, network_id) if not self.connect(): return - if self.wireless_net_check(node_one) or self.wireless_net_check(node_two): + if self.wireless_net_check(node1_id) or self.wireless_net_check(node2_id): return - link_id = get_link_id(node_one, node_two, network_id) - self.cmd(f"delete link,{node_one},{node_two},{link_id}") + link_id = get_link_id(node1_id, node2_id, network_id) + self.cmd(f"delete link,{node1_id},{node2_id},{link_id}") def edit_link( - self, node_one: int, node_two: int, network_id: int, label: str + self, node1_id: int, node2_id: int, network_id: int, label: str ) -> None: """ Handle editing a link in SDT. - :param node_one: node one id - :param node_two: node two id + :param node1_id: node one id + :param node2_id: node two id :param network_id: network link is associated with, None otherwise :param label: label to update :return: nothing """ - logging.debug("sdt edit link: %s, %s, %s", node_one, node_two, network_id) + logging.debug("sdt edit link: %s, %s, %s", node1_id, node2_id, network_id) if not self.connect(): return - if self.wireless_net_check(node_one) or self.wireless_net_check(node_two): + if self.wireless_net_check(node1_id) or self.wireless_net_check(node2_id): return - link_id = get_link_id(node_one, node_two, network_id) + link_id = get_link_id(node1_id, node2_id, network_id) link_label = f'linklabel on,"{label}"' - self.cmd(f"link {node_one},{node_two},{link_id} {link_label}") + self.cmd(f"link {node1_id},{node2_id},{link_id} {link_label}") def handle_link_update(self, link_data: LinkData) -> None: """ @@ -432,13 +432,13 @@ class Sdt: :param link_data: link data to handle :return: nothing """ - node_one = link_data.node1_id - node_two = link_data.node2_id + node1_id = link_data.node1_id + node2_id = link_data.node2_id network_id = link_data.network_id label = link_data.label if link_data.message_type == MessageFlags.ADD: - self.add_link(node_one, node_two, network_id, label) + self.add_link(node1_id, node2_id, network_id, label) elif link_data.message_type == MessageFlags.DELETE: - self.delete_link(node_one, node_two, network_id) + self.delete_link(node1_id, node2_id, network_id) elif link_data.message_type == MessageFlags.NONE and label: - self.edit_link(node_one, node_two, network_id, label) + self.edit_link(node1_id, node2_id, network_id, label) diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 973eb77f..afc1d826 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -534,7 +534,7 @@ class CoreXmlWriter: # check for interface one if link_data.interface1_id is not None: - interface_one = self.create_interface_element( + interface1 = self.create_interface_element( "interface_one", link_data.node1_id, link_data.interface1_id, @@ -544,11 +544,11 @@ class CoreXmlWriter: link_data.interface1_ip6, link_data.interface1_ip6_mask, ) - link_element.append(interface_one) + link_element.append(interface1) # check for interface two if link_data.interface2_id is not None: - interface_two = self.create_interface_element( + interface2 = self.create_interface_element( "interface_two", link_data.node2_id, link_data.interface2_id, @@ -558,14 +558,14 @@ class CoreXmlWriter: link_data.interface2_ip6, link_data.interface2_ip6_mask, ) - link_element.append(interface_two) + link_element.append(interface2) # check for options, don't write for emane/wlan links - node_one = self.session.get_node(link_data.node1_id, NodeBase) - node_two = self.session.get_node(link_data.node2_id, NodeBase) - is_node_one_wireless = isinstance(node_one, (WlanNode, EmaneNet)) - is_node_two_wireless = isinstance(node_two, (WlanNode, EmaneNet)) - if not any([is_node_one_wireless, is_node_two_wireless]): + node1 = self.session.get_node(link_data.node1_id, NodeBase) + node2 = self.session.get_node(link_data.node2_id, NodeBase) + is_node1_wireless = isinstance(node1, (WlanNode, EmaneNet)) + is_node2_wireless = isinstance(node2, (WlanNode, EmaneNet)) + if not any([is_node1_wireless, is_node2_wireless]): options = etree.Element("options") add_attribute(options, "delay", link_data.delay) add_attribute(options, "bandwidth", link_data.bandwidth) @@ -932,19 +932,19 @@ class CoreXmlReader: node_sets = set() for link_element in link_elements.iterchildren(): - node_one = get_int(link_element, "node_one") - node_two = get_int(link_element, "node_two") - node_set = frozenset((node_one, node_two)) + node1_id = get_int(link_element, "node_one") + node2_id = get_int(link_element, "node_two") + node_set = frozenset((node1_id, node2_id)) - interface_one_element = link_element.find("interface_one") - interface_one = None - if interface_one_element is not None: - interface_one = create_interface_data(interface_one_element) + interface1_element = link_element.find("interface_one") + interface1_data = None + if interface1_element is not None: + interface1_data = create_interface_data(interface1_element) - interface_two_element = link_element.find("interface_two") - interface_two = None - if interface_two_element is not None: - interface_two = create_interface_data(interface_two_element) + interface2_element = link_element.find("interface_two") + interface2_data = None + if interface2_element is not None: + interface2_data = create_interface_data(interface2_element) options_element = link_element.find("options") link_options = LinkOptions() @@ -966,18 +966,18 @@ class CoreXmlReader: link_options.gui_attributes = options_element.get("gui_attributes") if link_options.unidirectional == 1 and node_set in node_sets: - logging.info( - "updating link node_one(%s) node_two(%s)", node_one, node_two - ) + logging.info("updating link node1(%s) node2(%s)", node1_id, node2_id) self.session.update_link( - node_one, node_two, interface_one.id, interface_two.id, link_options + node1_id, + node2_id, + interface1_data.id, + interface2_data.id, + link_options, ) else: - logging.info( - "adding link node_one(%s) node_two(%s)", node_one, node_two - ) + logging.info("adding link node1(%s) node2(%s)", node1_id, node2_id) self.session.add_link( - node_one, node_two, interface_one, interface_two, link_options + node1_id, node2_id, interface1_data, interface2_data, link_options ) node_sets.add(node_set) diff --git a/daemon/examples/configservices/testing.py b/daemon/examples/configservices/testing.py index bc67ff46..948ec739 100644 --- a/daemon/examples/configservices/testing.py +++ b/daemon/examples/configservices/testing.py @@ -11,7 +11,7 @@ if __name__ == "__main__": # setup basic network prefixes = IpPrefixes(ip4_prefix="10.83.0.0/16") - options = NodeOptions(model="nothing") + options = NodeOptions(model=None) coreemu = CoreEmu() session = coreemu.create_session() session.set_state(EventTypes.CONFIGURATION_STATE) @@ -19,14 +19,14 @@ if __name__ == "__main__": # node one options.config_services = ["DefaultRoute", "IPForward"] - node_one = session.add_node(CoreNode, options=options) - interface = prefixes.create_interface(node_one) - session.add_link(node_one.id, switch.id, interface_one=interface) + node1 = session.add_node(CoreNode, options=options) + interface = prefixes.create_interface(node1) + session.add_link(node1.id, switch.id, interface1_data=interface) # node two - node_two = session.add_node(CoreNode, options=options) - interface = prefixes.create_interface(node_two) - session.add_link(node_two.id, switch.id, interface_one=interface) + node2 = session.add_node(CoreNode, options=options) + interface = prefixes.create_interface(node2) + session.add_link(node2.id, switch.id, interface1_data=interface) # start session and run services session.instantiate() diff --git a/daemon/examples/docker/docker2core.py b/daemon/examples/docker/docker2core.py index 1211a16f..8151a590 100644 --- a/daemon/examples/docker/docker2core.py +++ b/daemon/examples/docker/docker2core.py @@ -17,15 +17,15 @@ if __name__ == "__main__": options = NodeOptions(model=None, image="ubuntu") # create node one - node_one = session.add_node(DockerNode, options=options) - interface_one = prefixes.create_interface(node_one) + node1 = session.add_node(DockerNode, options=options) + interface1_data = prefixes.create_interface(node1) # create node two - node_two = session.add_node(CoreNode) - interface_two = prefixes.create_interface(node_two) + node2 = session.add_node(CoreNode) + interface2_data = prefixes.create_interface(node2) # add link - session.add_link(node_one.id, node_two.id, interface_one, interface_two) + session.add_link(node1.id, node2.id, interface1_data, interface2_data) # instantiate session.instantiate() diff --git a/daemon/examples/docker/docker2docker.py b/daemon/examples/docker/docker2docker.py index 9e1ae11f..a7a70534 100644 --- a/daemon/examples/docker/docker2docker.py +++ b/daemon/examples/docker/docker2docker.py @@ -18,15 +18,15 @@ if __name__ == "__main__": options = NodeOptions(model=None, image="ubuntu") # create node one - node_one = session.add_node(DockerNode, options=options) - interface_one = prefixes.create_interface(node_one) + node1 = session.add_node(DockerNode, options=options) + interface1_data = prefixes.create_interface(node1) # create node two - node_two = session.add_node(DockerNode, options=options) - interface_two = prefixes.create_interface(node_two) + node2 = session.add_node(DockerNode, options=options) + interface2_data = prefixes.create_interface(node2) # add link - session.add_link(node_one.id, node_two.id, interface_one, interface_two) + session.add_link(node1.id, node2.id, interface1_data, interface2_data) # instantiate session.instantiate() diff --git a/daemon/examples/docker/switch.py b/daemon/examples/docker/switch.py index 74d58fe0..ef057945 100644 --- a/daemon/examples/docker/switch.py +++ b/daemon/examples/docker/switch.py @@ -22,20 +22,20 @@ if __name__ == "__main__": switch = session.add_node(SwitchNode) # node one - node_one = session.add_node(DockerNode, options=options) - interface_one = prefixes.create_interface(node_one) + node1 = session.add_node(DockerNode, options=options) + interface1_data = prefixes.create_interface(node1) # node two - node_two = session.add_node(DockerNode, options=options) - interface_two = prefixes.create_interface(node_two) + node2 = session.add_node(DockerNode, options=options) + interface2_data = prefixes.create_interface(node2) # node three node_three = session.add_node(CoreNode) interface_three = prefixes.create_interface(node_three) # add links - session.add_link(node_one.id, switch.id, interface_one) - session.add_link(node_two.id, switch.id, interface_two) + session.add_link(node1.id, switch.id, interface1_data) + session.add_link(node2.id, switch.id, interface2_data) session.add_link(node_three.id, switch.id, interface_three) # instantiate diff --git a/daemon/examples/grpc/distributed_switch.py b/daemon/examples/grpc/distributed_switch.py index 0477efdd..e847016f 100644 --- a/daemon/examples/grpc/distributed_switch.py +++ b/daemon/examples/grpc/distributed_switch.py @@ -44,11 +44,11 @@ def main(args): node = Node(position=position) response = core.add_node(session_id, node) logging.info("created node one: %s", response) - node_one_id = response.node_id + node1_id = response.node_id # create link - interface_one = interface_helper.create_interface(node_one_id, 0) - response = core.add_link(session_id, node_one_id, switch_id, interface_one) + interface1 = interface_helper.create_interface(node1_id, 0) + response = core.add_link(session_id, node1_id, switch_id, interface1) logging.info("created link from node one to switch: %s", response) # create node two @@ -56,11 +56,11 @@ def main(args): node = Node(position=position, server=server_name) response = core.add_node(session_id, node) logging.info("created node two: %s", response) - node_two_id = response.node_id + node2_id = response.node_id # create link - interface_one = interface_helper.create_interface(node_two_id, 0) - response = core.add_link(session_id, node_two_id, switch_id, interface_one) + interface1 = interface_helper.create_interface(node2_id, 0) + response = core.add_link(session_id, node2_id, switch_id, interface1) logging.info("created link from node two to switch: %s", response) # change session state diff --git a/daemon/examples/grpc/emane80211.py b/daemon/examples/grpc/emane80211.py index 5656268c..24532266 100644 --- a/daemon/examples/grpc/emane80211.py +++ b/daemon/examples/grpc/emane80211.py @@ -57,11 +57,11 @@ def main(): node2_id = response.node_id # links nodes to switch - interface_one = interface_helper.create_interface(node1_id, 0) - response = core.add_link(session_id, node1_id, emane_id, interface_one) + interface1 = interface_helper.create_interface(node1_id, 0) + response = core.add_link(session_id, node1_id, emane_id, interface1) logging.info("created link: %s", response) - interface_one = interface_helper.create_interface(node2_id, 0) - response = core.add_link(session_id, node2_id, emane_id, interface_one) + interface1 = interface_helper.create_interface(node2_id, 0) + response = core.add_link(session_id, node2_id, emane_id, interface1) logging.info("created link: %s", response) # change session state diff --git a/daemon/examples/grpc/switch.py b/daemon/examples/grpc/switch.py index 3ab0e0ba..74e315c6 100644 --- a/daemon/examples/grpc/switch.py +++ b/daemon/examples/grpc/switch.py @@ -53,11 +53,11 @@ def main(): node2_id = response.node_id # links nodes to switch - interface_one = interface_helper.create_interface(node1_id, 0) - response = core.add_link(session_id, node1_id, switch_id, interface_one) + interface1 = interface_helper.create_interface(node1_id, 0) + response = core.add_link(session_id, node1_id, switch_id, interface1) logging.info("created link: %s", response) - interface_one = interface_helper.create_interface(node2_id, 0) - response = core.add_link(session_id, node2_id, switch_id, interface_one) + interface1 = interface_helper.create_interface(node2_id, 0) + response = core.add_link(session_id, node2_id, switch_id, interface1) logging.info("created link: %s", response) # change session state diff --git a/daemon/examples/grpc/wlan.py b/daemon/examples/grpc/wlan.py index 6118ae4c..d60ca1be 100644 --- a/daemon/examples/grpc/wlan.py +++ b/daemon/examples/grpc/wlan.py @@ -65,11 +65,11 @@ def main(): node2_id = response.node_id # links nodes to switch - interface_one = interface_helper.create_interface(node1_id, 0) - response = core.add_link(session_id, node1_id, wlan_id, interface_one) + interface1 = interface_helper.create_interface(node1_id, 0) + response = core.add_link(session_id, node1_id, wlan_id, interface1) logging.info("created link: %s", response) - interface_one = interface_helper.create_interface(node2_id, 0) - response = core.add_link(session_id, node2_id, wlan_id, interface_one) + interface1 = interface_helper.create_interface(node2_id, 0) + response = core.add_link(session_id, node2_id, wlan_id, interface1) logging.info("created link: %s", response) # change session state diff --git a/daemon/examples/lxd/lxd2core.py b/daemon/examples/lxd/lxd2core.py index 1365bd4c..49b68943 100644 --- a/daemon/examples/lxd/lxd2core.py +++ b/daemon/examples/lxd/lxd2core.py @@ -17,15 +17,15 @@ if __name__ == "__main__": options = NodeOptions(image="ubuntu") # create node one - node_one = session.add_node(LxcNode, options=options) - interface_one = prefixes.create_interface(node_one) + node1 = session.add_node(LxcNode, options=options) + interface1_data = prefixes.create_interface(node1) # create node two - node_two = session.add_node(CoreNode) - interface_two = prefixes.create_interface(node_two) + node2 = session.add_node(CoreNode) + interface2_data = prefixes.create_interface(node2) # add link - session.add_link(node_one.id, node_two.id, interface_one, interface_two) + session.add_link(node1.id, node2.id, interface1_data, interface2_data) # instantiate session.instantiate() diff --git a/daemon/examples/lxd/lxd2lxd.py b/daemon/examples/lxd/lxd2lxd.py index 53a360e8..18af8037 100644 --- a/daemon/examples/lxd/lxd2lxd.py +++ b/daemon/examples/lxd/lxd2lxd.py @@ -18,15 +18,15 @@ if __name__ == "__main__": options = NodeOptions(image="ubuntu:18.04") # create node one - node_one = session.add_node(LxcNode, options=options) - interface_one = prefixes.create_interface(node_one) + node1 = session.add_node(LxcNode, options=options) + interface1_data = prefixes.create_interface(node1) # create node two - node_two = session.add_node(LxcNode, options=options) - interface_two = prefixes.create_interface(node_two) + node2 = session.add_node(LxcNode, options=options) + interface2_data = prefixes.create_interface(node2) # add link - session.add_link(node_one.id, node_two.id, interface_one, interface_two) + session.add_link(node1.id, node2.id, interface1_data, interface2_data) # instantiate session.instantiate() diff --git a/daemon/examples/lxd/switch.py b/daemon/examples/lxd/switch.py index 3b6226e4..31a79887 100644 --- a/daemon/examples/lxd/switch.py +++ b/daemon/examples/lxd/switch.py @@ -22,21 +22,21 @@ if __name__ == "__main__": switch = session.add_node(SwitchNode) # node one - node_one = session.add_node(LxcNode, options=options) - interface_one = prefixes.create_interface(node_one) + node1 = session.add_node(LxcNode, options=options) + interface1_data = prefixes.create_interface(node1) # node two - node_two = session.add_node(LxcNode, options=options) - interface_two = prefixes.create_interface(node_two) + node2 = session.add_node(LxcNode, options=options) + interface2_data = prefixes.create_interface(node2) # node three - node_three = session.add_node(CoreNode) - interface_three = prefixes.create_interface(node_three) + node3 = session.add_node(CoreNode) + interface3_data = prefixes.create_interface(node3) # add links - session.add_link(node_one.id, switch.id, interface_one) - session.add_link(node_two.id, switch.id, interface_two) - session.add_link(node_three.id, switch.id, interface_three) + session.add_link(node1.id, switch.id, interface1_data) + session.add_link(node2.id, switch.id, interface2_data) + session.add_link(node3.id, switch.id, interface3_data) # instantiate session.instantiate() diff --git a/daemon/examples/python/distributed_emane.py b/daemon/examples/python/distributed_emane.py index 3248a8e3..d9b41ea4 100644 --- a/daemon/examples/python/distributed_emane.py +++ b/daemon/examples/python/distributed_emane.py @@ -52,17 +52,17 @@ def main(args): # create local node, switch, and remote nodes options = NodeOptions(model="mdr") options.set_position(0, 0) - node_one = session.add_node(CoreNode, options=options) + node1 = session.add_node(CoreNode, options=options) emane_net = session.add_node(EmaneNet) session.emane.set_model(emane_net, EmaneIeee80211abgModel) options.server = server_name - node_two = session.add_node(CoreNode, options=options) + node2 = session.add_node(CoreNode, options=options) # create node interfaces and link - interface_one = prefixes.create_interface(node_one) - interface_two = prefixes.create_interface(node_two) - session.add_link(node_one.id, emane_net.id, interface_one=interface_one) - session.add_link(node_two.id, emane_net.id, interface_one=interface_two) + interface1_data = prefixes.create_interface(node1) + interface2_data = prefixes.create_interface(node2) + session.add_link(node1.id, emane_net.id, interface1_data=interface1_data) + session.add_link(node2.id, emane_net.id, interface1_data=interface2_data) # instantiate session session.instantiate() diff --git a/daemon/examples/python/distributed_lxd.py b/daemon/examples/python/distributed_lxd.py index de919012..affb16a8 100644 --- a/daemon/examples/python/distributed_lxd.py +++ b/daemon/examples/python/distributed_lxd.py @@ -43,14 +43,14 @@ def main(args): # create local node, switch, and remote nodes options = NodeOptions(image="ubuntu:18.04") - node_one = session.add_node(LxcNode, options=options) + node1 = session.add_node(LxcNode, options=options) options.server = server_name - node_two = session.add_node(LxcNode, options=options) + node2 = session.add_node(LxcNode, options=options) # create node interfaces and link - interface_one = prefixes.create_interface(node_one) - interface_two = prefixes.create_interface(node_two) - session.add_link(node_one.id, node_two.id, interface_one, interface_two) + interface1_data = prefixes.create_interface(node1) + interface2_data = prefixes.create_interface(node2) + session.add_link(node1.id, node2.id, interface1_data, interface2_data) # instantiate session session.instantiate() diff --git a/daemon/examples/python/distributed_ptp.py b/daemon/examples/python/distributed_ptp.py index 26531399..6bf33474 100644 --- a/daemon/examples/python/distributed_ptp.py +++ b/daemon/examples/python/distributed_ptp.py @@ -43,14 +43,14 @@ def main(args): # create local node, switch, and remote nodes options = NodeOptions() - node_one = session.add_node(CoreNode, options=options) + node1 = session.add_node(CoreNode, options=options) options.server = server_name - node_two = session.add_node(CoreNode, options=options) + node2 = session.add_node(CoreNode, options=options) # create node interfaces and link - interface_one = prefixes.create_interface(node_one) - interface_two = prefixes.create_interface(node_two) - session.add_link(node_one.id, node_two.id, interface_one, interface_two) + interface1_data = prefixes.create_interface(node1) + interface2_data = prefixes.create_interface(node2) + session.add_link(node1.id, node2.id, interface1_data, interface2_data) # instantiate session session.instantiate() diff --git a/daemon/examples/python/distributed_switch.py b/daemon/examples/python/distributed_switch.py index c52c1cc1..8991161e 100644 --- a/daemon/examples/python/distributed_switch.py +++ b/daemon/examples/python/distributed_switch.py @@ -45,17 +45,17 @@ def main(args): session.set_state(EventTypes.CONFIGURATION_STATE) # create local node, switch, and remote nodes - node_one = session.add_node(CoreNode) + node1 = session.add_node(CoreNode) switch = session.add_node(SwitchNode) options = NodeOptions() options.server = server_name - node_two = session.add_node(CoreNode, options=options) + node2 = session.add_node(CoreNode, options=options) # create node interfaces and link - interface_one = prefixes.create_interface(node_one) - interface_two = prefixes.create_interface(node_two) - session.add_link(node_one.id, switch.id, interface_one=interface_one) - session.add_link(node_two.id, switch.id, interface_one=interface_two) + interface1_data = prefixes.create_interface(node1) + interface2_data = prefixes.create_interface(node2) + session.add_link(node1.id, switch.id, interface1_data=interface1_data) + session.add_link(node2.id, switch.id, interface1_data=interface2_data) # instantiate session session.instantiate() diff --git a/daemon/examples/python/emane80211.py b/daemon/examples/python/emane80211.py index da93026b..d3f6652a 100644 --- a/daemon/examples/python/emane80211.py +++ b/daemon/examples/python/emane80211.py @@ -43,7 +43,7 @@ def main(): node = session.add_node(CoreNode, options=options) node.setposition(x=150 * (i + 1), y=150) interface = prefixes.create_interface(node) - session.add_link(node.id, emane_network.id, interface_one=interface) + session.add_link(node.id, emane_network.id, interface1_data=interface) # instantiate session session.instantiate() diff --git a/daemon/examples/python/switch.py b/daemon/examples/python/switch.py index 9475fc47..1b939cd7 100644 --- a/daemon/examples/python/switch.py +++ b/daemon/examples/python/switch.py @@ -32,7 +32,7 @@ def main(): for _ in range(NODES): node = session.add_node(CoreNode) interface = prefixes.create_interface(node) - session.add_link(node.id, switch.id, interface_one=interface) + session.add_link(node.id, switch.id, interface1_data=interface) # instantiate session session.instantiate() diff --git a/daemon/examples/python/switch_inject.py b/daemon/examples/python/switch_inject.py index 8c929e91..59816b19 100644 --- a/daemon/examples/python/switch_inject.py +++ b/daemon/examples/python/switch_inject.py @@ -34,7 +34,7 @@ def main(): for _ in range(NODES): node = session.add_node(CoreNode) interface = prefixes.create_interface(node) - session.add_link(node.id, switch.id, interface_one=interface) + session.add_link(node.id, switch.id, interface1_data=interface) # instantiate session session.instantiate() diff --git a/daemon/examples/python/wlan.py b/daemon/examples/python/wlan.py index b09ae5ce..0302bbd3 100644 --- a/daemon/examples/python/wlan.py +++ b/daemon/examples/python/wlan.py @@ -36,7 +36,7 @@ def main(): for _ in range(NODES): node = session.add_node(CoreNode, options=options) interface = prefixes.create_interface(node) - session.add_link(node.id, wlan.id, interface_one=interface) + session.add_link(node.id, wlan.id, interface1_data=interface) # instantiate session session.instantiate() diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index d602f9d3..8062a731 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -492,16 +492,16 @@ message AddLinkRequest { message AddLinkResponse { bool result = 1; - Interface interface_one = 2; - Interface interface_two = 3; + Interface interface1 = 2; + Interface interface2 = 3; } message EditLinkRequest { int32 session_id = 1; - int32 node_one_id = 2; - int32 node_two_id = 3; - int32 interface_one_id = 4; - int32 interface_two_id = 5; + int32 node1_id = 2; + int32 node2_id = 3; + int32 interface1_id = 4; + int32 interface2_id = 5; LinkOptions options = 6; } @@ -511,10 +511,10 @@ message EditLinkResponse { message DeleteLinkRequest { int32 session_id = 1; - int32 node_one_id = 2; - int32 node_two_id = 3; - int32 interface_one_id = 4; - int32 interface_two_id = 5; + int32 node1_id = 2; + int32 node2_id = 3; + int32 interface1_id = 4; + int32 interface2_id = 5; } message DeleteLinkResponse { @@ -702,11 +702,11 @@ message Node { } message Link { - int32 node_one_id = 1; - int32 node_two_id = 2; + int32 node1_id = 1; + int32 node2_id = 2; LinkType.Enum type = 3; - Interface interface_one = 4; - Interface interface_two = 5; + Interface interface1 = 4; + Interface interface2 = 5; LinkOptions options = 6; int32 network_id = 7; string label = 8; diff --git a/daemon/proto/core/api/grpc/emane.proto b/daemon/proto/core/api/grpc/emane.proto index 8c3ee4ca..e4189700 100644 --- a/daemon/proto/core/api/grpc/emane.proto +++ b/daemon/proto/core/api/grpc/emane.proto @@ -75,8 +75,8 @@ message GetEmaneEventChannelResponse { message EmaneLinkRequest { int32 session_id = 1; - int32 nem_one = 2; - int32 nem_two = 3; + int32 nem1 = 2; + int32 nem2 = 3; bool linked = 4; } @@ -93,12 +93,12 @@ message EmaneModelConfig { message EmanePathlossesRequest { int32 session_id = 1; - int32 node_one = 2; - float rx_one = 3; - int32 interface_one_id = 4; - int32 node_two = 5; - float rx_two = 6; - int32 interface_two_id = 7; + int32 node1_id = 2; + float rx1 = 3; + int32 interface1_id = 4; + int32 node2_id = 5; + float rx2 = 6; + int32 interface2_id = 7; } message EmanePathlossesResponse { diff --git a/daemon/proto/core/api/grpc/wlan.proto b/daemon/proto/core/api/grpc/wlan.proto index bbb9757f..9605d633 100644 --- a/daemon/proto/core/api/grpc/wlan.proto +++ b/daemon/proto/core/api/grpc/wlan.proto @@ -38,8 +38,8 @@ message SetWlanConfigResponse { message WlanLinkRequest { int32 session_id = 1; int32 wlan = 2; - int32 node_one = 3; - int32 node_two = 4; + int32 node1_id = 3; + int32 node2_id = 4; bool linked = 5; } diff --git a/daemon/tests/emane/test_emane.py b/daemon/tests/emane/test_emane.py index 2d90ebcc..15e3d869 100644 --- a/daemon/tests/emane/test_emane.py +++ b/daemon/tests/emane/test_emane.py @@ -3,6 +3,7 @@ Unit tests for testing CORE EMANE networks. """ import os from tempfile import TemporaryFile +from typing import Type from xml.etree import ElementTree import pytest @@ -43,7 +44,9 @@ def ping( class TestEmane: @pytest.mark.parametrize("model", _EMANE_MODELS) - def test_models(self, session: Session, model: EmaneModel, ip_prefixes: IpPrefixes): + def test_models( + self, session: Session, model: Type[EmaneModel], ip_prefixes: IpPrefixes + ): """ Test emane models within a basic network. @@ -70,20 +73,20 @@ class TestEmane: # create nodes options = NodeOptions(model="mdr") options.set_position(150, 150) - node_one = session.add_node(CoreNode, options=options) + node1 = session.add_node(CoreNode, options=options) options.set_position(300, 150) - node_two = session.add_node(CoreNode, options=options) + node2 = session.add_node(CoreNode, options=options) - for i, node in enumerate([node_one, node_two]): + for i, node in enumerate([node1, node2]): node.setposition(x=150 * (i + 1), y=150) interface = ip_prefixes.create_interface(node) - session.add_link(node.id, emane_network.id, interface_one=interface) + session.add_link(node.id, emane_network.id, interface1_data=interface) # instantiate session session.instantiate() - # ping n2 from n1 and assert success - status = ping(node_one, node_two, ip_prefixes, count=5) + # ping node2 from node1 and assert success + status = ping(node1, node2, ip_prefixes, count=5) assert not status def test_xml_emane( @@ -110,22 +113,22 @@ class TestEmane: # create nodes options = NodeOptions(model="mdr") options.set_position(150, 150) - node_one = session.add_node(CoreNode, options=options) + node1 = session.add_node(CoreNode, options=options) options.set_position(300, 150) - node_two = session.add_node(CoreNode, options=options) + node2 = session.add_node(CoreNode, options=options) - for i, node in enumerate([node_one, node_two]): + for i, node in enumerate([node1, node2]): node.setposition(x=150 * (i + 1), y=150) interface = ip_prefixes.create_interface(node) - session.add_link(node.id, emane_network.id, interface_one=interface) + session.add_link(node.id, emane_network.id, interface1_data=interface) # instantiate session session.instantiate() # get ids for nodes emane_id = emane_network.id - n1_id = node_one.id - n2_id = node_two.id + node1_id = node1.id + node2_id = node2.id # save xml xml_file = tmpdir.join("session.xml") @@ -141,9 +144,9 @@ class TestEmane: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id, CoreNode) + assert not session.get_node(node1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id, CoreNode) + assert not session.get_node(node2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) @@ -154,7 +157,7 @@ class TestEmane: ) # verify nodes and configuration were restored - assert session.get_node(n1_id, CoreNode) - assert session.get_node(n2_id, CoreNode) + assert session.get_node(node1_id, CoreNode) + assert session.get_node(node2_id, CoreNode) assert session.get_node(emane_id, EmaneNet) assert value == config_value diff --git a/daemon/tests/test_conf.py b/daemon/tests/test_conf.py index 1973dcee..e90acfbd 100644 --- a/daemon/tests/test_conf.py +++ b/daemon/tests/test_conf.py @@ -14,11 +14,11 @@ from core.nodes.network import WlanNode class TestConfigurableOptions(ConfigurableOptions): - name_one = "value1" - name_two = "value2" + name1 = "value1" + name2 = "value2" options = [ - Configuration(_id=name_one, _type=ConfigDataTypes.STRING, label=name_one), - Configuration(_id=name_two, _type=ConfigDataTypes.STRING, label=name_two), + Configuration(_id=name1, _type=ConfigDataTypes.STRING, label=name1), + Configuration(_id=name2, _type=ConfigDataTypes.STRING, label=name2), ] @@ -33,11 +33,11 @@ class TestConf: # then assert len(default_values) == 2 - assert TestConfigurableOptions.name_one in default_values - assert TestConfigurableOptions.name_two in default_values + assert TestConfigurableOptions.name1 in default_values + assert TestConfigurableOptions.name2 in default_values assert len(instance_default_values) == 2 - assert TestConfigurableOptions.name_one in instance_default_values - assert TestConfigurableOptions.name_two in instance_default_values + assert TestConfigurableOptions.name1 in instance_default_values + assert TestConfigurableOptions.name2 in instance_default_values def test_nodes(self): # given diff --git a/daemon/tests/test_core.py b/daemon/tests/test_core.py index 68515a41..626f84a7 100644 --- a/daemon/tests/test_core.py +++ b/daemon/tests/test_core.py @@ -48,19 +48,19 @@ class TestCore: net_node = session.add_node(net_type) # create nodes - node_one = session.add_node(CoreNode) - node_two = session.add_node(CoreNode) + node1 = session.add_node(CoreNode) + node2 = session.add_node(CoreNode) # link nodes to net node - for node in [node_one, node_two]: + for node in [node1, node2]: interface = ip_prefixes.create_interface(node) - session.add_link(node.id, net_node.id, interface_one=interface) + session.add_link(node.id, net_node.id, interface1_data=interface) # instantiate session session.instantiate() - # ping n2 from n1 and assert success - status = ping(node_one, node_two, ip_prefixes) + # ping node2 from node1 and assert success + status = ping(node1, node2, ip_prefixes) assert not status def test_vnode_client(self, request, session: Session, ip_prefixes: IpPrefixes): @@ -75,16 +75,16 @@ class TestCore: ptp_node = session.add_node(PtpNet) # create nodes - node_one = session.add_node(CoreNode) - node_two = session.add_node(CoreNode) + node1 = session.add_node(CoreNode) + node2 = session.add_node(CoreNode) # link nodes to ptp net - for node in [node_one, node_two]: + for node in [node1, node2]: interface = ip_prefixes.create_interface(node) - session.add_link(node.id, ptp_node.id, interface_one=interface) + session.add_link(node.id, ptp_node.id, interface1_data=interface) # get node client for testing - client = node_one.client + client = node1.client # instantiate session session.instantiate() @@ -108,13 +108,13 @@ class TestCore: ptp_node = session.add_node(PtpNet) # create nodes - node_one = session.add_node(CoreNode) - node_two = session.add_node(CoreNode) + node1 = session.add_node(CoreNode) + node2 = session.add_node(CoreNode) # link nodes to ptp net - for node in [node_one, node_two]: + for node in [node1, node2]: interface = ip_prefixes.create_interface(node) - session.add_link(node.id, ptp_node.id, interface_one=interface) + session.add_link(node.id, ptp_node.id, interface1_data=interface) # instantiate session session.instantiate() @@ -123,22 +123,22 @@ class TestCore: assert ptp_node.all_link_data(MessageFlags.ADD) # check common nets exist between linked nodes - assert node_one.commonnets(node_two) - assert node_two.commonnets(node_one) + assert node1.commonnets(node2) + assert node2.commonnets(node1) # check we can retrieve netif index - assert node_one.ifname(0) - assert node_two.ifname(0) + assert node1.ifname(0) + assert node2.ifname(0) # check interface parameters - interface = node_one.netif(0) + interface = node1.netif(0) interface.setparam("test", 1) assert interface.getparam("test") == 1 assert interface.getparams() # delete netif and test that if no longer exists - node_one.delnetif(0) - assert not node_one.netif(0) + node1.delnetif(0) + assert not node1.netif(0) def test_wlan_ping(self, session: Session, ip_prefixes: IpPrefixes): """ @@ -155,19 +155,19 @@ class TestCore: # create nodes options = NodeOptions(model="mdr") options.set_position(0, 0) - node_one = session.add_node(CoreNode, options=options) - node_two = session.add_node(CoreNode, options=options) + node1 = session.add_node(CoreNode, options=options) + node2 = session.add_node(CoreNode, options=options) # link nodes - for node in [node_one, node_two]: + for node in [node1, node2]: interface = ip_prefixes.create_interface(node) - session.add_link(node.id, wlan_node.id, interface_one=interface) + session.add_link(node.id, wlan_node.id, interface1_data=interface) # instantiate session session.instantiate() - # ping n2 from n1 and assert success - status = ping(node_one, node_two, ip_prefixes) + # ping node2 from node1 and assert success + status = ping(node1, node2, ip_prefixes) assert not status def test_mobility(self, session: Session, ip_prefixes: IpPrefixes): @@ -185,13 +185,13 @@ class TestCore: # create nodes options = NodeOptions(model="mdr") options.set_position(0, 0) - node_one = session.add_node(CoreNode, options=options) - node_two = session.add_node(CoreNode, options=options) + node1 = session.add_node(CoreNode, options=options) + node2 = session.add_node(CoreNode, options=options) # link nodes - for node in [node_one, node_two]: + for node in [node1, node2]: interface = ip_prefixes.create_interface(node) - session.add_link(node.id, wlan_node.id, interface_one=interface) + session.add_link(node.id, wlan_node.id, interface1_data=interface) # configure mobility script for session config = { diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index c0686d71..131af93d 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -34,23 +34,23 @@ class TestGrpc: client = CoreGrpcClient() session = grpc_server.coreemu.create_session() position = core_pb2.Position(x=50, y=100) - node_one = core_pb2.Node(id=1, position=position, model="PC") + node1 = core_pb2.Node(id=1, position=position, model="PC") position = core_pb2.Position(x=100, y=100) - node_two = core_pb2.Node(id=2, position=position, model="PC") + node2 = core_pb2.Node(id=2, position=position, model="PC") position = core_pb2.Position(x=200, y=200) wlan_node = core_pb2.Node( id=3, type=NodeTypes.WIRELESS_LAN.value, position=position ) - nodes = [node_one, node_two, wlan_node] + nodes = [node1, node2, wlan_node] interface_helper = InterfaceHelper(ip4_prefix="10.83.0.0/16") - interface_one = interface_helper.create_interface(node_one.id, 0) - interface_two = interface_helper.create_interface(node_two.id, 0) + interface1 = interface_helper.create_interface(node1.id, 0) + interface2 = interface_helper.create_interface(node2.id, 0) link = core_pb2.Link( type=core_pb2.LinkType.WIRED, - node_one_id=node_one.id, - node_two_id=node_two.id, - interface_one=interface_one, - interface_two=interface_two, + node1_id=node1.id, + node2_id=node2.id, + interface1=interface1, + interface2=interface2, ) links = [link] hook = core_pb2.Hook( @@ -99,11 +99,11 @@ class TestGrpc: ) mobility_configs = [mobility_config] service_config = ServiceConfig( - node_id=node_one.id, service="DefaultRoute", validate=["echo hello"] + node_id=node1.id, service="DefaultRoute", validate=["echo hello"] ) service_configs = [service_config] service_file_config = ServiceFileConfig( - node_id=node_one.id, + node_id=node1.id, service="DefaultRoute", file="defaultroute.sh", data="echo hello", @@ -128,11 +128,11 @@ class TestGrpc: ) # then - assert node_one.id in session.nodes - assert node_two.id in session.nodes + assert node1.id in session.nodes + assert node2.id in session.nodes assert wlan_node.id in session.nodes - assert session.nodes[node_one.id].netif(0) is not None - assert session.nodes[node_two.id].netif(0) is not None + assert session.nodes[node1.id].netif(0) is not None + assert session.nodes[node2.id].netif(0) is not None hook_file, hook_data = session._hooks[EventTypes.RUNTIME_STATE][0] assert hook_file == hook.file assert hook_data == hook.data @@ -153,11 +153,11 @@ class TestGrpc: ) assert set_model_config[model_config_key] == model_config_value service = session.services.get_service( - node_one.id, service_config.service, default_service=True + node1.id, service_config.service, default_service=True ) assert service.validate == tuple(service_config.validate) service_file = session.services.get_service_file( - node_one, service_file_config.service, service_file_config.file + node1, service_file_config.service, service_file_config.file ) assert service_file.data == service_file_config.data @@ -596,7 +596,7 @@ class TestGrpc: # then with client.context_connect(): response = client.edit_link( - session.id, node.id, switch.id, options, interface_one_id=interface.id + session.id, node.id, switch.id, options, interface1_id=interface.id ) # then @@ -608,28 +608,28 @@ class TestGrpc: # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() - node_one = session.add_node(CoreNode) - interface_one = ip_prefixes.create_interface(node_one) - node_two = session.add_node(CoreNode) - interface_two = ip_prefixes.create_interface(node_two) - session.add_link(node_one.id, node_two.id, interface_one, interface_two) + node1 = session.add_node(CoreNode) + interface1 = ip_prefixes.create_interface(node1) + node2 = session.add_node(CoreNode) + interface2 = ip_prefixes.create_interface(node2) + session.add_link(node1.id, node2.id, interface1, interface2) link_node = None for node_id in session.nodes: node = session.nodes[node_id] - if node.id not in {node_one.id, node_two.id}: + if node.id not in {node1.id, node2.id}: link_node = node break - assert len(link_node.all_link_data(0)) == 1 + assert len(link_node.all_link_data()) == 1 # then with client.context_connect(): response = client.delete_link( - session.id, node_one.id, node_two.id, interface_one.id, interface_two.id + session.id, node1.id, node2.id, interface1.id, interface2.id ) # then assert response.result is True - assert len(link_node.all_link_data(0)) == 0 + assert len(link_node.all_link_data()) == 0 def test_get_wlan_config(self, grpc_server: CoreGrpcServer): # given diff --git a/daemon/tests/test_gui.py b/daemon/tests/test_gui.py index 800a8e62..1187b4d7 100644 --- a/daemon/tests/test_gui.py +++ b/daemon/tests/test_gui.py @@ -50,12 +50,13 @@ class TestGui: self, coretlv: CoreHandler, node_type: NodeTypes, model: Optional[str] ): node_id = 1 + name = "node1" message = coreapi.CoreNodeMessage.create( MessageFlags.ADD.value, [ (NodeTlvs.NUMBER, node_id), (NodeTlvs.TYPE, node_type.value), - (NodeTlvs.NAME, "n1"), + (NodeTlvs.NAME, name), (NodeTlvs.X_POSITION, 0), (NodeTlvs.Y_POSITION, 0), (NodeTlvs.MODEL, model), @@ -63,7 +64,9 @@ class TestGui: ) coretlv.handle_message(message) - assert coretlv.session.get_node(node_id, NodeBase) is not None + node = coretlv.session.get_node(node_id, NodeBase) + assert node + assert node.name == name def test_node_update(self, coretlv: CoreHandler): node_id = 1 @@ -99,71 +102,71 @@ class TestGui: coretlv.session.get_node(node_id, NodeBase) def test_link_add_node_to_net(self, coretlv: CoreHandler): - node_one = 1 - coretlv.session.add_node(CoreNode, _id=node_one) - switch = 2 - coretlv.session.add_node(SwitchNode, _id=switch) + node1_id = 1 + coretlv.session.add_node(CoreNode, _id=node1_id) + switch_id = 2 + coretlv.session.add_node(SwitchNode, _id=switch_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface_one = str(ip_prefix[node_one]) + interface1_ip4 = str(ip_prefix[node1_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ - (LinkTlvs.N1_NUMBER, node_one), - (LinkTlvs.N2_NUMBER, switch), + (LinkTlvs.N1_NUMBER, node1_id), + (LinkTlvs.N2_NUMBER, switch_id), (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface_one), + (LinkTlvs.INTERFACE1_IP4, interface1_ip4), (LinkTlvs.INTERFACE1_IP4_MASK, 24), ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch, SwitchNode) + switch_node = coretlv.session.get_node(switch_id, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 def test_link_add_net_to_node(self, coretlv: CoreHandler): - node_one = 1 - coretlv.session.add_node(CoreNode, _id=node_one) - switch = 2 - coretlv.session.add_node(SwitchNode, _id=switch) + node1_id = 1 + coretlv.session.add_node(CoreNode, _id=node1_id) + switch_id = 2 + coretlv.session.add_node(SwitchNode, _id=switch_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface_one = str(ip_prefix[node_one]) + interface2_ip4 = str(ip_prefix[node1_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ - (LinkTlvs.N1_NUMBER, switch), - (LinkTlvs.N2_NUMBER, node_one), + (LinkTlvs.N1_NUMBER, switch_id), + (LinkTlvs.N2_NUMBER, node1_id), (LinkTlvs.INTERFACE2_NUMBER, 0), - (LinkTlvs.INTERFACE2_IP4, interface_one), + (LinkTlvs.INTERFACE2_IP4, interface2_ip4), (LinkTlvs.INTERFACE2_IP4_MASK, 24), ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch, SwitchNode) + switch_node = coretlv.session.get_node(switch_id, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 def test_link_add_node_to_node(self, coretlv: CoreHandler): - node_one = 1 - coretlv.session.add_node(CoreNode, _id=node_one) - node_two = 2 - coretlv.session.add_node(CoreNode, _id=node_two) + node1_id = 1 + coretlv.session.add_node(CoreNode, _id=node1_id) + node2_id = 2 + coretlv.session.add_node(CoreNode, _id=node2_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface_one = str(ip_prefix[node_one]) - interface_two = str(ip_prefix[node_two]) + interface1_ip4 = str(ip_prefix[node1_id]) + interface2_ip4 = str(ip_prefix[node2_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ - (LinkTlvs.N1_NUMBER, node_one), - (LinkTlvs.N2_NUMBER, node_two), + (LinkTlvs.N1_NUMBER, node1_id), + (LinkTlvs.N2_NUMBER, node2_id), (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface_one), + (LinkTlvs.INTERFACE1_IP4, interface1_ip4), (LinkTlvs.INTERFACE1_IP4_MASK, 24), (LinkTlvs.INTERFACE2_NUMBER, 0), - (LinkTlvs.INTERFACE2_IP4, interface_two), + (LinkTlvs.INTERFACE2_IP4, interface2_ip4), (LinkTlvs.INTERFACE2_IP4_MASK, 24), ], ) @@ -177,24 +180,24 @@ class TestGui: assert len(all_links) == 1 def test_link_update(self, coretlv: CoreHandler): - node_one = 1 - coretlv.session.add_node(CoreNode, _id=node_one) - switch = 2 - coretlv.session.add_node(SwitchNode, _id=switch) + node1_id = 1 + coretlv.session.add_node(CoreNode, _id=node1_id) + switch_id = 2 + coretlv.session.add_node(SwitchNode, _id=switch_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface_one = str(ip_prefix[node_one]) + interface1_ip4 = str(ip_prefix[node1_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ - (LinkTlvs.N1_NUMBER, node_one), - (LinkTlvs.N2_NUMBER, switch), + (LinkTlvs.N1_NUMBER, node1_id), + (LinkTlvs.N2_NUMBER, switch_id), (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface_one), + (LinkTlvs.INTERFACE1_IP4, interface1_ip4), (LinkTlvs.INTERFACE1_IP4_MASK, 24), ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch, SwitchNode) + switch_node = coretlv.session.get_node(switch_id, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 link = all_links[0] @@ -204,37 +207,37 @@ class TestGui: message = coreapi.CoreLinkMessage.create( 0, [ - (LinkTlvs.N1_NUMBER, node_one), - (LinkTlvs.N2_NUMBER, switch), + (LinkTlvs.N1_NUMBER, node1_id), + (LinkTlvs.N2_NUMBER, switch_id), (LinkTlvs.INTERFACE1_NUMBER, 0), (LinkTlvs.BANDWIDTH, bandwidth), ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch, SwitchNode) + switch_node = coretlv.session.get_node(switch_id, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 link = all_links[0] assert link.bandwidth == bandwidth def test_link_delete_node_to_node(self, coretlv: CoreHandler): - node_one = 1 - coretlv.session.add_node(CoreNode, _id=node_one) - node_two = 2 - coretlv.session.add_node(CoreNode, _id=node_two) + node1_id = 1 + coretlv.session.add_node(CoreNode, _id=node1_id) + node2_id = 2 + coretlv.session.add_node(CoreNode, _id=node2_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface_one = str(ip_prefix[node_one]) - interface_two = str(ip_prefix[node_two]) + interface1_ip4 = str(ip_prefix[node1_id]) + interface2_ip4 = str(ip_prefix[node2_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ - (LinkTlvs.N1_NUMBER, node_one), - (LinkTlvs.N2_NUMBER, node_two), + (LinkTlvs.N1_NUMBER, node1_id), + (LinkTlvs.N2_NUMBER, node2_id), (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface_one), + (LinkTlvs.INTERFACE1_IP4, interface1_ip4), (LinkTlvs.INTERFACE1_IP4_MASK, 24), - (LinkTlvs.INTERFACE2_IP4, interface_two), + (LinkTlvs.INTERFACE2_IP4, interface2_ip4), (LinkTlvs.INTERFACE2_IP4_MASK, 24), ], ) @@ -248,8 +251,8 @@ class TestGui: message = coreapi.CoreLinkMessage.create( MessageFlags.DELETE.value, [ - (LinkTlvs.N1_NUMBER, node_one), - (LinkTlvs.N2_NUMBER, node_two), + (LinkTlvs.N1_NUMBER, node1_id), + (LinkTlvs.N2_NUMBER, node2_id), (LinkTlvs.INTERFACE1_NUMBER, 0), (LinkTlvs.INTERFACE2_NUMBER, 0), ], @@ -263,74 +266,74 @@ class TestGui: assert len(all_links) == 0 def test_link_delete_node_to_net(self, coretlv: CoreHandler): - node_one = 1 - coretlv.session.add_node(CoreNode, _id=node_one) - switch = 2 - coretlv.session.add_node(SwitchNode, _id=switch) + node1_id = 1 + coretlv.session.add_node(CoreNode, _id=node1_id) + switch_id = 2 + coretlv.session.add_node(SwitchNode, _id=switch_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface_one = str(ip_prefix[node_one]) + interface1_ip4 = str(ip_prefix[node1_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ - (LinkTlvs.N1_NUMBER, node_one), - (LinkTlvs.N2_NUMBER, switch), + (LinkTlvs.N1_NUMBER, node1_id), + (LinkTlvs.N2_NUMBER, switch_id), (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface_one), + (LinkTlvs.INTERFACE1_IP4, interface1_ip4), (LinkTlvs.INTERFACE1_IP4_MASK, 24), ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch, SwitchNode) + switch_node = coretlv.session.get_node(switch_id, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 message = coreapi.CoreLinkMessage.create( MessageFlags.DELETE.value, [ - (LinkTlvs.N1_NUMBER, node_one), - (LinkTlvs.N2_NUMBER, switch), + (LinkTlvs.N1_NUMBER, node1_id), + (LinkTlvs.N2_NUMBER, switch_id), (LinkTlvs.INTERFACE1_NUMBER, 0), ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch, SwitchNode) + switch_node = coretlv.session.get_node(switch_id, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 0 def test_link_delete_net_to_node(self, coretlv: CoreHandler): - node_one = 1 - coretlv.session.add_node(CoreNode, _id=node_one) - switch = 2 - coretlv.session.add_node(SwitchNode, _id=switch) + node1_id = 1 + coretlv.session.add_node(CoreNode, _id=node1_id) + switch_id = 2 + coretlv.session.add_node(SwitchNode, _id=switch_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface_one = str(ip_prefix[node_one]) + interface1_ip4 = str(ip_prefix[node1_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ - (LinkTlvs.N1_NUMBER, node_one), - (LinkTlvs.N2_NUMBER, switch), + (LinkTlvs.N1_NUMBER, node1_id), + (LinkTlvs.N2_NUMBER, switch_id), (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface_one), + (LinkTlvs.INTERFACE1_IP4, interface1_ip4), (LinkTlvs.INTERFACE1_IP4_MASK, 24), ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch, SwitchNode) + switch_node = coretlv.session.get_node(switch_id, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 1 message = coreapi.CoreLinkMessage.create( MessageFlags.DELETE.value, [ - (LinkTlvs.N1_NUMBER, switch), - (LinkTlvs.N2_NUMBER, node_one), + (LinkTlvs.N1_NUMBER, switch_id), + (LinkTlvs.N2_NUMBER, node1_id), (LinkTlvs.INTERFACE2_NUMBER, 0), ], ) coretlv.handle_message(message) - switch_node = coretlv.session.get_node(switch, SwitchNode) + switch_node = coretlv.session.get_node(switch_id, SwitchNode) all_links = switch_node.all_link_data() assert len(all_links) == 0 diff --git a/daemon/tests/test_links.py b/daemon/tests/test_links.py index 9f693da1..61f9d13d 100644 --- a/daemon/tests/test_links.py +++ b/daemon/tests/test_links.py @@ -10,71 +10,71 @@ def create_ptp_network( session: Session, ip_prefixes: IpPrefixes ) -> Tuple[CoreNode, CoreNode]: # create nodes - node_one = session.add_node(CoreNode) - node_two = session.add_node(CoreNode) + node1 = session.add_node(CoreNode) + node2 = session.add_node(CoreNode) # link nodes to net node - interface_one = ip_prefixes.create_interface(node_one) - interface_two = ip_prefixes.create_interface(node_two) - session.add_link(node_one.id, node_two.id, interface_one, interface_two) + interface1_data = ip_prefixes.create_interface(node1) + interface2_data = ip_prefixes.create_interface(node2) + session.add_link(node1.id, node2.id, interface1_data, interface2_data) # instantiate session session.instantiate() - return node_one, node_two + return node1, node2 class TestLinks: def test_add_ptp(self, session: Session, ip_prefixes: IpPrefixes): # given - node_one = session.add_node(CoreNode) - node_two = session.add_node(CoreNode) - interface_one = ip_prefixes.create_interface(node_one) - interface_two = ip_prefixes.create_interface(node_two) + node1 = session.add_node(CoreNode) + node2 = session.add_node(CoreNode) + interface1_data = ip_prefixes.create_interface(node1) + interface2_data = ip_prefixes.create_interface(node2) # when - session.add_link(node_one.id, node_two.id, interface_one, interface_two) + session.add_link(node1.id, node2.id, interface1_data, interface2_data) # then - assert node_one.netif(interface_one.id) - assert node_two.netif(interface_two.id) + assert node1.netif(interface1_data.id) + assert node2.netif(interface2_data.id) def test_add_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): # given - node_one = session.add_node(CoreNode) - node_two = session.add_node(SwitchNode) - interface_one = ip_prefixes.create_interface(node_one) + node1 = session.add_node(CoreNode) + node2 = session.add_node(SwitchNode) + interface1_data = ip_prefixes.create_interface(node1) # when - session.add_link(node_one.id, node_two.id, interface_one=interface_one) + session.add_link(node1.id, node2.id, interface1_data=interface1_data) # then - assert node_two.all_link_data() - assert node_one.netif(interface_one.id) + assert node2.all_link_data() + assert node1.netif(interface1_data.id) def test_add_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): # given - node_one = session.add_node(SwitchNode) - node_two = session.add_node(CoreNode) - interface_two = ip_prefixes.create_interface(node_two) + node1 = session.add_node(SwitchNode) + node2 = session.add_node(CoreNode) + interface2_data = ip_prefixes.create_interface(node2) # when - session.add_link(node_one.id, node_two.id, interface_two=interface_two) + session.add_link(node1.id, node2.id, interface2_data=interface2_data) # then - assert node_one.all_link_data() - assert node_two.netif(interface_two.id) + assert node1.all_link_data() + assert node2.netif(interface2_data.id) def test_add_net_to_net(self, session): # given - node_one = session.add_node(SwitchNode) - node_two = session.add_node(SwitchNode) + node1 = session.add_node(SwitchNode) + node2 = session.add_node(SwitchNode) # when - session.add_link(node_one.id, node_two.id) + session.add_link(node1.id, node2.id) # then - assert node_one.all_link_data() + assert node1.all_link_data() def test_update_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): # given @@ -83,34 +83,31 @@ class TestLinks: per = 25 dup = 25 jitter = 10 - node_one = session.add_node(CoreNode) - node_two = session.add_node(SwitchNode) - interface_one_data = ip_prefixes.create_interface(node_one) - session.add_link(node_one.id, node_two.id, interface_one_data) - interface_one = node_one.netif(interface_one_data.id) - assert interface_one.getparam("delay") != delay - assert interface_one.getparam("bw") != bandwidth - assert interface_one.getparam("loss") != per - assert interface_one.getparam("duplicate") != dup - assert interface_one.getparam("jitter") != jitter + node1 = session.add_node(CoreNode) + node2 = session.add_node(SwitchNode) + interface1_data = ip_prefixes.create_interface(node1) + session.add_link(node1.id, node2.id, interface1_data) + interface1 = node1.netif(interface1_data.id) + assert interface1.getparam("delay") != delay + assert interface1.getparam("bw") != bandwidth + assert interface1.getparam("loss") != per + assert interface1.getparam("duplicate") != dup + assert interface1.getparam("jitter") != jitter # when - link_options = LinkOptions( + options = LinkOptions( delay=delay, bandwidth=bandwidth, per=per, dup=dup, jitter=jitter ) session.update_link( - node_one.id, - node_two.id, - interface_one_id=interface_one_data.id, - options=link_options, + node1.id, node2.id, interface1_id=interface1_data.id, options=options ) # then - assert interface_one.getparam("delay") == delay - assert interface_one.getparam("bw") == bandwidth - assert interface_one.getparam("loss") == per - assert interface_one.getparam("duplicate") == dup - assert interface_one.getparam("jitter") == jitter + assert interface1.getparam("delay") == delay + assert interface1.getparam("bw") == bandwidth + assert interface1.getparam("loss") == per + assert interface1.getparam("duplicate") == dup + assert interface1.getparam("jitter") == jitter def test_update_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): # given @@ -119,34 +116,31 @@ class TestLinks: per = 25 dup = 25 jitter = 10 - node_one = session.add_node(SwitchNode) - node_two = session.add_node(CoreNode) - interface_two_data = ip_prefixes.create_interface(node_two) - session.add_link(node_one.id, node_two.id, interface_two=interface_two_data) - interface_two = node_two.netif(interface_two_data.id) - assert interface_two.getparam("delay") != delay - assert interface_two.getparam("bw") != bandwidth - assert interface_two.getparam("loss") != per - assert interface_two.getparam("duplicate") != dup - assert interface_two.getparam("jitter") != jitter + node1 = session.add_node(SwitchNode) + node2 = session.add_node(CoreNode) + interface2_data = ip_prefixes.create_interface(node2) + session.add_link(node1.id, node2.id, interface2_data=interface2_data) + interface2 = node2.netif(interface2_data.id) + assert interface2.getparam("delay") != delay + assert interface2.getparam("bw") != bandwidth + assert interface2.getparam("loss") != per + assert interface2.getparam("duplicate") != dup + assert interface2.getparam("jitter") != jitter # when - link_options = LinkOptions( + options = LinkOptions( delay=delay, bandwidth=bandwidth, per=per, dup=dup, jitter=jitter ) session.update_link( - node_one.id, - node_two.id, - interface_two_id=interface_two_data.id, - options=link_options, + node1.id, node2.id, interface2_id=interface2_data.id, options=options ) # then - assert interface_two.getparam("delay") == delay - assert interface_two.getparam("bw") == bandwidth - assert interface_two.getparam("loss") == per - assert interface_two.getparam("duplicate") == dup - assert interface_two.getparam("jitter") == jitter + assert interface2.getparam("delay") == delay + assert interface2.getparam("bw") == bandwidth + assert interface2.getparam("loss") == per + assert interface2.getparam("duplicate") == dup + assert interface2.getparam("jitter") == jitter def test_update_ptp(self, session: Session, ip_prefixes: IpPrefixes): # given @@ -155,93 +149,85 @@ class TestLinks: per = 25 dup = 25 jitter = 10 - node_one = session.add_node(CoreNode) - node_two = session.add_node(CoreNode) - interface_one_data = ip_prefixes.create_interface(node_one) - interface_two_data = ip_prefixes.create_interface(node_two) - session.add_link( - node_one.id, node_two.id, interface_one_data, interface_two_data - ) - interface_one = node_one.netif(interface_one_data.id) - interface_two = node_two.netif(interface_two_data.id) - assert interface_one.getparam("delay") != delay - assert interface_one.getparam("bw") != bandwidth - assert interface_one.getparam("loss") != per - assert interface_one.getparam("duplicate") != dup - assert interface_one.getparam("jitter") != jitter - assert interface_two.getparam("delay") != delay - assert interface_two.getparam("bw") != bandwidth - assert interface_two.getparam("loss") != per - assert interface_two.getparam("duplicate") != dup - assert interface_two.getparam("jitter") != jitter + node1 = session.add_node(CoreNode) + node2 = session.add_node(CoreNode) + interface1_data = ip_prefixes.create_interface(node1) + interface2_data = ip_prefixes.create_interface(node2) + session.add_link(node1.id, node2.id, interface1_data, interface2_data) + interface1 = node1.netif(interface1_data.id) + interface2 = node2.netif(interface2_data.id) + assert interface1.getparam("delay") != delay + assert interface1.getparam("bw") != bandwidth + assert interface1.getparam("loss") != per + assert interface1.getparam("duplicate") != dup + assert interface1.getparam("jitter") != jitter + assert interface2.getparam("delay") != delay + assert interface2.getparam("bw") != bandwidth + assert interface2.getparam("loss") != per + assert interface2.getparam("duplicate") != dup + assert interface2.getparam("jitter") != jitter # when - link_options = LinkOptions( + options = LinkOptions( delay=delay, bandwidth=bandwidth, per=per, dup=dup, jitter=jitter ) session.update_link( - node_one.id, - node_two.id, - interface_one_data.id, - interface_two_data.id, - link_options, + node1.id, node2.id, interface1_data.id, interface2_data.id, options ) # then - assert interface_one.getparam("delay") == delay - assert interface_one.getparam("bw") == bandwidth - assert interface_one.getparam("loss") == per - assert interface_one.getparam("duplicate") == dup - assert interface_one.getparam("jitter") == jitter - assert interface_two.getparam("delay") == delay - assert interface_two.getparam("bw") == bandwidth - assert interface_two.getparam("loss") == per - assert interface_two.getparam("duplicate") == dup - assert interface_two.getparam("jitter") == jitter + assert interface1.getparam("delay") == delay + assert interface1.getparam("bw") == bandwidth + assert interface1.getparam("loss") == per + assert interface1.getparam("duplicate") == dup + assert interface1.getparam("jitter") == jitter + assert interface2.getparam("delay") == delay + assert interface2.getparam("bw") == bandwidth + assert interface2.getparam("loss") == per + assert interface2.getparam("duplicate") == dup + assert interface2.getparam("jitter") == jitter def test_delete_ptp(self, session: Session, ip_prefixes: IpPrefixes): # given - node_one = session.add_node(CoreNode) - node_two = session.add_node(CoreNode) - interface_one = ip_prefixes.create_interface(node_one) - interface_two = ip_prefixes.create_interface(node_two) - session.add_link(node_one.id, node_two.id, interface_one, interface_two) - assert node_one.netif(interface_one.id) - assert node_two.netif(interface_two.id) + node1 = session.add_node(CoreNode) + node2 = session.add_node(CoreNode) + interface1_data = ip_prefixes.create_interface(node1) + interface2_data = ip_prefixes.create_interface(node2) + session.add_link(node1.id, node2.id, interface1_data, interface2_data) + assert node1.netif(interface1_data.id) + assert node2.netif(interface2_data.id) # when - session.delete_link( - node_one.id, node_two.id, interface_one.id, interface_two.id - ) + session.delete_link(node1.id, node2.id, interface1_data.id, interface2_data.id) # then - assert not node_one.netif(interface_one.id) - assert not node_two.netif(interface_two.id) + assert not node1.netif(interface1_data.id) + assert not node2.netif(interface2_data.id) def test_delete_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): # given - node_one = session.add_node(CoreNode) - node_two = session.add_node(SwitchNode) - interface_one = ip_prefixes.create_interface(node_one) - session.add_link(node_one.id, node_two.id, interface_one) - assert node_one.netif(interface_one.id) + node1 = session.add_node(CoreNode) + node2 = session.add_node(SwitchNode) + interface1_data = ip_prefixes.create_interface(node1) + session.add_link(node1.id, node2.id, interface1_data) + assert node1.netif(interface1_data.id) # when - session.delete_link(node_one.id, node_two.id, interface_one_id=interface_one.id) + session.delete_link(node1.id, node2.id, interface1_id=interface1_data.id) # then - assert not node_one.netif(interface_one.id) + assert not node1.netif(interface1_data.id) def test_delete_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): # given - node_one = session.add_node(SwitchNode) - node_two = session.add_node(CoreNode) - interface_two = ip_prefixes.create_interface(node_two) - session.add_link(node_one.id, node_two.id, interface_two=interface_two) - assert node_two.netif(interface_two.id) + node1 = session.add_node(SwitchNode) + node2 = session.add_node(CoreNode) + interface2_data = ip_prefixes.create_interface(node2) + session.add_link(node1.id, node2.id, interface2_data=interface2_data) + assert node2.netif(interface2_data.id) # when - session.delete_link(node_one.id, node_two.id, interface_two_id=interface_two.id) + session.delete_link(node1.id, node2.id, interface2_id=interface2_data.id) # then - assert not node_two.netif(interface_two.id) + assert not node2.netif(interface2_data.id) diff --git a/daemon/tests/test_services.py b/daemon/tests/test_services.py index e304a275..264a6566 100644 --- a/daemon/tests/test_services.py +++ b/daemon/tests/test_services.py @@ -206,23 +206,23 @@ class TestServices: # given ServiceManager.add_services(_SERVICES_PATH) my_service = ServiceManager.get(SERVICE_ONE) - node_one = session.add_node(CoreNode) - node_two = session.add_node(CoreNode) + node1 = session.add_node(CoreNode) + node2 = session.add_node(CoreNode) file_name = my_service.configs[0] - file_data_one = "# custom file one" - file_data_two = "# custom file two" + file_data1 = "# custom file one" + file_data2 = "# custom file two" session.services.set_service_file( - node_one.id, my_service.name, file_name, file_data_one + node1.id, my_service.name, file_name, file_data1 ) session.services.set_service_file( - node_two.id, my_service.name, file_name, file_data_two + node2.id, my_service.name, file_name, file_data2 ) # when - custom_service_one = session.services.get_service(node_one.id, my_service.name) - session.services.create_service_files(node_one, custom_service_one) - custom_service_two = session.services.get_service(node_two.id, my_service.name) - session.services.create_service_files(node_two, custom_service_two) + custom_service1 = session.services.get_service(node1.id, my_service.name) + session.services.create_service_files(node1, custom_service1) + custom_service2 = session.services.get_service(node2.id, my_service.name) + session.services.create_service_files(node2, custom_service2) def test_service_import(self): """ diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index c40a9ef3..35e03f7d 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -68,20 +68,20 @@ class TestXml: ptp_node = session.add_node(PtpNet) # create nodes - node_one = session.add_node(CoreNode) - node_two = session.add_node(CoreNode) + node1 = session.add_node(CoreNode) + node2 = session.add_node(CoreNode) # link nodes to ptp net - for node in [node_one, node_two]: + for node in [node1, node2]: interface = ip_prefixes.create_interface(node) - session.add_link(node.id, ptp_node.id, interface_one=interface) + session.add_link(node.id, ptp_node.id, interface1_data=interface) # instantiate session session.instantiate() # get ids for nodes - n1_id = node_one.id - n2_id = node_two.id + node1_id = node1.id + node2_id = node2.id # save xml xml_file = tmpdir.join("session.xml") @@ -97,16 +97,16 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id, CoreNode) + assert not session.get_node(node1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id, CoreNode) + assert not session.get_node(node2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) # verify nodes have been recreated - assert session.get_node(n1_id, CoreNode) - assert session.get_node(n2_id, CoreNode) + assert session.get_node(node1_id, CoreNode) + assert session.get_node(node2_id, CoreNode) def test_xml_ptp_services( self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes @@ -123,28 +123,28 @@ class TestXml: # create nodes options = NodeOptions(model="host") - node_one = session.add_node(CoreNode, options=options) - node_two = session.add_node(CoreNode) + node1 = session.add_node(CoreNode, options=options) + node2 = session.add_node(CoreNode) # link nodes to ptp net - for node in [node_one, node_two]: + for node in [node1, node2]: interface = ip_prefixes.create_interface(node) - session.add_link(node.id, ptp_node.id, interface_one=interface) + session.add_link(node.id, ptp_node.id, interface1_data=interface) # set custom values for node service - session.services.set_service(node_one.id, SshService.name) + session.services.set_service(node1.id, SshService.name) service_file = SshService.configs[0] file_data = "# test" session.services.set_service_file( - node_one.id, SshService.name, service_file, file_data + node1.id, SshService.name, service_file, file_data ) # instantiate session session.instantiate() # get ids for nodes - n1_id = node_one.id - n2_id = node_two.id + node1_id = node1.id + node2_id = node2.id # save xml xml_file = tmpdir.join("session.xml") @@ -160,19 +160,19 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id, CoreNode) + assert not session.get_node(node1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id, CoreNode) + assert not session.get_node(node2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) # retrieve custom service - service = session.services.get_service(node_one.id, SshService.name) + service = session.services.get_service(node1.id, SshService.name) # verify nodes have been recreated - assert session.get_node(n1_id, CoreNode) - assert session.get_node(n2_id, CoreNode) + assert session.get_node(node1_id, CoreNode) + assert session.get_node(node2_id, CoreNode) assert service.config_data.get(service_file) == file_data def test_xml_mobility( @@ -192,21 +192,21 @@ class TestXml: # create nodes options = NodeOptions(model="mdr") options.set_position(0, 0) - node_one = session.add_node(CoreNode, options=options) - node_two = session.add_node(CoreNode, options=options) + node1 = session.add_node(CoreNode, options=options) + node2 = session.add_node(CoreNode, options=options) # link nodes - for node in [node_one, node_two]: + for node in [node1, node2]: interface = ip_prefixes.create_interface(node) - session.add_link(node.id, wlan_node.id, interface_one=interface) + session.add_link(node.id, wlan_node.id, interface1_data=interface) # instantiate session session.instantiate() # get ids for nodes wlan_id = wlan_node.id - n1_id = node_one.id - n2_id = node_two.id + node1_id = node1.id + node2_id = node2.id # save xml xml_file = tmpdir.join("session.xml") @@ -222,9 +222,9 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id, CoreNode) + assert not session.get_node(node1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id, CoreNode) + assert not session.get_node(node2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) @@ -233,8 +233,8 @@ class TestXml: value = str(session.mobility.get_config("test", wlan_id, BasicRangeModel.name)) # verify nodes and configuration were restored - assert session.get_node(n1_id, CoreNode) - assert session.get_node(n2_id, CoreNode) + assert session.get_node(node1_id, CoreNode) + assert session.get_node(node2_id, CoreNode) assert session.get_node(wlan_id, WlanNode) assert value == "1" @@ -246,18 +246,18 @@ class TestXml: :param tmpdir: tmpdir to create data in """ # create nodes - switch_one = session.add_node(SwitchNode) - switch_two = session.add_node(SwitchNode) + switch1 = session.add_node(SwitchNode) + switch2 = session.add_node(SwitchNode) # link nodes - session.add_link(switch_one.id, switch_two.id) + session.add_link(switch1.id, switch2.id) # instantiate session session.instantiate() # get ids for nodes - n1_id = switch_one.id - n2_id = switch_two.id + node1_id = switch1.id + node2_id = switch2.id # save xml xml_file = tmpdir.join("session.xml") @@ -273,19 +273,19 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id, SwitchNode) + assert not session.get_node(node1_id, SwitchNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id, SwitchNode) + assert not session.get_node(node2_id, SwitchNode) # load saved xml session.open_xml(file_path, start=True) # verify nodes have been recreated - switch_one = session.get_node(n1_id, SwitchNode) - switch_two = session.get_node(n2_id, SwitchNode) - assert switch_one - assert switch_two - assert len(switch_one.all_link_data() + switch_two.all_link_data()) == 1 + switch1 = session.get_node(node1_id, SwitchNode) + switch2 = session.get_node(node2_id, SwitchNode) + assert switch1 + assert switch2 + assert len(switch1.all_link_data() + switch2.all_link_data()) == 1 def test_link_options( self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes @@ -298,25 +298,25 @@ class TestXml: :param ip_prefixes: generates ip addresses for nodes """ # create nodes - node_one = session.add_node(CoreNode) - interface_one = ip_prefixes.create_interface(node_one) + node1 = session.add_node(CoreNode) + interface1_data = ip_prefixes.create_interface(node1) switch = session.add_node(SwitchNode) # create link - link_options = LinkOptions() - link_options.per = 10.5 - link_options.bandwidth = 50000 - link_options.jitter = 10 - link_options.delay = 30 - link_options.dup = 5 - session.add_link(node_one.id, switch.id, interface_one, options=link_options) + options = LinkOptions() + options.per = 10.5 + options.bandwidth = 50000 + options.jitter = 10 + options.delay = 30 + options.dup = 5 + session.add_link(node1.id, switch.id, interface1_data, options=options) # instantiate session session.instantiate() # get ids for nodes - n1_id = node_one.id - n2_id = switch.id + node1_id = node1.id + node2_id = switch.id # save xml xml_file = tmpdir.join("session.xml") @@ -332,26 +332,26 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id, CoreNode) + assert not session.get_node(node1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id, SwitchNode) + assert not session.get_node(node2_id, SwitchNode) # load saved xml session.open_xml(file_path, start=True) # verify nodes have been recreated - assert session.get_node(n1_id, CoreNode) - assert session.get_node(n2_id, SwitchNode) + assert session.get_node(node1_id, CoreNode) + assert session.get_node(node2_id, SwitchNode) links = [] for node_id in session.nodes: node = session.nodes[node_id] links += node.all_link_data() link = links[0] - assert link_options.per == link.per - assert link_options.bandwidth == link.bandwidth - assert link_options.jitter == link.jitter - assert link_options.delay == link.delay - assert link_options.dup == link.dup + assert options.per == link.per + assert options.bandwidth == link.bandwidth + assert options.jitter == link.jitter + assert options.delay == link.delay + assert options.dup == link.dup def test_link_options_ptp( self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes @@ -364,28 +364,26 @@ class TestXml: :param ip_prefixes: generates ip addresses for nodes """ # create nodes - node_one = session.add_node(CoreNode) - interface_one = ip_prefixes.create_interface(node_one) - node_two = session.add_node(CoreNode) - interface_two = ip_prefixes.create_interface(node_two) + node1 = session.add_node(CoreNode) + interface1_data = ip_prefixes.create_interface(node1) + node2 = session.add_node(CoreNode) + interface2_data = ip_prefixes.create_interface(node2) # create link - link_options = LinkOptions() - link_options.per = 10.5 - link_options.bandwidth = 50000 - link_options.jitter = 10 - link_options.delay = 30 - link_options.dup = 5 - session.add_link( - node_one.id, node_two.id, interface_one, interface_two, link_options - ) + options = LinkOptions() + options.per = 10.5 + options.bandwidth = 50000 + options.jitter = 10 + options.delay = 30 + options.dup = 5 + session.add_link(node1.id, node2.id, interface1_data, interface2_data, options) # instantiate session session.instantiate() # get ids for nodes - n1_id = node_one.id - n2_id = node_two.id + node1_id = node1.id + node2_id = node2.id # save xml xml_file = tmpdir.join("session.xml") @@ -401,26 +399,26 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id, CoreNode) + assert not session.get_node(node1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id, CoreNode) + assert not session.get_node(node2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) # verify nodes have been recreated - assert session.get_node(n1_id, CoreNode) - assert session.get_node(n2_id, CoreNode) + assert session.get_node(node1_id, CoreNode) + assert session.get_node(node2_id, CoreNode) links = [] for node_id in session.nodes: node = session.nodes[node_id] links += node.all_link_data() link = links[0] - assert link_options.per == link.per - assert link_options.bandwidth == link.bandwidth - assert link_options.jitter == link.jitter - assert link_options.delay == link.delay - assert link_options.dup == link.dup + assert options.per == link.per + assert options.bandwidth == link.bandwidth + assert options.jitter == link.jitter + assert options.delay == link.delay + assert options.dup == link.dup def test_link_options_bidirectional( self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes @@ -433,43 +431,37 @@ class TestXml: :param ip_prefixes: generates ip addresses for nodes """ # create nodes - node_one = session.add_node(CoreNode) - interface_one = ip_prefixes.create_interface(node_one) - node_two = session.add_node(CoreNode) - interface_two = ip_prefixes.create_interface(node_two) + node1 = session.add_node(CoreNode) + interface1_data = ip_prefixes.create_interface(node1) + node2 = session.add_node(CoreNode) + interface2_data = ip_prefixes.create_interface(node2) # create link - link_options_one = LinkOptions() - link_options_one.unidirectional = 1 - link_options_one.bandwidth = 5000 - link_options_one.delay = 10 - link_options_one.per = 10.5 - link_options_one.dup = 5 - link_options_one.jitter = 5 - session.add_link( - node_one.id, node_two.id, interface_one, interface_two, link_options_one - ) - link_options_two = LinkOptions() - link_options_two.unidirectional = 1 - link_options_two.bandwidth = 10000 - link_options_two.delay = 20 - link_options_two.per = 10 - link_options_two.dup = 10 - link_options_two.jitter = 10 + options1 = LinkOptions() + options1.unidirectional = 1 + options1.bandwidth = 5000 + options1.delay = 10 + options1.per = 10.5 + options1.dup = 5 + options1.jitter = 5 + session.add_link(node1.id, node2.id, interface1_data, interface2_data, options1) + options2 = LinkOptions() + options2.unidirectional = 1 + options2.bandwidth = 10000 + options2.delay = 20 + options2.per = 10 + options2.dup = 10 + options2.jitter = 10 session.update_link( - node_two.id, - node_one.id, - interface_two.id, - interface_one.id, - link_options_two, + node2.id, node1.id, interface2_data.id, interface1_data.id, options2 ) # instantiate session session.instantiate() # get ids for nodes - n1_id = node_one.id - n2_id = node_two.id + node1_id = node1.id + node2_id = node2.id # save xml xml_file = tmpdir.join("session.xml") @@ -485,30 +477,30 @@ class TestXml: # verify nodes have been removed from session with pytest.raises(CoreError): - assert not session.get_node(n1_id, CoreNode) + assert not session.get_node(node1_id, CoreNode) with pytest.raises(CoreError): - assert not session.get_node(n2_id, CoreNode) + assert not session.get_node(node2_id, CoreNode) # load saved xml session.open_xml(file_path, start=True) # verify nodes have been recreated - assert session.get_node(n1_id, CoreNode) - assert session.get_node(n2_id, CoreNode) + assert session.get_node(node1_id, CoreNode) + assert session.get_node(node2_id, CoreNode) links = [] for node_id in session.nodes: node = session.nodes[node_id] links += node.all_link_data() assert len(links) == 2 - link_one = links[0] - link_two = links[1] - assert link_options_one.bandwidth == link_one.bandwidth - assert link_options_one.delay == link_one.delay - assert link_options_one.per == link_one.per - assert link_options_one.dup == link_one.dup - assert link_options_one.jitter == link_one.jitter - assert link_options_two.bandwidth == link_two.bandwidth - assert link_options_two.delay == link_two.delay - assert link_options_two.per == link_two.per - assert link_options_two.dup == link_two.dup - assert link_options_two.jitter == link_two.jitter + link1 = links[0] + link2 = links[1] + assert options1.bandwidth == link1.bandwidth + assert options1.delay == link1.delay + assert options1.per == link1.per + assert options1.dup == link1.dup + assert options1.jitter == link1.jitter + assert options2.bandwidth == link2.bandwidth + assert options2.delay == link2.delay + assert options2.per == link2.per + assert options2.dup == link2.dup + assert options2.jitter == link2.jitter diff --git a/docs/scripting.md b/docs/scripting.md index 8c1a705c..59bc02ae 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -62,7 +62,7 @@ def main(): for _ in range(NODES): node = session.add_node(CoreNode) interface = prefixes.create_interface(node) - session.add_link(node.id, switch.id, interface_one=interface) + session.add_link(node.id, switch.id, interface1_data=interface) # instantiate session session.instantiate() From 178d12b32761a1a5708b70cfe8948a09115aaf4a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 12 Jun 2020 17:32:55 -0700 Subject: [PATCH 143/929] daemon: updated variables for InterfaceData to be denote data to make it more clear --- daemon/core/api/grpc/grpcutils.py | 6 +++--- daemon/core/emulator/emudata.py | 6 +++--- daemon/core/nodes/base.py | 20 ++++++++++---------- daemon/core/nodes/physical.py | 24 ++++++++++++++---------- 4 files changed, 30 insertions(+), 26 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 539face1..9a944bbe 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -59,13 +59,13 @@ def link_interface(interface_proto: core_pb2.Interface) -> InterfaceData: :param interface_proto: interface proto :return: interface data """ - interface = None + interface_data = None if interface_proto: name = interface_proto.name if interface_proto.name else None mac = interface_proto.mac if interface_proto.mac else None ip4 = interface_proto.ip4 if interface_proto.ip4 else None ip6 = interface_proto.ip6 if interface_proto.ip6 else None - interface = InterfaceData( + interface_data = InterfaceData( id=interface_proto.id, name=name, mac=mac, @@ -74,7 +74,7 @@ def link_interface(interface_proto: core_pb2.Interface) -> InterfaceData: ip6=ip6, ip6_mask=interface_proto.ip6mask, ) - return interface + return interface_data def add_link_data( diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index b6dbd57c..24b9495a 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -201,6 +201,6 @@ class IpPrefixes: generation :return: new interface data for the provided node """ - interface = self.gen_interface(node.id, name, mac) - interface.id = node.newifindex() - return interface + interface_data = self.gen_interface(node.id, name, mac) + interface_data.id = node.newifindex() + return interface_data diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 4b8d513b..37a41b81 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -475,13 +475,13 @@ class CoreNodeBase(NodeBase): raise NotImplementedError def newnetif( - self, net: "CoreNetworkBase", interface: InterfaceData + self, net: "CoreNetworkBase", interface_data: InterfaceData ) -> CoreInterface: """ Create a new network interface. :param net: network to associate with - :param interface: interface data for new interface + :param interface_data: interface data for new interface :return: interface index """ raise NotImplementedError @@ -860,34 +860,34 @@ class CoreNode(CoreNodeBase): self.node_net_client.device_up(interface_name) def newnetif( - self, net: "CoreNetworkBase", interface: InterfaceData + self, net: "CoreNetworkBase", interface_data: InterfaceData ) -> CoreInterface: """ Create a new network interface. :param net: network to associate with - :param interface: interface data for new interface + :param interface_data: interface data for new interface :return: interface index """ - addresses = interface.get_addresses() + addresses = interface_data.get_addresses() with self.lock: # TODO: emane specific code if net.is_emane is True: - ifindex = self.newtuntap(interface.id, interface.name) + ifindex = self.newtuntap(interface_data.id, interface_data.name) # TUN/TAP is not ready for addressing yet; the device may # take some time to appear, and installing it into a # namespace after it has been bound removes addressing; # save addresses with the interface now self.attachnet(ifindex, net) netif = self.netif(ifindex) - netif.sethwaddr(interface.mac) + netif.sethwaddr(interface_data.mac) for address in addresses: netif.addaddr(address) else: - ifindex = self.newveth(interface.id, interface.name) + ifindex = self.newveth(interface_data.id, interface_data.name) self.attachnet(ifindex, net) - if interface.mac: - self.sethwaddr(ifindex, interface.mac) + if interface_data.mac: + self.sethwaddr(ifindex, interface_data.mac) for address in addresses: self.addaddr(ifindex, address) self.ifup(ifindex) diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 6faa7824..a72ff128 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -157,25 +157,27 @@ class PhysicalNode(CoreNodeBase): self.ifindex += 1 return ifindex - def newnetif(self, net: CoreNetworkBase, interface: InterfaceData) -> CoreInterface: + def newnetif( + self, net: CoreNetworkBase, interface_data: InterfaceData + ) -> CoreInterface: logging.info("creating interface") - addresses = interface.get_addresses() - ifindex = interface.id + addresses = interface_data.get_addresses() + ifindex = interface_data.id if ifindex is None: ifindex = self.newifindex() - name = interface.name + name = interface_data.name if name is None: name = f"gt{ifindex}" if self.up: # this is reached when this node is linked to a network node # tunnel to net not built yet, so build it now and adopt it _, remote_tap = self.session.distributed.create_gre_tunnel(net, self.server) - self.adoptnetif(remote_tap, ifindex, interface.mac, addresses) + self.adoptnetif(remote_tap, ifindex, interface_data.mac, addresses) return remote_tap else: # this is reached when configuring services (self.up=False) netif = GreTap(node=self, name=name, session=self.session, start=False) - self.adoptnetif(netif, ifindex, interface.mac, addresses) + self.adoptnetif(netif, ifindex, interface_data.mac, addresses) return netif def privatedir(self, path: str) -> None: @@ -297,19 +299,21 @@ class Rj45Node(CoreNodeBase): self.up = False self.restorestate() - def newnetif(self, net: CoreNetworkBase, interface: InterfaceData) -> CoreInterface: + def newnetif( + self, net: CoreNetworkBase, interface_data: InterfaceData + ) -> CoreInterface: """ This is called when linking with another node. Since this node represents an interface, we do not create another object here, but attach ourselves to the given network. :param net: new network instance - :param interface: interface data for new interface + :param interface_data: interface data for new interface :return: interface index :raises ValueError: when an interface has already been created, one max """ with self.lock: - ifindex = interface.id + ifindex = interface_data.id if ifindex is None: ifindex = 0 if self.interface.net is not None: @@ -318,7 +322,7 @@ class Rj45Node(CoreNodeBase): self.ifindex = ifindex if net is not None: self.interface.attachnet(net) - for addr in interface.get_addresses(): + for addr in interface_data.get_addresses(): self.addaddr(addr) return self.interface From 23d957679e5bf121607fff392c3213ce8b0637b3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 12 Jun 2020 20:22:51 -0700 Subject: [PATCH 144/929] daemon: Session cleanup, removed unused functions, used context managers for writing files, made variables used externally no longer private --- daemon/core/api/grpc/server.py | 4 +- daemon/core/api/tlv/corehandlers.py | 12 +- daemon/core/emane/emanemanager.py | 2 +- daemon/core/emulator/session.py | 277 +++++++-------------------- daemon/core/nodes/base.py | 2 +- daemon/core/plugins/sdt.py | 2 +- daemon/core/services/coreservices.py | 8 +- daemon/core/xml/corexml.py | 4 +- daemon/tests/test_grpc.py | 2 +- daemon/tests/test_gui.py | 4 +- daemon/tests/test_xml.py | 2 +- 11 files changed, 99 insertions(+), 220 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index a0ddf806..ca9e0133 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -930,8 +930,8 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): logging.debug("get hooks: %s", request) session = self.get_session(request.session_id, context) hooks = [] - for state in session._hooks: - state_hooks = session._hooks[state] + for state in session.hooks: + state_hooks = session.hooks[state] for file_name, file_data in state_hooks: hook = core_pb2.Hook(state=state.value, file=file_name, data=file_data) hooks.append(hook) diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index e7a67b3e..33222cf3 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -12,6 +12,7 @@ import threading import time from itertools import repeat from queue import Empty, Queue +from typing import Optional from core import utils from core.api.tlv import coreapi, dataconversion, structutils @@ -39,6 +40,7 @@ from core.emulator.enumerations import ( NodeTypes, RegisterTlvs, ) +from core.emulator.session import Session from core.errors import CoreCommandError, CoreError from core.location.mobility import BasicRangeModel from core.nodes.base import CoreNode, CoreNodeBase, NodeBase @@ -83,7 +85,7 @@ class CoreHandler(socketserver.BaseRequestHandler): thread.start() self.handler_threads.append(thread) - self.session = None + self.session: Optional[Session] = None self.coreemu = server.coreemu utils.close_onexec(request.fileno()) socketserver.BaseRequestHandler.__init__(self, request, client_address, server) @@ -176,7 +178,7 @@ class CoreHandler(socketserver.BaseRequestHandler): node_count_list.append(str(session.get_node_count())) - date_list.append(time.ctime(session._state_time)) + date_list.append(time.ctime(session.state_time)) thumb = session.thumbnail if not thumb: @@ -1819,7 +1821,7 @@ class CoreHandler(socketserver.BaseRequestHandler): """ # find all nodes and links links_data = [] - with self.session._nodes_lock: + with self.session.nodes_lock: for node_id in self.session.nodes: node = self.session.nodes[node_id] self.session.broadcast_node(node, MessageFlags.ADD) @@ -1897,8 +1899,8 @@ class CoreHandler(socketserver.BaseRequestHandler): # TODO: send location info # send hook scripts - for state in sorted(self.session._hooks.keys()): - for file_name, config_data in self.session._hooks[state]: + for state in sorted(self.session.hooks.keys()): + for file_name, config_data in self.session.hooks[state]: file_data = FileData( message_type=MessageFlags.ADD, name=str(file_name), diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 146d186f..cb978cb9 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -279,7 +279,7 @@ class EmaneManager(ModelManager): logging.debug("emane setup") # TODO: drive this from the session object - with self.session._nodes_lock: + with self.session.nodes_lock: for node_id in self.session.nodes: node = self.session.nodes[node_id] if isinstance(node, EmaneNet): diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 0a90b943..f3506048 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -6,7 +6,6 @@ that manages a CORE session. import logging import os import pwd -import random import shutil import subprocess import tempfile @@ -113,15 +112,13 @@ class Session: # dict of nodes: all nodes and nets self.nodes: Dict[int, NodeBase] = {} - self._nodes_lock = threading.Lock() + self.nodes_lock = threading.Lock() + # states and hooks handlers self.state: EventTypes = EventTypes.DEFINITION_STATE - self._state_time: float = time.monotonic() - self._state_file: str = os.path.join(self.session_dir, "state") - - # hooks handlers - self._hooks: Dict[EventTypes, Tuple[str, str]] = {} - self._state_hooks: Dict[EventTypes, Callable[[int], None]] = {} + self.state_time: float = time.monotonic() + self.hooks: Dict[EventTypes, Tuple[str, str]] = {} + self.state_hooks: Dict[EventTypes, List[Callable[[EventTypes], None]]] = {} self.add_state_hook( state=EventTypes.RUNTIME_STATE, hook=self.runtime_state_hook ) @@ -154,15 +151,6 @@ class Session: self.emane: EmaneManager = EmaneManager(self) self.sdt: Sdt = Sdt(self) - # initialize default node services - self.services.default_services = { - "mdr": ("zebra", "OSPFv3MDR", "IPForward"), - "PC": ("DefaultRoute",), - "prouter": (), - "router": ("zebra", "OSPFv2", "OSPFv3", "IPForward"), - "host": ("DefaultRoute", "SSH"), - } - # config services self.service_manager: Optional[ConfigServiceManager] = None @@ -473,7 +461,7 @@ class Session: f"cannot update link node1({type(node1)}) node2({type(node2)})" ) - def _next_node_id(self) -> int: + def next_node_id(self) -> int: """ Find the next valid node id, starting from 1. @@ -506,7 +494,7 @@ class Session: # determine node id if not _id: - _id = self._next_node_id() + _id = self.next_node_id() # generate name if not provided if not options: @@ -692,7 +680,7 @@ class Session: "setting state hook: %s - %s source(%s)", state, file_name, source_name ) hook = file_name, data - state_hooks = self._hooks.setdefault(state, []) + state_hooks = self.hooks.setdefault(state, []) state_hooks.append(hook) # immediately run a hook if it is in the current state @@ -727,7 +715,7 @@ class Session: self.emane.shutdown() self.delete_nodes() self.distributed.shutdown() - self.del_hooks() + self.hooks.clear() self.emane.reset() self.emane.config_reset() self.location.reset() @@ -795,7 +783,6 @@ class Session: :param event_data: event data to send out :return: nothing """ - for handler in self.event_handlers: handler(event_data) @@ -806,7 +793,6 @@ class Session: :param exception_data: exception data to send out :return: nothing """ - for handler in self.exception_handlers: handler(exception_data) @@ -837,7 +823,6 @@ class Session: :param file_data: file data to send out :return: nothing """ - for handler in self.file_handlers: handler(file_data) @@ -848,7 +833,6 @@ class Session: :param config_data: config data to send out :return: nothing """ - for handler in self.config_handlers: handler(config_data) @@ -859,7 +843,6 @@ class Session: :param link_data: link data to send out :return: nothing """ - for handler in self.link_handlers: handler(link_data) @@ -871,22 +854,14 @@ class Session: :param send_event: if true, generate core API event messages :return: nothing """ - state_name = state.name if self.state == state: - logging.info( - "session(%s) is already in state: %s, skipping change", - self.id, - state_name, - ) return - self.state = state - self._state_time = time.monotonic() - logging.info("changing session(%s) to state %s", self.id, state_name) + self.state_time = time.monotonic() + logging.info("changing session(%s) to state %s", self.id, state.name) self.write_state(state) self.run_hooks(state) self.run_state_hooks(state) - if send_event: event_data = EventData(event_type=state, time=str(time.monotonic())) self.broadcast_event(event_data) @@ -898,10 +873,10 @@ class Session: :param state: state to write to file :return: nothing """ + state_file = os.path.join(self.session_dir, "state") try: - state_file = open(self._state_file, "w") - state_file.write(f"{state.value} {state.name}\n") - state_file.close() + with open(state_file, "w") as f: + f.write(f"{state.value} {state.name}\n") except IOError: logging.exception("error writing state file: %s", state.name) @@ -913,61 +888,10 @@ class Session: :param state: state to run hooks for :return: nothing """ - - # check that state change hooks exist - if state not in self._hooks: - return - - # retrieve all state hooks - hooks = self._hooks.get(state, []) - - # execute all state hooks - if hooks: - for hook in hooks: - self.run_hook(hook) - else: - logging.info("no state hooks for %s", state) - - def set_hook( - self, hook_type: str, file_name: str, source_name: str, data: str - ) -> None: - """ - Store a hook from a received file message. - - :param hook_type: hook type - :param file_name: file name for hook - :param source_name: source name - :param data: hook data - :return: nothing - """ - logging.info( - "setting state hook: %s - %s from %s", hook_type, file_name, source_name - ) - - _hook_id, state = hook_type.split(":")[:2] - if not state.isdigit(): - logging.error("error setting hook having state '%s'", state) - return - - state = int(state) - hook = file_name, data - - # append hook to current state hooks - state_hooks = self._hooks.setdefault(state, []) - state_hooks.append(hook) - - # immediately run a hook if it is in the current state - # (this allows hooks in the definition and configuration states) - if self.state == state: - logging.info("immediately running new state hook") + hooks = self.hooks.get(state, []) + for hook in hooks: self.run_hook(hook) - def del_hooks(self) -> None: - """ - Clear the hook scripts dict. - """ - self._hooks.clear() - def run_hook(self, hook: Tuple[str, str]) -> None: """ Run a hook. @@ -977,37 +901,23 @@ class Session: """ file_name, data = hook logging.info("running hook %s", file_name) - - # write data to hook file + file_path = os.path.join(self.session_dir, file_name) + log_path = os.path.join(self.session_dir, f"{file_name}.log") try: - hook_file = open(os.path.join(self.session_dir, file_name), "w") - hook_file.write(data) - hook_file.close() - except IOError: - logging.exception("error writing hook '%s'", file_name) - - # setup hook stdout and stderr - try: - stdout = open(os.path.join(self.session_dir, file_name + ".log"), "w") - stderr = subprocess.STDOUT - except IOError: - logging.exception("error setting up hook stderr and stdout") - stdout = None - stderr = None - - # execute hook file - try: - args = ["/bin/sh", file_name] - subprocess.check_call( - args, - stdout=stdout, - stderr=stderr, - close_fds=True, - cwd=self.session_dir, - env=self.get_environment(), - ) - except (OSError, subprocess.CalledProcessError): - logging.exception("error running hook: %s", file_name) + with open(file_path, "w") as f: + f.write(data) + with open(log_path, "w") as f: + args = ["/bin/sh", file_name] + subprocess.check_call( + args, + stdout=f, + stderr=subprocess.STDOUT, + close_fds=True, + cwd=self.session_dir, + env=self.get_environment(), + ) + except (IOError, subprocess.CalledProcessError): + logging.exception("error running hook: %s", file_path) def run_state_hooks(self, state: EventTypes) -> None: """ @@ -1016,17 +926,16 @@ class Session: :param state: state to run hooks for :return: nothing """ - for hook in self._state_hooks.get(state, []): - try: - hook(state) - except Exception: - message = ( - f"exception occured when running {state.name} state hook: {hook}" - ) - logging.exception(message) - self.exception( - ExceptionLevels.ERROR, "Session.run_state_hooks", message - ) + for hook in self.state_hooks.get(state, []): + self.run_state_hook(state, hook) + + def run_state_hook(self, state: EventTypes, hook: Callable[[EventTypes], None]): + try: + hook(state) + except Exception: + message = f"exception occurred when running {state.name} state hook: {hook}" + logging.exception(message) + self.exception(ExceptionLevels.ERROR, "Session.run_state_hooks", message) def add_state_hook( self, state: EventTypes, hook: Callable[[EventTypes], None] @@ -1038,15 +947,16 @@ class Session: :param hook: hook callback for the state :return: nothing """ - hooks = self._state_hooks.setdefault(state, []) + hooks = self.state_hooks.setdefault(state, []) if hook in hooks: raise CoreError("attempting to add duplicate state hook") hooks.append(hook) - if self.state == state: - hook(state) + self.run_state_hook(state, hook) - def del_state_hook(self, state: int, hook: Callable[[int], None]) -> None: + def del_state_hook( + self, state: EventTypes, hook: Callable[[EventTypes], None] + ) -> None: """ Delete a state hook. @@ -1054,24 +964,23 @@ class Session: :param hook: hook to delete :return: nothing """ - hooks = self._state_hooks.setdefault(state, []) - hooks.remove(hook) + hooks = self.state_hooks.get(state, []) + if hook in hooks: + hooks.remove(hook) - def runtime_state_hook(self, state: EventTypes) -> None: + def runtime_state_hook(self, _state: EventTypes) -> None: """ Runtime state hook check. - :param state: state to check + :param _state: state to check :return: nothing """ - if state == EventTypes.RUNTIME_STATE: - self.emane.poststartup() - - # create session deployed xml - xml_file_name = os.path.join(self.session_dir, "session-deployed.xml") - xml_writer = corexml.CoreXmlWriter(self) - corexmldeployment.CoreXmlDeployment(self, xml_writer.scenario) - xml_writer.write(xml_file_name) + self.emane.poststartup() + # create session deployed xml + xml_file_name = os.path.join(self.session_dir, "session-deployed.xml") + xml_writer = corexml.CoreXmlWriter(self) + corexmldeployment.CoreXmlDeployment(self, xml_writer.scenario) + xml_writer.write(xml_file_name) def get_environment(self, state: bool = True) -> Dict[str, str]: """ @@ -1090,10 +999,8 @@ class Session: env["SESSION_FILENAME"] = str(self.file_name) env["SESSION_USER"] = str(self.user) env["SESSION_NODE_COUNT"] = str(self.get_node_count()) - if state: env["SESSION_STATE"] = str(self.state) - # attempt to read and add environment config file environment_config_file = os.path.join(constants.CORE_CONF_DIR, "environment") try: @@ -1104,7 +1011,6 @@ class Session: "environment configuration file does not exist: %s", environment_config_file, ) - # attempt to read and add user environment file if self.user: environment_user_file = os.path.join( @@ -1117,7 +1023,6 @@ class Session: "user core environment settings file not present: %s", environment_user_file, ) - return env def set_thumbnail(self, thumb_file: str) -> None: @@ -1131,7 +1036,6 @@ class Session: logging.error("thumbnail file to set does not exist: %s", thumb_file) self.thumbnail = None return - destination_file = os.path.join(self.session_dir, os.path.basename(thumb_file)) shutil.copy(thumb_file, destination_file) self.thumbnail = destination_file @@ -1151,20 +1055,8 @@ class Session: os.chown(self.session_dir, uid, gid) except IOError: logging.exception("failed to set permission on %s", self.session_dir) - self.user = user - def get_node_id(self) -> int: - """ - Return a unique, new node id. - """ - with self._nodes_lock: - while True: - node_id = random.randint(1, 0xFFFF) - if node_id not in self.nodes: - break - return node_id - def create_node(self, _class: Type[NT], *args: Any, **kwargs: Any) -> NT: """ Create an emulation node. @@ -1176,7 +1068,7 @@ class Session: :raises core.CoreError: when id of the node to create already exists """ node = _class(self, *args, **kwargs) - with self._nodes_lock: + with self.nodes_lock: if node.id in self.nodes: node.shutdown() raise CoreError(f"duplicate node id {node.id} for {node.name}") @@ -1192,9 +1084,9 @@ class Session: :return: node for the given id :raises core.CoreError: when node does not exist """ - if _id not in self.nodes: + node = self.nodes.get(_id) + if node is None: raise CoreError(f"unknown node id {_id}") - node = self.nodes[_id] if not isinstance(node, _class): actual = node.__class__.__name__ expected = _class.__name__ @@ -1210,7 +1102,7 @@ class Session: """ # delete node and check for session shutdown if a node was removed node = None - with self._nodes_lock: + with self.nodes_lock: if _id in self.nodes: node = self.nodes.pop(_id) logging.info("deleted node(%s)", node.name) @@ -1224,7 +1116,7 @@ class Session: """ Clear the nodes dictionary, and call shutdown for each node. """ - with self._nodes_lock: + with self.nodes_lock: funcs = [] while self.nodes: _, node = self.nodes.popitem() @@ -1237,29 +1129,15 @@ class Session: Write nodes to a 'nodes' file in the session dir. The 'nodes' file lists: number, name, api-type, class-type """ + file_path = os.path.join(self.session_dir, "nodes") try: - with self._nodes_lock: - file_path = os.path.join(self.session_dir, "nodes") + with self.nodes_lock: with open(file_path, "w") as f: - for _id in self.nodes.keys(): - node = self.nodes[_id] + for _id, node in self.nodes.items(): f.write(f"{_id} {node.name} {node.apitype} {type(node)}\n") except IOError: logging.exception("error writing nodes file") - def dump_session(self) -> None: - """ - Log information about the session in its current state. - """ - logging.info("session id=%s name=%s state=%s", self.id, self.name, self.state) - logging.info( - "file=%s thumbnail=%s node_count=%s/%s", - self.file_name, - self.thumbnail, - self.get_node_count(), - len(self.nodes), - ) - def exception( self, level: ExceptionLevels, source: str, text: str, node_id: int = None ) -> None: @@ -1327,17 +1205,15 @@ class Session: :return: created node count """ - with self._nodes_lock: + with self.nodes_lock: count = 0 - for node_id in self.nodes: - node = self.nodes[node_id] + for node in self.nodes.values(): is_p2p_ctrlnet = isinstance(node, (PtpNet, CtrlNet)) is_tap = isinstance(node, GreTapBridge) and not isinstance( node, TunnelNode ) if is_p2p_ctrlnet or is_tap: continue - count += 1 return count @@ -1359,7 +1235,6 @@ class Session: if self.state == EventTypes.RUNTIME_STATE: logging.info("valid runtime state found, returning") return - # start event loop and set to runtime self.event_loop.run() self.set_state(EventTypes.RUNTIME_STATE, send_event=True) @@ -1375,7 +1250,7 @@ class Session: self.event_loop.stop() # stop node services - with self._nodes_lock: + with self.nodes_lock: funcs = [] for node_id in self.nodes: node = self.nodes[node_id] @@ -1447,7 +1322,7 @@ class Session: :return: service boot exceptions """ - with self._nodes_lock: + with self.nodes_lock: funcs = [] start = time.monotonic() for _id in self.nodes: @@ -1545,7 +1420,6 @@ class Session: else: prefix_spec = CtrlNet.DEFAULT_PREFIX_LIST[net_index] logging.debug("prefix spec: %s", prefix_spec) - server_interface = self.get_control_net_server_interfaces()[net_index] # return any existing controlnet bridge @@ -1685,7 +1559,7 @@ class Session: if not in runtime. """ if self.state == EventTypes.RUNTIME_STATE: - return time.monotonic() - self._state_time + return time.monotonic() - self.state_time else: return 0.0 @@ -1708,7 +1582,6 @@ class Session: """ event_time = float(event_time) current_time = self.runtime() - if current_time > 0: if event_time <= current_time: logging.warning( @@ -1718,11 +1591,9 @@ class Session: ) return event_time = event_time - current_time - self.event_loop.add_event( event_time, self.run_event, node=node, name=name, data=data ) - if not name: name = "" logging.info( @@ -1732,8 +1603,6 @@ class Session: data, ) - # TODO: if data is None, this blows up, but this ties into how event functions - # are ran, need to clean that up def run_event( self, node_id: int = None, name: str = None, data: str = None ) -> None: @@ -1745,10 +1614,12 @@ class Session: :param data: event data :return: nothing """ + if data is None: + logging.warning("no data for event node(%s) name(%s)", node_id, name) + return now = self.runtime() if not name: name = "" - logging.info("running event %s at time %s cmd=%s", name, now, data) if not node_id: utils.mute_detach(data) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 37a41b81..66ea41a0 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -63,7 +63,7 @@ class NodeBase: self.session: "Session" = session if _id is None: - _id = session.get_node_id() + _id = session.next_node_id() self.id: int = _id if name is None: name = f"o{self.id}" diff --git a/daemon/core/plugins/sdt.py b/daemon/core/plugins/sdt.py index 062217cb..04fff3e4 100644 --- a/daemon/core/plugins/sdt.py +++ b/daemon/core/plugins/sdt.py @@ -215,7 +215,7 @@ class Sdt: for layer in CORE_LAYERS: self.cmd(f"layer {layer}") - with self.session._nodes_lock: + with self.session.nodes_lock: for node_id in self.session.nodes: node = self.session.nodes[node_id] if isinstance(node, CoreNetworkBase): diff --git a/daemon/core/services/coreservices.py b/daemon/core/services/coreservices.py index 491113ff..391b53d1 100644 --- a/daemon/core/services/coreservices.py +++ b/daemon/core/services/coreservices.py @@ -325,7 +325,13 @@ class CoreServices: """ self.session = session # dict of default services tuples, key is node type - self.default_services = {} + self.default_services = { + "mdr": ("zebra", "OSPFv3MDR", "IPForward"), + "PC": ("DefaultRoute",), + "prouter": (), + "router": ("zebra", "OSPFv2", "OSPFv3", "IPForward"), + "host": ("DefaultRoute", "SSH"), + } # dict of node ids to dict of custom services by name self.custom_services = {} diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index afc1d826..45e8d9c5 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -320,8 +320,8 @@ class CoreXmlWriter: def write_session_hooks(self) -> None: # hook scripts hooks = etree.Element("session_hooks") - for state in sorted(self.session._hooks, key=lambda x: x.value): - for file_name, data in self.session._hooks[state]: + for state in sorted(self.session.hooks, key=lambda x: x.value): + for file_name, data in self.session.hooks[state]: hook = etree.SubElement(hooks, "hook") add_attribute(hook, "name", file_name) add_attribute(hook, "state", state.value) diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 131af93d..8beb4b9a 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -133,7 +133,7 @@ class TestGrpc: assert wlan_node.id in session.nodes assert session.nodes[node1.id].netif(0) is not None assert session.nodes[node2.id].netif(0) is not None - hook_file, hook_data = session._hooks[EventTypes.RUNTIME_STATE][0] + hook_file, hook_data = session.hooks[EventTypes.RUNTIME_STATE][0] assert hook_file == hook.file assert hook_data == hook.data assert session.location.refxyz == (location_x, location_y, location_z) diff --git a/daemon/tests/test_gui.py b/daemon/tests/test_gui.py index 1187b4d7..d3b9362d 100644 --- a/daemon/tests/test_gui.py +++ b/daemon/tests/test_gui.py @@ -382,7 +382,7 @@ class TestGui: def test_file_hook_add(self, coretlv: CoreHandler): state = EventTypes.DATACOLLECT_STATE - assert coretlv.session._hooks.get(state) is None + assert coretlv.session.hooks.get(state) is None file_name = "test.sh" file_data = "echo hello" message = coreapi.CoreFileMessage.create( @@ -396,7 +396,7 @@ class TestGui: coretlv.handle_message(message) - hooks = coretlv.session._hooks.get(state) + hooks = coretlv.session.hooks.get(state) assert len(hooks) == 1 name, data = hooks[0] assert file_name == name diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index 35e03f7d..b28e0986 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -48,7 +48,7 @@ class TestXml: session.open_xml(file_path, start=True) # verify nodes have been recreated - runtime_hooks = session._hooks.get(state) + runtime_hooks = session.hooks.get(state) assert runtime_hooks runtime_hook = runtime_hooks[0] assert file_name == runtime_hook[0] From e18ffaafce70b5c467d03ad11c6777e17197097c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 13 Jun 2020 17:41:13 -0700 Subject: [PATCH 145/929] daemon: xml files will now write and read loss, but fallback to looking for per for compatibility --- daemon/core/xml/corexml.py | 42 ++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 820f1cea..2b15aa5a 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -569,7 +569,7 @@ class CoreXmlWriter: options = etree.Element("options") add_attribute(options, "delay", link_data.delay) add_attribute(options, "bandwidth", link_data.bandwidth) - add_attribute(options, "per", link_data.loss) + add_attribute(options, "loss", link_data.loss) add_attribute(options, "dup", link_data.dup) add_attribute(options, "jitter", link_data.jitter) add_attribute(options, "mer", link_data.mer) @@ -947,37 +947,39 @@ class CoreXmlReader: interface_two = create_interface_data(interface_two_element) options_element = link_element.find("options") - link_options = LinkOptions() + options = LinkOptions() if options_element is not None: - link_options.bandwidth = get_int(options_element, "bandwidth") - link_options.burst = get_int(options_element, "burst") - link_options.delay = get_int(options_element, "delay") - link_options.dup = get_int(options_element, "dup") - link_options.mer = get_int(options_element, "mer") - link_options.mburst = get_int(options_element, "mburst") - link_options.jitter = get_int(options_element, "jitter") - link_options.key = get_int(options_element, "key") - link_options.loss = get_float(options_element, "per") - link_options.unidirectional = get_int(options_element, "unidirectional") - link_options.session = options_element.get("session") - link_options.emulation_id = get_int(options_element, "emulation_id") - link_options.network_id = get_int(options_element, "network_id") - link_options.opaque = options_element.get("opaque") - link_options.gui_attributes = options_element.get("gui_attributes") + options.bandwidth = get_int(options_element, "bandwidth") + options.burst = get_int(options_element, "burst") + options.delay = get_int(options_element, "delay") + options.dup = get_int(options_element, "dup") + options.mer = get_int(options_element, "mer") + options.mburst = get_int(options_element, "mburst") + options.jitter = get_int(options_element, "jitter") + options.key = get_int(options_element, "key") + options.loss = get_float(options_element, "loss") + if options.loss is None: + options.loss = get_float(options_element, "per") + options.unidirectional = get_int(options_element, "unidirectional") + options.session = options_element.get("session") + options.emulation_id = get_int(options_element, "emulation_id") + options.network_id = get_int(options_element, "network_id") + options.opaque = options_element.get("opaque") + options.gui_attributes = options_element.get("gui_attributes") - if link_options.unidirectional == 1 and node_set in node_sets: + if options.unidirectional == 1 and node_set in node_sets: logging.info( "updating link node_one(%s) node_two(%s)", node_one, node_two ) self.session.update_link( - node_one, node_two, interface_one.id, interface_two.id, link_options + node_one, node_two, interface_one.id, interface_two.id, options ) else: logging.info( "adding link node_one(%s) node_two(%s)", node_one, node_two ) self.session.add_link( - node_one, node_two, interface_one, interface_two, link_options + node_one, node_two, interface_one, interface_two, options ) node_sets.add(node_set) From 5df2e36083acaec721d3ef6b78fe2110c6d411e2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 13 Jun 2020 21:48:51 -0700 Subject: [PATCH 146/929] daemon: fixed session.add_event parameter to be specific to node_id --- daemon/core/api/tlv/corehandlers.py | 32 ++++++++++++++--------------- daemon/core/emulator/session.py | 11 +++------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 562f8c89..2cd7bfac 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -809,38 +809,38 @@ class CoreHandler(socketserver.BaseRequestHandler): :param core.api.tlv.coreapi.CoreExecMessage message: execute message to handle :return: reply messages """ - node_num = message.get_tlv(ExecuteTlvs.NODE.value) + node_id = message.get_tlv(ExecuteTlvs.NODE.value) execute_num = message.get_tlv(ExecuteTlvs.NUMBER.value) execute_time = message.get_tlv(ExecuteTlvs.TIME.value) command = message.get_tlv(ExecuteTlvs.COMMAND.value) # local flag indicates command executed locally, not on a node - if node_num is None and not message.flags & MessageFlags.LOCAL.value: + if node_id is None and not message.flags & MessageFlags.LOCAL.value: raise ValueError("Execute Message is missing node number.") if execute_num is None: raise ValueError("Execute Message is missing execution number.") if execute_time is not None: - self.session.add_event(execute_time, node=node_num, name=None, data=command) + self.session.add_event( + float(execute_time), node_id=node_id, name=None, data=command + ) return () try: - node = self.session.get_node(node_num, CoreNodeBase) + node = self.session.get_node(node_id, CoreNodeBase) # build common TLV items for reply tlv_data = b"" - if node_num is not None: - tlv_data += coreapi.CoreExecuteTlv.pack( - ExecuteTlvs.NODE.value, node_num - ) + if node_id is not None: + tlv_data += coreapi.CoreExecuteTlv.pack(ExecuteTlvs.NODE.value, node_id) tlv_data += coreapi.CoreExecuteTlv.pack( ExecuteTlvs.NUMBER.value, execute_num ) tlv_data += coreapi.CoreExecuteTlv.pack(ExecuteTlvs.COMMAND.value, command) if message.flags & MessageFlags.TTY.value: - if node_num is None: + if node_id is None: raise NotImplementedError # echo back exec message with cmd for spawning interactive terminal if command == "bash": @@ -850,7 +850,6 @@ class CoreHandler(socketserver.BaseRequestHandler): reply = coreapi.CoreExecMessage.pack(MessageFlags.TTY.value, tlv_data) return (reply,) else: - logging.info("execute message with cmd=%s", command) # execute command and send a response if ( message.flags & MessageFlags.STRING.value @@ -870,7 +869,6 @@ class CoreHandler(socketserver.BaseRequestHandler): except CoreCommandError as e: res = e.stderr status = e.returncode - logging.info("done exec cmd=%s with status=%d", command, status) if message.flags & MessageFlags.TEXT.value: tlv_data += coreapi.CoreExecuteTlv.pack( ExecuteTlvs.RESULT.value, res @@ -888,7 +886,7 @@ class CoreHandler(socketserver.BaseRequestHandler): else: node.cmd(command, wait=False) except CoreError: - logging.exception("error getting object: %s", node_num) + logging.exception("error getting object: %s", node_id) # XXX wait and queue this message to try again later # XXX maybe this should be done differently if not message.flags & MessageFlags.LOCAL.value: @@ -1549,11 +1547,11 @@ class CoreHandler(socketserver.BaseRequestHandler): if event_type == EventTypes.INSTANTIATION_STATE and isinstance( node, WlanNode ): - self.session.start_mobility(node_ids=(node.id,)) + self.session.start_mobility(node_ids=[node.id]) return () logging.warning( - "dropping unhandled event message for node: %s", node_id + "dropping unhandled event message for node: %s", node.name ) return () self.session.set_state(event_type) @@ -1611,14 +1609,16 @@ class CoreHandler(socketserver.BaseRequestHandler): self.session.save_xml(filename) elif event_type == EventTypes.SCHEDULED: etime = event_data.time - node = event_data.node + node_id = event_data.node name = event_data.name data = event_data.data if etime is None: logging.warning("Event message scheduled event missing start time") return () if message.flags & MessageFlags.ADD.value: - self.session.add_event(float(etime), node=node, name=name, data=data) + self.session.add_event( + float(etime), node_id=node_id, name=name, data=data + ) else: raise NotImplementedError diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index f3506048..2225bb6f 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -1564,23 +1564,18 @@ class Session: return 0.0 def add_event( - self, - event_time: float, - node: CoreNode = None, - name: str = None, - data: str = None, + self, event_time: float, node_id: int = None, name: str = None, data: str = None ) -> None: """ Add an event to the event queue, with a start time relative to the start of the runtime state. :param event_time: event time - :param node: node to add event for + :param node_id: node to add event for :param name: name of event :param data: data for event :return: nothing """ - event_time = float(event_time) current_time = self.runtime() if current_time > 0: if event_time <= current_time: @@ -1592,7 +1587,7 @@ class Session: return event_time = event_time - current_time self.event_loop.add_event( - event_time, self.run_event, node=node, name=name, data=data + event_time, self.run_event, node_id=node_id, name=name, data=data ) if not name: name = "" From 8d48393525094387f7598d192e21a68cbdabfae0 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 13 Jun 2020 21:53:09 -0700 Subject: [PATCH 147/929] daemon: updated usage of if1/2 to be consistent with interface1/2 for now --- daemon/core/nodes/network.py | 54 +++++++++++++++++------------------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 8ac1939e..9b46dfd5 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -898,16 +898,16 @@ class PtpNet(CoreNetwork): if len(self._netif) != 2: return all_links - if1, if2 = self._netif.values() + interface1, interface2 = self._netif.values() unidirectional = 0 - if if1.getparams() != if2.getparams(): + if interface1.getparams() != interface2.getparams(): unidirectional = 1 interface1_ip4 = None interface1_ip4_mask = None interface1_ip6 = None interface1_ip6_mask = None - for address in if1.addrlist: + for address in interface1.addrlist: ip, _sep, mask = address.partition("/") mask = int(mask) if netaddr.valid_ipv4(ip): @@ -921,7 +921,7 @@ class PtpNet(CoreNetwork): interface2_ip4_mask = None interface2_ip6 = None interface2_ip6_mask = None - for address in if2.addrlist: + for address in interface2.addrlist: ip, _sep, mask = address.partition("/") mask = int(mask) if netaddr.valid_ipv4(ip): @@ -933,31 +933,30 @@ class PtpNet(CoreNetwork): link_data = LinkData( message_type=flags, - node1_id=if1.node.id, - node2_id=if2.node.id, + node1_id=interface1.node.id, + node2_id=interface2.node.id, link_type=self.linktype, unidirectional=unidirectional, - delay=if1.getparam("delay"), - bandwidth=if1.getparam("bw"), - loss=if1.getparam("loss"), - dup=if1.getparam("duplicate"), - jitter=if1.getparam("jitter"), - interface1_id=if1.node.getifindex(if1), - interface1_name=if1.name, - interface1_mac=if1.hwaddr, + delay=interface1.getparam("delay"), + bandwidth=interface1.getparam("bw"), + loss=interface1.getparam("loss"), + dup=interface1.getparam("duplicate"), + jitter=interface1.getparam("jitter"), + interface1_id=interface1.node.getifindex(interface1), + interface1_name=interface1.name, + interface1_mac=interface1.hwaddr, interface1_ip4=interface1_ip4, interface1_ip4_mask=interface1_ip4_mask, interface1_ip6=interface1_ip6, interface1_ip6_mask=interface1_ip6_mask, - interface2_id=if2.node.getifindex(if2), - interface2_name=if2.name, - interface2_mac=if2.hwaddr, + interface2_id=interface2.node.getifindex(interface2), + interface2_name=interface2.name, + interface2_mac=interface2.hwaddr, interface2_ip4=interface2_ip4, interface2_ip4_mask=interface2_ip4_mask, interface2_ip6=interface2_ip6, interface2_ip6_mask=interface2_ip6_mask, ) - all_links.append(link_data) # build a 2nd link message for the upstream link parameters @@ -966,19 +965,18 @@ class PtpNet(CoreNetwork): link_data = LinkData( message_type=MessageFlags.NONE, link_type=self.linktype, - node1_id=if2.node.id, - node2_id=if1.node.id, - delay=if2.getparam("delay"), - bandwidth=if2.getparam("bw"), - loss=if2.getparam("loss"), - dup=if2.getparam("duplicate"), - jitter=if2.getparam("jitter"), + node1_id=interface2.node.id, + node2_id=interface1.node.id, + delay=interface2.getparam("delay"), + bandwidth=interface2.getparam("bw"), + loss=interface2.getparam("loss"), + dup=interface2.getparam("duplicate"), + jitter=interface2.getparam("jitter"), unidirectional=1, - interface1_id=if2.node.getifindex(if2), - interface2_id=if1.node.getifindex(if1), + interface1_id=interface2.node.getifindex(interface2), + interface2_id=interface1.node.getifindex(interface1), ) all_links.append(link_data) - return all_links From 91f1f7f004dba90735b735753a492b8e193424a8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 13 Jun 2020 22:01:07 -0700 Subject: [PATCH 148/929] daemon: added global type hinting to core.emulator.session and core.api.grpc.server --- daemon/core/api/grpc/server.py | 6 +++--- daemon/core/emulator/session.py | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 3374df2e..8b349b67 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -6,7 +6,7 @@ import tempfile import threading import time from concurrent import futures -from typing import Iterable, Optional, Type +from typing import Iterable, Optional, Pattern, Type import grpc from grpc import ServicerContext @@ -118,8 +118,8 @@ from core.nodes.base import CoreNode, CoreNodeBase, NodeBase from core.nodes.network import WlanNode from core.services.coreservices import ServiceManager -_ONE_DAY_IN_SECONDS = 60 * 60 * 24 -_INTERFACE_REGEX = re.compile(r"veth(?P[0-9a-fA-F]+)") +_ONE_DAY_IN_SECONDS: int = 60 * 60 * 24 +_INTERFACE_REGEX: Pattern = re.compile(r"veth(?P[0-9a-fA-F]+)") class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 2225bb6f..53f5156d 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -11,7 +11,7 @@ import subprocess import tempfile import threading import time -from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar from core import constants, utils from core.configservice.manager import ConfigServiceManager @@ -59,7 +59,7 @@ from core.xml import corexml, corexmldeployment from core.xml.corexml import CoreXmlReader, CoreXmlWriter # maps for converting from API call node type values to classes and vice versa -NODES = { +NODES: Dict[NodeTypes, Type[NodeBase]] = { NodeTypes.DEFAULT: CoreNode, NodeTypes.PHYSICAL: PhysicalNode, NodeTypes.SWITCH: SwitchNode, @@ -74,11 +74,11 @@ NODES = { NodeTypes.DOCKER: DockerNode, NodeTypes.LXC: LxcNode, } -NODES_TYPE = {NODES[x]: x for x in NODES} -CONTAINER_NODES = {DockerNode, LxcNode} -CTRL_NET_ID = 9001 -LINK_COLORS = ["green", "blue", "orange", "purple", "turquoise"] -NT = TypeVar("NT", bound=NodeBase) +NODES_TYPE: Dict[Type[NodeBase], NodeTypes] = {NODES[x]: x for x in NODES} +CONTAINER_NODES: Set[Type[NodeBase]] = {DockerNode, LxcNode} +CTRL_NET_ID: int = 9001 +LINK_COLORS: List[str] = ["green", "blue", "orange", "purple", "turquoise"] +NT: TypeVar = TypeVar("NT", bound=NodeBase) class Session: From d94bae6b42aabddd92d42a99a1fa7b4a9f4cab6b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 13 Jun 2020 22:25:38 -0700 Subject: [PATCH 149/929] daemon: added class variable type hinting to core.services.coreservices --- daemon/core/services/coreservices.py | 83 ++++++++++++++-------------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/daemon/core/services/coreservices.py b/daemon/core/services/coreservices.py index 391b53d1..d22bc7a5 100644 --- a/daemon/core/services/coreservices.py +++ b/daemon/core/services/coreservices.py @@ -10,7 +10,7 @@ services. import enum import logging import time -from typing import TYPE_CHECKING, Iterable, List, Tuple, Type +from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Tuple, Type from core import utils from core.constants import which @@ -36,14 +36,15 @@ class ServiceMode(enum.Enum): class ServiceDependencies: """ Can generate boot paths for services, based on their dependencies. Will validate - that all services will be booted and that all dependencies exist within the services provided. + that all services will be booted and that all dependencies exist within the services + provided. """ - def __init__(self, services: List[Type["CoreService"]]) -> None: + def __init__(self, services: List["CoreService"]) -> None: # helpers to check validity - self.dependents = {} - self.booted = set() - self.node_services = {} + self.dependents: Dict[str, Set[str]] = {} + self.booted: Set[str] = set() + self.node_services: Dict[str, "CoreService"] = {} for service in services: self.node_services[service.name] = service for dependency in service.dependencies: @@ -51,9 +52,9 @@ class ServiceDependencies: dependents.add(service.name) # used to find paths - self.path = [] - self.visited = set() - self.visiting = set() + self.path: List["CoreService"] = [] + self.visited: Set[str] = set() + self.visiting: Set[str] = set() def boot_paths(self) -> List[List["CoreService"]]: """ @@ -131,7 +132,7 @@ class ServiceDependencies: class ServiceShim: - keys = [ + keys: List[str] = [ "dirs", "files", "startidx", @@ -241,10 +242,10 @@ class ServiceManager: Manages services available for CORE nodes to use. """ - services = {} + services: Dict[str, Type["CoreService"]] = {} @classmethod - def add(cls, service: "CoreService") -> None: + def add(cls, service: Type["CoreService"]) -> None: """ Add a service to manager. @@ -314,8 +315,8 @@ class CoreServices: custom service configuration. A CoreService is not a Configurable. """ - name = "services" - config_type = RegisterTlvs.UTILITY + name: str = "services" + config_type: RegisterTlvs = RegisterTlvs.UTILITY def __init__(self, session: "Session") -> None: """ @@ -323,17 +324,17 @@ class CoreServices: :param session: session this manager is tied to """ - self.session = session + self.session: "Session" = session # dict of default services tuples, key is node type - self.default_services = { - "mdr": ("zebra", "OSPFv3MDR", "IPForward"), - "PC": ("DefaultRoute",), - "prouter": (), - "router": ("zebra", "OSPFv2", "OSPFv3", "IPForward"), - "host": ("DefaultRoute", "SSH"), + self.default_services: Dict[str, List[str]] = { + "mdr": ["zebra", "OSPFv3MDR", "IPForward"], + "PC": ["DefaultRoute"], + "prouter": [], + "router": ["zebra", "OSPFv2", "OSPFv3", "IPForward"], + "host": ["DefaultRoute", "SSH"], } # dict of node ids to dict of custom services by name - self.custom_services = {} + self.custom_services: Dict[int, Dict[str, "CoreService"]] = {} def reset(self) -> None: """ @@ -425,7 +426,7 @@ class CoreServices: continue node.services.append(service) - def all_configs(self) -> List[Tuple[int, Type["CoreService"]]]: + def all_configs(self) -> List[Tuple[int, "CoreService"]]: """ Return (node_id, service) tuples for all stored configs. Used when reconnecting to a session or opening XML. @@ -808,50 +809,50 @@ class CoreService: """ # service name should not include spaces - name = None + name: Optional[str] = None # executables that must exist for service to run - executables = () + executables: Tuple[str, ...] = () # sets service requirements that must be started prior to this service starting - dependencies = () + dependencies: Tuple[str, ...] = () # group string allows grouping services together - group = None + group: Optional[str] = None # private, per-node directories required by this service - dirs = () + dirs: Tuple[str, ...] = () # config files written by this service - configs = () + configs: Tuple[str, ...] = () # config file data - config_data = {} + config_data: Dict[str, str] = {} # list of startup commands - startup = () + startup: Tuple[str, ...] = () # list of shutdown commands - shutdown = () + shutdown: Tuple[str, ...] = () # list of validate commands - validate = () + validate: Tuple[str, ...] = () # validation mode, used to determine startup success - validation_mode = ServiceMode.NON_BLOCKING + validation_mode: ServiceMode = ServiceMode.NON_BLOCKING # time to wait in seconds for determining if service started successfully - validation_timer = 5 + validation_timer: int = 5 # validation period in seconds, how frequent validation is attempted - validation_period = 0.5 + validation_period: float = 0.5 # metadata associated with this service - meta = None + meta: Optional[str] = None # custom configuration text - custom = False - custom_needed = False + custom: bool = False + custom_needed: bool = False def __init__(self) -> None: """ @@ -859,8 +860,8 @@ class CoreService: against their config. Services are instantiated when a custom configuration is used to override their default parameters. """ - self.custom = True - self.config_data = self.__class__.config_data.copy() + self.custom: bool = True + self.config_data: Dict[str, str] = self.__class__.config_data.copy() @classmethod def on_load(cls) -> None: From 8587da062161130c9ab81b072548c79a7e860b5d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 13 Jun 2020 23:50:08 -0700 Subject: [PATCH 150/929] daemon: moved node instantiation into lock to guarantee id uniqueness, removed node count from environment as it also attmpts to use lock and wouldnt be accurate either --- daemon/core/emulator/session.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 53f5156d..826e3f0a 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -998,7 +998,6 @@ class Session: env["SESSION_NAME"] = str(self.name) env["SESSION_FILENAME"] = str(self.file_name) env["SESSION_USER"] = str(self.user) - env["SESSION_NODE_COUNT"] = str(self.get_node_count()) if state: env["SESSION_STATE"] = str(self.state) # attempt to read and add environment config file @@ -1067,8 +1066,8 @@ class Session: :return: the created node instance :raises core.CoreError: when id of the node to create already exists """ - node = _class(self, *args, **kwargs) with self.nodes_lock: + node = _class(self, *args, **kwargs) if node.id in self.nodes: node.shutdown() raise CoreError(f"duplicate node id {node.id} for {node.name}") From 3243a69afa891a23d75fcd149b2cc0d1b98261fb Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 14 Jun 2020 00:46:11 -0700 Subject: [PATCH 151/929] daemon: updated xml files to use node1 and interface1 instead of node_one and interface_one, will still fallback to parse old names --- daemon/core/xml/corexml.py | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 831ffae6..759de680 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -45,11 +45,11 @@ def get_type(element: etree.Element, name: str, _type: Generic[T]) -> Optional[T return value -def get_float(element: etree.Element, name: str) -> float: +def get_float(element: etree.Element, name: str) -> Optional[float]: return get_type(element, name, float) -def get_int(element: etree.Element, name: str) -> int: +def get_int(element: etree.Element, name: str) -> Optional[int]: return get_type(element, name, int) @@ -529,13 +529,13 @@ class CoreXmlWriter: def create_link_element(self, link_data: LinkData) -> etree.Element: link_element = etree.Element("link") - add_attribute(link_element, "node_one", link_data.node1_id) - add_attribute(link_element, "node_two", link_data.node2_id) + add_attribute(link_element, "node1", link_data.node1_id) + add_attribute(link_element, "node2", link_data.node2_id) # check for interface one if link_data.interface1_id is not None: interface1 = self.create_interface_element( - "interface_one", + "interface1", link_data.node1_id, link_data.interface1_id, link_data.interface1_mac, @@ -549,7 +549,7 @@ class CoreXmlWriter: # check for interface two if link_data.interface2_id is not None: interface2 = self.create_interface_element( - "interface_two", + "interface2", link_data.node2_id, link_data.interface2_id, link_data.interface2_mac, @@ -932,16 +932,24 @@ class CoreXmlReader: node_sets = set() for link_element in link_elements.iterchildren(): - node1_id = get_int(link_element, "node_one") - node2_id = get_int(link_element, "node_two") + node1_id = get_int(link_element, "node1") + if node1_id is None: + node1_id = get_int(link_element, "node_one") + node2_id = get_int(link_element, "node2") + if node2_id is None: + node2_id = get_int(link_element, "node_two") node_set = frozenset((node1_id, node2_id)) - interface1_element = link_element.find("interface_one") + interface1_element = link_element.find("interface1") + if interface1_element is None: + interface1_element = link_element.find("interface_one") interface1_data = None if interface1_element is not None: interface1_data = create_interface_data(interface1_element) - interface2_element = link_element.find("interface_two") + interface2_element = link_element.find("interface2") + if interface2_element is None: + interface2_element = link_element.find("interface_two") interface2_data = None if interface2_element is not None: interface2_data = create_interface_data(interface2_element) From c4c667bb741fbd2027eedd61c61ddb35c38ca414 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 14 Jun 2020 09:37:58 -0700 Subject: [PATCH 152/929] daemon: removed node.startup from inside constructor, session is now responsible, providing more control and avoiding issues when using super calls where you dont want to start just yet --- daemon/core/emane/nodes.py | 3 +-- daemon/core/emulator/session.py | 14 ++++++++++---- daemon/core/nodes/base.py | 26 ++++++++------------------ daemon/core/nodes/docker.py | 4 +--- daemon/core/nodes/lxd.py | 4 +--- daemon/core/nodes/network.py | 20 +++++--------------- daemon/core/nodes/physical.py | 15 ++++----------- 7 files changed, 30 insertions(+), 56 deletions(-) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index e88cb194..68c1bc05 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -55,10 +55,9 @@ class EmaneNet(CoreNetworkBase): session: "Session", _id: int = None, name: str = None, - start: bool = True, server: DistributedServer = None, ) -> None: - super().__init__(session, _id, name, start, server) + super().__init__(session, _id, name, server) self.conf: str = "" self.nemidmap: Dict[CoreInterface, int] = {} self.model: "OptionalEmaneModel" = None diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 826e3f0a..e63c30c7 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -257,7 +257,7 @@ class Session: if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): logging.info("linking ptp: %s - %s", node1.name, node2.name) start = self.state.should_start() - ptp = self.create_node(PtpNet, start=start) + ptp = self.create_node(PtpNet, start) interface1 = node1.newnetif(ptp, interface1_data) interface2 = node2.newnetif(ptp, interface2_data) ptp.linkconfig(interface1, options) @@ -517,10 +517,10 @@ class Session: name, start, ) - kwargs = dict(_id=_id, name=name, start=start, server=server) + kwargs = dict(_id=_id, name=name, server=server) if _class in CONTAINER_NODES: kwargs["image"] = options.image - node = self.create_node(_class, **kwargs) + node = self.create_node(_class, start, **kwargs) # set node attributes node.icon = options.icon @@ -1056,11 +1056,14 @@ class Session: logging.exception("failed to set permission on %s", self.session_dir) self.user = user - def create_node(self, _class: Type[NT], *args: Any, **kwargs: Any) -> NT: + def create_node( + self, _class: Type[NT], start: bool, *args: Any, **kwargs: Any + ) -> NT: """ Create an emulation node. :param _class: node class to create + :param start: True to start node, False otherwise :param args: list of arguments for the class to create :param kwargs: dictionary of arguments for the class to create :return: the created node instance @@ -1072,6 +1075,8 @@ class Session: node.shutdown() raise CoreError(f"duplicate node id {node.id} for {node.name}") self.nodes[node.id] = node + if start: + node.startup() return node def get_node(self, _id: int, _class: Type[NT]) -> NT: @@ -1464,6 +1469,7 @@ class Session: ) control_net = self.create_node( CtrlNet, + True, prefix, _id=_id, updown_script=updown_script, diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index cd77f857..49fe7620 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -7,7 +7,7 @@ import os import shutil import threading from threading import RLock -from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type import netaddr @@ -47,7 +47,6 @@ class NodeBase: session: "Session", _id: int = None, name: str = None, - start: bool = True, server: "DistributedServer" = None, ) -> None: """ @@ -56,7 +55,6 @@ class NodeBase: :param session: CORE session object :param _id: id :param name: object name - :param start: start value :param server: remote server node will run on, default is None for localhost """ @@ -254,7 +252,6 @@ class CoreNodeBase(NodeBase): session: "Session", _id: int = None, name: str = None, - start: bool = True, server: "DistributedServer" = None, ) -> None: """ @@ -263,11 +260,10 @@ class CoreNodeBase(NodeBase): :param session: CORE session object :param _id: object id :param name: object name - :param start: boolean for starting :param server: remote server node will run on, default is None for localhost """ - super().__init__(session, _id, name, start, server) + super().__init__(session, _id, name, server) self.config_services: Dict[str, "ConfigService"] = {} self.nodedir: Optional[str] = None self.tmpnodedir: bool = False @@ -492,8 +488,8 @@ class CoreNode(CoreNodeBase): Provides standard core node logic. """ - apitype = NodeTypes.DEFAULT - valid_address_types = {"inet", "inet6", "inet6link"} + apitype: NodeTypes = NodeTypes.DEFAULT + valid_address_types: Set[str] = {"inet", "inet6", "inet6link"} def __init__( self, @@ -501,7 +497,6 @@ class CoreNode(CoreNodeBase): _id: int = None, name: str = None, nodedir: str = None, - start: bool = True, server: "DistributedServer" = None, ) -> None: """ @@ -511,11 +506,10 @@ class CoreNode(CoreNodeBase): :param _id: object id :param name: object name :param nodedir: node directory - :param start: start flag :param server: remote server node will run on, default is None for localhost """ - super().__init__(session, _id, name, start, server) + super().__init__(session, _id, name, server) self.nodedir: Optional[str] = nodedir self.ctrlchnlname: str = os.path.abspath( os.path.join(self.session.session_dir, self.name) @@ -526,8 +520,6 @@ class CoreNode(CoreNodeBase): self._mounts: List[Tuple[str, str]] = [] use_ovs = session.options.get_config("ovs") == "True" self.node_net_client: LinuxNetClient = self.create_node_net_client(use_ovs) - if start: - self.startup() def create_node_net_client(self, use_ovs: bool) -> LinuxNetClient: """ @@ -981,15 +973,14 @@ class CoreNetworkBase(NodeBase): Base class for networks """ - linktype = LinkTypes.WIRED - is_emane = False + linktype: LinkTypes = LinkTypes.WIRED + is_emane: bool = False def __init__( self, session: "Session", _id: int, name: str, - start: bool = True, server: "DistributedServer" = None, ) -> None: """ @@ -998,11 +989,10 @@ class CoreNetworkBase(NodeBase): :param session: CORE session object :param _id: object id :param name: object name - :param start: should object start :param server: remote server node will run on, default is None for localhost """ - super().__init__(session, _id, name, start, server) + super().__init__(session, _id, name, server) self.brname = None self._linked = {} self._linked_lock = threading.Lock() diff --git a/daemon/core/nodes/docker.py b/daemon/core/nodes/docker.py index fa4b8f8b..e911db74 100644 --- a/daemon/core/nodes/docker.py +++ b/daemon/core/nodes/docker.py @@ -77,7 +77,6 @@ class DockerNode(CoreNode): _id: int = None, name: str = None, nodedir: str = None, - start: bool = True, server: DistributedServer = None, image: str = None ) -> None: @@ -88,7 +87,6 @@ class DockerNode(CoreNode): :param _id: object id :param name: object name :param nodedir: node directory - :param start: start flag :param server: remote server node will run on, default is None for localhost :param image: image to start container with @@ -96,7 +94,7 @@ class DockerNode(CoreNode): if image is None: image = "ubuntu" self.image: str = image - super().__init__(session, _id, name, nodedir, start, server) + super().__init__(session, _id, name, nodedir, server) def create_node_net_client(self, use_ovs: bool) -> LinuxNetClient: """ diff --git a/daemon/core/nodes/lxd.py b/daemon/core/nodes/lxd.py index af906f01..a66791ce 100644 --- a/daemon/core/nodes/lxd.py +++ b/daemon/core/nodes/lxd.py @@ -74,7 +74,6 @@ class LxcNode(CoreNode): _id: int = None, name: str = None, nodedir: str = None, - start: bool = True, server: DistributedServer = None, image: str = None, ) -> None: @@ -85,7 +84,6 @@ class LxcNode(CoreNode): :param _id: object id :param name: object name :param nodedir: node directory - :param start: start flag :param server: remote server node will run on, default is None for localhost :param image: image to start container with @@ -93,7 +91,7 @@ class LxcNode(CoreNode): if image is None: image = "ubuntu" self.image: str = image - super().__init__(session, _id, name, nodedir, start, server) + super().__init__(session, _id, name, nodedir, server) def alive(self) -> bool: """ diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 9b46dfd5..b85c2eee 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -264,7 +264,6 @@ class CoreNetwork(CoreNetworkBase): session: "Session", _id: int = None, name: str = None, - start: bool = True, server: "DistributedServer" = None, policy: NetworkPolicy = None, ) -> None: @@ -274,12 +273,11 @@ class CoreNetwork(CoreNetworkBase): :param session: core session instance :param _id: object id :param name: object name - :param start: start flag :param server: remote server node will run on, default is None for localhost :param policy: network policy """ - super().__init__(session, _id, name, start, server) + super().__init__(session, _id, name, server) if name is None: name = str(self.id) if policy is not None: @@ -288,9 +286,6 @@ class CoreNetwork(CoreNetworkBase): sessionid = self.session.short_session_id() self.brname: str = f"b.{self.id}.{sessionid}" self.has_ebtables_chain: bool = False - if start: - self.startup() - ebq.startupdateloop(self) def host_cmd( self, @@ -327,6 +322,7 @@ class CoreNetwork(CoreNetworkBase): self.net_client.create_bridge(self.brname) self.has_ebtables_chain = False self.up = True + ebq.startupdateloop(self) def shutdown(self) -> None: """ @@ -610,7 +606,6 @@ class GreTapBridge(CoreNetwork): localip: str = None, ttl: int = 255, key: int = None, - start: bool = True, server: "DistributedServer" = None, ) -> None: """ @@ -628,7 +623,7 @@ class GreTapBridge(CoreNetwork): :param server: remote server node will run on, default is None for localhost """ - CoreNetwork.__init__(self, session, _id, name, False, server, policy) + CoreNetwork.__init__(self, session, _id, name, server, policy) if key is None: key = self.session.id ^ self.id self.grekey: int = key @@ -647,8 +642,6 @@ class GreTapBridge(CoreNetwork): ttl=ttl, key=self.grekey, ) - if start: - self.startup() def startup(self) -> None: """ @@ -734,7 +727,6 @@ class CtrlNet(CoreNetwork): _id: int = None, name: str = None, hostid: int = None, - start: bool = True, server: "DistributedServer" = None, assign_address: bool = True, updown_script: str = None, @@ -748,7 +740,6 @@ class CtrlNet(CoreNetwork): :param name: node namee :param prefix: control network ipv4 prefix :param hostid: host id - :param start: start flag :param server: remote server node will run on, default is None for localhost :param assign_address: assigned address @@ -761,7 +752,7 @@ class CtrlNet(CoreNetwork): self.assign_address: bool = assign_address self.updown_script: Optional[str] = updown_script self.serverintf: Optional[str] = serverintf - super().__init__(session, _id, name, start, server) + super().__init__(session, _id, name, server) def add_addresses(self, index: int) -> None: """ @@ -1025,7 +1016,6 @@ class WlanNode(CoreNetwork): session: "Session", _id: int = None, name: str = None, - start: bool = True, server: "DistributedServer" = None, policy: NetworkPolicy = None, ) -> None: @@ -1040,7 +1030,7 @@ class WlanNode(CoreNetwork): will run on, default is None for localhost :param policy: wlan policy """ - super().__init__(session, _id, name, start, server, policy) + super().__init__(session, _id, name, server, policy) # wireless and mobility models (BasicRangeModel, Ns2WaypointMobility) self.model: Optional[WirelessModel] = None self.mobility: Optional[WayPointMobility] = None diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index a72ff128..a2e39d49 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -28,22 +28,19 @@ class PhysicalNode(CoreNodeBase): _id: int = None, name: str = None, nodedir: str = None, - start: bool = True, server: DistributedServer = None, ) -> None: - super().__init__(session, _id, name, start, server) + super().__init__(session, _id, name, server) if not self.server: raise CoreError("physical nodes must be assigned to a remote server") self.nodedir: Optional[str] = nodedir - self.up: bool = start self.lock: threading.RLock = threading.RLock() self._mounts: List[Tuple[str, str]] = [] - if start: - self.startup() def startup(self) -> None: with self.lock: self.makenodedir() + self.up = True def shutdown(self) -> None: if not self.up: @@ -144,7 +141,7 @@ class PhysicalNode(CoreNodeBase): """ Apply tc queing disciplines using linkconfig. """ - linux_bridge = CoreNetwork(session=self.session, start=False) + linux_bridge = CoreNetwork(self.session) linux_bridge.up = True linux_bridge.linkconfig(netif, options, netif2) del linux_bridge @@ -244,7 +241,6 @@ class Rj45Node(CoreNodeBase): _id: int = None, name: str = None, mtu: int = 1500, - start: bool = True, server: DistributedServer = None, ) -> None: """ @@ -254,19 +250,16 @@ class Rj45Node(CoreNodeBase): :param _id: node id :param name: node name :param mtu: rj45 mtu - :param start: start flag :param server: remote server node will run on, default is None for localhost """ - super().__init__(session, _id, name, start, server) + super().__init__(session, _id, name, server) self.interface = CoreInterface(session, self, name, name, mtu, server) self.interface.transport_type = TransportType.RAW self.lock: threading.RLock = threading.RLock() self.ifindex: Optional[int] = None self.old_up: bool = False self.old_addrs: List[Tuple[str, Optional[str]]] = [] - if start: - self.startup() def startup(self) -> None: """ From cf4194889410c1faab5d04ca307a85f4f9b4f656 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 14 Jun 2020 12:36:07 -0700 Subject: [PATCH 153/929] daemon: fixed error with EmaneNet startup throwing an error, updated Rj45Node and PhysicalNode to implement all abstract methods --- daemon/core/emane/nodes.py | 3 +++ daemon/core/nodes/physical.py | 26 ++++++++++++++++---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index 68c1bc05..8383cdd1 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -76,6 +76,9 @@ class EmaneNet(CoreNetworkBase): def config(self, conf: str) -> None: self.conf = conf + def startup(self) -> None: + pass + def shutdown(self) -> None: pass diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index a2e39d49..13214093 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -217,14 +217,17 @@ class PhysicalNode(CoreNodeBase): return open(hostfilename, mode) def nodefile(self, filename: str, contents: str, mode: int = 0o644) -> None: - with self.opennodefile(filename, "w") as node_file: - node_file.write(contents) - os.chmod(node_file.name, mode) - logging.info("created nodefile: '%s'; mode: 0%o", node_file.name, mode) + with self.opennodefile(filename, "w") as f: + f.write(contents) + os.chmod(f.name, mode) + logging.info("created nodefile: '%s'; mode: 0%o", f.name, mode) def cmd(self, args: str, wait: bool = True, shell: bool = False) -> str: return self.host_cmd(args, wait=wait) + def addfile(self, srcname: str, filename: str) -> None: + raise NotImplementedError + class Rj45Node(CoreNodeBase): """ @@ -446,10 +449,13 @@ class Rj45Node(CoreNodeBase): self.interface.setposition() def termcmdstring(self, sh: str) -> str: - """ - Create a terminal command string. - - :param sh: shell to execute command in - :return: str - """ + raise NotImplementedError + + def addfile(self, srcname: str, filename: str) -> None: + raise NotImplementedError + + def nodefile(self, filename: str, contents: str, mode: int = 0o644) -> None: + raise NotImplementedError + + def cmd(self, args: str, wait: bool = True, shell: bool = False) -> str: raise NotImplementedError From f5916fab5b041c3dcb20ba8589abd4f7e6e698df Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 14 Jun 2020 12:44:51 -0700 Subject: [PATCH 154/929] daemon: added not implemented methods to CoreNodeBase --- daemon/core/nodes/base.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 49fe7620..3e9dfe7a 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -41,7 +41,6 @@ class NodeBase: apitype: Optional[NodeTypes] = None - # TODO: appears start has no usage, verify and remove def __init__( self, session: "Session", @@ -268,6 +267,12 @@ class CoreNodeBase(NodeBase): self.nodedir: Optional[str] = None self.tmpnodedir: bool = False + def startup(self) -> None: + raise NotImplementedError + + def shutdown(self) -> None: + raise NotImplementedError + def add_config_service(self, service_class: "ConfigServiceType") -> None: """ Adds a configuration service to the node. From 0462c1b0841bebdc0516d885344b3c52a3a03096 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 14 Jun 2020 13:35:06 -0700 Subject: [PATCH 155/929] daemon: added usage of ABC to NodeBase, CoreNodeBase, and CoreNetworkBase to help enforce accounting for abstract functions --- daemon/core/emane/nodes.py | 3 + daemon/core/nodes/base.py | 155 ++++++++++++++++++---------------- daemon/core/nodes/physical.py | 10 +-- 3 files changed, 92 insertions(+), 76 deletions(-) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index 8383cdd1..c4c3428b 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -88,6 +88,9 @@ class EmaneNet(CoreNetworkBase): def unlink(self, netif1: CoreInterface, netif2: CoreInterface) -> None: pass + def linknet(self, net: "CoreNetworkBase") -> CoreInterface: + raise CoreError("emane networks cannot be linked to other networks") + def updatemodel(self, config: Dict[str, str]) -> None: if not self.model: raise CoreError(f"no model set to update for node({self.name})") diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 3e9dfe7a..6c7ebcf0 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -1,7 +1,7 @@ """ Defines the base logic for nodes used within core. """ - +import abc import logging import os import shutil @@ -34,7 +34,7 @@ if TYPE_CHECKING: _DEFAULT_MTU = 1500 -class NodeBase: +class NodeBase(abc.ABC): """ Base class for CORE nodes (nodes and networks) """ @@ -78,6 +78,7 @@ class NodeBase: use_ovs = session.options.get_config("ovs") == "True" self.net_client: LinuxNetClient = get_net_client(use_ovs, self.host_cmd) + @abc.abstractmethod def startup(self) -> None: """ Each object implements its own startup method. @@ -86,6 +87,7 @@ class NodeBase: """ raise NotImplementedError + @abc.abstractmethod def shutdown(self) -> None: """ Each object implements its own shutdown method. @@ -267,12 +269,74 @@ class CoreNodeBase(NodeBase): self.nodedir: Optional[str] = None self.tmpnodedir: bool = False + @abc.abstractmethod def startup(self) -> None: raise NotImplementedError + @abc.abstractmethod def shutdown(self) -> None: raise NotImplementedError + @abc.abstractmethod + def nodefile(self, filename: str, contents: str, mode: int = 0o644) -> None: + """ + Create a node file with a given mode. + + :param filename: name of file to create + :param contents: contents of file + :param mode: mode for file + :return: nothing + """ + raise NotImplementedError + + @abc.abstractmethod + def addfile(self, srcname: str, filename: str) -> None: + """ + Add a file. + + :param srcname: source file name + :param filename: file name to add + :return: nothing + :raises CoreCommandError: when a non-zero exit status occurs + """ + raise NotImplementedError + + @abc.abstractmethod + def cmd(self, args: str, wait: bool = True, shell: bool = False) -> str: + """ + Runs a command within a node container. + + :param args: command to run + :param wait: True to wait for status, False otherwise + :param shell: True to use shell, False otherwise + :return: combined stdout and stderr + :raises CoreCommandError: when a non-zero exit status occurs + """ + raise NotImplementedError + + @abc.abstractmethod + def termcmdstring(self, sh: str) -> str: + """ + Create a terminal command string. + + :param sh: shell to execute command in + :return: str + """ + raise NotImplementedError + + @abc.abstractmethod + def newnetif( + self, net: "CoreNetworkBase", interface_data: InterfaceData + ) -> CoreInterface: + """ + Create a new network interface. + + :param net: network to associate with + :param interface_data: interface data for new interface + :return: interface index + """ + raise NotImplementedError + def add_config_service(self, service_class: "ConfigServiceType") -> None: """ Adds a configuration service to the node. @@ -432,61 +496,6 @@ class CoreNodeBase(NodeBase): common.append((netif1.net, netif1, netif2)) return common - def nodefile(self, filename: str, contents: str, mode: int = 0o644) -> None: - """ - Create a node file with a given mode. - - :param filename: name of file to create - :param contents: contents of file - :param mode: mode for file - :return: nothing - """ - raise NotImplementedError - - def addfile(self, srcname: str, filename: str) -> None: - """ - Add a file. - - :param srcname: source file name - :param filename: file name to add - :return: nothing - :raises CoreCommandError: when a non-zero exit status occurs - """ - raise NotImplementedError - - def cmd(self, args: str, wait: bool = True, shell: bool = False) -> str: - """ - Runs a command within a node container. - - :param args: command to run - :param wait: True to wait for status, False otherwise - :param shell: True to use shell, False otherwise - :return: combined stdout and stderr - :raises CoreCommandError: when a non-zero exit status occurs - """ - raise NotImplementedError - - def termcmdstring(self, sh: str) -> str: - """ - Create a terminal command string. - - :param sh: shell to execute command in - :return: str - """ - raise NotImplementedError - - def newnetif( - self, net: "CoreNetworkBase", interface_data: InterfaceData - ) -> CoreInterface: - """ - Create a new network interface. - - :param net: network to associate with - :param interface_data: interface data for new interface - :return: interface index - """ - raise NotImplementedError - class CoreNode(CoreNodeBase): """ @@ -1002,6 +1011,7 @@ class CoreNetworkBase(NodeBase): self._linked = {} self._linked_lock = threading.Lock() + @abc.abstractmethod def startup(self) -> None: """ Each object implements its own startup method. @@ -1010,6 +1020,7 @@ class CoreNetworkBase(NodeBase): """ raise NotImplementedError + @abc.abstractmethod def shutdown(self) -> None: """ Each object implements its own shutdown method. @@ -1018,6 +1029,7 @@ class CoreNetworkBase(NodeBase): """ raise NotImplementedError + @abc.abstractmethod def linknet(self, net: "CoreNetworkBase") -> CoreInterface: """ Link network to another. @@ -1025,7 +1037,21 @@ class CoreNetworkBase(NodeBase): :param net: network to link with :return: created interface """ - pass + raise NotImplementedError + + @abc.abstractmethod + def linkconfig( + self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None + ) -> None: + """ + Configure link parameters by applying tc queuing disciplines on the interface. + + :param netif: interface one + :param options: options for configuring link + :param netif2: interface two + :return: nothing + """ + raise NotImplementedError def getlinknetif(self, net: "CoreNetworkBase") -> Optional[CoreInterface]: """ @@ -1156,19 +1182,6 @@ class CoreNetworkBase(NodeBase): return all_links - def linkconfig( - self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None - ) -> None: - """ - Configure link parameters by applying tc queuing disciplines on the interface. - - :param netif: interface one - :param options: options for configuring link - :param netif2: interface two - :return: nothing - """ - raise NotImplementedError - class Position: """ diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 13214093..741fe7d5 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -226,7 +226,7 @@ class PhysicalNode(CoreNodeBase): return self.host_cmd(args, wait=wait) def addfile(self, srcname: str, filename: str) -> None: - raise NotImplementedError + raise CoreError("physical node does not support addfile") class Rj45Node(CoreNodeBase): @@ -449,13 +449,13 @@ class Rj45Node(CoreNodeBase): self.interface.setposition() def termcmdstring(self, sh: str) -> str: - raise NotImplementedError + raise CoreError("rj45 does not support terminal commands") def addfile(self, srcname: str, filename: str) -> None: - raise NotImplementedError + raise CoreError("rj45 does not support addfile") def nodefile(self, filename: str, contents: str, mode: int = 0o644) -> None: - raise NotImplementedError + raise CoreError("rj45 does not support nodefile") def cmd(self, args: str, wait: bool = True, shell: bool = False) -> str: - raise NotImplementedError + raise CoreError("rj45 does not support cmds") From 0725199d6d2f9f5420628201861e0e78b64bd4b7 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 16 Jun 2020 09:30:16 -0700 Subject: [PATCH 156/929] initial sweeping changes to call all usages of various interface related variables and functions (netif, interface, if, ifc, etc) to use a consistent name iface --- daemon/core/api/grpc/client.py | 72 ++-- daemon/core/api/grpc/events.py | 2 +- daemon/core/api/grpc/grpcutils.py | 134 +++--- daemon/core/api/grpc/server.py | 117 +++--- daemon/core/api/tlv/coreapi.py | 26 +- daemon/core/api/tlv/corehandlers.py | 88 ++-- daemon/core/api/tlv/dataconversion.py | 2 +- daemon/core/api/tlv/enumerations.py | 26 +- .../configservices/frrservices/services.py | 88 ++-- .../frrservices/templates/frr.conf | 6 +- .../frrservices/templates/frrboot.sh | 6 +- .../configservices/nrlservices/services.py | 42 +- .../nrlservices/templates/nrlnhdp.sh | 4 +- .../nrlservices/templates/nrlolsrv2.sh | 4 +- .../nrlservices/templates/olsrd.sh | 4 +- .../nrlservices/templates/startsmf.sh | 4 +- .../configservices/quaggaservices/services.py | 98 ++--- .../quaggaservices/templates/Quagga.conf | 6 +- .../sercurityservices/services.py | 12 +- .../configservices/utilservices/services.py | 64 ++- .../utilservices/templates/index.html | 4 +- daemon/core/emane/commeffect.py | 18 +- daemon/core/emane/emanemanager.py | 84 ++-- daemon/core/emane/emanemodel.py | 28 +- daemon/core/emane/linkmonitor.py | 6 +- daemon/core/emane/nodes.py | 80 ++-- daemon/core/emulator/data.py | 30 +- daemon/core/emulator/distributed.py | 4 +- daemon/core/emulator/emudata.py | 10 +- daemon/core/emulator/session.py | 184 ++++----- daemon/core/gui/coreclient.py | 117 +++--- daemon/core/gui/dialogs/emaneconfig.py | 10 +- daemon/core/gui/dialogs/ipdialog.py | 2 +- daemon/core/gui/dialogs/linkconfig.py | 36 +- daemon/core/gui/dialogs/macdialog.py | 2 +- daemon/core/gui/dialogs/nodeconfig.py | 76 ++-- daemon/core/gui/graph/edges.py | 26 +- daemon/core/gui/graph/graph.py | 84 ++-- daemon/core/gui/graph/node.py | 14 +- daemon/core/gui/interface.py | 64 +-- daemon/core/gui/menubar.py | 2 +- daemon/core/location/mobility.py | 127 +++--- daemon/core/nodes/base.py | 388 ++++++++---------- daemon/core/nodes/docker.py | 2 +- daemon/core/nodes/interface.py | 13 +- daemon/core/nodes/lxd.py | 6 +- daemon/core/nodes/netclient.py | 34 +- daemon/core/nodes/network.py | 282 ++++++------- daemon/core/nodes/physical.py | 193 ++++----- daemon/core/services/bird.py | 34 +- daemon/core/services/emaneservices.py | 6 +- daemon/core/services/frr.py | 144 +++---- daemon/core/services/nrl.py | 58 ++- daemon/core/services/quagga.py | 138 +++---- daemon/core/services/sdn.py | 24 +- daemon/core/services/security.py | 18 +- daemon/core/services/utility.py | 62 ++- daemon/core/services/xorp.py | 100 ++--- daemon/core/xml/corexml.py | 118 +++--- daemon/core/xml/corexmldeployment.py | 36 +- daemon/core/xml/emanexml.py | 76 ++-- daemon/examples/configservices/testing.py | 8 +- daemon/examples/docker/docker2core.py | 4 +- daemon/examples/docker/docker2docker.py | 4 +- daemon/examples/docker/switch.py | 6 +- daemon/examples/grpc/distributed_switch.py | 4 +- daemon/examples/grpc/emane80211.py | 4 +- daemon/examples/grpc/switch.py | 4 +- daemon/examples/grpc/wlan.py | 4 +- daemon/examples/lxd/lxd2core.py | 4 +- daemon/examples/lxd/lxd2lxd.py | 4 +- daemon/examples/lxd/switch.py | 6 +- daemon/examples/myservices/sample.py | 4 +- daemon/examples/python/distributed_emane.py | 8 +- daemon/examples/python/distributed_lxd.py | 4 +- daemon/examples/python/distributed_ptp.py | 4 +- daemon/examples/python/distributed_switch.py | 8 +- daemon/examples/python/emane80211.py | 4 +- daemon/examples/python/switch.py | 4 +- daemon/examples/python/switch_inject.py | 4 +- daemon/examples/python/wlan.py | 4 +- daemon/proto/core/api/grpc/core.proto | 26 +- daemon/proto/core/api/grpc/emane.proto | 10 +- daemon/scripts/core-route-monitor | 4 +- daemon/tests/conftest.py | 2 +- daemon/tests/emane/test_emane.py | 8 +- daemon/tests/test_core.py | 44 +- daemon/tests/test_grpc.py | 60 +-- daemon/tests/test_gui.py | 80 ++-- daemon/tests/test_links.py | 174 ++++---- daemon/tests/test_nodes.py | 28 +- daemon/tests/test_xml.py | 30 +- docs/scripting.md | 4 +- 93 files changed, 1955 insertions(+), 2156 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 3a16d4fd..47aaef63 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -110,27 +110,27 @@ class InterfaceHelper: """ self.prefixes: IpPrefixes = IpPrefixes(ip4_prefix, ip6_prefix) - def create_interface( - self, node_id: int, interface_id: int, name: str = None, mac: str = None + def create_iface( + self, node_id: int, iface_id: int, name: str = None, mac: str = None ) -> core_pb2.Interface: """ Create an interface protobuf object. :param node_id: node id to create interface for - :param interface_id: interface id + :param iface_id: interface id :param name: name of interface :param mac: mac address for interface :return: interface protobuf """ - interface_data = self.prefixes.gen_interface(node_id, name, mac) + iface_data = self.prefixes.gen_iface(node_id, name, mac) return core_pb2.Interface( - id=interface_id, - name=interface_data.name, - ip4=interface_data.ip4, - ip4mask=interface_data.ip4_mask, - ip6=interface_data.ip6, - ip6mask=interface_data.ip6_mask, - mac=interface_data.mac, + id=iface_id, + name=iface_data.name, + ip4=iface_data.ip4, + ip4mask=iface_data.ip4_mask, + ip6=iface_data.ip6, + ip6mask=iface_data.ip6_mask, + mac=iface_data.mac, ) @@ -611,8 +611,8 @@ class CoreGrpcClient: session_id: int, node1_id: int, node2_id: int, - interface1: core_pb2.Interface = None, - interface2: core_pb2.Interface = None, + iface1: core_pb2.Interface = None, + iface2: core_pb2.Interface = None, options: core_pb2.LinkOptions = None, ) -> core_pb2.AddLinkResponse: """ @@ -621,8 +621,8 @@ class CoreGrpcClient: :param session_id: session id :param node1_id: node one id :param node2_id: node two id - :param interface1: node one interface data - :param interface2: node two interface data + :param iface1: node one interface data + :param iface2: node two interface data :param options: options for link (jitter, bandwidth, etc) :return: response with result of success or failure :raises grpc.RpcError: when session or one of the nodes don't exist @@ -631,8 +631,8 @@ class CoreGrpcClient: node1_id=node1_id, node2_id=node2_id, type=core_pb2.LinkType.WIRED, - interface1=interface1, - interface2=interface2, + iface1=iface1, + iface2=iface2, options=options, ) request = core_pb2.AddLinkRequest(session_id=session_id, link=link) @@ -644,8 +644,8 @@ class CoreGrpcClient: node1_id: int, node2_id: int, options: core_pb2.LinkOptions, - interface1_id: int = None, - interface2_id: int = None, + iface1_id: int = None, + iface2_id: int = None, ) -> core_pb2.EditLinkResponse: """ Edit a link between nodes. @@ -654,8 +654,8 @@ class CoreGrpcClient: :param node1_id: node one id :param node2_id: node two id :param options: options for link (jitter, bandwidth, etc) - :param interface1_id: node one interface id - :param interface2_id: node two interface id + :param iface1_id: node one interface id + :param iface2_id: node two interface id :return: response with result of success or failure :raises grpc.RpcError: when session or one of the nodes don't exist """ @@ -664,8 +664,8 @@ class CoreGrpcClient: node1_id=node1_id, node2_id=node2_id, options=options, - interface1_id=interface1_id, - interface2_id=interface2_id, + iface1_id=iface1_id, + iface2_id=iface2_id, ) return self.stub.EditLink(request) @@ -674,8 +674,8 @@ class CoreGrpcClient: session_id: int, node1_id: int, node2_id: int, - interface1_id: int = None, - interface2_id: int = None, + iface1_id: int = None, + iface2_id: int = None, ) -> core_pb2.DeleteLinkResponse: """ Delete a link between nodes. @@ -683,8 +683,8 @@ class CoreGrpcClient: :param session_id: session id :param node1_id: node one id :param node2_id: node two id - :param interface1_id: node one interface id - :param interface2_id: node two interface id + :param iface1_id: node one interface id + :param iface2_id: node two interface id :return: response with result of success or failure :raises grpc.RpcError: when session doesn't exist """ @@ -692,8 +692,8 @@ class CoreGrpcClient: session_id=session_id, node1_id=node1_id, node2_id=node2_id, - interface1_id=interface1_id, - interface2_id=interface2_id, + iface1_id=iface1_id, + iface2_id=iface2_id, ) return self.stub.DeleteLink(request) @@ -1028,7 +1028,7 @@ class CoreGrpcClient: return self.stub.GetEmaneModels(request) def get_emane_model_config( - self, session_id: int, node_id: int, model: str, interface_id: int = -1 + self, session_id: int, node_id: int, model: str, iface_id: int = -1 ) -> GetEmaneModelConfigResponse: """ Get emane model configuration for a node or a node's interface. @@ -1036,12 +1036,12 @@ class CoreGrpcClient: :param session_id: session id :param node_id: node id :param model: emane model name - :param interface_id: node interface id + :param iface_id: node interface id :return: response with a list of configuration groups :raises grpc.RpcError: when session doesn't exist """ request = GetEmaneModelConfigRequest( - session_id=session_id, node_id=node_id, model=model, interface=interface_id + session_id=session_id, node_id=node_id, model=model, iface_id=iface_id ) return self.stub.GetEmaneModelConfig(request) @@ -1051,7 +1051,7 @@ class CoreGrpcClient: node_id: int, model: str, config: Dict[str, str] = None, - interface_id: int = -1, + iface_id: int = -1, ) -> SetEmaneModelConfigResponse: """ Set emane model configuration for a node or a node's interface. @@ -1060,12 +1060,12 @@ class CoreGrpcClient: :param node_id: node id :param model: emane model name :param config: emane model configuration - :param interface_id: node interface id + :param iface_id: node interface id :return: response with result of success or failure :raises grpc.RpcError: when session doesn't exist """ model_config = EmaneModelConfig( - node_id=node_id, model=model, config=config, interface_id=interface_id + node_id=node_id, model=model, config=config, iface_id=iface_id ) request = SetEmaneModelConfigRequest( session_id=session_id, emane_model_config=model_config @@ -1128,7 +1128,7 @@ class CoreGrpcClient: ) return self.stub.EmaneLink(request) - def get_interfaces(self) -> core_pb2.GetInterfacesResponse: + def get_ifaces(self) -> core_pb2.GetInterfacesResponse: """ Retrieves a list of interfaces available on the host machine that are not a part of a CORE session. diff --git a/daemon/core/api/grpc/events.py b/daemon/core/api/grpc/events.py index 82cf1eac..ff65142d 100644 --- a/daemon/core/api/grpc/events.py +++ b/daemon/core/api/grpc/events.py @@ -82,7 +82,7 @@ def handle_config_event(event: ConfigData) -> core_pb2.ConfigEvent: data_values=event.data_values, possible_values=event.possible_values, groups=event.groups, - interface=event.interface_number, + iface_id=event.iface_id, network_id=event.network_id, opaque=event.opaque, data_types=event.data_types, diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index c9b76b73..f2f85798 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -52,29 +52,29 @@ def add_node_data(node_proto: core_pb2.Node) -> Tuple[NodeTypes, int, NodeOption return _type, _id, options -def link_interface(interface_proto: core_pb2.Interface) -> InterfaceData: +def link_iface(iface_proto: core_pb2.Interface) -> InterfaceData: """ Create interface data from interface proto. - :param interface_proto: interface proto + :param iface_proto: interface proto :return: interface data """ - interface_data = None - if interface_proto: - name = interface_proto.name if interface_proto.name else None - mac = interface_proto.mac if interface_proto.mac else None - ip4 = interface_proto.ip4 if interface_proto.ip4 else None - ip6 = interface_proto.ip6 if interface_proto.ip6 else None - interface_data = InterfaceData( - id=interface_proto.id, + iface_data = None + if iface_proto: + name = iface_proto.name if iface_proto.name else None + mac = iface_proto.mac if iface_proto.mac else None + ip4 = iface_proto.ip4 if iface_proto.ip4 else None + ip6 = iface_proto.ip6 if iface_proto.ip6 else None + iface_data = InterfaceData( + id=iface_proto.id, name=name, mac=mac, ip4=ip4, - ip4_mask=interface_proto.ip4mask, + ip4_mask=iface_proto.ip4mask, ip6=ip6, - ip6_mask=interface_proto.ip6mask, + ip6_mask=iface_proto.ip6mask, ) - return interface_data + return iface_data def add_link_data( @@ -86,8 +86,8 @@ def add_link_data( :param link_proto: link proto :return: link interfaces and options """ - interface1_data = link_interface(link_proto.interface1) - interface2_data = link_interface(link_proto.interface2) + iface1_data = link_iface(link_proto.iface1) + iface2_data = link_iface(link_proto.iface2) link_type = LinkTypes(link_proto.type) options = LinkOptions(type=link_type) options_data = link_proto.options @@ -103,7 +103,7 @@ def add_link_data( options.unidirectional = options_data.unidirectional options.key = options_data.key options.opaque = options_data.opaque - return interface1_data, interface2_data, options + return iface1_data, iface2_data, options def create_nodes( @@ -143,8 +143,8 @@ def create_links( for link_proto in link_protos: node1_id = link_proto.node1_id node2_id = link_proto.node2_id - interface1, interface2, options = add_link_data(link_proto) - args = (node1_id, node2_id, interface1, interface2, options) + iface1, iface2, options = add_link_data(link_proto) + args = (node1_id, node2_id, iface1, iface2, options) funcs.append((session.add_link, args, {})) start = time.monotonic() results, exceptions = utils.threadpool(funcs) @@ -167,8 +167,8 @@ def edit_links( for link_proto in link_protos: node1_id = link_proto.node1_id node2_id = link_proto.node2_id - interface1, interface2, options = add_link_data(link_proto) - args = (node1_id, node2_id, interface1.id, interface2.id, options) + iface1, iface2, options = add_link_data(link_proto) + args = (node1_id, node2_id, iface1.id, iface2.id, options) funcs.append((session.update_link, args, {})) start = time.monotonic() results, exceptions = utils.threadpool(funcs) @@ -279,16 +279,16 @@ def get_links(node: NodeBase): return links -def get_emane_model_id(node_id: int, interface_id: int) -> int: +def get_emane_model_id(node_id: int, iface_id: int) -> int: """ Get EMANE model id :param node_id: node id - :param interface_id: interface id + :param iface_id: interface id :return: EMANE model id """ - if interface_id >= 0: - return node_id * 1000 + interface_id + if iface_id >= 0: + return node_id * 1000 + iface_id else: return node_id @@ -300,12 +300,12 @@ def parse_emane_model_id(_id: int) -> Tuple[int, int]: :param _id: id to parse :return: node id and interface id """ - interface = -1 + iface_id = -1 node_id = _id if _id >= 1000: - interface = _id % 1000 + iface_id = _id % 1000 node_id = int(_id / 1000) - return node_id, interface + return node_id, iface_id def convert_link(link_data: LinkData) -> core_pb2.Link: @@ -315,27 +315,27 @@ def convert_link(link_data: LinkData) -> core_pb2.Link: :param link_data: link to convert :return: core protobuf Link """ - interface1 = None - if link_data.interface1_id is not None: - interface1 = core_pb2.Interface( - id=link_data.interface1_id, - name=link_data.interface1_name, - mac=convert_value(link_data.interface1_mac), - ip4=convert_value(link_data.interface1_ip4), - ip4mask=link_data.interface1_ip4_mask, - ip6=convert_value(link_data.interface1_ip6), - ip6mask=link_data.interface1_ip6_mask, + iface1 = None + if link_data.iface1_id is not None: + iface1 = core_pb2.Interface( + id=link_data.iface1_id, + name=link_data.iface1_name, + mac=convert_value(link_data.iface1_mac), + ip4=convert_value(link_data.iface1_ip4), + ip4mask=link_data.iface1_ip4_mask, + ip6=convert_value(link_data.iface1_ip6), + ip6mask=link_data.iface1_ip6_mask, ) - interface2 = None - if link_data.interface2_id is not None: - interface2 = core_pb2.Interface( - id=link_data.interface2_id, - name=link_data.interface2_name, - mac=convert_value(link_data.interface2_mac), - ip4=convert_value(link_data.interface2_ip4), - ip4mask=link_data.interface2_ip4_mask, - ip6=convert_value(link_data.interface2_ip6), - ip6mask=link_data.interface2_ip6_mask, + iface2 = None + if link_data.iface2_id is not None: + iface2 = core_pb2.Interface( + id=link_data.iface2_id, + name=link_data.iface2_name, + mac=convert_value(link_data.iface2_mac), + ip4=convert_value(link_data.iface2_ip4), + ip4mask=link_data.iface2_ip4_mask, + ip6=convert_value(link_data.iface2_ip6), + ip6mask=link_data.iface2_ip6_mask, ) options = core_pb2.LinkOptions( opaque=link_data.opaque, @@ -354,8 +354,8 @@ def convert_link(link_data: LinkData) -> core_pb2.Link: type=link_data.link_type.value, node1_id=link_data.node1_id, node2_id=link_data.node2_id, - interface1=interface1, - interface2=interface2, + iface1=iface1, + iface2=iface2, options=options, network_id=link_data.network_id, label=link_data.label, @@ -440,20 +440,20 @@ def get_service_configuration(service: CoreService) -> NodeServiceData: ) -def interface_to_proto(interface: CoreInterface) -> core_pb2.Interface: +def iface_to_proto(iface: CoreInterface) -> core_pb2.Interface: """ Convenience for converting a core interface to the protobuf representation. - :param interface: interface to convert + :param iface: interface to convert :return: interface proto """ net_id = None - if interface.net: - net_id = interface.net.id + if iface.net: + net_id = iface.net.id ip4 = None ip4mask = None ip6 = None ip6mask = None - for addr in interface.addrlist: + for addr in iface.addrlist: network = netaddr.IPNetwork(addr) mask = network.prefixlen ip = str(network.ip) @@ -464,12 +464,12 @@ def interface_to_proto(interface: CoreInterface) -> core_pb2.Interface: ip6 = ip ip6mask = mask return core_pb2.Interface( - id=interface.netindex, + id=iface.node_id, netid=net_id, - name=interface.name, - mac=str(interface.hwaddr), - mtu=interface.mtu, - flowid=interface.flow_id, + name=iface.name, + mac=str(iface.hwaddr), + mtu=iface.mtu, + flowid=iface.flow_id, ip4=ip4, ip4mask=ip4mask, ip6=ip6, @@ -477,21 +477,21 @@ def interface_to_proto(interface: CoreInterface) -> core_pb2.Interface: ) -def get_nem_id(node: CoreNode, netif_id: int, context: ServicerContext) -> int: +def get_nem_id(node: CoreNode, iface_id: int, context: ServicerContext) -> int: """ Get nem id for a given node and interface id. :param node: node to get nem id for - :param netif_id: id of interface on node to get nem id for + :param iface_id: id of interface on node to get nem id for :param context: request context :return: nem id """ - netif = node.netif(netif_id) - if not netif: - message = f"{node.name} missing interface {netif_id}" + iface = node.ifaces.get(iface_id) + if not iface: + message = f"{node.name} missing interface {iface_id}" context.abort(grpc.StatusCode.NOT_FOUND, message) - net = netif.net + net = iface.net if not isinstance(net, EmaneNet): - message = f"{node.name} interface {netif_id} is not an EMANE network" + message = f"{node.name} interface {iface_id} is not an EMANE network" context.abort(grpc.StatusCode.INVALID_ARGUMENT, message) - return net.getnemid(netif) + return net.getnemid(iface) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 8b349b67..87b69a77 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -246,7 +246,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): config = session.emane.get_configs() config.update(request.emane_config) for config in request.emane_model_configs: - _id = get_emane_model_id(config.node_id, config.interface_id) + _id = get_emane_model_id(config.node_id, config.iface_id) session.emane.set_model_config(_id, config.model, config.config) # wlan configs @@ -625,16 +625,14 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): key = key.split(".") node_id = _INTERFACE_REGEX.search(key[0]).group("node") node_id = int(node_id, base=16) - interface_id = int(key[1], base=16) + iface_id = int(key[1], base=16) session_id = int(key[2], base=16) if session.id != session_id: continue - interface_throughput = ( - throughputs_event.interface_throughputs.add() - ) - interface_throughput.node_id = node_id - interface_throughput.interface_id = interface_id - interface_throughput.throughput = throughput + iface_throughput = throughputs_event.iface_throughputs.add() + iface_throughput.node_id = node_id + iface_throughput.iface_id = iface_id + iface_throughput.throughput = throughput elif key.startswith("b."): try: key = key.split(".") @@ -686,13 +684,13 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): logging.debug("get node: %s", request) session = self.get_session(request.session_id, context) node = self.get_node(session, request.node_id, context, NodeBase) - interfaces = [] - for interface_id in node._netif: - interface = node._netif[interface_id] - interface_proto = grpcutils.interface_to_proto(interface) - interfaces.append(interface_proto) + ifaces = [] + for iface_id in node.ifaces: + iface = node.ifaces[iface_id] + iface_proto = grpcutils.iface_to_proto(iface) + ifaces.append(iface_proto) node_proto = grpcutils.get_node_proto(session, node) - return core_pb2.GetNodeResponse(node=node_proto, interfaces=interfaces) + return core_pb2.GetNodeResponse(node=node_proto, ifaces=ifaces) def MoveNodes( self, @@ -850,18 +848,18 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): node2_id = request.link.node2_id self.get_node(session, node1_id, context, NodeBase) self.get_node(session, node2_id, context, NodeBase) - interface1, interface2, options = grpcutils.add_link_data(request.link) - node1_interface, node2_interface = session.add_link( - node1_id, node2_id, interface1, interface2, options=options + iface1_data, iface2_data, options = grpcutils.add_link_data(request.link) + node1_iface, node2_iface = session.add_link( + node1_id, node2_id, iface1_data, iface2_data, options=options ) - interface1_proto = None - interface2_proto = None - if node1_interface: - interface1_proto = grpcutils.interface_to_proto(node1_interface) - if node2_interface: - interface2_proto = grpcutils.interface_to_proto(node2_interface) + iface1_proto = None + iface2_proto = None + if node1_iface: + iface1_proto = grpcutils.iface_to_proto(node1_iface) + if node2_iface: + iface2_proto = grpcutils.iface_to_proto(node2_iface) return core_pb2.AddLinkResponse( - result=True, interface1=interface1_proto, interface2=interface2_proto + result=True, iface1=iface1_proto, iface2=iface2_proto ) def EditLink( @@ -878,8 +876,8 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): session = self.get_session(request.session_id, context) node1_id = request.node1_id node2_id = request.node2_id - interface1_id = request.interface1_id - interface2_id = request.interface2_id + iface1_id = request.iface1_id + iface2_id = request.iface2_id options_data = request.options options = LinkOptions( delay=options_data.delay, @@ -894,7 +892,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): key=options_data.key, opaque=options_data.opaque, ) - session.update_link(node1_id, node2_id, interface1_id, interface2_id, options) + session.update_link(node1_id, node2_id, iface1_id, iface2_id, options) return core_pb2.EditLinkResponse(result=True) def DeleteLink( @@ -911,9 +909,9 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): session = self.get_session(request.session_id, context) node1_id = request.node1_id node2_id = request.node2_id - interface1_id = request.interface1_id - interface2_id = request.interface2_id - session.delete_link(node1_id, node2_id, interface1_id, interface2_id) + iface1_id = request.iface1_id + iface2_id = request.iface2_id + session.delete_link(node1_id, node2_id, iface1_id, iface2_id) return core_pb2.DeleteLinkResponse(result=True) def GetHooks( @@ -1371,7 +1369,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): logging.debug("get emane model config: %s", request) session = self.get_session(request.session_id, context) model = session.emane.models[request.model] - _id = get_emane_model_id(request.node_id, request.interface) + _id = get_emane_model_id(request.node_id, request.iface_id) current_config = session.emane.get_model_config(_id, request.model) config = get_config_options(current_config, model) return GetEmaneModelConfigResponse(config=config) @@ -1390,7 +1388,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): logging.debug("set emane model config: %s", request) session = self.get_session(request.session_id, context) model_config = request.emane_model_config - _id = get_emane_model_id(model_config.node_id, model_config.interface_id) + _id = get_emane_model_id(model_config.node_id, model_config.iface_id) session.emane.set_model_config(_id, model_config.model, model_config.config) return SetEmaneModelConfigResponse(result=True) @@ -1419,12 +1417,9 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): model = session.emane.models[model_name] current_config = session.emane.get_model_config(_id, model_name) config = get_config_options(current_config, model) - node_id, interface = grpcutils.parse_emane_model_id(_id) + node_id, iface_id = grpcutils.parse_emane_model_id(_id) model_config = GetEmaneModelConfigsResponse.ModelConfig( - node_id=node_id, - model=model_name, - interface=interface, - config=config, + node_id=node_id, model=model_name, iface_id=iface_id, config=config ) configs.append(model_config) return GetEmaneModelConfigsResponse(configs=configs) @@ -1489,16 +1484,12 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): :param context: context object :return: get-interfaces response that has all the system's interfaces """ - interfaces = [] - for interface in os.listdir("/sys/class/net"): - if ( - interface.startswith("b.") - or interface.startswith("veth") - or interface == "lo" - ): + ifaces = [] + for iface in os.listdir("/sys/class/net"): + if iface.startswith("b.") or iface.startswith("veth") or iface == "lo": continue - interfaces.append(interface) - return core_pb2.GetInterfacesResponse(interfaces=interfaces) + ifaces.append(iface) + return core_pb2.GetInterfacesResponse(ifaces=ifaces) def EmaneLink( self, request: EmaneLinkRequest, context: ServicerContext @@ -1513,16 +1504,16 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): logging.debug("emane link: %s", request) session = self.get_session(request.session_id, context) nem1 = request.nem1 - emane1, netif = session.emane.nemlookup(nem1) - if not emane1 or not netif: + emane1, iface = session.emane.nemlookup(nem1) + if not emane1 or not iface: context.abort(grpc.StatusCode.NOT_FOUND, f"nem one {nem1} not found") - node1 = netif.node + node1 = iface.node nem2 = request.nem2 - emane2, netif = session.emane.nemlookup(nem2) - if not emane2 or not netif: + emane2, iface = session.emane.nemlookup(nem2) + if not emane2 or not iface: context.abort(grpc.StatusCode.NOT_FOUND, f"nem two {nem2} not found") - node2 = netif.node + node2 = iface.node if emane1.id == emane2.id: if request.linked: @@ -1734,21 +1725,19 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): ) node1 = self.get_node(session, request.node1_id, context, CoreNode) node2 = self.get_node(session, request.node2_id, context, CoreNode) - node1_interface, node2_interface = None, None - for net, interface1, interface2 in node1.commonnets(node2): + node1_iface, node2_iface = None, None + for net, iface1, iface2 in node1.commonnets(node2): if net == wlan: - node1_interface = interface1 - node2_interface = interface2 + node1_iface = iface1 + node2_iface = iface2 break result = False - if node1_interface and node2_interface: + if node1_iface and node2_iface: if request.linked: - wlan.link(node1_interface, node2_interface) + wlan.link(node1_iface, node2_iface) else: - wlan.unlink(node1_interface, node2_interface) - wlan.model.sendlinkmsg( - node1_interface, node2_interface, unlink=not request.linked - ) + wlan.unlink(node1_iface, node2_iface) + wlan.model.sendlinkmsg(node1_iface, node2_iface, unlink=not request.linked) result = True return WlanLinkResponse(result=result) @@ -1760,8 +1749,8 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): for request in request_iterator: session = self.get_session(request.session_id, context) node1 = self.get_node(session, request.node1_id, context, CoreNode) - nem1 = grpcutils.get_nem_id(node1, request.interface1_id, context) + nem1 = grpcutils.get_nem_id(node1, request.iface1_id, context) node2 = self.get_node(session, request.node2_id, context, CoreNode) - nem2 = grpcutils.get_nem_id(node2, request.interface2_id, context) + nem2 = grpcutils.get_nem_id(node2, request.iface2_id, context) session.emane.publish_pathloss(nem1, nem2, request.rx1, request.rx2) return EmanePathlossesResponse() diff --git a/daemon/core/api/tlv/coreapi.py b/daemon/core/api/tlv/coreapi.py index 088a7631..5d0b08e7 100644 --- a/daemon/core/api/tlv/coreapi.py +++ b/daemon/core/api/tlv/coreapi.py @@ -508,18 +508,18 @@ class CoreLinkTlv(CoreTlv): LinkTlvs.EMULATION_ID.value: CoreTlvDataUint32, LinkTlvs.NETWORK_ID.value: CoreTlvDataUint32, LinkTlvs.KEY.value: CoreTlvDataUint32, - LinkTlvs.INTERFACE1_NUMBER.value: CoreTlvDataUint16, - LinkTlvs.INTERFACE1_IP4.value: CoreTlvDataIpv4Addr, - LinkTlvs.INTERFACE1_IP4_MASK.value: CoreTlvDataUint16, - LinkTlvs.INTERFACE1_MAC.value: CoreTlvDataMacAddr, - LinkTlvs.INTERFACE1_IP6.value: CoreTlvDataIPv6Addr, - LinkTlvs.INTERFACE1_IP6_MASK.value: CoreTlvDataUint16, - LinkTlvs.INTERFACE2_NUMBER.value: CoreTlvDataUint16, - LinkTlvs.INTERFACE2_IP4.value: CoreTlvDataIpv4Addr, - LinkTlvs.INTERFACE2_IP4_MASK.value: CoreTlvDataUint16, - LinkTlvs.INTERFACE2_MAC.value: CoreTlvDataMacAddr, - LinkTlvs.INTERFACE2_IP6.value: CoreTlvDataIPv6Addr, - LinkTlvs.INTERFACE2_IP6_MASK.value: CoreTlvDataUint16, + LinkTlvs.IFACE1_NUMBER.value: CoreTlvDataUint16, + LinkTlvs.IFACE1_IP4.value: CoreTlvDataIpv4Addr, + LinkTlvs.IFACE1_IP4_MASK.value: CoreTlvDataUint16, + LinkTlvs.IFACE1_MAC.value: CoreTlvDataMacAddr, + LinkTlvs.IFACE1_IP6.value: CoreTlvDataIPv6Addr, + LinkTlvs.IFACE1_IP6_MASK.value: CoreTlvDataUint16, + LinkTlvs.IFACE2_NUMBER.value: CoreTlvDataUint16, + LinkTlvs.IFACE2_IP4.value: CoreTlvDataIpv4Addr, + LinkTlvs.IFACE2_IP4_MASK.value: CoreTlvDataUint16, + LinkTlvs.IFACE2_MAC.value: CoreTlvDataMacAddr, + LinkTlvs.IFACE2_IP6.value: CoreTlvDataIPv6Addr, + LinkTlvs.IFACE2_IP6_MASK.value: CoreTlvDataUint16, LinkTlvs.INTERFACE1_NAME.value: CoreTlvDataString, LinkTlvs.INTERFACE2_NAME.value: CoreTlvDataString, LinkTlvs.OPAQUE.value: CoreTlvDataString, @@ -577,7 +577,7 @@ class CoreConfigTlv(CoreTlv): ConfigTlvs.POSSIBLE_VALUES.value: CoreTlvDataString, ConfigTlvs.GROUPS.value: CoreTlvDataString, ConfigTlvs.SESSION.value: CoreTlvDataString, - ConfigTlvs.INTERFACE_NUMBER.value: CoreTlvDataUint16, + ConfigTlvs.IFACE_ID.value: CoreTlvDataUint16, ConfigTlvs.NETWORK_ID.value: CoreTlvDataUint32, ConfigTlvs.OPAQUE.value: CoreTlvDataString, } diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 2cd7bfac..b09a37fe 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -71,7 +71,7 @@ class CoreHandler(socketserver.BaseRequestHandler): MessageTypes.REGISTER.value: self.handle_register_message, MessageTypes.CONFIG.value: self.handle_config_message, MessageTypes.FILE.value: self.handle_file_message, - MessageTypes.INTERFACE.value: self.handle_interface_message, + MessageTypes.INTERFACE.value: self.handle_iface_message, MessageTypes.EVENT.value: self.handle_event_message, MessageTypes.SESSION.value: self.handle_session_message, } @@ -363,18 +363,18 @@ class CoreHandler(socketserver.BaseRequestHandler): (LinkTlvs.EMULATION_ID, link_data.emulation_id), (LinkTlvs.NETWORK_ID, link_data.network_id), (LinkTlvs.KEY, link_data.key), - (LinkTlvs.INTERFACE1_NUMBER, link_data.interface1_id), - (LinkTlvs.INTERFACE1_IP4, link_data.interface1_ip4), - (LinkTlvs.INTERFACE1_IP4_MASK, link_data.interface1_ip4_mask), - (LinkTlvs.INTERFACE1_MAC, link_data.interface1_mac), - (LinkTlvs.INTERFACE1_IP6, link_data.interface1_ip6), - (LinkTlvs.INTERFACE1_IP6_MASK, link_data.interface1_ip6_mask), - (LinkTlvs.INTERFACE2_NUMBER, link_data.interface2_id), - (LinkTlvs.INTERFACE2_IP4, link_data.interface2_ip4), - (LinkTlvs.INTERFACE2_IP4_MASK, link_data.interface2_ip4_mask), - (LinkTlvs.INTERFACE2_MAC, link_data.interface2_mac), - (LinkTlvs.INTERFACE2_IP6, link_data.interface2_ip6), - (LinkTlvs.INTERFACE2_IP6_MASK, link_data.interface2_ip6_mask), + (LinkTlvs.IFACE1_NUMBER, link_data.iface1_id), + (LinkTlvs.IFACE1_IP4, link_data.iface1_ip4), + (LinkTlvs.IFACE1_IP4_MASK, link_data.iface1_ip4_mask), + (LinkTlvs.IFACE1_MAC, link_data.iface1_mac), + (LinkTlvs.IFACE1_IP6, link_data.iface1_ip6), + (LinkTlvs.IFACE1_IP6_MASK, link_data.iface1_ip6_mask), + (LinkTlvs.IFACE2_NUMBER, link_data.iface2_id), + (LinkTlvs.IFACE2_IP4, link_data.iface2_ip4), + (LinkTlvs.IFACE2_IP4_MASK, link_data.iface2_ip4_mask), + (LinkTlvs.IFACE2_MAC, link_data.iface2_mac), + (LinkTlvs.IFACE2_IP6, link_data.iface2_ip6), + (LinkTlvs.IFACE2_IP6_MASK, link_data.iface2_ip6_mask), (LinkTlvs.OPAQUE, link_data.opaque), ], ) @@ -749,23 +749,23 @@ class CoreHandler(socketserver.BaseRequestHandler): """ node1_id = message.get_tlv(LinkTlvs.N1_NUMBER.value) node2_id = message.get_tlv(LinkTlvs.N2_NUMBER.value) - interface1_data = InterfaceData( - id=message.get_tlv(LinkTlvs.INTERFACE1_NUMBER.value), + iface1_data = InterfaceData( + id=message.get_tlv(LinkTlvs.IFACE1_NUMBER.value), name=message.get_tlv(LinkTlvs.INTERFACE1_NAME.value), - mac=message.get_tlv(LinkTlvs.INTERFACE1_MAC.value), - ip4=message.get_tlv(LinkTlvs.INTERFACE1_IP4.value), - ip4_mask=message.get_tlv(LinkTlvs.INTERFACE1_IP4_MASK.value), - ip6=message.get_tlv(LinkTlvs.INTERFACE1_IP6.value), - ip6_mask=message.get_tlv(LinkTlvs.INTERFACE1_IP6_MASK.value), + mac=message.get_tlv(LinkTlvs.IFACE1_MAC.value), + ip4=message.get_tlv(LinkTlvs.IFACE1_IP4.value), + ip4_mask=message.get_tlv(LinkTlvs.IFACE1_IP4_MASK.value), + ip6=message.get_tlv(LinkTlvs.IFACE1_IP6.value), + ip6_mask=message.get_tlv(LinkTlvs.IFACE1_IP6_MASK.value), ) - interface2_data = InterfaceData( - id=message.get_tlv(LinkTlvs.INTERFACE2_NUMBER.value), + iface2_data = InterfaceData( + id=message.get_tlv(LinkTlvs.IFACE2_NUMBER.value), name=message.get_tlv(LinkTlvs.INTERFACE2_NAME.value), - mac=message.get_tlv(LinkTlvs.INTERFACE2_MAC.value), - ip4=message.get_tlv(LinkTlvs.INTERFACE2_IP4.value), - ip4_mask=message.get_tlv(LinkTlvs.INTERFACE2_IP4_MASK.value), - ip6=message.get_tlv(LinkTlvs.INTERFACE2_IP6.value), - ip6_mask=message.get_tlv(LinkTlvs.INTERFACE2_IP6_MASK.value), + mac=message.get_tlv(LinkTlvs.IFACE2_MAC.value), + ip4=message.get_tlv(LinkTlvs.IFACE2_IP4.value), + ip4_mask=message.get_tlv(LinkTlvs.IFACE2_IP4_MASK.value), + ip6=message.get_tlv(LinkTlvs.IFACE2_IP6.value), + ip6_mask=message.get_tlv(LinkTlvs.IFACE2_IP6_MASK.value), ) link_type = LinkTypes.WIRED link_type_value = message.get_tlv(LinkTlvs.TYPE.value) @@ -789,16 +789,12 @@ class CoreHandler(socketserver.BaseRequestHandler): options.opaque = message.get_tlv(LinkTlvs.OPAQUE.value) if message.flags & MessageFlags.ADD.value: - self.session.add_link( - node1_id, node2_id, interface1_data, interface2_data, options - ) + self.session.add_link(node1_id, node2_id, iface1_data, iface2_data, options) elif message.flags & MessageFlags.DELETE.value: - self.session.delete_link( - node1_id, node2_id, interface1_data.id, interface2_data.id - ) + self.session.delete_link(node1_id, node2_id, iface1_data.id, iface2_data.id) else: self.session.update_link( - node1_id, node2_id, interface1_data.id, interface2_data.id, options + node1_id, node2_id, iface1_data.id, iface2_data.id, options ) return () @@ -1008,7 +1004,7 @@ class CoreHandler(socketserver.BaseRequestHandler): possible_values=message.get_tlv(ConfigTlvs.POSSIBLE_VALUES.value), groups=message.get_tlv(ConfigTlvs.GROUPS.value), session=message.get_tlv(ConfigTlvs.SESSION.value), - interface_number=message.get_tlv(ConfigTlvs.INTERFACE_NUMBER.value), + iface_id=message.get_tlv(ConfigTlvs.IFACE_ID.value), network_id=message.get_tlv(ConfigTlvs.NETWORK_ID.value), opaque=message.get_tlv(ConfigTlvs.OPAQUE.value), ) @@ -1325,11 +1321,11 @@ class CoreHandler(socketserver.BaseRequestHandler): replies = [] node_id = config_data.node object_name = config_data.object - interface_id = config_data.interface_number + iface_id = config_data.iface_id values_str = config_data.data_values - if interface_id is not None: - node_id = node_id * 1000 + interface_id + if iface_id is not None: + node_id = node_id * 1000 + iface_id logging.debug( "received configure message for %s nodenum: %s", object_name, node_id @@ -1375,11 +1371,11 @@ class CoreHandler(socketserver.BaseRequestHandler): replies = [] node_id = config_data.node object_name = config_data.object - interface_id = config_data.interface_number + iface_id = config_data.iface_id values_str = config_data.data_values - if interface_id is not None: - node_id = node_id * 1000 + interface_id + if iface_id is not None: + node_id = node_id * 1000 + iface_id logging.debug( "received configure message for %s nodenum: %s", object_name, node_id @@ -1407,11 +1403,11 @@ class CoreHandler(socketserver.BaseRequestHandler): replies = [] node_id = config_data.node object_name = config_data.object - interface_id = config_data.interface_number + iface_id = config_data.iface_id values_str = config_data.data_values - if interface_id is not None: - node_id = node_id * 1000 + interface_id + if iface_id is not None: + node_id = node_id * 1000 + iface_id logging.debug( "received configure message for %s nodenum: %s", object_name, node_id @@ -1505,7 +1501,7 @@ class CoreHandler(socketserver.BaseRequestHandler): return () - def handle_interface_message(self, message): + def handle_iface_message(self, message): """ Interface Message handler. @@ -1950,7 +1946,7 @@ class CoreUdpHandler(CoreHandler): MessageTypes.REGISTER.value: self.handle_register_message, MessageTypes.CONFIG.value: self.handle_config_message, MessageTypes.FILE.value: self.handle_file_message, - MessageTypes.INTERFACE.value: self.handle_interface_message, + MessageTypes.INTERFACE.value: self.handle_iface_message, MessageTypes.EVENT.value: self.handle_event_message, MessageTypes.SESSION.value: self.handle_session_message, } diff --git a/daemon/core/api/tlv/dataconversion.py b/daemon/core/api/tlv/dataconversion.py index 876e72a5..cd10ef04 100644 --- a/daemon/core/api/tlv/dataconversion.py +++ b/daemon/core/api/tlv/dataconversion.py @@ -75,7 +75,7 @@ def convert_config(config_data): (ConfigTlvs.POSSIBLE_VALUES, config_data.possible_values), (ConfigTlvs.GROUPS, config_data.groups), (ConfigTlvs.SESSION, session), - (ConfigTlvs.INTERFACE_NUMBER, config_data.interface_number), + (ConfigTlvs.IFACE_ID, config_data.iface_id), (ConfigTlvs.NETWORK_ID, config_data.network_id), (ConfigTlvs.OPAQUE, config_data.opaque), ], diff --git a/daemon/core/api/tlv/enumerations.py b/daemon/core/api/tlv/enumerations.py index 0efb7c99..b4ec254a 100644 --- a/daemon/core/api/tlv/enumerations.py +++ b/daemon/core/api/tlv/enumerations.py @@ -72,18 +72,18 @@ class LinkTlvs(Enum): EMULATION_ID = 0x23 NETWORK_ID = 0x24 KEY = 0x25 - INTERFACE1_NUMBER = 0x30 - INTERFACE1_IP4 = 0x31 - INTERFACE1_IP4_MASK = 0x32 - INTERFACE1_MAC = 0x33 - INTERFACE1_IP6 = 0x34 - INTERFACE1_IP6_MASK = 0x35 - INTERFACE2_NUMBER = 0x36 - INTERFACE2_IP4 = 0x37 - INTERFACE2_IP4_MASK = 0x38 - INTERFACE2_MAC = 0x39 - INTERFACE2_IP6 = 0x40 - INTERFACE2_IP6_MASK = 0x41 + IFACE1_NUMBER = 0x30 + IFACE1_IP4 = 0x31 + IFACE1_IP4_MASK = 0x32 + IFACE1_MAC = 0x33 + IFACE1_IP6 = 0x34 + IFACE1_IP6_MASK = 0x35 + IFACE2_NUMBER = 0x36 + IFACE2_IP4 = 0x37 + IFACE2_IP4_MASK = 0x38 + IFACE2_MAC = 0x39 + IFACE2_IP6 = 0x40 + IFACE2_IP6_MASK = 0x41 INTERFACE1_NAME = 0x42 INTERFACE2_NAME = 0x43 OPAQUE = 0x50 @@ -118,7 +118,7 @@ class ConfigTlvs(Enum): POSSIBLE_VALUES = 0x08 GROUPS = 0x09 SESSION = 0x0A - INTERFACE_NUMBER = 0x0B + IFACE_ID = 0x0B NETWORK_ID = 0x24 OPAQUE = 0x50 diff --git a/daemon/core/configservices/frrservices/services.py b/daemon/core/configservices/frrservices/services.py index c4502f86..8764e32c 100644 --- a/daemon/core/configservices/frrservices/services.py +++ b/daemon/core/configservices/frrservices/services.py @@ -13,33 +13,33 @@ from core.nodes.network import WlanNode GROUP = "FRR" -def has_mtu_mismatch(ifc: CoreInterface) -> bool: +def has_mtu_mismatch(iface: CoreInterface) -> bool: """ Helper to detect MTU mismatch and add the appropriate FRR mtu-ignore command. This is needed when e.g. a node is linked via a GreTap device. """ - if ifc.mtu != 1500: + if iface.mtu != 1500: return True - if not ifc.net: + if not iface.net: return False - for i in ifc.net.netifs(): - if i.mtu != ifc.mtu: + for iface in iface.net.get_ifaces(): + if iface.mtu != iface.mtu: return True return False -def get_min_mtu(ifc): +def get_min_mtu(iface): """ Helper to discover the minimum MTU of interfaces linked with the given interface. """ - mtu = ifc.mtu - if not ifc.net: + mtu = iface.mtu + if not iface.net: return mtu - for i in ifc.net.netifs(): - if i.mtu < mtu: - mtu = i.mtu + for iface in iface.net.get_ifaces(): + if iface.mtu < mtu: + mtu = iface.mtu return mtu @@ -47,10 +47,8 @@ def get_router_id(node: CoreNodeBase) -> str: """ Helper to return the first IPv4 address of a node as its router ID. """ - for ifc in node.netifs(): - if getattr(ifc, "control", False): - continue - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + for a in iface.addrlist: a = a.split("/")[0] if netaddr.valid_ipv4(a): return a @@ -97,25 +95,25 @@ class FRRZebra(ConfigService): want_ip6 = True services.append(service) - interfaces = [] - for ifc in self.node.netifs(): + ifaces = [] + for iface in self.node.get_ifaces(): ip4s = [] ip6s = [] - for x in ifc.addrlist: + for x in iface.addrlist: addr = x.split("/")[0] if netaddr.valid_ipv4(addr): ip4s.append(x) else: ip6s.append(x) - is_control = getattr(ifc, "control", False) - interfaces.append((ifc, ip4s, ip6s, is_control)) + is_control = getattr(iface, "control", False) + ifaces.append((iface, ip4s, ip6s, is_control)) return dict( frr_conf=frr_conf, frr_sbin_search=frr_sbin_search, frr_bin_search=frr_bin_search, frr_state_dir=constants.FRR_STATE_DIR, - interfaces=interfaces, + ifaces=ifaces, want_ip4=want_ip4, want_ip6=want_ip6, services=services, @@ -138,7 +136,7 @@ class FrrService(abc.ABC): ipv6_routing = False @abc.abstractmethod - def frr_interface_config(self, ifc: CoreInterface) -> str: + def frr_iface_config(self, iface: CoreInterface) -> str: raise NotImplementedError @abc.abstractmethod @@ -162,10 +160,8 @@ class FRROspfv2(FrrService, ConfigService): def frr_config(self) -> str: router_id = get_router_id(self.node) addresses = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - for a in ifc.addrlist: + for iface in self.node.get_ifaces(control=False): + for a in iface.addrlist: addr = a.split("/")[0] if netaddr.valid_ipv4(addr): addresses.append(a) @@ -180,8 +176,8 @@ class FRROspfv2(FrrService, ConfigService): """ return self.render_text(text, data) - def frr_interface_config(self, ifc: CoreInterface) -> str: - if has_mtu_mismatch(ifc): + def frr_iface_config(self, iface: CoreInterface) -> str: + if has_mtu_mismatch(iface): return "ip ospf mtu-ignore" else: return "" @@ -203,10 +199,8 @@ class FRROspfv3(FrrService, ConfigService): def frr_config(self) -> str: router_id = get_router_id(self.node) ifnames = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) data = dict(router_id=router_id, ifnames=ifnames) text = """ router ospf6 @@ -218,9 +212,9 @@ class FRROspfv3(FrrService, ConfigService): """ return self.render_text(text, data) - def frr_interface_config(self, ifc: CoreInterface) -> str: - mtu = get_min_mtu(ifc) - if mtu < ifc.mtu: + def frr_iface_config(self, iface: CoreInterface) -> str: + mtu = get_min_mtu(iface) + if mtu < iface.mtu: return f"ipv6 ospf6 ifmtu {mtu}" else: return "" @@ -254,7 +248,7 @@ class FRRBgp(FrrService, ConfigService): """ return self.clean_text(text) - def frr_interface_config(self, ifc: CoreInterface) -> str: + def frr_iface_config(self, iface: CoreInterface) -> str: return "" @@ -279,7 +273,7 @@ class FRRRip(FrrService, ConfigService): """ return self.clean_text(text) - def frr_interface_config(self, ifc: CoreInterface) -> str: + def frr_iface_config(self, iface: CoreInterface) -> str: return "" @@ -304,7 +298,7 @@ class FRRRipng(FrrService, ConfigService): """ return self.clean_text(text) - def frr_interface_config(self, ifc: CoreInterface) -> str: + def frr_iface_config(self, iface: CoreInterface) -> str: return "" @@ -321,10 +315,8 @@ class FRRBabel(FrrService, ConfigService): def frr_config(self) -> str: ifnames = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) text = """ router babel % for ifname in ifnames: @@ -337,8 +329,8 @@ class FRRBabel(FrrService, ConfigService): data = dict(ifnames=ifnames) return self.render_text(text, data) - def frr_interface_config(self, ifc: CoreInterface) -> str: - if isinstance(ifc.net, (WlanNode, EmaneNet)): + def frr_iface_config(self, iface: CoreInterface) -> str: + if isinstance(iface.net, (WlanNode, EmaneNet)): text = """ babel wireless no babel split-horizon @@ -363,9 +355,9 @@ class FRRpimd(FrrService, ConfigService): def frr_config(self) -> str: ifname = "eth0" - for ifc in self.node.netifs(): - if ifc.name != "lo": - ifname = ifc.name + for iface in self.node.get_ifaces(): + if iface.name != "lo": + ifname = iface.name break text = f""" @@ -382,7 +374,7 @@ class FRRpimd(FrrService, ConfigService): """ return self.clean_text(text) - def frr_interface_config(self, ifc: CoreInterface) -> str: + def frr_iface_config(self, iface: CoreInterface) -> str: text = """ ip mfea ip igmp diff --git a/daemon/core/configservices/frrservices/templates/frr.conf b/daemon/core/configservices/frrservices/templates/frr.conf index 748c8692..8e036136 100644 --- a/daemon/core/configservices/frrservices/templates/frr.conf +++ b/daemon/core/configservices/frrservices/templates/frr.conf @@ -1,5 +1,5 @@ -% for ifc, ip4s, ip6s, is_control in interfaces: -interface ${ifc.name} +% for iface, ip4s, ip6s, is_control in ifaces: +interface ${iface.name} % if want_ip4: % for addr in ip4s: ip address ${addr} @@ -12,7 +12,7 @@ interface ${ifc.name} % endif % if not is_control: % for service in services: - % for line in service.frr_interface_config(ifc).split("\n"): + % for line in service.frr_iface_config(iface).split("\n"): ${line} % endfor % endfor diff --git a/daemon/core/configservices/frrservices/templates/frrboot.sh b/daemon/core/configservices/frrservices/templates/frrboot.sh index 3c14cd1a..db47b6d1 100644 --- a/daemon/core/configservices/frrservices/templates/frrboot.sh +++ b/daemon/core/configservices/frrservices/templates/frrboot.sh @@ -98,8 +98,8 @@ confcheck bootfrr # reset interfaces -% for ifc, _, _ , _ in interfaces: -ip link set dev ${ifc.name} down +% for iface, _, _ , _ in ifaces: +ip link set dev ${iface.name} down sleep 1 -ip link set dev ${ifc.name} up +ip link set dev ${iface.name} up % endfor diff --git a/daemon/core/configservices/nrlservices/services.py b/daemon/core/configservices/nrlservices/services.py index 3dddf1ba..ca95b8f6 100644 --- a/daemon/core/configservices/nrlservices/services.py +++ b/daemon/core/configservices/nrlservices/services.py @@ -24,8 +24,8 @@ class MgenSinkService(ConfigService): def data(self) -> Dict[str, Any]: ifnames = [] - for ifc in self.node.netifs(): - name = utils.sysctl_devname(ifc.name) + for iface in self.node.get_ifaces(): + name = utils.sysctl_devname(iface.name) ifnames.append(name) return dict(ifnames=ifnames) @@ -47,10 +47,8 @@ class NrlNhdp(ConfigService): def data(self) -> Dict[str, Any]: has_smf = "SMF" in self.node.config_services ifnames = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) return dict(has_smf=has_smf, ifnames=ifnames) @@ -74,13 +72,11 @@ class NrlSmf(ConfigService): has_olsr = "OLSR" in self.node.config_services ifnames = [] ip4_prefix = None - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) if ip4_prefix: continue - for a in ifc.addrlist: + for a in iface.addrlist: a = a.split("/")[0] if netaddr.valid_ipv4(a): ip4_prefix = f"{a}/{24}" @@ -112,10 +108,8 @@ class NrlOlsr(ConfigService): has_smf = "SMF" in self.node.config_services has_zebra = "zebra" in self.node.config_services ifname = None - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifname = ifc.name + for iface in self.node.get_ifaces(control=False): + ifname = iface.name break return dict(has_smf=has_smf, has_zebra=has_zebra, ifname=ifname) @@ -137,10 +131,8 @@ class NrlOlsrv2(ConfigService): def data(self) -> Dict[str, Any]: has_smf = "SMF" in self.node.config_services ifnames = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) return dict(has_smf=has_smf, ifnames=ifnames) @@ -161,10 +153,8 @@ class OlsrOrg(ConfigService): def data(self) -> Dict[str, Any]: has_smf = "SMF" in self.node.config_services ifnames = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) return dict(has_smf=has_smf, ifnames=ifnames) @@ -199,12 +189,10 @@ class Arouted(ConfigService): def data(self) -> Dict[str, Any]: ip4_prefix = None - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue + for iface in self.node.get_ifaces(control=False): if ip4_prefix: continue - for a in ifc.addrlist: + for a in iface.addrlist: a = a.split("/")[0] if netaddr.valid_ipv4(a): ip4_prefix = f"{a}/{24}" diff --git a/daemon/core/configservices/nrlservices/templates/nrlnhdp.sh b/daemon/core/configservices/nrlservices/templates/nrlnhdp.sh index 00b7e11d..4513dfe9 100644 --- a/daemon/core/configservices/nrlservices/templates/nrlnhdp.sh +++ b/daemon/core/configservices/nrlservices/templates/nrlnhdp.sh @@ -1,7 +1,7 @@ <% - interfaces = "-i " + " -i ".join(ifnames) + ifaces = "-i " + " -i ".join(ifnames) smf = "" if has_smf: smf = "-flooding ecds -smfClient %s_smf" % node.name %> -nrlnhdp -l /var/log/nrlnhdp.log -rpipe ${node.name}_nhdp ${smf} ${interfaces} +nrlnhdp -l /var/log/nrlnhdp.log -rpipe ${node.name}_nhdp ${smf} ${ifaces} diff --git a/daemon/core/configservices/nrlservices/templates/nrlolsrv2.sh b/daemon/core/configservices/nrlservices/templates/nrlolsrv2.sh index d7a8d3b6..81196e26 100644 --- a/daemon/core/configservices/nrlservices/templates/nrlolsrv2.sh +++ b/daemon/core/configservices/nrlservices/templates/nrlolsrv2.sh @@ -1,7 +1,7 @@ <% - interfaces = "-i " + " -i ".join(ifnames) + ifaces = "-i " + " -i ".join(ifnames) smf = "" if has_smf: smf = "-flooding ecds -smfClient %s_smf" % node.name %> -nrlolsrv2 -l /var/log/nrlolsrv2.log -rpipe ${node.name}_olsrv2 -p olsr ${smf} ${interfaces} +nrlolsrv2 -l /var/log/nrlolsrv2.log -rpipe ${node.name}_olsrv2 -p olsr ${smf} ${ifaces} diff --git a/daemon/core/configservices/nrlservices/templates/olsrd.sh b/daemon/core/configservices/nrlservices/templates/olsrd.sh index 076f049b..3040ca6b 100644 --- a/daemon/core/configservices/nrlservices/templates/olsrd.sh +++ b/daemon/core/configservices/nrlservices/templates/olsrd.sh @@ -1,4 +1,4 @@ <% - interfaces = "-i " + " -i ".join(ifnames) + ifaces = "-i " + " -i ".join(ifnames) %> -olsrd ${interfaces} +olsrd ${ifaces} diff --git a/daemon/core/configservices/nrlservices/templates/startsmf.sh b/daemon/core/configservices/nrlservices/templates/startsmf.sh index 67fc0fe6..921568de 100644 --- a/daemon/core/configservices/nrlservices/templates/startsmf.sh +++ b/daemon/core/configservices/nrlservices/templates/startsmf.sh @@ -1,5 +1,5 @@ <% - interfaces = ",".join(ifnames) + ifaces = ",".join(ifnames) arouted = "" if has_arouted: arouted = "tap %s_tap unicast %s push lo,%s resequence on" % (node.name, ip4_prefix, ifnames[0]) @@ -12,4 +12,4 @@ %> #!/bin/sh # auto-generated by NrlSmf service -nrlsmf instance ${node.name}_smf ${interfaces} ${arouted} ${flood} hash MD5 log /var/log/nrlsmf.log < /dev/null > /dev/null 2>&1 & +nrlsmf instance ${node.name}_smf ${ifaces} ${arouted} ${flood} hash MD5 log /var/log/nrlsmf.log < /dev/null > /dev/null 2>&1 & diff --git a/daemon/core/configservices/quaggaservices/services.py b/daemon/core/configservices/quaggaservices/services.py index 32ce99be..19e21476 100644 --- a/daemon/core/configservices/quaggaservices/services.py +++ b/daemon/core/configservices/quaggaservices/services.py @@ -14,33 +14,33 @@ from core.nodes.network import WlanNode GROUP = "Quagga" -def has_mtu_mismatch(ifc: CoreInterface) -> bool: +def has_mtu_mismatch(iface: CoreInterface) -> bool: """ Helper to detect MTU mismatch and add the appropriate OSPF mtu-ignore command. This is needed when e.g. a node is linked via a GreTap device. """ - if ifc.mtu != 1500: + if iface.mtu != 1500: return True - if not ifc.net: + if not iface.net: return False - for i in ifc.net.netifs(): - if i.mtu != ifc.mtu: + for iface in iface.net.get_ifaces(): + if iface.mtu != iface.mtu: return True return False -def get_min_mtu(ifc): +def get_min_mtu(iface: CoreInterface): """ Helper to discover the minimum MTU of interfaces linked with the given interface. """ - mtu = ifc.mtu - if not ifc.net: + mtu = iface.mtu + if not iface.net: return mtu - for i in ifc.net.netifs(): - if i.mtu < mtu: - mtu = i.mtu + for iface in iface.net.get_ifaces(): + if iface.mtu < mtu: + mtu = iface.mtu return mtu @@ -48,10 +48,8 @@ def get_router_id(node: CoreNodeBase) -> str: """ Helper to return the first IPv4 address of a node as its router ID. """ - for ifc in node.netifs(): - if getattr(ifc, "control", False): - continue - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + for a in iface.addrlist: a = a.split("/")[0] if netaddr.valid_ipv4(a): return a @@ -98,25 +96,25 @@ class Zebra(ConfigService): want_ip6 = True services.append(service) - interfaces = [] - for ifc in self.node.netifs(): + ifaces = [] + for iface in self.node.get_ifaces(): ip4s = [] ip6s = [] - for x in ifc.addrlist: + for x in iface.addrlist: addr = x.split("/")[0] if netaddr.valid_ipv4(addr): ip4s.append(x) else: ip6s.append(x) - is_control = getattr(ifc, "control", False) - interfaces.append((ifc, ip4s, ip6s, is_control)) + is_control = getattr(iface, "control", False) + ifaces.append((iface, ip4s, ip6s, is_control)) return dict( quagga_bin_search=quagga_bin_search, quagga_sbin_search=quagga_sbin_search, quagga_state_dir=quagga_state_dir, quagga_conf=quagga_conf, - interfaces=interfaces, + ifaces=ifaces, want_ip4=want_ip4, want_ip6=want_ip6, services=services, @@ -139,7 +137,7 @@ class QuaggaService(abc.ABC): ipv6_routing = False @abc.abstractmethod - def quagga_interface_config(self, ifc: CoreInterface) -> str: + def quagga_iface_config(self, iface: CoreInterface) -> str: raise NotImplementedError @abc.abstractmethod @@ -159,8 +157,8 @@ class Ospfv2(QuaggaService, ConfigService): shutdown = ["killall ospfd"] ipv4_routing = True - def quagga_interface_config(self, ifc: CoreInterface) -> str: - if has_mtu_mismatch(ifc): + def quagga_iface_config(self, iface: CoreInterface) -> str: + if has_mtu_mismatch(iface): return "ip ospf mtu-ignore" else: return "" @@ -168,10 +166,8 @@ class Ospfv2(QuaggaService, ConfigService): def quagga_config(self) -> str: router_id = get_router_id(self.node) addresses = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - for a in ifc.addrlist: + for iface in self.node.get_ifaces(control=False): + for a in iface.addrlist: addr = a.split("/")[0] if netaddr.valid_ipv4(addr): addresses.append(a) @@ -200,9 +196,9 @@ class Ospfv3(QuaggaService, ConfigService): ipv4_routing = True ipv6_routing = True - def quagga_interface_config(self, ifc: CoreInterface) -> str: - mtu = get_min_mtu(ifc) - if mtu < ifc.mtu: + def quagga_iface_config(self, iface: CoreInterface) -> str: + mtu = get_min_mtu(iface) + if mtu < iface.mtu: return f"ipv6 ospf6 ifmtu {mtu}" else: return "" @@ -210,10 +206,8 @@ class Ospfv3(QuaggaService, ConfigService): def quagga_config(self) -> str: router_id = get_router_id(self.node) ifnames = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) data = dict(router_id=router_id, ifnames=ifnames) text = """ router ospf6 @@ -238,14 +232,14 @@ class Ospfv3mdr(Ospfv3): name = "OSPFv3MDR" def data(self) -> Dict[str, Any]: - for ifc in self.node.netifs(): - is_wireless = isinstance(ifc.net, (WlanNode, EmaneNet)) + for iface in self.node.get_ifaces(): + is_wireless = isinstance(iface.net, (WlanNode, EmaneNet)) logging.info("MDR wireless: %s", is_wireless) return dict() - def quagga_interface_config(self, ifc: CoreInterface) -> str: - config = super().quagga_interface_config(ifc) - if isinstance(ifc.net, (WlanNode, EmaneNet)): + def quagga_iface_config(self, iface: CoreInterface) -> str: + config = super().quagga_iface_config(iface) + if isinstance(iface.net, (WlanNode, EmaneNet)): config = self.clean_text( f""" {config} @@ -277,7 +271,7 @@ class Bgp(QuaggaService, ConfigService): def quagga_config(self) -> str: return "" - def quagga_interface_config(self, ifc: CoreInterface) -> str: + def quagga_iface_config(self, iface: CoreInterface) -> str: router_id = get_router_id(self.node) text = f""" ! BGP configuration @@ -313,7 +307,7 @@ class Rip(QuaggaService, ConfigService): """ return self.clean_text(text) - def quagga_interface_config(self, ifc: CoreInterface) -> str: + def quagga_iface_config(self, iface: CoreInterface) -> str: return "" @@ -338,7 +332,7 @@ class Ripng(QuaggaService, ConfigService): """ return self.clean_text(text) - def quagga_interface_config(self, ifc: CoreInterface) -> str: + def quagga_iface_config(self, iface: CoreInterface) -> str: return "" @@ -355,10 +349,8 @@ class Babel(QuaggaService, ConfigService): def quagga_config(self) -> str: ifnames = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) text = """ router babel % for ifname in ifnames: @@ -371,8 +363,8 @@ class Babel(QuaggaService, ConfigService): data = dict(ifnames=ifnames) return self.render_text(text, data) - def quagga_interface_config(self, ifc: CoreInterface) -> str: - if isinstance(ifc.net, (WlanNode, EmaneNet)): + def quagga_iface_config(self, iface: CoreInterface) -> str: + if isinstance(iface.net, (WlanNode, EmaneNet)): text = """ babel wireless no babel split-horizon @@ -397,9 +389,9 @@ class Xpimd(QuaggaService, ConfigService): def quagga_config(self) -> str: ifname = "eth0" - for ifc in self.node.netifs(): - if ifc.name != "lo": - ifname = ifc.name + for iface in self.node.get_ifaces(): + if iface.name != "lo": + ifname = iface.name break text = f""" @@ -416,7 +408,7 @@ class Xpimd(QuaggaService, ConfigService): """ return self.clean_text(text) - def quagga_interface_config(self, ifc: CoreInterface) -> str: + def quagga_iface_config(self, iface: CoreInterface) -> str: text = """ ip mfea ip pim diff --git a/daemon/core/configservices/quaggaservices/templates/Quagga.conf b/daemon/core/configservices/quaggaservices/templates/Quagga.conf index 853b1707..1d69838f 100644 --- a/daemon/core/configservices/quaggaservices/templates/Quagga.conf +++ b/daemon/core/configservices/quaggaservices/templates/Quagga.conf @@ -1,5 +1,5 @@ -% for ifc, ip4s, ip6s, is_control in interfaces: -interface ${ifc.name} +% for iface, ip4s, ip6s, is_control in ifaces: +interface ${iface.name} % if want_ip4: % for addr in ip4s: ip address ${addr} @@ -12,7 +12,7 @@ interface ${ifc.name} % endif % if not is_control: % for service in services: - % for line in service.quagga_interface_config(ifc).split("\n"): + % for line in service.quagga_iface_config(iface).split("\n"): ${line} % endfor % endfor diff --git a/daemon/core/configservices/sercurityservices/services.py b/daemon/core/configservices/sercurityservices/services.py index 17f081cd..6e92bf62 100644 --- a/daemon/core/configservices/sercurityservices/services.py +++ b/daemon/core/configservices/sercurityservices/services.py @@ -78,10 +78,8 @@ class VpnServer(ConfigService): def data(self) -> Dict[str, Any]: address = None - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - for x in ifc.addrlist: + for iface in self.node.get_ifaces(control=False): + for x in iface.addrlist: addr = x.split("/")[0] if netaddr.valid_ipv4(addr): address = addr @@ -134,8 +132,6 @@ class Nat(ConfigService): def data(self) -> Dict[str, Any]: ifnames = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) return dict(ifnames=ifnames) diff --git a/daemon/core/configservices/utilservices/services.py b/daemon/core/configservices/utilservices/services.py index 8ddf1cc7..5aa3bb54 100644 --- a/daemon/core/configservices/utilservices/services.py +++ b/daemon/core/configservices/utilservices/services.py @@ -25,10 +25,10 @@ class DefaultRouteService(ConfigService): def data(self) -> Dict[str, Any]: # only add default routes for linked routing nodes routes = [] - netifs = self.node.netifs(sort=True) - if netifs: - netif = netifs[0] - for x in netif.addrlist: + ifaces = self.node.get_ifaces() + if ifaces: + iface = ifaces[0] + for x in iface.addrlist: net = netaddr.IPNetwork(x).cidr if net.size > 1: router = net[1] @@ -52,10 +52,8 @@ class DefaultMulticastRouteService(ConfigService): def data(self) -> Dict[str, Any]: ifname = None - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifname = ifc.name + for iface in self.node.get_ifaces(control=False): + ifname = iface.name break return dict(ifname=ifname) @@ -76,10 +74,8 @@ class StaticRouteService(ConfigService): def data(self) -> Dict[str, Any]: routes = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - for x in ifc.addrlist: + for iface in self.node.get_ifaces(control=False): + for x in iface.addrlist: addr = x.split("/")[0] if netaddr.valid_ipv6(addr): dst = "3ffe:4::/64" @@ -107,8 +103,8 @@ class IpForwardService(ConfigService): def data(self) -> Dict[str, Any]: devnames = [] - for ifc in self.node.netifs(): - devname = utils.sysctl_devname(ifc.name) + for iface in self.node.get_ifaces(): + devname = utils.sysctl_devname(iface.name) devnames.append(devname) return dict(devnames=devnames) @@ -151,10 +147,8 @@ class DhcpService(ConfigService): def data(self) -> Dict[str, Any]: subnets = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - for x in ifc.addrlist: + for iface in self.node.get_ifaces(control=False): + for x in iface.addrlist: addr = x.split("/")[0] if netaddr.valid_ipv4(addr): net = netaddr.IPNetwork(x) @@ -182,10 +176,8 @@ class DhcpClientService(ConfigService): def data(self) -> Dict[str, Any]: ifnames = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) return dict(ifnames=ifnames) @@ -220,10 +212,8 @@ class PcapService(ConfigService): def data(self) -> Dict[str, Any]: ifnames = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - ifnames.append(ifc.name) + for iface in self.node.get_ifaces(control=False): + ifnames.append(iface.name) return dict() @@ -242,19 +232,17 @@ class RadvdService(ConfigService): modes = {} def data(self) -> Dict[str, Any]: - interfaces = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue + ifaces = [] + for iface in self.node.get_ifaces(control=False): prefixes = [] - for x in ifc.addrlist: + for x in iface.addrlist: addr = x.split("/")[0] if netaddr.valid_ipv6(addr): prefixes.append(x) if not prefixes: continue - interfaces.append((ifc.name, prefixes)) - return dict(interfaces=interfaces) + ifaces.append((iface.name, prefixes)) + return dict(ifaces=ifaces) class AtdService(ConfigService): @@ -294,9 +282,7 @@ class HttpService(ConfigService): modes = {} def data(self) -> Dict[str, Any]: - interfaces = [] - for ifc in self.node.netifs(): - if getattr(ifc, "control", False): - continue - interfaces.append(ifc) - return dict(interfaces=interfaces) + ifaces = [] + for iface in self.node.get_ifaces(control=False): + ifaces.append(iface) + return dict(ifaces=ifaces) diff --git a/daemon/core/configservices/utilservices/templates/index.html b/daemon/core/configservices/utilservices/templates/index.html index aaf9d9fa..bed270ae 100644 --- a/daemon/core/configservices/utilservices/templates/index.html +++ b/daemon/core/configservices/utilservices/templates/index.html @@ -5,8 +5,8 @@

This is the default web page for this server.

The web server software is running but no content has been added, yet.

    -% for ifc in interfaces: -
  • ${ifc.name} - ${ifc.addrlist}
  • +% for iface in ifaces: +
  • ${iface.name} - ${iface.addrlist}
  • % endfor
diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index 21252b6f..0f441d76 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -63,7 +63,7 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): return [ConfigGroup("CommEffect SHIM Parameters", 1, len(cls.configurations()))] def build_xml_files( - self, config: Dict[str, str], interface: CoreInterface = None + self, config: Dict[str, str], iface: CoreInterface = None ) -> None: """ Build the necessary nem and commeffect XMLs in the given path. @@ -72,17 +72,17 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): nXXemane_commeffectnem.xml, nXXemane_commeffectshim.xml are used. :param config: emane model configuration for the node and interface - :param interface: interface for the emane node + :param iface: interface for the emane node :return: nothing """ # retrieve xml names - nem_name = emanexml.nem_file_name(self, interface) - shim_name = emanexml.shim_file_name(self, interface) + nem_name = emanexml.nem_file_name(self, iface) + shim_name = emanexml.shim_file_name(self, iface) # create and write nem document nem_element = etree.Element("nem", name=f"{self.name} NEM", type="unstructured") transport_type = TransportType.VIRTUAL - if interface and interface.transport_type == TransportType.RAW: + if iface and iface.transport_type == TransportType.RAW: transport_type = TransportType.RAW transport_file = emanexml.transport_file_name(self.id, transport_type) etree.SubElement(nem_element, "transport", definition=transport_file) @@ -115,7 +115,7 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): emanexml.create_file(shim_element, "shim", shim_file) def linkconfig( - self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None + self, iface: CoreInterface, options: LinkOptions, iface2: CoreInterface = None ) -> None: """ Generate CommEffect events when a Link Message is received having @@ -126,7 +126,7 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): logging.warning("%s: EMANE event service unavailable", self.name) return - if netif is None or netif2 is None: + if iface is None or iface2 is None: logging.warning("%s: missing NEM information", self.name) return @@ -134,8 +134,8 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): # TODO: may want to split out seconds portion of delay and jitter event = CommEffectEvent() emane_node = self.session.get_node(self.id, EmaneNet) - nemid = emane_node.getnemid(netif) - nemid2 = emane_node.getnemid(netif2) + nemid = emane_node.getnemid(iface) + nemid2 = emane_node.getnemid(iface2) logging.info("sending comm effect event") event.append( nemid, diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index cb978cb9..58b85080 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -111,41 +111,39 @@ class EmaneManager(ModelManager): self.event_device: Optional[str] = None self.emane_check() - def getifcconfig( - self, node_id: int, interface: CoreInterface, model_name: str + def get_iface_config( + self, node_id: int, iface: CoreInterface, model_name: str ) -> Dict[str, str]: """ Retrieve interface configuration or node configuration if not provided. :param node_id: node id - :param interface: node interface + :param iface: node interface :param model_name: model to get configuration for :return: node/interface model configuration """ # use the network-wide config values or interface(NEM)-specific values? - if interface is None: + if iface is None: return self.get_configs(node_id=node_id, config_type=model_name) else: # don"t use default values when interface config is the same as net - # note here that using ifc.node.id as key allows for only one type + # note here that using iface.node.id as key allows for only one type # of each model per node; # TODO: use both node and interface as key - # Adamson change: first check for iface config keyed by "node:ifc.name" + # Adamson change: first check for iface config keyed by "node:iface.name" # (so that nodes w/ multiple interfaces of same conftype can have # different configs for each separate interface) - key = 1000 * interface.node.id - if interface.netindex is not None: - key += interface.netindex + key = 1000 * iface.node.id + if iface.node_id is not None: + key += iface.node_id # try retrieve interface specific configuration, avoid getting defaults config = self.get_configs(node_id=key, config_type=model_name) # otherwise retrieve the interfaces node configuration, avoid using defaults if not config: - config = self.get_configs( - node_id=interface.node.id, config_type=model_name - ) + config = self.get_configs(node_id=iface.node.id, config_type=model_name) # get non interface config, when none found if not config: @@ -265,8 +263,8 @@ class EmaneManager(ModelManager): # assumes self._objslock already held nodes = set() for emane_net in self._emane_nets.values(): - for netif in emane_net.netifs(): - nodes.add(netif.node) + for iface in emane_net.get_ifaces(): + nodes.add(iface.node) return nodes def setup(self) -> int: @@ -352,13 +350,13 @@ class EmaneManager(ModelManager): if self.numnems() > 0: self.startdaemons() - self.installnetifs() + self.install_ifaces() for node_id in self._emane_nets: emane_node = self._emane_nets[node_id] - for netif in emane_node.netifs(): + for iface in emane_node.get_ifaces(): nems.append( - (netif.node.name, netif.name, emane_node.getnemid(netif)) + (iface.node.name, iface.name, emane_node.getnemid(iface)) ) if nems: @@ -392,8 +390,8 @@ class EmaneManager(ModelManager): emane_node.name, ) emane_node.model.post_startup() - for netif in emane_node.netifs(): - netif.setposition() + for iface in emane_node.get_ifaces(): + iface.setposition() def reset(self) -> None: """ @@ -420,7 +418,7 @@ class EmaneManager(ModelManager): logging.info("stopping EMANE daemons") if self.links_enabled(): self.link_monitor.stop() - self.deinstallnetifs() + self.deinstall_ifaces() self.stopdaemons() self.stopeventmonitor() @@ -474,31 +472,31 @@ class EmaneManager(ModelManager): EMANE network and NEM interface. """ emane_node = None - netif = None + iface = None for node_id in self._emane_nets: emane_node = self._emane_nets[node_id] - netif = emane_node.getnemnetif(nemid) - if netif is not None: + iface = emane_node.get_nem_iface(nemid) + if iface is not None: break else: emane_node = None - return emane_node, netif + return emane_node, iface def get_nem_link( self, nem1: int, nem2: int, flags: MessageFlags = MessageFlags.NONE ) -> Optional[LinkData]: - emane1, netif = self.nemlookup(nem1) - if not emane1 or not netif: + emane1, iface = self.nemlookup(nem1) + if not emane1 or not iface: logging.error("invalid nem: %s", nem1) return None - node1 = netif.node - emane2, netif = self.nemlookup(nem2) - if not emane2 or not netif: + node1 = iface.node + emane2, iface = self.nemlookup(nem2) + if not emane2 or not iface: logging.error("invalid nem: %s", nem2) return None - node2 = netif.node + node2 = iface.node color = self.session.get_link_color(emane1.id) return LinkData( message_type=flags, @@ -516,7 +514,7 @@ class EmaneManager(ModelManager): count = 0 for node_id in self._emane_nets: emane_node = self._emane_nets[node_id] - count += len(emane_node.netifs()) + count += len(emane_node.ifaces) return count def buildplatformxml(self, ctrlnet: CtrlNet) -> None: @@ -607,19 +605,19 @@ class EmaneManager(ModelManager): n = node.id # control network not yet started here - self.session.add_remove_control_interface( + self.session.add_remove_control_iface( node, 0, remove=False, conf_required=False ) if otanetidx > 0: logging.info("adding ota device ctrl%d", otanetidx) - self.session.add_remove_control_interface( + self.session.add_remove_control_iface( node, otanetidx, remove=False, conf_required=False ) if eventservicenetidx >= 0: logging.info("adding event service device ctrl%d", eventservicenetidx) - self.session.add_remove_control_interface( + self.session.add_remove_control_iface( node, eventservicenetidx, remove=False, conf_required=False ) @@ -676,23 +674,23 @@ class EmaneManager(ModelManager): except CoreCommandError: logging.exception("error shutting down emane daemons") - def installnetifs(self) -> None: + def install_ifaces(self) -> None: """ Install TUN/TAP virtual interfaces into their proper namespaces now that the EMANE daemons are running. """ for key in sorted(self._emane_nets.keys()): - emane_node = self._emane_nets[key] - logging.info("emane install netifs for node: %d", key) - emane_node.installnetifs() + node = self._emane_nets[key] + logging.info("emane install interface for node(%s): %d", node.name, key) + node.install_ifaces() - def deinstallnetifs(self) -> None: + def deinstall_ifaces(self) -> None: """ Uninstall TUN/TAP virtual interfaces. """ for key in sorted(self._emane_nets.keys()): emane_node = self._emane_nets[key] - emane_node.deinstallnetifs() + emane_node.deinstall_ifaces() def doeventmonitor(self) -> bool: """ @@ -808,12 +806,12 @@ class EmaneManager(ModelManager): Returns True if successfully parsed and a Node Message was sent. """ # convert nemid to node number - _emanenode, netif = self.nemlookup(nemid) - if netif is None: + _emanenode, iface = self.nemlookup(nemid) + if iface is None: logging.info("location event for unknown NEM %s", nemid) return False - n = netif.node.id + n = iface.node.id # convert from lat/long/alt to x,y,z coordinates x, y, z = self.session.location.getxyz(lat, lon, alt) x = int(x) diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 78d5ec5e..1a14011a 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -97,28 +97,28 @@ class EmaneModel(WirelessModel): ] def build_xml_files( - self, config: Dict[str, str], interface: CoreInterface = None + self, config: Dict[str, str], iface: CoreInterface = None ) -> None: """ Builds xml files for this emane model. Creates a nem.xml file that points to both mac.xml and phy.xml definitions. :param config: emane model configuration for the node and interface - :param interface: interface for the emane node + :param iface: interface for the emane node :return: nothing """ - nem_name = emanexml.nem_file_name(self, interface) - mac_name = emanexml.mac_file_name(self, interface) - phy_name = emanexml.phy_file_name(self, interface) + nem_name = emanexml.nem_file_name(self, iface) + mac_name = emanexml.mac_file_name(self, iface) + phy_name = emanexml.phy_file_name(self, iface) # remote server for file server = None - if interface is not None: - server = interface.node.server + if iface is not None: + server = iface.node.server # check if this is external transport_type = TransportType.VIRTUAL - if interface and interface.transport_type == TransportType.RAW: + if iface and iface.transport_type == TransportType.RAW: transport_type = TransportType.RAW transport_name = emanexml.transport_file_name(self.id, transport_type) @@ -144,31 +144,31 @@ class EmaneModel(WirelessModel): """ logging.debug("emane model(%s) has no post setup tasks", self.name) - def update(self, moved: List[CoreNode], moved_netifs: List[CoreInterface]) -> None: + def update(self, moved: List[CoreNode], moved_ifaces: List[CoreInterface]) -> None: """ Invoked from MobilityModel when nodes are moved; this causes emane location events to be generated for the nodes in the moved list, making EmaneModels compatible with Ns2ScriptedMobility. :param moved: moved nodes - :param moved_netifs: interfaces that were moved + :param moved_ifaces: interfaces that were moved :return: nothing """ try: wlan = self.session.get_node(self.id, EmaneNet) - wlan.setnempositions(moved_netifs) + wlan.setnempositions(moved_ifaces) except CoreError: logging.exception("error during update") def linkconfig( - self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None + self, iface: CoreInterface, options: LinkOptions, iface2: CoreInterface = None ) -> None: """ Invoked when a Link Message is received. Default is unimplemented. - :param netif: interface one + :param iface: interface one :param options: options for configuring link - :param netif2: interface two + :param iface2: interface two :return: nothing """ logging.warning("emane model(%s) does not support link config", self.name) diff --git a/daemon/core/emane/linkmonitor.py b/daemon/core/emane/linkmonitor.py index ca9f4493..097080c3 100644 --- a/daemon/core/emane/linkmonitor.py +++ b/daemon/core/emane/linkmonitor.py @@ -212,10 +212,10 @@ class EmaneLinkMonitor: addresses = [] nodes = self.emane_manager.getnodes() for node in nodes: - for netif in node.netifs(): - if isinstance(netif.net, CtrlNet): + for iface in node.get_ifaces(): + if isinstance(iface.net, CtrlNet): ip4 = None - for x in netif.addrlist: + for x in iface.addrlist: address, prefix = x.split("/") if netaddr.valid_ipv4(address): ip4 = address diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index c4c3428b..eed51ff2 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -64,14 +64,14 @@ class EmaneNet(CoreNetworkBase): self.mobility: Optional[WayPointMobility] = None def linkconfig( - self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None + self, iface: CoreInterface, options: LinkOptions, iface2: CoreInterface = None ) -> None: """ The CommEffect model supports link configuration. """ if not self.model: return - self.model.linkconfig(netif, options, netif2) + self.model.linkconfig(iface, options, iface2) def config(self, conf: str) -> None: self.conf = conf @@ -82,10 +82,10 @@ class EmaneNet(CoreNetworkBase): def shutdown(self) -> None: pass - def link(self, netif1: CoreInterface, netif2: CoreInterface) -> None: + def link(self, iface1: CoreInterface, iface2: CoreInterface) -> None: pass - def unlink(self, netif1: CoreInterface, netif2: CoreInterface) -> None: + def unlink(self, iface1: CoreInterface, iface2: CoreInterface) -> None: pass def linknet(self, net: "CoreNetworkBase") -> CoreInterface: @@ -113,39 +113,33 @@ class EmaneNet(CoreNetworkBase): self.mobility = model(session=self.session, _id=self.id) self.mobility.update_config(config) - def setnemid(self, netif: CoreInterface, nemid: int) -> None: + def setnemid(self, iface: CoreInterface, nemid: int) -> None: """ Record an interface to numerical ID mapping. The Emane controller object manages and assigns these IDs for all NEMs. """ - self.nemidmap[netif] = nemid + self.nemidmap[iface] = nemid - def getnemid(self, netif: CoreInterface) -> Optional[int]: + def getnemid(self, iface: CoreInterface) -> Optional[int]: """ Given an interface, return its numerical ID. """ - if netif not in self.nemidmap: + if iface not in self.nemidmap: return None else: - return self.nemidmap[netif] + return self.nemidmap[iface] - def getnemnetif(self, nemid: int) -> Optional[CoreInterface]: + def get_nem_iface(self, nemid: int) -> Optional[CoreInterface]: """ Given a numerical NEM ID, return its interface. This returns the first interface that matches the given NEM ID. """ - for netif in self.nemidmap: - if self.nemidmap[netif] == nemid: - return netif + for iface in self.nemidmap: + if self.nemidmap[iface] == nemid: + return iface return None - def netifs(self, sort: bool = True) -> List[CoreInterface]: - """ - Retrieve list of linked interfaces sorted by node number. - """ - return sorted(self._netif.values(), key=lambda ifc: ifc.node.id) - - def installnetifs(self) -> None: + def install_ifaces(self) -> None: """ Install TAP devices into their namespaces. This is done after EMANE daemons have been started, because that is their only chance @@ -159,48 +153,48 @@ class EmaneNet(CoreNetworkBase): warntxt += "Python bindings failed to load" logging.error(warntxt) - for netif in self.netifs(): + for iface in self.get_ifaces(): external = self.session.emane.get_config( "external", self.id, self.model.name ) if external == "0": - netif.setaddrs() + iface.setaddrs() if not self.session.emane.genlocationevents(): - netif.poshook = None + iface.poshook = None continue # at this point we register location handlers for generating # EMANE location events - netif.poshook = self.setnemposition - netif.setposition() + iface.poshook = self.setnemposition + iface.setposition() - def deinstallnetifs(self) -> None: + def deinstall_ifaces(self) -> None: """ Uninstall TAP devices. This invokes their shutdown method for any required cleanup; the device may be actually removed when emanetransportd terminates. """ - for netif in self.netifs(): - if netif.transport_type == TransportType.VIRTUAL: - netif.shutdown() - netif.poshook = None + for iface in self.get_ifaces(): + if iface.transport_type == TransportType.VIRTUAL: + iface.shutdown() + iface.poshook = None def _nem_position( - self, netif: CoreInterface + self, iface: CoreInterface ) -> Optional[Tuple[int, float, float, float]]: """ Creates nem position for emane event for a given interface. - :param netif: interface to get nem emane position for + :param iface: interface to get nem emane position for :return: nem position tuple, None otherwise """ - nemid = self.getnemid(netif) - ifname = netif.localname + nemid = self.getnemid(iface) + ifname = iface.localname if nemid is None: logging.info("nemid for %s is unknown", ifname) return - node = netif.node + node = iface.node x, y, z = node.getposition() lat, lon, alt = self.session.location.getgeo(x, y, z) if node.position.alt is not None: @@ -210,30 +204,30 @@ class EmaneNet(CoreNetworkBase): alt = int(round(alt)) return nemid, lon, lat, alt - def setnemposition(self, netif: CoreInterface) -> None: + def setnemposition(self, iface: CoreInterface) -> None: """ Publish a NEM location change event using the EMANE event service. - :param netif: interface to set nem position for + :param iface: interface to set nem position for """ if self.session.emane.service is None: logging.info("position service not available") return - position = self._nem_position(netif) + position = self._nem_position(iface) if position: nemid, lon, lat, alt = position event = LocationEvent() event.append(nemid, latitude=lat, longitude=lon, altitude=alt) self.session.emane.service.publish(0, event) - def setnempositions(self, moved_netifs: List[CoreInterface]) -> None: + def setnempositions(self, moved_ifaces: List[CoreInterface]) -> None: """ Several NEMs have moved, from e.g. a WaypointMobilityModel calculation. Generate an EMANE Location Event having several - entries for each netif that has moved. + entries for each interface that has moved. """ - if len(moved_netifs) == 0: + if len(moved_ifaces) == 0: return if self.session.emane.service is None: @@ -241,8 +235,8 @@ class EmaneNet(CoreNetworkBase): return event = LocationEvent() - for netif in moved_netifs: - position = self._nem_position(netif) + for iface in moved_ifaces: + position = self._nem_position(iface) if position: nemid, lon, lat, alt = position event.append(nemid, latitude=lat, longitude=lon, altitude=alt) diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index 819716e3..47f45820 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -27,7 +27,7 @@ class ConfigData: possible_values: str = None groups: str = None session: int = None - interface_number: int = None + iface_id: int = None network_id: int = None opaque: str = None @@ -114,19 +114,19 @@ class LinkData: emulation_id: int = None network_id: int = None key: int = None - interface1_id: int = None - interface1_name: str = None - interface1_ip4: str = None - interface1_ip4_mask: int = None - interface1_mac: str = None - interface1_ip6: str = None - interface1_ip6_mask: int = None - interface2_id: int = None - interface2_name: str = None - interface2_ip4: str = None - interface2_ip4_mask: int = None - interface2_mac: str = None - interface2_ip6: str = None - interface2_ip6_mask: int = None + iface1_id: int = None + iface1_name: str = None + iface1_ip4: str = None + iface1_ip4_mask: int = None + iface1_mac: str = None + iface1_ip6: str = None + iface1_ip6_mask: int = None + iface2_id: int = None + iface2_name: str = None + iface2_ip4: str = None + iface2_ip4_mask: int = None + iface2_mac: str = None + iface2_ip6: str = None + iface2_ip6_mask: int = None opaque: str = None color: str = None diff --git a/daemon/core/emulator/distributed.py b/daemon/core/emulator/distributed.py index 75081447..381eb019 100644 --- a/daemon/core/emulator/distributed.py +++ b/daemon/core/emulator/distributed.py @@ -208,7 +208,7 @@ class DistributedController: "local tunnel node(%s) to remote(%s) key(%s)", node.name, host, key ) local_tap = GreTap(session=self.session, remoteip=host, key=key) - local_tap.net_client.set_interface_master(node.brname, local_tap.localname) + local_tap.net_client.set_iface_master(node.brname, local_tap.localname) # server to local logging.info( @@ -217,7 +217,7 @@ class DistributedController: remote_tap = GreTap( session=self.session, remoteip=self.address, key=key, server=server ) - remote_tap.net_client.set_interface_master(node.brname, remote_tap.localname) + remote_tap.net_client.set_iface_master(node.brname, remote_tap.localname) # save tunnels for shutdown tunnel = (local_tap, remote_tap) diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py index 2aecdace..25ce71ac 100644 --- a/daemon/core/emulator/emudata.py +++ b/daemon/core/emulator/emudata.py @@ -155,7 +155,7 @@ class IpPrefixes: raise ValueError("ip6 prefixes have not been set") return str(self.ip6[node_id]) - def gen_interface(self, node_id: int, name: str = None, mac: str = None): + def gen_iface(self, node_id: int, name: str = None, mac: str = None): """ Creates interface data for linking nodes, using the nodes unique id for generation, along with a random mac address, unless provided. @@ -188,7 +188,7 @@ class IpPrefixes: name=name, ip4=ip4, ip4_mask=ip4_mask, ip6=ip6, ip6_mask=ip6_mask, mac=mac ) - def create_interface( + def create_iface( self, node: "CoreNode", name: str = None, mac: str = None ) -> InterfaceData: """ @@ -201,6 +201,6 @@ class IpPrefixes: generation :return: new interface data for the provided node """ - interface_data = self.gen_interface(node.id, name, mac) - interface_data.id = node.newifindex() - return interface_data + iface_data = self.gen_iface(node.id, name, mac) + iface_data.id = node.next_iface_id() + return iface_data diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index e63c30c7..2dc5ad12 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -203,7 +203,7 @@ class Session: common_networks = node1.commonnets(node1) if not common_networks: raise CoreError("no common network found for wireless link/unlink") - for common_network, interface1, interface2 in common_networks: + for common_network, iface1, iface2 in common_networks: if not isinstance(common_network, (WlanNode, EmaneNet)): logging.info( "skipping common network that is not wireless/emane: %s", @@ -211,16 +211,16 @@ class Session: ) continue if connect: - common_network.link(interface1, interface2) + common_network.link(iface1, iface2) else: - common_network.unlink(interface1, interface2) + common_network.unlink(iface1, iface2) def add_link( self, node1_id: int, node2_id: int, - interface1_data: InterfaceData = None, - interface2_data: InterfaceData = None, + iface1_data: InterfaceData = None, + iface2_data: InterfaceData = None, options: LinkOptions = None, ) -> Tuple[CoreInterface, CoreInterface]: """ @@ -228,9 +228,9 @@ class Session: :param node1_id: node one id :param node2_id: node two id - :param interface1_data: node one interface + :param iface1_data: node one interface data, defaults to none - :param interface2_data: node two interface + :param iface2_data: node two interface data, defaults to none :param options: data for creating link, defaults to no options @@ -240,8 +240,8 @@ class Session: options = LinkOptions() node1 = self.get_node(node1_id, NodeBase) node2 = self.get_node(node2_id, NodeBase) - interface1 = None - interface2 = None + iface1 = None + iface2 = None # wireless link if options.type == LinkTypes.WIRELESS: @@ -258,22 +258,22 @@ class Session: logging.info("linking ptp: %s - %s", node1.name, node2.name) start = self.state.should_start() ptp = self.create_node(PtpNet, start) - interface1 = node1.newnetif(ptp, interface1_data) - interface2 = node2.newnetif(ptp, interface2_data) - ptp.linkconfig(interface1, options) + iface1 = node1.new_iface(ptp, iface1_data) + iface2 = node2.new_iface(ptp, iface2_data) + ptp.linkconfig(iface1, options) if not options.unidirectional: - ptp.linkconfig(interface2, options) + ptp.linkconfig(iface2, options) # link node to net elif isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNetworkBase): - interface1 = node1.newnetif(node2, interface1_data) + iface1 = node1.new_iface(node2, iface1_data) if not isinstance(node2, (EmaneNet, WlanNode)): - node2.linkconfig(interface1, options) + node2.linkconfig(iface1, options) # link net to node elif isinstance(node2, CoreNodeBase) and isinstance(node1, CoreNetworkBase): - interface2 = node2.newnetif(node1, interface2_data) + iface2 = node2.new_iface(node1, iface2_data) wireless_net = isinstance(node1, (EmaneNet, WlanNode)) if not options.unidirectional and not wireless_net: - node1.linkconfig(interface2, options) + node1.linkconfig(iface2, options) # network to network elif isinstance(node1, CoreNetworkBase) and isinstance( node2, CoreNetworkBase @@ -281,12 +281,12 @@ class Session: logging.info( "linking network to network: %s - %s", node1.name, node2.name ) - interface1 = node1.linknet(node2) - node1.linkconfig(interface1, options) + iface1 = node1.linknet(node2) + node1.linkconfig(iface1, options) if not options.unidirectional: - interface1.swapparams("_params_up") - node2.linkconfig(interface1, options) - interface1.swapparams("_params_up") + iface1.swapparams("_params_up") + node2.linkconfig(iface1, options) + iface1.swapparams("_params_up") else: raise CoreError( f"cannot link node1({type(node1)}) node2({type(node2)})" @@ -296,19 +296,19 @@ class Session: key = options.key if isinstance(node1, TunnelNode): logging.info("setting tunnel key for: %s", node1.name) - node1.setkey(key, interface1_data) + node1.setkey(key, iface1_data) if isinstance(node2, TunnelNode): logging.info("setting tunnel key for: %s", node2.name) - node2.setkey(key, interface2_data) + node2.setkey(key, iface2_data) self.sdt.add_link(node1_id, node2_id) - return interface1, interface2 + return iface1, iface2 def delete_link( self, node1_id: int, node2_id: int, - interface1_id: int = None, - interface2_id: int = None, + iface1_id: int = None, + iface2_id: int = None, link_type: LinkTypes = LinkTypes.WIRED, ) -> None: """ @@ -316,8 +316,8 @@ class Session: :param node1_id: node one id :param node2_id: node two id - :param interface1_id: interface id for node one - :param interface2_id: interface id for node two + :param iface1_id: interface id for node one + :param iface2_id: interface id for node two :param link_type: link type to delete :return: nothing :raises core.CoreError: when no common network is found for link being deleted @@ -328,9 +328,9 @@ class Session: "deleting link(%s) node(%s):interface(%s) node(%s):interface(%s)", link_type.name, node1.name, - interface1_id, + iface1_id, node2.name, - interface2_id, + iface2_id, ) # wireless link @@ -345,37 +345,29 @@ class Session: # wired link else: if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): - interface1 = node1.netif(interface1_id) - interface2 = node2.netif(interface2_id) - if not interface1: - raise CoreError( - f"node({node1.name}) missing interface({interface1_id})" - ) - if not interface2: - raise CoreError( - f"node({node2.name}) missing interface({interface2_id})" - ) - if interface1.net != interface2.net: + iface1 = node1.get_iface(iface1_id) + iface2 = node2.get_iface(iface2_id) + if iface1.net != iface2.net: raise CoreError( f"node1({node1.name}) node2({node2.name}) " "not connected to same net" ) - ptp = interface1.net - node1.delnetif(interface1_id) - node2.delnetif(interface2_id) + ptp = iface1.net + node1.delete_iface(iface1_id) + node2.delete_iface(iface2_id) self.delete_node(ptp.id) elif isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNetworkBase): - node1.delnetif(interface1_id) + node1.delete_iface(iface1_id) elif isinstance(node2, CoreNodeBase) and isinstance(node1, CoreNetworkBase): - node2.delnetif(interface2_id) + node2.delete_iface(iface2_id) self.sdt.delete_link(node1_id, node2_id) def update_link( self, node1_id: int, node2_id: int, - interface1_id: int = None, - interface2_id: int = None, + iface1_id: int = None, + iface2_id: int = None, options: LinkOptions = None, ) -> None: """ @@ -383,8 +375,8 @@ class Session: :param node1_id: node one id :param node2_id: node two id - :param interface1_id: interface id for node one - :param interface2_id: interface id for node two + :param iface1_id: interface id for node one + :param iface2_id: interface id for node two :param options: data to update link with :return: nothing :raises core.CoreError: when updating a wireless type link, when there is a @@ -398,9 +390,9 @@ class Session: "update link(%s) node(%s):interface(%s) node(%s):interface(%s)", options.type.name, node1.name, - interface1_id, + iface1_id, node2.name, - interface2_id, + iface2_id, ) # wireless link @@ -408,54 +400,54 @@ class Session: raise CoreError("cannot update wireless link") else: if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): - interface1 = node1.netif(interface1_id) - interface2 = node2.netif(interface2_id) - if not interface1: + iface1 = node1.ifaces.get(iface1_id) + iface2 = node2.ifaces.get(iface2_id) + if not iface1: raise CoreError( - f"node({node1.name}) missing interface({interface1_id})" + f"node({node1.name}) missing interface({iface1_id})" ) - if not interface2: + if not iface2: raise CoreError( - f"node({node2.name}) missing interface({interface2_id})" + f"node({node2.name}) missing interface({iface2_id})" ) - if interface1.net != interface2.net: + if iface1.net != iface2.net: raise CoreError( f"node1({node1.name}) node2({node2.name}) " "not connected to same net" ) - ptp = interface1.net - ptp.linkconfig(interface1, options, interface2) + ptp = iface1.net + ptp.linkconfig(iface1, options, iface2) if not options.unidirectional: - ptp.linkconfig(interface2, options, interface1) + ptp.linkconfig(iface2, options, iface1) elif isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNetworkBase): - interface = node1.netif(interface1_id) - node2.linkconfig(interface, options) + iface = node1.get_iface(iface1_id) + node2.linkconfig(iface, options) elif isinstance(node2, CoreNodeBase) and isinstance(node1, CoreNetworkBase): - interface = node2.netif(interface2_id) - node1.linkconfig(interface, options) + iface = node2.get_iface(iface2_id) + node1.linkconfig(iface, options) elif isinstance(node1, CoreNetworkBase) and isinstance( node2, CoreNetworkBase ): - interface = node1.getlinknetif(node2) + iface = node1.get_linked_iface(node2) upstream = False - if not interface: + if not iface: upstream = True - interface = node2.getlinknetif(node1) - if not interface: + iface = node2.get_linked_iface(node1) + if not iface: raise CoreError("modify unknown link between nets") if upstream: - interface.swapparams("_params_up") - node1.linkconfig(interface, options) - interface.swapparams("_params_up") + iface.swapparams("_params_up") + node1.linkconfig(iface, options) + iface.swapparams("_params_up") else: - node1.linkconfig(interface, options) + node1.linkconfig(iface, options) if not options.unidirectional: if upstream: - node2.linkconfig(interface, options) + node2.linkconfig(iface, options) else: - interface.swapparams("_params_up") - node2.linkconfig(interface, options) - interface.swapparams("_params_up") + iface.swapparams("_params_up") + node2.linkconfig(iface, options) + iface.swapparams("_params_up") else: raise CoreError( f"cannot update link node1({type(node1)}) node2({type(node2)})" @@ -553,7 +545,7 @@ class Session: is_boot_node = isinstance(node, CoreNodeBase) and not isinstance(node, Rj45Node) if self.state == EventTypes.RUNTIME_STATE and is_boot_node: self.write_nodes() - self.add_remove_control_interface(node=node, remove=False) + self.add_remove_control_iface(node=node, remove=False) self.services.boot_services(node) self.sdt.add_node(node) @@ -1268,7 +1260,7 @@ class Session: self.emane.shutdown() # update control interface hosts - self.update_control_interface_hosts(remove=True) + self.update_control_iface_hosts(remove=True) # remove all four possible control networks self.add_remove_control_net(0, remove=True) @@ -1314,7 +1306,7 @@ class Session: :return: nothing """ logging.info("booting node(%s): %s", node.name, [x.name for x in node.services]) - self.add_remove_control_interface(node=node, remove=False) + self.add_remove_control_iface(node=node, remove=False) self.services.boot_services(node) node.start_config_services() @@ -1338,7 +1330,7 @@ class Session: total = time.monotonic() - start logging.debug("boot run time: %s", total) if not exceptions: - self.update_control_interface_hosts() + self.update_control_iface_hosts() return exceptions def get_control_net_prefixes(self) -> List[str]: @@ -1356,7 +1348,7 @@ class Session: p0 = p return [p0, p1, p2, p3] - def get_control_net_server_interfaces(self) -> List[str]: + def get_control_net_server_ifaces(self) -> List[str]: """ Retrieve control net server interfaces. @@ -1424,7 +1416,7 @@ class Session: else: prefix_spec = CtrlNet.DEFAULT_PREFIX_LIST[net_index] logging.debug("prefix spec: %s", prefix_spec) - server_interface = self.get_control_net_server_interfaces()[net_index] + server_iface = self.get_control_net_server_ifaces()[net_index] # return any existing controlnet bridge try: @@ -1465,7 +1457,7 @@ class Session: _id, prefix, updown_script, - server_interface, + server_iface, ) control_net = self.create_node( CtrlNet, @@ -1473,11 +1465,11 @@ class Session: prefix, _id=_id, updown_script=updown_script, - serverintf=server_interface, + serverintf=server_iface, ) return control_net - def add_remove_control_interface( + def add_remove_control_iface( self, node: CoreNode, net_index: int = 0, @@ -1503,27 +1495,27 @@ class Session: if not node: return # ctrl# already exists - if node.netif(control_net.CTRLIF_IDX_BASE + net_index): + if node.ifaces.get(control_net.CTRLIF_IDX_BASE + net_index): return try: ip4 = control_net.prefix[node.id] ip4_mask = control_net.prefix.prefixlen - interface_data = InterfaceData( + iface_data = InterfaceData( id=control_net.CTRLIF_IDX_BASE + net_index, name=f"ctrl{net_index}", mac=utils.random_mac(), ip4=ip4, ip4_mask=ip4_mask, ) - interface = node.newnetif(control_net, interface_data) - interface.control = True + iface = node.new_iface(control_net, iface_data) + iface.control = True except ValueError: msg = f"Control interface not added to node {node.id}. " msg += f"Invalid control network prefix ({control_net.prefix}). " msg += "A longer prefix length may be required for this many nodes." logging.exception(msg) - def update_control_interface_hosts( + def update_control_iface_hosts( self, net_index: int = 0, remove: bool = False ) -> None: """ @@ -1549,9 +1541,9 @@ class Session: return entries = [] - for interface in control_net.netifs(): - name = interface.node.name - for address in interface.addrlist: + for iface in control_net.get_ifaces(): + name = iface.node.name + for address in iface.addrlist: address = address.split("/")[0] entries.append(f"{address} {name}") diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 5c1c52a0..3be58e17 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -57,8 +57,8 @@ class CoreClient: self.read_config() # helpers - self.interface_to_edge = {} - self.interfaces_manager = InterfaceManager(self.app) + self.iface_to_edge = {} + self.ifaces_manager = InterfaceManager(self.app) # session data self.state = None @@ -91,8 +91,8 @@ class CoreClient: def reset(self): # helpers - self.interfaces_manager.reset() - self.interface_to_edge.clear() + self.ifaces_manager.reset() + self.iface_to_edge.clear() # session data self.canvas_nodes.clear() self.links.clear() @@ -263,7 +263,7 @@ class CoreClient: self.emane_config = response.config # update interface manager - self.interfaces_manager.joined(session.links) + self.ifaces_manager.joined(session.links) # draw session self.app.canvas.reset_and_redraw(session) @@ -278,11 +278,11 @@ class CoreClient: # get emane model config response = self.client.get_emane_model_configs(self.session_id) for config in response.configs: - interface = None - if config.interface != -1: - interface = config.interface + iface_id = None + if config.iface_id != -1: + iface_id = config.iface_id canvas_node = self.canvas_nodes[config.node_id] - canvas_node.emane_model_configs[(config.model, interface)] = dict( + canvas_node.emane_model_configs[(config.model, iface_id)] = dict( config.config ) @@ -460,16 +460,16 @@ class CoreClient: self.app.show_grpc_exception("Edit Node Error", e) def start_session(self) -> core_pb2.StartSessionResponse: - self.interfaces_manager.reset_mac() + self.ifaces_manager.reset_mac() nodes = [x.core_node for x in self.canvas_nodes.values()] links = [] for edge in self.links.values(): link = core_pb2.Link() link.CopyFrom(edge.link) - if link.HasField("interface1") and not link.interface1.mac: - link.interface1.mac = self.interfaces_manager.next_mac() - if link.HasField("interface2") and not link.interface2.mac: - link.interface2.mac = self.interfaces_manager.next_mac() + if link.HasField("iface1") and not link.iface1.mac: + link.iface1.mac = self.ifaces_manager.next_mac() + if link.HasField("iface2") and not link.iface2.mac: + link.iface2.mac = self.ifaces_manager.next_mac() links.append(link) wlan_configs = self.get_wlan_configs_proto() mobility_configs = self.get_mobility_configs_proto() @@ -689,8 +689,8 @@ class CoreClient: self.session_id, link_proto.node1_id, link_proto.node2_id, - link_proto.interface1, - link_proto.interface2, + link_proto.iface1, + link_proto.iface2, link_proto.options, ) logging.debug("create link: %s", response) @@ -733,7 +733,7 @@ class CoreClient: config_proto.node_id, config_proto.model, config_proto.config, - config_proto.interface_id, + config_proto.iface_id, ) if self.emane_config: config = {x: self.emane_config[x].value for x in self.emane_config} @@ -824,31 +824,26 @@ class CoreClient: for edge in edges: del self.links[edge.token] links.append(edge.link) - self.interfaces_manager.removed(links) + self.ifaces_manager.removed(links) - def create_interface(self, canvas_node: CanvasNode) -> core_pb2.Interface: + def create_iface(self, canvas_node: CanvasNode) -> core_pb2.Interface: node = canvas_node.core_node - ip4, ip6 = self.interfaces_manager.get_ips(node) - ip4_mask = self.interfaces_manager.ip4_mask - ip6_mask = self.interfaces_manager.ip6_mask - interface_id = canvas_node.next_interface_id() - name = f"eth{interface_id}" - interface = core_pb2.Interface( - id=interface_id, - name=name, - ip4=ip4, - ip4mask=ip4_mask, - ip6=ip6, - ip6mask=ip6_mask, + ip4, ip6 = self.ifaces_manager.get_ips(node) + ip4_mask = self.ifaces_manager.ip4_mask + ip6_mask = self.ifaces_manager.ip6_mask + iface_id = canvas_node.next_iface_id() + name = f"eth{iface_id}" + iface = core_pb2.Interface( + id=iface_id, name=name, ip4=ip4, ip4mask=ip4_mask, ip6=ip6, ip6mask=ip6_mask ) logging.info( "create node(%s) interface(%s) IPv4(%s) IPv6(%s)", node.name, - interface.name, - interface.ip4, - interface.ip6, + iface.name, + iface.ip4, + iface.ip6, ) - return interface + return iface def create_link( self, edge: CanvasEdge, canvas_src_node: CanvasNode, canvas_dst_node: CanvasNode @@ -861,34 +856,34 @@ class CoreClient: dst_node = canvas_dst_node.core_node # determine subnet - self.interfaces_manager.determine_subnets(canvas_src_node, canvas_dst_node) + self.ifaces_manager.determine_subnets(canvas_src_node, canvas_dst_node) - src_interface = None + src_iface = None if NodeUtils.is_container_node(src_node.type): - src_interface = self.create_interface(canvas_src_node) - self.interface_to_edge[(src_node.id, src_interface.id)] = edge.token + src_iface = self.create_iface(canvas_src_node) + self.iface_to_edge[(src_node.id, src_iface.id)] = edge.token - dst_interface = None + dst_iface = None if NodeUtils.is_container_node(dst_node.type): - dst_interface = self.create_interface(canvas_dst_node) - self.interface_to_edge[(dst_node.id, dst_interface.id)] = edge.token + dst_iface = self.create_iface(canvas_dst_node) + self.iface_to_edge[(dst_node.id, dst_iface.id)] = edge.token link = core_pb2.Link( type=core_pb2.LinkType.WIRED, node1_id=src_node.id, node2_id=dst_node.id, - interface1=src_interface, - interface2=dst_interface, + iface1=src_iface, + iface2=dst_iface, ) # assign after creating link proto, since interfaces are copied - if src_interface: - interface1 = link.interface1 - edge.src_interface = interface1 - canvas_src_node.interfaces[interface1.id] = interface1 - if dst_interface: - interface2 = link.interface2 - edge.dst_interface = interface2 - canvas_dst_node.interfaces[interface2.id] = interface2 + if src_iface: + iface1 = link.iface1 + edge.src_iface = iface1 + canvas_src_node.ifaces[iface1.id] = iface1 + if dst_iface: + iface2 = link.iface2 + edge.dst_iface = iface2 + canvas_dst_node.ifaces[iface2.id] = iface2 edge.set_link(link) self.links[edge.token] = edge logging.info("Add link between %s and %s", src_node.name, dst_node.name) @@ -928,12 +923,12 @@ class CoreClient: continue node_id = canvas_node.core_node.id for key, config in canvas_node.emane_model_configs.items(): - model, interface = key + model, iface_id = key config = {x: config[x].value for x in config} - if interface is None: - interface = -1 + if iface_id is None: + iface_id = -1 config_proto = EmaneModelConfig( - node_id=node_id, interface_id=interface, model=model, config=config + node_id=node_id, iface_id=iface_id, model=model, config=config ) configs.append(config_proto) return configs @@ -1021,19 +1016,19 @@ class CoreClient: return dict(config) def get_emane_model_config( - self, node_id: int, model: str, interface: int = None + self, node_id: int, model: str, iface_id: int = None ) -> Dict[str, common_pb2.ConfigOption]: - if interface is None: - interface = -1 + if iface_id is None: + iface_id = -1 response = self.client.get_emane_model_config( - self.session_id, node_id, model, interface + self.session_id, node_id, model, iface_id ) config = response.config logging.debug( "get emane model config: node id: %s, EMANE model: %s, interface: %s, config: %s", node_id, model, - interface, + iface_id, config, ) return dict(config) diff --git a/daemon/core/gui/dialogs/emaneconfig.py b/daemon/core/gui/dialogs/emaneconfig.py index 000ebb05..8f7ca089 100644 --- a/daemon/core/gui/dialogs/emaneconfig.py +++ b/daemon/core/gui/dialogs/emaneconfig.py @@ -56,7 +56,7 @@ class EmaneModelDialog(Dialog): app: "Application", canvas_node: "CanvasNode", model: str, - interface: int = None, + iface_id: int = None, ): super().__init__( app, f"{canvas_node.core_node.name} {model} Configuration", master=master @@ -64,16 +64,16 @@ class EmaneModelDialog(Dialog): self.canvas_node = canvas_node self.node = canvas_node.core_node self.model = f"emane_{model}" - self.interface = interface + self.iface_id = iface_id self.config_frame = None self.has_error = False try: self.config = self.canvas_node.emane_model_configs.get( - (self.model, self.interface) + (self.model, self.iface_id) ) if not self.config: self.config = self.app.core.get_emane_model_config( - self.node.id, self.model, self.interface + self.node.id, self.model, self.iface_id ) self.draw() except grpc.RpcError as e: @@ -103,7 +103,7 @@ class EmaneModelDialog(Dialog): def click_apply(self): self.config_frame.parse_config() - key = (self.model, self.interface) + key = (self.model, self.iface_id) self.canvas_node.emane_model_configs[key] = self.config self.destroy() diff --git a/daemon/core/gui/dialogs/ipdialog.py b/daemon/core/gui/dialogs/ipdialog.py index 62f5d0ba..d31dcdff 100644 --- a/daemon/core/gui/dialogs/ipdialog.py +++ b/daemon/core/gui/dialogs/ipdialog.py @@ -146,6 +146,6 @@ class IpConfigDialog(Dialog): ip_config.ip6 = self.ip6 ip_config.ip4s = ip4s ip_config.ip6s = ip6s - self.app.core.interfaces_manager.update_ips(self.ip4, self.ip6) + self.app.core.ifaces_manager.update_ips(self.ip4, self.ip6) self.app.save_config() self.destroy() diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index 9c3fc987..adf8156f 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -227,21 +227,21 @@ class LinkConfigurationDialog(Dialog): ) link.options.CopyFrom(options) - interface1_id = None - if link.HasField("interface1"): - interface1_id = link.interface1.id - interface2_id = None - if link.HasField("interface2"): - interface2_id = link.interface2.id + iface1_id = None + if link.HasField("iface1"): + iface1_id = link.iface1.id + iface2_id = None + if link.HasField("iface2"): + iface2_id = link.iface2.id if not self.is_symmetric: link.options.unidirectional = True - asym_interface1 = None - if interface1_id: - asym_interface1 = core_pb2.Interface(id=interface1_id) - asym_interface2 = None - if interface2_id: - asym_interface2 = core_pb2.Interface(id=interface2_id) + asym_iface1 = None + if iface1_id: + asym_iface1 = core_pb2.Interface(id=iface1_id) + asym_iface2 = None + if iface2_id: + asym_iface2 = core_pb2.Interface(id=iface2_id) down_bandwidth = get_int(self.down_bandwidth) down_jitter = get_int(self.down_jitter) down_delay = get_int(self.down_delay) @@ -258,8 +258,8 @@ class LinkConfigurationDialog(Dialog): self.edge.asymmetric_link = core_pb2.Link( node1_id=link.node2_id, node2_id=link.node1_id, - interface1=asym_interface1, - interface2=asym_interface2, + iface1=asym_iface1, + iface2=asym_iface2, options=options, ) else: @@ -273,8 +273,8 @@ class LinkConfigurationDialog(Dialog): link.node1_id, link.node2_id, link.options, - interface1_id, - interface2_id, + iface1_id, + iface2_id, ) if self.edge.asymmetric_link: self.app.core.client.edit_link( @@ -282,8 +282,8 @@ class LinkConfigurationDialog(Dialog): link.node2_id, link.node1_id, self.edge.asymmetric_link.options, - interface1_id, - interface2_id, + iface1_id, + iface2_id, ) self.destroy() diff --git a/daemon/core/gui/dialogs/macdialog.py b/daemon/core/gui/dialogs/macdialog.py index caca9fd0..46414cf9 100644 --- a/daemon/core/gui/dialogs/macdialog.py +++ b/daemon/core/gui/dialogs/macdialog.py @@ -55,7 +55,7 @@ class MacConfigDialog(Dialog): if not netaddr.valid_mac(mac): messagebox.showerror("MAC Error", f"{mac} is an invalid mac") else: - self.app.core.interfaces_manager.mac = netaddr.EUI(mac) + self.app.core.ifaces_manager.mac = netaddr.EUI(mac) self.app.guiconfig.mac = mac self.app.save_config() self.destroy() diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index 0d46ae06..29ce2010 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -111,7 +111,7 @@ class NodeConfigDialog(Dialog): if self.node.server: server = self.node.server self.server = tk.StringVar(value=server) - self.interfaces = {} + self.ifaces = {} self.draw() def draw(self): @@ -183,53 +183,53 @@ class NodeConfigDialog(Dialog): row += 1 if NodeUtils.is_rj45_node(self.node.type): - response = self.app.core.client.get_interfaces() + response = self.app.core.client.get_ifaces() logging.debug("host machine available interfaces: %s", response) - interfaces = ListboxScroll(frame) - interfaces.listbox.config(state=state) - interfaces.grid( + ifaces = ListboxScroll(frame) + ifaces.listbox.config(state=state) + ifaces.grid( row=row, column=0, columnspan=2, sticky="ew", padx=PADX, pady=PADY ) - for inf in sorted(response.interfaces[:]): - interfaces.listbox.insert(tk.END, inf) + for inf in sorted(response.ifaces[:]): + ifaces.listbox.insert(tk.END, inf) row += 1 - interfaces.listbox.bind("<>", self.interface_select) + ifaces.listbox.bind("<>", self.iface_select) # interfaces - if self.canvas_node.interfaces: - self.draw_interfaces() + if self.canvas_node.ifaces: + self.draw_ifaces() self.draw_spacer() self.draw_buttons() - def draw_interfaces(self): + def draw_ifaces(self): notebook = ttk.Notebook(self.top) notebook.grid(sticky="nsew", pady=PADY) self.top.rowconfigure(notebook.grid_info()["row"], weight=1) state = tk.DISABLED if self.app.core.is_runtime() else tk.NORMAL - for interface_id in sorted(self.canvas_node.interfaces): - interface = self.canvas_node.interfaces[interface_id] + for iface_id in sorted(self.canvas_node.ifaces): + iface = self.canvas_node.ifaces[iface_id] tab = ttk.Frame(notebook, padding=FRAME_PAD) tab.grid(sticky="nsew", pady=PADY) tab.columnconfigure(1, weight=1) tab.columnconfigure(2, weight=1) - notebook.add(tab, text=interface.name) + notebook.add(tab, text=iface.name) row = 0 - emane_node = self.canvas_node.has_emane_link(interface.id) + emane_node = self.canvas_node.has_emane_link(iface.id) if emane_node: emane_model = emane_node.emane.split("_")[1] button = ttk.Button( tab, text=f"Configure EMANE {emane_model}", - command=lambda: self.click_emane_config(emane_model, interface.id), + command=lambda: self.click_emane_config(emane_model, iface.id), ) button.grid(row=row, sticky="ew", columnspan=3, pady=PADY) row += 1 label = ttk.Label(tab, text="MAC") label.grid(row=row, column=0, padx=PADX, pady=PADY) - auto_set = not interface.mac + auto_set = not iface.mac mac_state = tk.DISABLED if auto_set else tk.NORMAL is_auto = tk.BooleanVar(value=auto_set) checkbutton = ttk.Checkbutton( @@ -237,7 +237,7 @@ class NodeConfigDialog(Dialog): ) checkbutton.var = is_auto checkbutton.grid(row=row, column=1, padx=PADX) - mac = tk.StringVar(value=interface.mac) + mac = tk.StringVar(value=iface.mac) entry = ttk.Entry(tab, textvariable=mac, state=mac_state) entry.grid(row=row, column=2, sticky="ew") func = partial(mac_auto, is_auto, entry, mac) @@ -247,8 +247,8 @@ class NodeConfigDialog(Dialog): label = ttk.Label(tab, text="IPv4") label.grid(row=row, column=0, padx=PADX, pady=PADY) ip4_net = "" - if interface.ip4: - ip4_net = f"{interface.ip4}/{interface.ip4mask}" + if iface.ip4: + ip4_net = f"{iface.ip4}/{iface.ip4mask}" ip4 = tk.StringVar(value=ip4_net) entry = ttk.Entry(tab, textvariable=ip4, state=state) entry.grid(row=row, column=1, columnspan=2, sticky="ew") @@ -257,13 +257,13 @@ class NodeConfigDialog(Dialog): label = ttk.Label(tab, text="IPv6") label.grid(row=row, column=0, padx=PADX, pady=PADY) ip6_net = "" - if interface.ip6: - ip6_net = f"{interface.ip6}/{interface.ip6mask}" + if iface.ip6: + ip6_net = f"{iface.ip6}/{iface.ip6mask}" ip6 = tk.StringVar(value=ip6_net) entry = ttk.Entry(tab, textvariable=ip6, state=state) entry.grid(row=row, column=1, columnspan=2, sticky="ew") - self.interfaces[interface.id] = InterfaceData(is_auto, mac, ip4, ip6) + self.ifaces[iface.id] = InterfaceData(is_auto, mac, ip4, ip6) def draw_buttons(self): frame = ttk.Frame(self.top) @@ -277,9 +277,9 @@ class NodeConfigDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_emane_config(self, emane_model: str, interface_id: int): + def click_emane_config(self, emane_model: str, iface_id: int): dialog = EmaneModelDialog( - self, self.app, self.canvas_node, emane_model, interface_id + self, self.app, self.canvas_node, emane_model, iface_id ) dialog.show() @@ -309,12 +309,12 @@ class NodeConfigDialog(Dialog): self.canvas_node.image = self.image # update node interface data - for interface in self.canvas_node.interfaces.values(): - data = self.interfaces[interface.id] + for iface in self.canvas_node.ifaces.values(): + data = self.ifaces[iface.id] # validate ip4 ip4_net = data.ip4.get() - if not check_ip4(self, interface.name, ip4_net): + if not check_ip4(self, iface.name, ip4_net): error = True break if ip4_net: @@ -322,12 +322,12 @@ class NodeConfigDialog(Dialog): ip4mask = int(ip4mask) else: ip4, ip4mask = "", 0 - interface.ip4 = ip4 - interface.ip4mask = ip4mask + iface.ip4 = ip4 + iface.ip4mask = ip4mask # validate ip6 ip6_net = data.ip6.get() - if not check_ip6(self, interface.name, ip6_net): + if not check_ip6(self, iface.name, ip6_net): error = True break if ip6_net: @@ -335,28 +335,28 @@ class NodeConfigDialog(Dialog): ip6mask = int(ip6mask) else: ip6, ip6mask = "", 0 - interface.ip6 = ip6 - interface.ip6mask = ip6mask + iface.ip6 = ip6 + iface.ip6mask = ip6mask mac = data.mac.get() auto_mac = data.is_auto.get() if not auto_mac and not netaddr.valid_mac(mac): - title = f"MAC Error for {interface.name}" + title = f"MAC Error for {iface.name}" messagebox.showerror(title, "Invalid MAC Address") error = True break elif not auto_mac: mac = netaddr.EUI(mac, dialect=netaddr.mac_unix_expanded) - interface.mac = str(mac) + iface.mac = str(mac) # redraw if not error: self.canvas_node.redraw() self.destroy() - def interface_select(self, event: tk.Event): + def iface_select(self, event: tk.Event): listbox = event.widget cur = listbox.curselection() if cur: - interface = listbox.get(cur[0]) - self.name.set(interface) + iface = listbox.get(cur[0]) + self.name.set(iface) diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index 1d2264eb..152e1a2f 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -259,8 +259,8 @@ class CanvasEdge(Edge): Create an instance of canvas edge object """ super().__init__(canvas, src) - self.src_interface = None - self.dst_interface = None + self.src_iface = None + self.dst_iface = None self.text_src = None self.text_dst = None self.link = None @@ -283,25 +283,25 @@ class CanvasEdge(Edge): self.link = link self.draw_labels() - def interface_label(self, interface: core_pb2.Interface) -> str: + def iface_label(self, iface: core_pb2.Interface) -> str: label = "" - if interface.name and self.canvas.show_interface_names.get(): - label = f"{interface.name}" - if interface.ip4 and self.canvas.show_ip4s.get(): + if iface.name and self.canvas.show_iface_names.get(): + label = f"{iface.name}" + if iface.ip4 and self.canvas.show_ip4s.get(): label = f"{label}\n" if label else "" - label += f"{interface.ip4}/{interface.ip4mask}" - if interface.ip6 and self.canvas.show_ip6s.get(): + label += f"{iface.ip4}/{iface.ip4mask}" + if iface.ip6 and self.canvas.show_ip6s.get(): label = f"{label}\n" if label else "" - label += f"{interface.ip6}/{interface.ip6mask}" + label += f"{iface.ip6}/{iface.ip6mask}" return label def create_node_labels(self) -> Tuple[str, str]: label1 = None - if self.link.HasField("interface1"): - label1 = self.interface_label(self.link.interface1) + if self.link.HasField("iface1"): + label1 = self.iface_label(self.link.iface1) label2 = None - if self.link.HasField("interface2"): - label2 = self.interface_label(self.link.interface2) + if self.link.HasField("iface2"): + label2 = self.iface_label(self.link.iface2) return label1, label2 def draw_labels(self) -> None: diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 90dcd9f6..269e3973 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -97,7 +97,7 @@ class CanvasGraph(tk.Canvas): self.show_link_labels = ShowVar(self, tags.LINK_LABEL, value=True) self.show_grid = ShowVar(self, tags.GRIDLINE, value=True) self.show_annotations = ShowVar(self, tags.ANNOTATION, value=True) - self.show_interface_names = BooleanVar(value=False) + self.show_iface_names = BooleanVar(value=False) self.show_ip4s = BooleanVar(value=True) self.show_ip6s = BooleanVar(value=True) @@ -136,7 +136,7 @@ class CanvasGraph(tk.Canvas): self.show_link_labels.set(True) self.show_grid.set(True) self.show_annotations.set(True) - self.show_interface_names.set(False) + self.show_iface_names.set(False) self.show_ip4s.set(True) self.show_ip6s.set(True) @@ -195,19 +195,19 @@ class CanvasGraph(tk.Canvas): return valid_topleft and valid_bottomright def set_throughputs(self, throughputs_event: core_pb2.ThroughputsEvent): - for interface_throughput in throughputs_event.interface_throughputs: - node_id = interface_throughput.node_id - interface_id = interface_throughput.interface_id - throughput = interface_throughput.throughput - interface_to_edge_id = (node_id, interface_id) - token = self.core.interface_to_edge.get(interface_to_edge_id) + for iface_throughput in throughputs_event.iface_throughputs: + node_id = iface_throughput.node_id + iface_id = iface_throughput.iface_id + throughput = iface_throughput.throughput + iface_to_edge_id = (node_id, iface_id) + token = self.core.iface_to_edge.get(iface_to_edge_id) if not token: continue edge = self.edges.get(token) if edge: edge.set_throughput(throughput) else: - del self.core.interface_to_edge[interface_to_edge_id] + del self.core.iface_to_edge[iface_to_edge_id] def draw_grid(self): """ @@ -321,18 +321,16 @@ class CanvasGraph(tk.Canvas): canvas_node2.edges.add(edge) self.edges[edge.token] = edge self.core.links[edge.token] = edge - if link.HasField("interface1"): - interface1 = link.interface1 - self.core.interface_to_edge[(node1.id, interface1.id)] = token - canvas_node1.interfaces[interface1.id] = interface1 - edge.src_interface = interface1 - if link.HasField("interface2"): - interface2 = link.interface2 - self.core.interface_to_edge[ - (node2.id, interface2.id) - ] = edge.token - canvas_node2.interfaces[interface2.id] = interface2 - edge.dst_interface = interface2 + if link.HasField("iface1"): + iface1 = link.iface1 + self.core.iface_to_edge[(node1.id, iface1.id)] = token + canvas_node1.ifaces[iface1.id] = iface1 + edge.src_iface = iface1 + if link.HasField("iface2"): + iface2 = link.iface2 + self.core.iface_to_edge[(node2.id, iface2.id)] = edge.token + canvas_node2.ifaces[iface2.id] = iface2 + edge.dst_iface = iface2 elif link.options.unidirectional: edge = self.edges[token] edge.asymmetric_link = link @@ -513,14 +511,14 @@ class CanvasGraph(tk.Canvas): edge.delete() # update node connected to edge being deleted other_id = edge.src - other_interface = edge.src_interface + other_iface = edge.src_iface if edge.src == object_id: other_id = edge.dst - other_interface = edge.dst_interface + other_iface = edge.dst_iface other_node = self.nodes[other_id] other_node.edges.remove(edge) - if other_interface: - del other_node.interfaces[other_interface.id] + if other_iface: + del other_node.ifaces[other_iface.id] if is_wireless: other_node.delete_antenna() @@ -538,12 +536,12 @@ class CanvasGraph(tk.Canvas): del self.edges[edge.token] src_node = self.nodes[edge.src] src_node.edges.discard(edge) - if edge.src_interface: - del src_node.interfaces[edge.src_interface.id] + if edge.src_iface: + del src_node.ifaces[edge.src_iface.id] dst_node = self.nodes[edge.dst] dst_node.edges.discard(edge) - if edge.dst_interface: - del dst_node.interfaces[edge.dst_interface.id] + if edge.dst_iface: + del dst_node.ifaces[edge.dst_iface.id] src_wireless = NodeUtils.is_wireless_node(src_node.core_node.type) if src_wireless: dst_node.delete_antenna() @@ -963,26 +961,26 @@ class CanvasGraph(tk.Canvas): copy_link = copy_edge.link options = edge.link.options copy_link.options.CopyFrom(options) - interface1_id = None - if copy_link.HasField("interface1"): - interface1_id = copy_link.interface1.id - interface2_id = None - if copy_link.HasField("interface2"): - interface2_id = copy_link.interface2.id + iface1_id = None + if copy_link.HasField("iface1"): + iface1_id = copy_link.iface1.id + iface2_id = None + if copy_link.HasField("iface2"): + iface2_id = copy_link.iface2.id if not options.unidirectional: copy_edge.asymmetric_link = None else: - asym_interface1 = None - if interface1_id: - asym_interface1 = core_pb2.Interface(id=interface1_id) - asym_interface2 = None - if interface2_id: - asym_interface2 = core_pb2.Interface(id=interface2_id) + asym_iface1 = None + if iface1_id: + asym_iface1 = core_pb2.Interface(id=iface1_id) + asym_iface2 = None + if iface2_id: + asym_iface2 = core_pb2.Interface(id=iface2_id) copy_edge.asymmetric_link = core_pb2.Link( node1_id=copy_link.node2_id, node2_id=copy_link.node1_id, - interface1=asym_interface1, - interface2=asym_interface2, + iface1=asym_iface1, + iface2=asym_iface2, options=edge.asymmetric_link.options, ) self.itemconfig( diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 8ad3f02a..3ba4b3f7 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -55,7 +55,7 @@ class CanvasNode: ) self.tooltip = CanvasTooltip(self.canvas) self.edges = set() - self.interfaces = {} + self.ifaces = {} self.wireless_edges = set() self.antennas = [] self.antenna_images = {} @@ -70,9 +70,9 @@ class CanvasNode: self.context = tk.Menu(self.canvas) themes.style_menu(self.context) - def next_interface_id(self) -> int: + def next_iface_id(self) -> int: i = 0 - while i in self.interfaces: + while i in self.ifaces: i += 1 return i @@ -300,16 +300,16 @@ class CanvasNode: dialog = NodeConfigServiceDialog(self.app, self) dialog.show() - def has_emane_link(self, interface_id: int) -> core_pb2.Node: + def has_emane_link(self, iface_id: int) -> core_pb2.Node: result = None for edge in self.edges: if self.id == edge.src: other_id = edge.dst - edge_interface_id = edge.src_interface.id + edge_iface_id = edge.src_iface.id else: other_id = edge.src - edge_interface_id = edge.dst_interface.id - if edge_interface_id != interface_id: + edge_iface_id = edge.dst_iface.id + if edge_iface_id != iface_id: continue other_node = self.canvas.nodes[other_id] if other_node.core_node.type == NodeType.EMANE: diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 34270f56..14cba024 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -12,10 +12,10 @@ if TYPE_CHECKING: from core.gui.graph.node import CanvasNode -def get_index(interface: "core_pb2.Interface") -> Optional[int]: - if not interface.ip4: +def get_index(iface: "core_pb2.Interface") -> Optional[int]: + if not iface.ip4: return None - net = netaddr.IPNetwork(f"{interface.ip4}/{interface.ip4mask}") + net = netaddr.IPNetwork(f"{iface.ip4}/{iface.ip4mask}") ip_value = net.value cidr_value = net.cidr.value return ip_value - cidr_value @@ -89,43 +89,43 @@ class InterfaceManager: remaining_subnets = set() for edge in self.app.core.links.values(): link = edge.link - if link.HasField("interface1"): - subnets = self.get_subnets(link.interface1) + if link.HasField("iface1"): + subnets = self.get_subnets(link.iface1) remaining_subnets.add(subnets) - if link.HasField("interface2"): - subnets = self.get_subnets(link.interface2) + if link.HasField("iface2"): + subnets = self.get_subnets(link.iface2) remaining_subnets.add(subnets) # remove all subnets from used subnets when no longer present # or remove used indexes from subnet - interfaces = [] + ifaces = [] for link in links: - if link.HasField("interface1"): - interfaces.append(link.interface1) - if link.HasField("interface2"): - interfaces.append(link.interface2) - for interface in interfaces: - subnets = self.get_subnets(interface) + if link.HasField("iface1"): + ifaces.append(link.iface1) + if link.HasField("iface2"): + ifaces.append(link.iface2) + for iface in ifaces: + subnets = self.get_subnets(iface) if subnets not in remaining_subnets: self.used_subnets.pop(subnets.key(), None) else: - index = get_index(interface) + index = get_index(iface) if index is not None: subnets.used_indexes.discard(index) self.current_subnets = None def joined(self, links: List["core_pb2.Link"]) -> None: - interfaces = [] + ifaces = [] for link in links: - if link.HasField("interface1"): - interfaces.append(link.interface1) - if link.HasField("interface2"): - interfaces.append(link.interface2) + if link.HasField("iface1"): + ifaces.append(link.iface1) + if link.HasField("iface2"): + ifaces.append(link.iface2) # add to used subnets and mark used indexes - for interface in interfaces: - subnets = self.get_subnets(interface) - index = get_index(interface) + for iface in ifaces: + subnets = self.get_subnets(iface) + index = get_index(iface) if index is None: continue subnets.used_indexes.add(index) @@ -150,13 +150,13 @@ class InterfaceManager: ip6 = self.current_subnets.ip6[index] return str(ip4), str(ip6) - def get_subnets(self, interface: "core_pb2.Interface") -> Subnets: + def get_subnets(self, iface: "core_pb2.Interface") -> Subnets: ip4_subnet = self.ip4_subnets - if interface.ip4: - ip4_subnet = IPNetwork(f"{interface.ip4}/{interface.ip4mask}").cidr + if iface.ip4: + ip4_subnet = IPNetwork(f"{iface.ip4}/{iface.ip4mask}").cidr ip6_subnet = self.ip6_subnets - if interface.ip6: - ip6_subnet = IPNetwork(f"{interface.ip6}/{interface.ip6mask}").cidr + if iface.ip6: + ip6_subnet = IPNetwork(f"{iface.ip6}/{iface.ip6mask}").cidr subnets = Subnets(ip4_subnet, ip6_subnet) return self.used_subnets.get(subnets.key(), subnets) @@ -196,16 +196,16 @@ class InterfaceManager: for edge in canvas_node.edges: src_node = canvas.nodes[edge.src] dst_node = canvas.nodes[edge.dst] - interface = edge.src_interface + iface = edge.src_iface check_node = src_node if src_node == canvas_node: - interface = edge.dst_interface + iface = edge.dst_iface check_node = dst_node if check_node.core_node.id in visited: continue visited.add(check_node.core_node.id) - if interface: - subnets = self.get_subnets(interface) + if iface: + subnets = self.get_subnets(iface) else: subnets = self.find_subnets(check_node, visited) if subnets: diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index 62a9ceae..cf4216d8 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -139,7 +139,7 @@ class Menubar(tk.Menu): menu.add_checkbutton( label="Interface Names", command=self.click_edge_label_change, - variable=self.canvas.show_interface_names, + variable=self.canvas.show_iface_names, ) menu.add_checkbutton( label="IPv4 Addresses", diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index 43996ba3..d56c40aa 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -178,7 +178,7 @@ class MobilityManager(ModelManager): self.session.broadcast_event(event_data) def updatewlans( - self, moved: List[CoreNode], moved_netifs: List[CoreInterface] + self, moved: List[CoreNode], moved_ifaces: List[CoreInterface] ) -> None: """ A mobility script has caused nodes in the 'moved' list to move. @@ -186,7 +186,7 @@ class MobilityManager(ModelManager): were to recalculate for each individual node movement. :param moved: moved nodes - :param moved_netifs: moved network interfaces + :param moved_ifaces: moved network interfaces :return: nothing """ for node_id in self.nodes(): @@ -195,7 +195,7 @@ class MobilityManager(ModelManager): except CoreError: continue if node.model: - node.model.update(moved, moved_netifs) + node.model.update(moved, moved_ifaces) class WirelessModel(ConfigurableOptions): @@ -228,12 +228,12 @@ class WirelessModel(ConfigurableOptions): """ return [] - def update(self, moved: List[CoreNode], moved_netifs: List[CoreInterface]) -> None: + def update(self, moved: List[CoreNode], moved_ifaces: List[CoreInterface]) -> None: """ Update this wireless model. :param moved: moved nodes - :param moved_netifs: moved network interfaces + :param moved_ifaces: moved network interfaces :return: nothing """ raise NotImplementedError @@ -301,8 +301,8 @@ class BasicRangeModel(WirelessModel): super().__init__(session, _id) self.session: "Session" = session self.wlan: WlanNode = session.get_node(_id, WlanNode) - self._netifs: Dict[CoreInterface, Tuple[float, float, float]] = {} - self._netifslock: threading.Lock = threading.Lock() + self.iface_to_pos: Dict[CoreInterface, Tuple[float, float, float]] = {} + self.iface_lock: threading.Lock = threading.Lock() self.range: int = 0 self.bw: Optional[int] = None self.delay: Optional[int] = None @@ -333,48 +333,48 @@ class BasicRangeModel(WirelessModel): Apply link parameters to all interfaces. This is invoked from WlanNode.setmodel() after the position callback has been set. """ - with self._netifslock: - for netif in self._netifs: + with self.iface_lock: + for iface in self.iface_to_pos: options = LinkOptions( bandwidth=self.bw, delay=self.delay, loss=self.loss, jitter=self.jitter, ) - self.wlan.linkconfig(netif, options) + self.wlan.linkconfig(iface, options) - def get_position(self, netif: CoreInterface) -> Tuple[float, float, float]: + def get_position(self, iface: CoreInterface) -> Tuple[float, float, float]: """ Retrieve network interface position. - :param netif: network interface position to retrieve + :param iface: network interface position to retrieve :return: network interface position """ - with self._netifslock: - return self._netifs[netif] + with self.iface_lock: + return self.iface_to_pos[iface] - def set_position(self, netif: CoreInterface) -> None: + def set_position(self, iface: CoreInterface) -> None: """ A node has moved; given an interface, a new (x,y,z) position has been set; calculate the new distance between other nodes and link or unlink node pairs based on the configured range. - :param netif: network interface to set position for + :param iface: network interface to set position for :return: nothing """ - x, y, z = netif.node.position.get() - self._netifslock.acquire() - self._netifs[netif] = (x, y, z) + x, y, z = iface.node.position.get() + self.iface_lock.acquire() + self.iface_to_pos[iface] = (x, y, z) if x is None or y is None: - self._netifslock.release() + self.iface_lock.release() return - for netif2 in self._netifs: - self.calclink(netif, netif2) - self._netifslock.release() + for iface2 in self.iface_to_pos: + self.calclink(iface, iface2) + self.iface_lock.release() position_callback = set_position - def update(self, moved: List[CoreNode], moved_netifs: List[CoreInterface]) -> None: + def update(self, moved: List[CoreNode], moved_ifaces: List[CoreInterface]) -> None: """ Node positions have changed without recalc. Update positions from node.position, then re-calculate links for those that have moved. @@ -382,37 +382,37 @@ class BasicRangeModel(WirelessModel): one of the nodes has moved. :param moved: moved nodes - :param moved_netifs: moved network interfaces + :param moved_ifaces: moved network interfaces :return: nothing """ - with self._netifslock: - while len(moved_netifs): - netif = moved_netifs.pop() - nx, ny, nz = netif.node.getposition() - if netif in self._netifs: - self._netifs[netif] = (nx, ny, nz) - for netif2 in self._netifs: - if netif2 in moved_netifs: + with self.iface_lock: + while len(moved_ifaces): + iface = moved_ifaces.pop() + nx, ny, nz = iface.node.getposition() + if iface in self.iface_to_pos: + self.iface_to_pos[iface] = (nx, ny, nz) + for iface2 in self.iface_to_pos: + if iface2 in moved_ifaces: continue - self.calclink(netif, netif2) + self.calclink(iface, iface2) - def calclink(self, netif: CoreInterface, netif2: CoreInterface) -> None: + def calclink(self, iface: CoreInterface, iface2: CoreInterface) -> None: """ Helper used by set_position() and update() to calculate distance between two interfaces and perform linking/unlinking. Sends link/unlink messages and updates the WlanNode's linked dict. - :param netif: interface one - :param netif2: interface two + :param iface: interface one + :param iface2: interface two :return: nothing """ - if netif == netif2: + if iface == iface2: return try: - x, y, z = self._netifs[netif] - x2, y2, z2 = self._netifs[netif2] + x, y, z = self.iface_to_pos[iface] + x2, y2, z2 = self.iface_to_pos[iface2] if x2 is None or y2 is None: return @@ -420,8 +420,8 @@ class BasicRangeModel(WirelessModel): d = self.calcdistance((x, y, z), (x2, y2, z2)) # ordering is important, to keep the wlan._linked dict organized - a = min(netif, netif2) - b = max(netif, netif2) + a = min(iface, iface2) + b = max(iface, iface2) with self.wlan._linked_lock: linked = self.wlan.linked(a, b) @@ -475,42 +475,39 @@ class BasicRangeModel(WirelessModel): self.setlinkparams() def create_link_data( - self, - interface1: CoreInterface, - interface2: CoreInterface, - message_type: MessageFlags, + self, iface1: CoreInterface, iface2: CoreInterface, message_type: MessageFlags ) -> LinkData: """ Create a wireless link/unlink data message. - :param interface1: interface one - :param interface2: interface two + :param iface1: interface one + :param iface2: interface two :param message_type: link message type :return: link data """ color = self.session.get_link_color(self.wlan.id) return LinkData( message_type=message_type, - node1_id=interface1.node.id, - node2_id=interface2.node.id, + node1_id=iface1.node.id, + node2_id=iface2.node.id, network_id=self.wlan.id, link_type=LinkTypes.WIRELESS, color=color, ) def sendlinkmsg( - self, netif: CoreInterface, netif2: CoreInterface, unlink: bool = False + self, iface: CoreInterface, iface2: CoreInterface, unlink: bool = False ) -> None: """ Send a wireless link/unlink API message to the GUI. - :param netif: interface one - :param netif2: interface two + :param iface: interface one + :param iface2: interface two :param unlink: unlink or not :return: nothing """ message_type = MessageFlags.DELETE if unlink else MessageFlags.ADD - link_data = self.create_link_data(netif, netif2, message_type) + link_data = self.create_link_data(iface, iface2, message_type) self.session.broadcast_link(link_data) def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: @@ -643,17 +640,17 @@ class WayPointMobility(WirelessModel): return return self.run() - # only move netifs attached to self.wlan, or all nodenum in script? + # only move interfaces attached to self.wlan, or all nodenum in script? moved = [] - moved_netifs = [] - for netif in self.wlan.netifs(): - node = netif.node + moved_ifaces = [] + for iface in self.wlan.get_ifaces(): + node = iface.node if self.movenode(node, dt): moved.append(node) - moved_netifs.append(netif) + moved_ifaces.append(iface) # calculate all ranges after moving nodes; this saves calculations - self.session.mobility.updatewlans(moved, moved_netifs) + self.session.mobility.updatewlans(moved, moved_ifaces) # TODO: check session state self.session.event_loop.add_event(0.001 * self.refresh_ms, self.runround) @@ -725,16 +722,16 @@ class WayPointMobility(WirelessModel): :return: nothing """ moved = [] - moved_netifs = [] - for netif in self.wlan.netifs(): - node = netif.node + moved_ifaces = [] + for iface in self.wlan.get_ifaces(): + node = iface.node if node.id not in self.initial: continue x, y, z = self.initial[node.id].coords self.setnodeposition(node, x, y, z) moved.append(node) - moved_netifs.append(netif) - self.session.mobility.updatewlans(moved, moved_netifs) + moved_ifaces.append(iface) + self.session.mobility.updatewlans(moved, moved_ifaces) def addwaypoint( self, diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 6c7ebcf0..40aae6a8 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -68,8 +68,8 @@ class NodeBase(abc.ABC): self.server: "DistributedServer" = server self.type: Optional[str] = None self.services: CoreServices = [] - self._netif: Dict[int, CoreInterface] = {} - self.ifindex: int = 0 + self.ifaces: Dict[int, CoreInterface] = {} + self.iface_id: int = 0 self.canvas: Optional[int] = None self.icon: Optional[str] = None self.opaque: Optional[str] = None @@ -139,58 +139,50 @@ class NodeBase(abc.ABC): """ return self.position.get() - def ifname(self, ifindex: int) -> str: - """ - Retrieve interface name for index. + def get_iface(self, iface_id: int) -> CoreInterface: + if iface_id not in self.ifaces: + raise CoreError(f"node({self.name}) does not have interface({iface_id})") + return self.ifaces[iface_id] - :param ifindex: interface index - :return: interface name + def get_ifaces(self, control: bool = True) -> List[CoreInterface]: """ - return self._netif[ifindex].name + Retrieve sorted list of interfaces, optionally do not include control + interfaces. - def netifs(self, sort: bool = False) -> List[CoreInterface]: + :param control: False to exclude control interfaces, included otherwise + :return: list of interfaces """ - Retrieve network interfaces, sorted if desired. + ifaces = [] + for iface_id in sorted(self.ifaces): + iface = self.ifaces[iface_id] + if not control and getattr(iface, "control", False): + continue + ifaces.append(iface) + return ifaces - :param sort: boolean used to determine if interfaces should be sorted - :return: network interfaces + def get_iface_id(self, iface: CoreInterface) -> int: """ - if sort: - return [self._netif[x] for x in sorted(self._netif)] - else: - return list(self._netif.values()) + Retrieve id for an interface. - def numnetif(self) -> int: - """ - Return the attached interface count. - - :return: number of network interfaces - """ - return len(self._netif) - - def getifindex(self, netif: CoreInterface) -> int: - """ - Retrieve index for an interface. - - :param netif: interface to get index for + :param iface: interface to get id for :return: interface index if found, -1 otherwise """ - for ifindex in self._netif: - if self._netif[ifindex] is netif: - return ifindex - return -1 + for iface_id, local_iface in self.ifaces.items(): + if local_iface is iface: + return iface_id + raise CoreError(f"node({self.name}) does not have interface({iface.name})") - def newifindex(self) -> int: + def next_iface_id(self) -> int: """ Create a new interface index. :return: interface index """ - while self.ifindex in self._netif: - self.ifindex += 1 - ifindex = self.ifindex - self.ifindex += 1 - return ifindex + while self.iface_id in self.ifaces: + self.iface_id += 1 + iface_id = self.iface_id + self.iface_id += 1 + return iface_id def data( self, message_type: MessageFlags = MessageFlags.NONE, source: str = None @@ -325,14 +317,14 @@ class CoreNodeBase(NodeBase): raise NotImplementedError @abc.abstractmethod - def newnetif( - self, net: "CoreNetworkBase", interface_data: InterfaceData + def new_iface( + self, net: "CoreNetworkBase", iface_data: InterfaceData ) -> CoreInterface: """ - Create a new network interface. + Create a new interface. :param net: network to associate with - :param interface_data: interface data for new interface + :param iface_data: interface data for new interface :return: interface index """ raise NotImplementedError @@ -399,67 +391,53 @@ class CoreNodeBase(NodeBase): if self.tmpnodedir: self.host_cmd(f"rm -rf {self.nodedir}") - def addnetif(self, netif: CoreInterface, ifindex: int) -> None: + def add_iface(self, iface: CoreInterface, iface_id: int) -> None: """ Add network interface to node and set the network interface index if successful. - :param netif: network interface to add - :param ifindex: interface index + :param iface: network interface to add + :param iface_id: interface id :return: nothing """ - if ifindex in self._netif: - raise ValueError(f"ifindex {ifindex} already exists") - self._netif[ifindex] = netif - netif.netindex = ifindex + if iface_id in self.ifaces: + raise CoreError(f"interface({iface_id}) already exists") + self.ifaces[iface_id] = iface + iface.node_id = iface_id - def delnetif(self, ifindex: int) -> None: + def delete_iface(self, iface_id: int) -> None: """ Delete a network interface - :param ifindex: interface index to delete + :param iface_id: interface index to delete :return: nothing """ - if ifindex not in self._netif: - raise CoreError(f"node({self.name}) ifindex({ifindex}) does not exist") - netif = self._netif.pop(ifindex) - logging.info("node(%s) removing interface(%s)", self.name, netif.name) - netif.detachnet() - netif.shutdown() + if iface_id not in self.ifaces: + raise CoreError(f"node({self.name}) interface({iface_id}) does not exist") + iface = self.ifaces.pop(iface_id) + logging.info("node(%s) removing interface(%s)", self.name, iface.name) + iface.detachnet() + iface.shutdown() - def netif(self, ifindex: int) -> Optional[CoreInterface]: - """ - Retrieve network interface. - - :param ifindex: index of interface to retrieve - :return: network interface, or None if not found - """ - if ifindex in self._netif: - return self._netif[ifindex] - else: - return None - - def attachnet(self, ifindex: int, net: "CoreNetworkBase") -> None: + def attachnet(self, iface_id: int, net: "CoreNetworkBase") -> None: """ Attach a network. - :param ifindex: interface of index to attach + :param iface_id: interface of index to attach :param net: network to attach :return: nothing """ - if ifindex not in self._netif: - raise ValueError(f"ifindex {ifindex} does not exist") - self._netif[ifindex].attachnet(net) + iface = self.get_iface(iface_id) + iface.attachnet(net) - def detachnet(self, ifindex: int) -> None: + def detachnet(self, iface_id: int) -> None: """ Detach network interface. - :param ifindex: interface index to detach + :param iface_id: interface id to detach :return: nothing """ - if ifindex not in self._netif: - raise ValueError(f"ifindex {ifindex} does not exist") - self._netif[ifindex].detachnet() + iface = self.get_iface(iface_id) + iface.detachnet() def setposition(self, x: float = None, y: float = None, z: float = None) -> None: """ @@ -472,8 +450,8 @@ class CoreNodeBase(NodeBase): """ changed = super().setposition(x, y, z) if changed: - for netif in self.netifs(sort=True): - netif.setposition() + for iface in self.get_ifaces(): + iface.setposition() def commonnets( self, node: "CoreNodeBase", want_ctrl: bool = False @@ -488,12 +466,10 @@ class CoreNodeBase(NodeBase): :return: tuples of common networks """ common = [] - for netif1 in self.netifs(): - if not want_ctrl and hasattr(netif1, "control"): - continue - for netif2 in node.netifs(): - if netif1.net == netif2.net: - common.append((netif1.net, netif1, netif2)) + for iface1 in self.get_ifaces(control=want_ctrl): + for iface2 in node.get_ifaces(): + if iface1.net == iface2.net: + common.append((iface1.net, iface1, iface2)) return common @@ -620,8 +596,8 @@ class CoreNode(CoreNodeBase): self._mounts = [] # shutdown all interfaces - for netif in self.netifs(): - netif.shutdown() + for iface in self.get_ifaces(): + iface.shutdown() # kill node process if present try: @@ -636,7 +612,7 @@ class CoreNode(CoreNodeBase): logging.exception("error removing node directory") # clear interface data, close client, and mark self and not up - self._netif.clear() + self.ifaces.clear() self.client.close() self.up = False except OSError: @@ -704,36 +680,36 @@ class CoreNode(CoreNodeBase): self.cmd(f"{MOUNT_BIN} -n --bind {source} {target}") self._mounts.append((source, target)) - def newifindex(self) -> int: + def next_iface_id(self) -> int: """ Retrieve a new interface index. :return: new interface index """ with self.lock: - return super().newifindex() + return super().next_iface_id() - def newveth(self, ifindex: int = None, ifname: str = None) -> int: + def newveth(self, iface_id: int = None, ifname: str = None) -> int: """ Create a new interface. - :param ifindex: index for the new interface + :param iface_id: id for the new interface :param ifname: name for the new interface :return: nothing """ with self.lock: - if ifindex is None: - ifindex = self.newifindex() + if iface_id is None: + iface_id = self.next_iface_id() if ifname is None: - ifname = f"eth{ifindex}" + ifname = f"eth{iface_id}" sessionid = self.session.short_session_id() try: - suffix = f"{self.id:x}.{ifindex}.{sessionid}" + suffix = f"{self.id:x}.{iface_id}.{sessionid}" except TypeError: - suffix = f"{self.id}.{ifindex}.{sessionid}" + suffix = f"{self.id}.{iface_id}.{sessionid}" localname = f"veth{suffix}" if len(localname) >= 16: @@ -765,140 +741,138 @@ class CoreNode(CoreNodeBase): try: # add network interface to the node. If unsuccessful, destroy the # network interface and raise exception. - self.addnetif(veth, ifindex) + self.add_iface(veth, iface_id) except ValueError as e: veth.shutdown() del veth raise e - return ifindex + return iface_id - def newtuntap(self, ifindex: int = None, ifname: str = None) -> int: + def newtuntap(self, iface_id: int = None, ifname: str = None) -> int: """ Create a new tunnel tap. - :param ifindex: interface index + :param iface_id: interface id :param ifname: interface name :return: interface index """ with self.lock: - if ifindex is None: - ifindex = self.newifindex() + if iface_id is None: + iface_id = self.next_iface_id() if ifname is None: - ifname = f"eth{ifindex}" + ifname = f"eth{iface_id}" sessionid = self.session.short_session_id() - localname = f"tap{self.id}.{ifindex}.{sessionid}" + localname = f"tap{self.id}.{iface_id}.{sessionid}" name = ifname tuntap = TunTap(self.session, self, name, localname, start=self.up) try: - self.addnetif(tuntap, ifindex) + self.add_iface(tuntap, iface_id) except ValueError as e: tuntap.shutdown() del tuntap raise e - return ifindex + return iface_id - def sethwaddr(self, ifindex: int, addr: str) -> None: + def sethwaddr(self, iface_id: int, addr: str) -> None: """ - Set hardware addres for an interface. + Set hardware address for an interface. - :param ifindex: index of interface to set hardware address for + :param iface_id: id of interface to set hardware address for :param addr: hardware address to set :return: nothing :raises CoreCommandError: when a non-zero exit status occurs """ addr = utils.validate_mac(addr) - interface = self._netif[ifindex] - interface.sethwaddr(addr) + iface = self.get_iface(iface_id) + iface.sethwaddr(addr) if self.up: - self.node_net_client.device_mac(interface.name, addr) + self.node_net_client.device_mac(iface.name, addr) - def addaddr(self, ifindex: int, addr: str) -> None: + def addaddr(self, iface_id: int, addr: str) -> None: """ Add interface address. - :param ifindex: index of interface to add address to + :param iface_id: id of interface to add address to :param addr: address to add to interface :return: nothing """ addr = utils.validate_ip(addr) - interface = self._netif[ifindex] - interface.addaddr(addr) + iface = self.get_iface(iface_id) + iface.addaddr(addr) if self.up: # ipv4 check broadcast = None if netaddr.valid_ipv4(addr): broadcast = "+" - self.node_net_client.create_address(interface.name, addr, broadcast) + self.node_net_client.create_address(iface.name, addr, broadcast) - def deladdr(self, ifindex: int, addr: str) -> None: + def deladdr(self, iface_id: int, addr: str) -> None: """ Delete address from an interface. - :param ifindex: index of interface to delete address from + :param iface_id: id of interface to delete address from :param addr: address to delete from interface :return: nothing :raises CoreCommandError: when a non-zero exit status occurs """ - interface = self._netif[ifindex] - + iface = self.get_iface(iface_id) try: - interface.deladdr(addr) + iface.deladdr(addr) except ValueError: logging.exception("trying to delete unknown address: %s", addr) - if self.up: - self.node_net_client.delete_address(interface.name, addr) + self.node_net_client.delete_address(iface.name, addr) - def ifup(self, ifindex: int) -> None: + def ifup(self, iface_id: int) -> None: """ Bring an interface up. - :param ifindex: index of interface to bring up + :param iface_id: index of interface to bring up :return: nothing """ if self.up: - interface_name = self.ifname(ifindex) - self.node_net_client.device_up(interface_name) + iface = self.get_iface(iface_id) + self.node_net_client.device_up(iface.name) - def newnetif( - self, net: "CoreNetworkBase", interface_data: InterfaceData + def new_iface( + self, net: "CoreNetworkBase", iface_data: InterfaceData ) -> CoreInterface: """ Create a new network interface. :param net: network to associate with - :param interface_data: interface data for new interface + :param iface_data: interface data for new interface :return: interface index """ - addresses = interface_data.get_addresses() + addresses = iface_data.get_addresses() with self.lock: # TODO: emane specific code if net.is_emane is True: - ifindex = self.newtuntap(interface_data.id, interface_data.name) + iface_id = self.newtuntap(iface_data.id, iface_data.name) # TUN/TAP is not ready for addressing yet; the device may # take some time to appear, and installing it into a # namespace after it has been bound removes addressing; # save addresses with the interface now - self.attachnet(ifindex, net) - netif = self.netif(ifindex) - netif.sethwaddr(interface_data.mac) + self.attachnet(iface_id, net) + iface = self.get_iface(iface_id) + iface.sethwaddr(iface_data.mac) for address in addresses: - netif.addaddr(address) + iface.addaddr(address) else: - ifindex = self.newveth(interface_data.id, interface_data.name) - self.attachnet(ifindex, net) - if interface_data.mac: - self.sethwaddr(ifindex, interface_data.mac) + iface_id = self.newveth(iface_data.id, iface_data.name) + self.attachnet(iface_id, net) + if iface_data.mac: + self.sethwaddr(iface_id, iface_data.mac) for address in addresses: - self.addaddr(ifindex, address) - self.ifup(ifindex) - netif = self.netif(ifindex) - return netif + self.addaddr(iface_id, address) + self.ifup(iface_id) + iface = self.get_iface(iface_id) + return iface def addfile(self, srcname: str, filename: str) -> None: """ @@ -1041,54 +1015,54 @@ class CoreNetworkBase(NodeBase): @abc.abstractmethod def linkconfig( - self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None + self, iface: CoreInterface, options: LinkOptions, iface2: CoreInterface = None ) -> None: """ Configure link parameters by applying tc queuing disciplines on the interface. - :param netif: interface one + :param iface: interface one :param options: options for configuring link - :param netif2: interface two + :param iface2: interface two :return: nothing """ raise NotImplementedError - def getlinknetif(self, net: "CoreNetworkBase") -> Optional[CoreInterface]: + def get_linked_iface(self, net: "CoreNetworkBase") -> Optional[CoreInterface]: """ - Return the interface of that links this net with another net. + Return the interface that links this net with another net. :param net: interface to get link for :return: interface the provided network is linked to """ - for netif in self.netifs(): - if netif.othernet == net: - return netif + for iface in self.get_ifaces(): + if iface.othernet == net: + return iface return None - def attach(self, netif: CoreInterface) -> None: + def attach(self, iface: CoreInterface) -> None: """ Attach network interface. - :param netif: network interface to attach + :param iface: network interface to attach :return: nothing """ - i = self.newifindex() - self._netif[i] = netif - netif.netifi = i + i = self.next_iface_id() + self.ifaces[i] = iface + iface.net_id = i with self._linked_lock: - self._linked[netif] = {} + self._linked[iface] = {} - def detach(self, netif: CoreInterface) -> None: + def detach(self, iface: CoreInterface) -> None: """ Detach network interface. - :param netif: network interface to detach + :param iface: network interface to detach :return: nothing """ - del self._netif[netif.netifi] - netif.netifi = None + del self.ifaces[iface.net_id] + iface.net_id = None with self._linked_lock: - del self._linked[netif] + del self._linked[iface] def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ @@ -1102,41 +1076,39 @@ class CoreNetworkBase(NodeBase): # build a link message from this network node to each node having a # connected interface - for netif in self.netifs(sort=True): - if not hasattr(netif, "node"): - continue + for iface in self.get_ifaces(): uni = False - linked_node = netif.node + linked_node = iface.node if linked_node is None: # two layer-2 switches/hubs linked together via linknet() - if not netif.othernet: + if not iface.othernet: continue - linked_node = netif.othernet + linked_node = iface.othernet if linked_node.id == self.id: continue - netif.swapparams("_params_up") - upstream_params = netif.getparams() - netif.swapparams("_params_up") - if netif.getparams() != upstream_params: + iface.swapparams("_params_up") + upstream_params = iface.getparams() + iface.swapparams("_params_up") + if iface.getparams() != upstream_params: uni = True unidirectional = 0 if uni: unidirectional = 1 - interface2_ip4 = None - interface2_ip4_mask = None - interface2_ip6 = None - interface2_ip6_mask = None - for address in netif.addrlist: + iface2_ip4 = None + iface2_ip4_mask = None + iface2_ip6 = None + iface2_ip6_mask = None + for address in iface.addrlist: ip, _sep, mask = address.partition("/") mask = int(mask) if netaddr.valid_ipv4(ip): - interface2_ip4 = ip - interface2_ip4_mask = mask + iface2_ip4 = ip + iface2_ip4_mask = mask else: - interface2_ip6 = ip - interface2_ip6_mask = mask + iface2_ip6 = ip + iface2_ip6_mask = mask link_data = LinkData( message_type=flags, @@ -1144,42 +1116,38 @@ class CoreNetworkBase(NodeBase): node2_id=linked_node.id, link_type=self.linktype, unidirectional=unidirectional, - interface2_id=linked_node.getifindex(netif), - interface2_name=netif.name, - interface2_mac=netif.hwaddr, - interface2_ip4=interface2_ip4, - interface2_ip4_mask=interface2_ip4_mask, - interface2_ip6=interface2_ip6, - interface2_ip6_mask=interface2_ip6_mask, - delay=netif.getparam("delay"), - bandwidth=netif.getparam("bw"), - dup=netif.getparam("duplicate"), - jitter=netif.getparam("jitter"), - loss=netif.getparam("loss"), + iface2_id=linked_node.get_iface_id(iface), + iface2_name=iface.name, + iface2_mac=iface.hwaddr, + iface2_ip4=iface2_ip4, + iface2_ip4_mask=iface2_ip4_mask, + iface2_ip6=iface2_ip6, + iface2_ip6_mask=iface2_ip6_mask, + delay=iface.getparam("delay"), + bandwidth=iface.getparam("bw"), + dup=iface.getparam("duplicate"), + jitter=iface.getparam("jitter"), + loss=iface.getparam("loss"), ) - all_links.append(link_data) if not uni: continue - - netif.swapparams("_params_up") + iface.swapparams("_params_up") link_data = LinkData( message_type=MessageFlags.NONE, node1_id=linked_node.id, node2_id=self.id, link_type=self.linktype, unidirectional=1, - delay=netif.getparam("delay"), - bandwidth=netif.getparam("bw"), - dup=netif.getparam("duplicate"), - jitter=netif.getparam("jitter"), - loss=netif.getparam("loss"), + delay=iface.getparam("delay"), + bandwidth=iface.getparam("bw"), + dup=iface.getparam("duplicate"), + jitter=iface.getparam("jitter"), + loss=iface.getparam("loss"), ) - netif.swapparams("_params_up") - + iface.swapparams("_params_up") all_links.append(link_data) - return all_links diff --git a/daemon/core/nodes/docker.py b/daemon/core/nodes/docker.py index e911db74..1ef814ee 100644 --- a/daemon/core/nodes/docker.py +++ b/daemon/core/nodes/docker.py @@ -141,7 +141,7 @@ class DockerNode(CoreNode): return with self.lock: - self._netif.clear() + self.ifaces.clear() self.client.stop_container() self.up = False diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index e73e2989..dc16517f 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -57,11 +57,11 @@ class CoreInterface: self.poshook: Callable[[CoreInterface], None] = lambda x: None # used with EMANE self.transport_type: Optional[TransportType] = None - # node interface index - self.netindex: Optional[int] = None - # net interface index - self.netifi: Optional[int] = None - # index used to find flow data + # id of interface for node + self.node_id: Optional[int] = None + # id of interface for network + self.net_id: Optional[int] = None + # id used to find flow data self.flow_id: Optional[int] = None self.server: Optional["DistributedServer"] = server use_ovs = session.options.get_config("ovs") == "True" @@ -284,19 +284,16 @@ class Veth(CoreInterface): """ if not self.up: return - if self.node: try: self.node.node_net_client.device_flush(self.name) except CoreCommandError: logging.exception("error shutting down interface") - if self.localname: try: self.net_client.delete_device(self.localname) except CoreCommandError: logging.info("link already removed: %s", self.localname) - self.up = False diff --git a/daemon/core/nodes/lxd.py b/daemon/core/nodes/lxd.py index a66791ce..9773cb95 100644 --- a/daemon/core/nodes/lxd.py +++ b/daemon/core/nodes/lxd.py @@ -126,7 +126,7 @@ class LxcNode(CoreNode): return with self.lock: - self._netif.clear() + self.ifaces.clear() self.client.stop_container() self.up = False @@ -215,7 +215,7 @@ class LxcNode(CoreNode): self.client.copy_file(source, filename) self.cmd(f"chmod {mode:o} {filename}") - def addnetif(self, netif: CoreInterface, ifindex: int) -> None: - super().addnetif(netif, ifindex) + def add_iface(self, iface: CoreInterface, iface_id: int) -> None: + super().add_iface(iface, iface_id) # adding small delay to allow time for adding addresses to work correctly time.sleep(0.5) diff --git a/daemon/core/nodes/netclient.py b/daemon/core/nodes/netclient.py index 25a10b99..b6c164b5 100644 --- a/daemon/core/nodes/netclient.py +++ b/daemon/core/nodes/netclient.py @@ -155,14 +155,14 @@ class LinuxNetClient: """ self.run(f"{TC_BIN} qdisc delete dev {device} root") - def checksums_off(self, interface_name: str) -> None: + def checksums_off(self, iface_name: str) -> None: """ Turns interface checksums off. - :param interface_name: interface to update + :param iface_name: interface to update :return: nothing """ - self.run(f"{ETHTOOL_BIN} -K {interface_name} rx off tx off") + self.run(f"{ETHTOOL_BIN} -K {iface_name} rx off tx off") def create_address(self, device: str, address: str, broadcast: str = None) -> None: """ @@ -250,26 +250,26 @@ class LinuxNetClient: self.device_down(name) self.run(f"{IP_BIN} link delete {name} type bridge") - def set_interface_master(self, bridge_name: str, interface_name: str) -> None: + def set_iface_master(self, bridge_name: str, iface_name: str) -> None: """ Assign interface master to a Linux bridge. :param bridge_name: bridge name - :param interface_name: interface name + :param iface_name: interface name :return: nothing """ - self.run(f"{IP_BIN} link set dev {interface_name} master {bridge_name}") - self.device_up(interface_name) + self.run(f"{IP_BIN} link set dev {iface_name} master {bridge_name}") + self.device_up(iface_name) - def delete_interface(self, bridge_name: str, interface_name: str) -> None: + def delete_iface(self, bridge_name: str, iface_name: str) -> None: """ Delete an interface associated with a Linux bridge. :param bridge_name: bridge name - :param interface_name: interface name + :param iface_name: interface name :return: nothing """ - self.run(f"{IP_BIN} link set dev {interface_name} nomaster") + self.run(f"{IP_BIN} link set dev {iface_name} nomaster") def existing_bridges(self, _id: int) -> bool: """ @@ -330,26 +330,26 @@ class OvsNetClient(LinuxNetClient): self.device_down(name) self.run(f"{OVS_BIN} del-br {name}") - def set_interface_master(self, bridge_name: str, interface_name: str) -> None: + def set_iface_master(self, bridge_name: str, iface_name: str) -> None: """ Create an interface associated with a network bridge. :param bridge_name: bridge name - :param interface_name: interface name + :param iface_name: interface name :return: nothing """ - self.run(f"{OVS_BIN} add-port {bridge_name} {interface_name}") - self.device_up(interface_name) + self.run(f"{OVS_BIN} add-port {bridge_name} {iface_name}") + self.device_up(iface_name) - def delete_interface(self, bridge_name: str, interface_name: str) -> None: + def delete_iface(self, bridge_name: str, iface_name: str) -> None: """ Delete an interface associated with a OVS bridge. :param bridge_name: bridge name - :param interface_name: interface name + :param iface_name: interface name :return: nothing """ - self.run(f"{OVS_BIN} del-port {bridge_name} {interface_name}") + self.run(f"{OVS_BIN} del-port {bridge_name} {iface_name}") def existing_bridges(self, _id: int) -> bool: """ diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index b85c2eee..85e3e488 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -216,20 +216,20 @@ class EbtablesQueue: ] ) # rebuild the chain - for netif1, v in wlan._linked.items(): - for netif2, linked in v.items(): + for iface1, v in wlan._linked.items(): + for oface2, linked in v.items(): if wlan.policy == NetworkPolicy.DROP and linked: self.cmds.extend( [ - f"-A {wlan.brname} -i {netif1.localname} -o {netif2.localname} -j ACCEPT", - f"-A {wlan.brname} -o {netif1.localname} -i {netif2.localname} -j ACCEPT", + f"-A {wlan.brname} -i {iface1.localname} -o {oface2.localname} -j ACCEPT", + f"-A {wlan.brname} -o {iface1.localname} -i {oface2.localname} -j ACCEPT", ] ) elif wlan.policy == NetworkPolicy.ACCEPT and not linked: self.cmds.extend( [ - f"-A {wlan.brname} -i {netif1.localname} -o {netif2.localname} -j DROP", - f"-A {wlan.brname} -o {netif1.localname} -i {netif2.localname} -j DROP", + f"-A {wlan.brname} -i {iface1.localname} -o {oface2.localname} -j DROP", + f"-A {wlan.brname} -o {iface1.localname} -i {oface2.localname} -j DROP", ] ) @@ -347,53 +347,53 @@ class CoreNetwork(CoreNetworkBase): logging.exception("error during shutdown") # removes veth pairs used for bridge-to-bridge connections - for netif in self.netifs(): - netif.shutdown() + for iface in self.get_ifaces(): + iface.shutdown() - self._netif.clear() + self.ifaces.clear() self._linked.clear() del self.session self.up = False - def attach(self, netif: CoreInterface) -> None: + def attach(self, iface: CoreInterface) -> None: """ Attach a network interface. - :param netif: network interface to attach + :param iface: network interface to attach :return: nothing """ if self.up: - netif.net_client.set_interface_master(self.brname, netif.localname) - super().attach(netif) + iface.net_client.set_iface_master(self.brname, iface.localname) + super().attach(iface) - def detach(self, netif: CoreInterface) -> None: + def detach(self, iface: CoreInterface) -> None: """ Detach a network interface. - :param netif: network interface to detach + :param iface: network interface to detach :return: nothing """ if self.up: - netif.net_client.delete_interface(self.brname, netif.localname) - super().detach(netif) + iface.net_client.delete_iface(self.brname, iface.localname) + super().detach(iface) - def linked(self, netif1: CoreInterface, netif2: CoreInterface) -> bool: + def linked(self, iface1: CoreInterface, iface2: CoreInterface) -> bool: """ Determine if the provided network interfaces are linked. - :param netif1: interface one - :param netif2: interface two + :param iface1: interface one + :param iface2: interface two :return: True if interfaces are linked, False otherwise """ # check if the network interfaces are attached to this network - if self._netif[netif1.netifi] != netif1: - raise ValueError(f"inconsistency for netif {netif1.name}") + if self.ifaces[iface1.net_id] != iface1: + raise ValueError(f"inconsistency for interface {iface1.name}") - if self._netif[netif2.netifi] != netif2: - raise ValueError(f"inconsistency for netif {netif2.name}") + if self.ifaces[iface2.net_id] != iface2: + raise ValueError(f"inconsistency for interface {iface2.name}") try: - linked = self._linked[netif1][netif2] + linked = self._linked[iface1][iface2] except KeyError: if self.policy == NetworkPolicy.ACCEPT: linked = True @@ -401,93 +401,93 @@ class CoreNetwork(CoreNetworkBase): linked = False else: raise Exception(f"unknown policy: {self.policy.value}") - self._linked[netif1][netif2] = linked + self._linked[iface1][iface2] = linked return linked - def unlink(self, netif1: CoreInterface, netif2: CoreInterface) -> None: + def unlink(self, iface1: CoreInterface, iface2: CoreInterface) -> None: """ Unlink two interfaces, resulting in adding or removing ebtables filtering rules. - :param netif1: interface one - :param netif2: interface two + :param iface1: interface one + :param iface2: interface two :return: nothing """ with self._linked_lock: - if not self.linked(netif1, netif2): + if not self.linked(iface1, iface2): return - self._linked[netif1][netif2] = False + self._linked[iface1][iface2] = False ebq.ebchange(self) - def link(self, netif1: CoreInterface, netif2: CoreInterface) -> None: + def link(self, iface1: CoreInterface, iface2: CoreInterface) -> None: """ Link two interfaces together, resulting in adding or removing ebtables filtering rules. - :param netif1: interface one - :param netif2: interface two + :param iface1: interface one + :param iface2: interface two :return: nothing """ with self._linked_lock: - if self.linked(netif1, netif2): + if self.linked(iface1, iface2): return - self._linked[netif1][netif2] = True + self._linked[iface1][iface2] = True ebq.ebchange(self) def linkconfig( - self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None + self, iface: CoreInterface, options: LinkOptions, iface2: CoreInterface = None ) -> None: """ Configure link parameters by applying tc queuing disciplines on the interface. - :param netif: interface one + :param iface: interface one :param options: options for configuring link - :param netif2: interface two + :param iface2: interface two :return: nothing """ - devname = netif.localname + devname = iface.localname tc = f"{TC_BIN} qdisc replace dev {devname}" parent = "root" changed = False bw = options.bandwidth - if netif.setparam("bw", bw): + if iface.setparam("bw", bw): # from tc-tbf(8): minimum value for burst is rate / kernel_hz - burst = max(2 * netif.mtu, int(bw / 1000)) + burst = max(2 * iface.mtu, int(bw / 1000)) # max IP payload limit = 0xFFFF tbf = f"tbf rate {bw} burst {burst} limit {limit}" if bw > 0: if self.up: cmd = f"{tc} {parent} handle 1: {tbf}" - netif.host_cmd(cmd) - netif.setparam("has_tbf", True) + iface.host_cmd(cmd) + iface.setparam("has_tbf", True) changed = True - elif netif.getparam("has_tbf") and bw <= 0: + elif iface.getparam("has_tbf") and bw <= 0: if self.up: cmd = f"{TC_BIN} qdisc delete dev {devname} {parent}" - netif.host_cmd(cmd) - netif.setparam("has_tbf", False) + iface.host_cmd(cmd) + iface.setparam("has_tbf", False) # removing the parent removes the child - netif.setparam("has_netem", False) + iface.setparam("has_netem", False) changed = True - if netif.getparam("has_tbf"): + if iface.getparam("has_tbf"): parent = "parent 1:1" netem = "netem" delay = options.delay - changed = max(changed, netif.setparam("delay", delay)) + changed = max(changed, iface.setparam("delay", delay)) loss = options.loss if loss is not None: loss = float(loss) - changed = max(changed, netif.setparam("loss", loss)) + changed = max(changed, iface.setparam("loss", loss)) duplicate = options.dup if duplicate is not None: duplicate = int(duplicate) - changed = max(changed, netif.setparam("duplicate", duplicate)) + changed = max(changed, iface.setparam("duplicate", duplicate)) jitter = options.jitter - changed = max(changed, netif.setparam("jitter", jitter)) + changed = max(changed, iface.setparam("jitter", jitter)) if not changed: return # jitter and delay use the same delay statement @@ -510,19 +510,19 @@ class CoreNetwork(CoreNetworkBase): duplicate_check = duplicate is None or duplicate <= 0 if all([delay_check, jitter_check, loss_check, duplicate_check]): # possibly remove netem if it exists and parent queue wasn't removed - if not netif.getparam("has_netem"): + if not iface.getparam("has_netem"): return if self.up: cmd = f"{TC_BIN} qdisc delete dev {devname} {parent} handle 10:" - netif.host_cmd(cmd) - netif.setparam("has_netem", False) + iface.host_cmd(cmd) + iface.setparam("has_netem", False) elif len(netem) > 1: if self.up: cmd = ( f"{TC_BIN} qdisc replace dev {devname} {parent} handle 10: {netem}" ) - netif.host_cmd(cmd) - netif.setparam("has_netem", True) + iface.host_cmd(cmd) + iface.setparam("has_netem", True) def linknet(self, net: CoreNetworkBase) -> CoreInterface: """ @@ -551,19 +551,19 @@ class CoreNetwork(CoreNetworkBase): if len(name) >= 16: raise ValueError(f"interface name {name} too long") - netif = Veth(self.session, None, name, localname, start=self.up) - self.attach(netif) + iface = Veth(self.session, None, name, localname, start=self.up) + self.attach(iface) if net.up and net.brname: - netif.net_client.set_interface_master(net.brname, netif.name) - i = net.newifindex() - net._netif[i] = netif + iface.net_client.set_iface_master(net.brname, iface.name) + i = net.next_iface_id() + net.ifaces[i] = iface with net._linked_lock: - net._linked[netif] = {} - netif.net = self - netif.othernet = net - return netif + net._linked[iface] = {} + iface.net = self + iface.othernet = net + return iface - def getlinknetif(self, net: CoreNetworkBase) -> Optional[CoreInterface]: + def get_linked_iface(self, net: CoreNetworkBase) -> Optional[CoreInterface]: """ Return the interface of that links this net with another net (that were linked using linknet()). @@ -571,9 +571,9 @@ class CoreNetwork(CoreNetworkBase): :param net: interface to get link for :return: interface the provided network is linked to """ - for netif in self.netifs(): - if netif.othernet == net: - return netif + for iface in self.get_ifaces(): + if iface.othernet == net: + return iface return None def addrconfig(self, addrlist: List[str]) -> None: @@ -690,17 +690,17 @@ class GreTapBridge(CoreNetwork): ) self.attach(self.gretap) - def setkey(self, key: int, interface_data: InterfaceData) -> None: + def setkey(self, key: int, iface_data: InterfaceData) -> None: """ Set the GRE key used for the GreTap device. This needs to be set prior to instantiating the GreTap device (before addrconfig). :param key: gre key - :param interface_data: interface data for setting up tunnel key + :param iface_data: interface data for setting up tunnel key :return: nothing """ self.grekey = key - addresses = interface_data.get_addresses() + addresses = iface_data.get_addresses() if addresses: self.addrconfig(addresses) @@ -802,7 +802,7 @@ class CtrlNet(CoreNetwork): self.host_cmd(f"{self.updown_script} {self.brname} startup") if self.serverintf: - self.net_client.set_interface_master(self.brname, self.serverintf) + self.net_client.set_iface_master(self.brname, self.serverintf) def shutdown(self) -> None: """ @@ -812,7 +812,7 @@ class CtrlNet(CoreNetwork): """ if self.serverintf is not None: try: - self.net_client.delete_interface(self.brname, self.serverintf) + self.net_client.delete_iface(self.brname, self.serverintf) except CoreCommandError: logging.exception( "error deleting server interface %s from bridge %s", @@ -850,18 +850,18 @@ class PtpNet(CoreNetwork): policy: NetworkPolicy = NetworkPolicy.ACCEPT - def attach(self, netif: CoreInterface) -> None: + def attach(self, iface: CoreInterface) -> None: """ Attach a network interface, but limit attachment to two interfaces. - :param netif: network interface + :param iface: network interface :return: nothing """ - if len(self._netif) >= 2: + if len(self.ifaces) >= 2: raise ValueError( "Point-to-point links support at most 2 network interfaces" ) - super().attach(netif) + super().attach(iface) def data( self, message_type: MessageFlags = MessageFlags.NONE, source: str = None @@ -886,67 +886,67 @@ class PtpNet(CoreNetwork): """ all_links = [] - if len(self._netif) != 2: + if len(self.ifaces) != 2: return all_links - interface1, interface2 = self._netif.values() + iface1, iface2 = self.get_ifaces() unidirectional = 0 - if interface1.getparams() != interface2.getparams(): + if iface1.getparams() != iface2.getparams(): unidirectional = 1 - interface1_ip4 = None - interface1_ip4_mask = None - interface1_ip6 = None - interface1_ip6_mask = None - for address in interface1.addrlist: + iface1_ip4 = None + iface1_ip4_mask = None + iface1_ip6 = None + iface1_ip6_mask = None + for address in iface1.addrlist: ip, _sep, mask = address.partition("/") mask = int(mask) if netaddr.valid_ipv4(ip): - interface1_ip4 = ip - interface1_ip4_mask = mask + iface1_ip4 = ip + iface1_ip4_mask = mask else: - interface1_ip6 = ip - interface1_ip6_mask = mask + iface1_ip6 = ip + iface1_ip6_mask = mask - interface2_ip4 = None - interface2_ip4_mask = None - interface2_ip6 = None - interface2_ip6_mask = None - for address in interface2.addrlist: + iface2_ip4 = None + iface2_ip4_mask = None + iface2_ip6 = None + iface2_ip6_mask = None + for address in iface2.addrlist: ip, _sep, mask = address.partition("/") mask = int(mask) if netaddr.valid_ipv4(ip): - interface2_ip4 = ip - interface2_ip4_mask = mask + iface2_ip4 = ip + iface2_ip4_mask = mask else: - interface2_ip6 = ip - interface2_ip6_mask = mask + iface2_ip6 = ip + iface2_ip6_mask = mask link_data = LinkData( message_type=flags, - node1_id=interface1.node.id, - node2_id=interface2.node.id, + node1_id=iface1.node.id, + node2_id=iface2.node.id, link_type=self.linktype, unidirectional=unidirectional, - delay=interface1.getparam("delay"), - bandwidth=interface1.getparam("bw"), - loss=interface1.getparam("loss"), - dup=interface1.getparam("duplicate"), - jitter=interface1.getparam("jitter"), - interface1_id=interface1.node.getifindex(interface1), - interface1_name=interface1.name, - interface1_mac=interface1.hwaddr, - interface1_ip4=interface1_ip4, - interface1_ip4_mask=interface1_ip4_mask, - interface1_ip6=interface1_ip6, - interface1_ip6_mask=interface1_ip6_mask, - interface2_id=interface2.node.getifindex(interface2), - interface2_name=interface2.name, - interface2_mac=interface2.hwaddr, - interface2_ip4=interface2_ip4, - interface2_ip4_mask=interface2_ip4_mask, - interface2_ip6=interface2_ip6, - interface2_ip6_mask=interface2_ip6_mask, + delay=iface1.getparam("delay"), + bandwidth=iface1.getparam("bw"), + loss=iface1.getparam("loss"), + dup=iface1.getparam("duplicate"), + jitter=iface1.getparam("jitter"), + iface1_id=iface1.node.get_iface_id(iface1), + iface1_name=iface1.name, + iface1_mac=iface1.hwaddr, + iface1_ip4=iface1_ip4, + iface1_ip4_mask=iface1_ip4_mask, + iface1_ip6=iface1_ip6, + iface1_ip6_mask=iface1_ip6_mask, + iface2_id=iface2.node.get_iface_id(iface2), + iface2_name=iface2.name, + iface2_mac=iface2.hwaddr, + iface2_ip4=iface2_ip4, + iface2_ip4_mask=iface2_ip4_mask, + iface2_ip6=iface2_ip6, + iface2_ip6_mask=iface2_ip6_mask, ) all_links.append(link_data) @@ -956,16 +956,16 @@ class PtpNet(CoreNetwork): link_data = LinkData( message_type=MessageFlags.NONE, link_type=self.linktype, - node1_id=interface2.node.id, - node2_id=interface1.node.id, - delay=interface2.getparam("delay"), - bandwidth=interface2.getparam("bw"), - loss=interface2.getparam("loss"), - dup=interface2.getparam("duplicate"), - jitter=interface2.getparam("jitter"), + node1_id=iface2.node.id, + node2_id=iface1.node.id, + delay=iface2.getparam("delay"), + bandwidth=iface2.getparam("bw"), + loss=iface2.getparam("loss"), + dup=iface2.getparam("duplicate"), + jitter=iface2.getparam("jitter"), unidirectional=1, - interface1_id=interface2.node.getifindex(interface2), - interface2_id=interface1.node.getifindex(interface1), + iface1_id=iface2.node.get_iface_id(iface2), + iface2_id=iface1.node.get_iface_id(iface1), ) all_links.append(link_data) return all_links @@ -1045,17 +1045,17 @@ class WlanNode(CoreNetwork): self.net_client.disable_mac_learning(self.brname) ebq.ebchange(self) - def attach(self, netif: CoreInterface) -> None: + def attach(self, iface: CoreInterface) -> None: """ Attach a network interface. - :param netif: network interface + :param iface: network interface :return: nothing """ - super().attach(netif) + super().attach(iface) if self.model: - netif.poshook = self.model.position_callback - netif.setposition() + iface.poshook = self.model.position_callback + iface.setposition() def setmodel(self, model: "WirelessModelType", config: Dict[str, str]): """ @@ -1068,9 +1068,9 @@ class WlanNode(CoreNetwork): logging.debug("node(%s) setting model: %s", self.name, model.name) if model.config_type == RegisterTlvs.WIRELESS: self.model = model(session=self.session, _id=self.id) - for netif in self.netifs(): - netif.poshook = self.model.position_callback - netif.setposition() + for iface in self.get_ifaces(): + iface.poshook = self.model.position_callback + iface.setposition() self.updatemodel(config) elif model.config_type == RegisterTlvs.MOBILITY: self.mobility = model(session=self.session, _id=self.id) @@ -1088,8 +1088,8 @@ class WlanNode(CoreNetwork): "node(%s) updating model(%s): %s", self.id, self.model.name, config ) self.model.update_config(config) - for netif in self.netifs(): - netif.setposition() + for iface in self.get_ifaces(): + iface.setposition() def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 741fe7d5..555e0ec9 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -51,8 +51,8 @@ class PhysicalNode(CoreNodeBase): _source, target = self._mounts.pop(-1) self.umount(target) - for netif in self.netifs(): - netif.shutdown() + for iface in self.get_ifaces(): + iface.shutdown() self.rmnodedir() @@ -65,117 +65,115 @@ class PhysicalNode(CoreNodeBase): """ return sh - def sethwaddr(self, ifindex: int, addr: str) -> None: + def sethwaddr(self, iface_id: int, addr: str) -> None: """ Set hardware address for an interface. - :param ifindex: index of interface to set hardware address for + :param iface_id: index of interface to set hardware address for :param addr: hardware address to set :return: nothing :raises CoreCommandError: when a non-zero exit status occurs """ addr = utils.validate_mac(addr) - interface = self._netif[ifindex] - interface.sethwaddr(addr) + iface = self.ifaces[iface_id] + iface.sethwaddr(addr) if self.up: - self.net_client.device_mac(interface.name, addr) + self.net_client.device_mac(iface.name, addr) - def addaddr(self, ifindex: int, addr: str) -> None: + def addaddr(self, iface_id: int, addr: str) -> None: """ Add an address to an interface. - :param ifindex: index of interface to add address to + :param iface_id: index of interface to add address to :param addr: address to add :return: nothing """ addr = utils.validate_ip(addr) - interface = self._netif[ifindex] + iface = self.get_iface(iface_id) if self.up: - self.net_client.create_address(interface.name, addr) - interface.addaddr(addr) + self.net_client.create_address(iface.name, addr) + iface.addaddr(addr) - def deladdr(self, ifindex: int, addr: str) -> None: + def deladdr(self, iface_id: int, addr: str) -> None: """ Delete an address from an interface. - :param ifindex: index of interface to delete + :param iface_id: index of interface to delete :param addr: address to delete :return: nothing """ - interface = self._netif[ifindex] - + iface = self.ifaces[iface_id] try: - interface.deladdr(addr) + iface.deladdr(addr) except ValueError: logging.exception("trying to delete unknown address: %s", addr) - if self.up: - self.net_client.delete_address(interface.name, addr) + self.net_client.delete_address(iface.name, addr) - def adoptnetif( - self, netif: CoreInterface, ifindex: int, hwaddr: str, addrlist: List[str] + def adopt_iface( + self, iface: CoreInterface, iface_id: int, hwaddr: str, addrlist: List[str] ) -> None: """ When a link message is received linking this node to another part of the emulation, no new interface is created; instead, adopt the - GreTap netif as the node interface. + GreTap interface as the node interface. """ - netif.name = f"gt{ifindex}" - netif.node = self - self.addnetif(netif, ifindex) + iface.name = f"gt{iface_id}" + iface.node = self + self.add_iface(iface, iface_id) # use a more reasonable name, e.g. "gt0" instead of "gt.56286.150" if self.up: - self.net_client.device_down(netif.localname) - self.net_client.device_name(netif.localname, netif.name) - netif.localname = netif.name + self.net_client.device_down(iface.localname) + self.net_client.device_name(iface.localname, iface.name) + iface.localname = iface.name if hwaddr: - self.sethwaddr(ifindex, hwaddr) + self.sethwaddr(iface_id, hwaddr) for addr in addrlist: - self.addaddr(ifindex, addr) + self.addaddr(iface_id, addr) if self.up: - self.net_client.device_up(netif.localname) + self.net_client.device_up(iface.localname) def linkconfig( - self, netif: CoreInterface, options: LinkOptions, netif2: CoreInterface = None + self, iface: CoreInterface, options: LinkOptions, iface2: CoreInterface = None ) -> None: """ Apply tc queing disciplines using linkconfig. """ linux_bridge = CoreNetwork(self.session) linux_bridge.up = True - linux_bridge.linkconfig(netif, options, netif2) + linux_bridge.linkconfig(iface, options, iface2) del linux_bridge - def newifindex(self) -> int: + def next_iface_id(self) -> int: with self.lock: - while self.ifindex in self._netif: - self.ifindex += 1 - ifindex = self.ifindex - self.ifindex += 1 - return ifindex + while self.iface_id in self.ifaces: + self.iface_id += 1 + iface_id = self.iface_id + self.iface_id += 1 + return iface_id - def newnetif( - self, net: CoreNetworkBase, interface_data: InterfaceData + def new_iface( + self, net: CoreNetworkBase, iface_data: InterfaceData ) -> CoreInterface: logging.info("creating interface") - addresses = interface_data.get_addresses() - ifindex = interface_data.id - if ifindex is None: - ifindex = self.newifindex() - name = interface_data.name + addresses = iface_data.get_addresses() + iface_id = iface_data.id + if iface_id is None: + iface_id = self.next_iface_id() + name = iface_data.name if name is None: - name = f"gt{ifindex}" + name = f"gt{iface_id}" if self.up: # this is reached when this node is linked to a network node # tunnel to net not built yet, so build it now and adopt it _, remote_tap = self.session.distributed.create_gre_tunnel(net, self.server) - self.adoptnetif(remote_tap, ifindex, interface_data.mac, addresses) + self.adopt_iface(remote_tap, iface_id, iface_data.mac, addresses) return remote_tap else: # this is reached when configuring services (self.up=False) - netif = GreTap(node=self, name=name, session=self.session, start=False) - self.adoptnetif(netif, ifindex, interface_data.mac, addresses) - return netif + iface = GreTap(node=self, name=name, session=self.session, start=False) + self.adopt_iface(iface, iface_id, iface_data.mac, addresses) + return iface def privatedir(self, path: str) -> None: if path[0] != "/": @@ -257,10 +255,10 @@ class Rj45Node(CoreNodeBase): will run on, default is None for localhost """ super().__init__(session, _id, name, server) - self.interface = CoreInterface(session, self, name, name, mtu, server) - self.interface.transport_type = TransportType.RAW + self.iface = CoreInterface(session, self, name, name, mtu, server) + self.iface.transport_type = TransportType.RAW self.lock: threading.RLock = threading.RLock() - self.ifindex: Optional[int] = None + self.iface_id: Optional[int] = None self.old_up: bool = False self.old_addrs: List[Tuple[str, Optional[str]]] = [] @@ -273,7 +271,7 @@ class Rj45Node(CoreNodeBase): """ # interface will also be marked up during net.attach() self.savestate() - self.net_client.device_up(self.interface.localname) + self.net_client.device_up(self.iface.localname) self.up = True def shutdown(self) -> None: @@ -285,7 +283,7 @@ class Rj45Node(CoreNodeBase): """ if not self.up: return - localname = self.interface.localname + localname = self.iface.localname self.net_client.device_down(localname) self.net_client.device_flush(localname) try: @@ -295,8 +293,8 @@ class Rj45Node(CoreNodeBase): self.up = False self.restorestate() - def newnetif( - self, net: CoreNetworkBase, interface_data: InterfaceData + def new_iface( + self, net: CoreNetworkBase, iface_data: InterfaceData ) -> CoreInterface: """ This is called when linking with another node. Since this node @@ -304,70 +302,51 @@ class Rj45Node(CoreNodeBase): but attach ourselves to the given network. :param net: new network instance - :param interface_data: interface data for new interface + :param iface_data: interface data for new interface :return: interface index :raises ValueError: when an interface has already been created, one max """ with self.lock: - ifindex = interface_data.id - if ifindex is None: - ifindex = 0 - if self.interface.net is not None: - raise ValueError("RJ45 nodes support at most 1 network interface") - self._netif[ifindex] = self.interface - self.ifindex = ifindex + iface_id = iface_data.id + if iface_id is None: + iface_id = 0 + if self.iface.net is not None: + raise CoreError("RJ45 nodes support at most 1 network interface") + self.ifaces[iface_id] = self.iface + self.iface_id = iface_id if net is not None: - self.interface.attachnet(net) - for addr in interface_data.get_addresses(): + self.iface.attachnet(net) + for addr in iface_data.get_addresses(): self.addaddr(addr) - return self.interface + return self.iface - def delnetif(self, ifindex: int) -> None: + def delete_iface(self, iface_id: int) -> None: """ Delete a network interface. - :param ifindex: interface index to delete + :param iface_id: interface index to delete :return: nothing """ - if ifindex is None: - ifindex = 0 - self._netif.pop(ifindex) - if ifindex == self.ifindex: - self.shutdown() - else: - raise ValueError(f"ifindex {ifindex} does not exist") + self.get_iface(iface_id) + self.ifaces.pop(iface_id) + self.shutdown() - def netif( - self, ifindex: int, net: CoreNetworkBase = None - ) -> Optional[CoreInterface]: - """ - This object is considered the network interface, so we only - return self here. This keeps the RJ45Node compatible with - real nodes. + def get_iface(self, iface_id: int) -> CoreInterface: + if iface_id != self.iface_id or iface_id not in self.ifaces: + raise CoreError(f"node({self.name}) interface({iface_id}) does not exist") + return self.iface - :param ifindex: interface index to retrieve - :param net: network to retrieve - :return: a network interface - """ - if net is not None and net == self.interface.net: - return self.interface - if ifindex is None: - ifindex = 0 - if ifindex == self.ifindex: - return self.interface - return None - - def getifindex(self, netif: CoreInterface) -> Optional[int]: + def get_iface_id(self, iface: CoreInterface) -> Optional[int]: """ Retrieve network interface index. - :param netif: network interface to retrieve + :param iface: network interface to retrieve index for :return: interface index, None otherwise """ - if netif != self.interface: - return None - return self.ifindex + if iface is not self.iface: + raise CoreError(f"node({self.name}) does not have interface({iface.name})") + return self.iface_id def addaddr(self, addr: str) -> None: """ @@ -380,7 +359,7 @@ class Rj45Node(CoreNodeBase): addr = utils.validate_ip(addr) if self.up: self.net_client.create_address(self.name, addr) - self.interface.addaddr(addr) + self.iface.addaddr(addr) def deladdr(self, addr: str) -> None: """ @@ -392,7 +371,7 @@ class Rj45Node(CoreNodeBase): """ if self.up: self.net_client.delete_address(self.name, addr) - self.interface.deladdr(addr) + self.iface.deladdr(addr) def savestate(self) -> None: """ @@ -404,7 +383,7 @@ class Rj45Node(CoreNodeBase): """ self.old_up = False self.old_addrs: List[Tuple[str, Optional[str]]] = [] - localname = self.interface.localname + localname = self.iface.localname output = self.net_client.address_show(localname) for line in output.split("\n"): items = line.split() @@ -429,7 +408,7 @@ class Rj45Node(CoreNodeBase): :return: nothing :raises CoreCommandError: when there is a command exception """ - localname = self.interface.localname + localname = self.iface.localname logging.info("restoring rj45 state: %s", localname) for addr in self.old_addrs: self.net_client.create_address(localname, addr[0], addr[1]) @@ -446,7 +425,7 @@ class Rj45Node(CoreNodeBase): :return: True if position changed, False otherwise """ super().setposition(x, y, z) - self.interface.setposition() + self.iface.setposition() def termcmdstring(self, sh: str) -> str: raise CoreError("rj45 does not support terminal commands") diff --git a/daemon/core/services/bird.py b/daemon/core/services/bird.py index 4901ea56..16f0bb84 100644 --- a/daemon/core/services/bird.py +++ b/daemon/core/services/bird.py @@ -35,10 +35,8 @@ class Bird(CoreService): """ Helper to return the first IPv4 address of a node as its router ID. """ - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + for a in iface.addrlist: a = a.split("/")[0] if netaddr.valid_ipv4(a): return a @@ -84,7 +82,7 @@ protocol device { for s in node.services: if cls.name not in s.dependencies: continue - cfg += s.generatebirdconfig(node) + cfg += s.generate_bird_config(node) return cfg @@ -106,11 +104,11 @@ class BirdService(CoreService): meta = "The config file for this service can be found in the bird service." @classmethod - def generatebirdconfig(cls, node): + def generate_bird_config(cls, node): return "" @classmethod - def generatebirdifcconfig(cls, node): + def generate_bird_iface_config(cls, node): """ Use only bare interfaces descriptions in generated protocol configurations. This has the slight advantage of being the same @@ -118,10 +116,8 @@ class BirdService(CoreService): """ cfg = "" - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += ' interface "%s";\n' % ifc.name + for iface in node.get_ifaces(control=False): + cfg += ' interface "%s";\n' % iface.name return cfg @@ -135,7 +131,7 @@ class BirdBgp(BirdService): custom_needed = True @classmethod - def generatebirdconfig(cls, node): + def generate_bird_config(cls, node): return """ /* This is a sample config that should be customized with appropriate AS numbers * and peers; add one section like this for each neighbor */ @@ -165,7 +161,7 @@ class BirdOspf(BirdService): name = "BIRD_OSPFv2" @classmethod - def generatebirdconfig(cls, node): + def generate_bird_config(cls, node): cfg = "protocol ospf {\n" cfg += " export filter {\n" cfg += " if source = RTS_BGP then {\n" @@ -175,7 +171,7 @@ class BirdOspf(BirdService): cfg += " accept;\n" cfg += " };\n" cfg += " area 0.0.0.0 {\n" - cfg += cls.generatebirdifcconfig(node) + cfg += cls.generate_bird_iface_config(node) cfg += " };\n" cfg += "}\n\n" @@ -190,12 +186,12 @@ class BirdRadv(BirdService): name = "BIRD_RADV" @classmethod - def generatebirdconfig(cls, node): + def generate_bird_config(cls, node): cfg = "/* This is a sample config that must be customized */\n" cfg += "protocol radv {\n" cfg += " # auto configuration on all interfaces\n" - cfg += cls.generatebirdifcconfig(node) + cfg += cls.generate_bird_iface_config(node) cfg += " # Advertise DNS\n" cfg += " rdnss {\n" cfg += "# lifetime mult 10;\n" @@ -218,11 +214,11 @@ class BirdRip(BirdService): name = "BIRD_RIP" @classmethod - def generatebirdconfig(cls, node): + def generate_bird_config(cls, node): cfg = "protocol rip {\n" cfg += " period 10;\n" cfg += " garbage time 60;\n" - cfg += cls.generatebirdifcconfig(node) + cfg += cls.generate_bird_iface_config(node) cfg += " honor neighbor;\n" cfg += " authentication none;\n" cfg += " import all;\n" @@ -241,7 +237,7 @@ class BirdStatic(BirdService): custom_needed = True @classmethod - def generatebirdconfig(cls, node): + def generate_bird_config(cls, node): cfg = "/* This is a sample config that must be customized */\n" cfg += "protocol static {\n" cfg += "# route 0.0.0.0/0 via 198.51.100.130; # Default route. Do NOT advertise on BGP !\n" diff --git a/daemon/core/services/emaneservices.py b/daemon/core/services/emaneservices.py index 9d09516e..da438bab 100644 --- a/daemon/core/services/emaneservices.py +++ b/daemon/core/services/emaneservices.py @@ -20,14 +20,14 @@ class EmaneTransportService(CoreService): def generate_config(cls, node, filename): if filename == cls.configs[0]: transport_commands = [] - for interface in node.netifs(sort=True): + for iface in node.get_ifaces(): try: - network_node = node.session.get_node(interface.net.id, EmaneNet) + network_node = node.session.get_node(iface.net.id, EmaneNet) config = node.session.emane.get_configs( network_node.id, network_node.model.name ) if config and emanexml.is_external(config): - nem_id = network_node.getnemid(interface) + nem_id = network_node.getnemid(iface) command = ( "emanetransportd -r -l 0 -d ../transportdaemon%s.xml" % nem_id diff --git a/daemon/core/services/frr.py b/daemon/core/services/frr.py index 9a344339..97a8b334 100644 --- a/daemon/core/services/frr.py +++ b/daemon/core/services/frr.py @@ -59,12 +59,12 @@ class FRRZebra(CoreService): """ # we could verify here that filename == frr.conf cfg = "" - for ifc in node.netifs(): - cfg += "interface %s\n" % ifc.name + for iface in node.get_ifaces(): + cfg += "interface %s\n" % iface.name # include control interfaces in addressing but not routing daemons - if hasattr(ifc, "control") and ifc.control is True: + if hasattr(iface, "control") and iface.control is True: cfg += " " - cfg += "\n ".join(map(cls.addrstr, ifc.addrlist)) + cfg += "\n ".join(map(cls.addrstr, iface.addrlist)) cfg += "\n" continue cfgv4 = "" @@ -74,18 +74,18 @@ class FRRZebra(CoreService): for s in node.services: if cls.name not in s.dependencies: continue - ifccfg = s.generatefrrifcconfig(node, ifc) + iface_config = s.generate_frr_iface_config(node, iface) if s.ipv4_routing: want_ipv4 = True if s.ipv6_routing: want_ipv6 = True - cfgv6 += ifccfg + cfgv6 += iface_config else: - cfgv4 += ifccfg + cfgv4 += iface_config if want_ipv4: ipv4list = filter( - lambda x: netaddr.valid_ipv4(x.split("/")[0]), ifc.addrlist + lambda x: netaddr.valid_ipv4(x.split("/")[0]), iface.addrlist ) cfg += " " cfg += "\n ".join(map(cls.addrstr, ipv4list)) @@ -93,7 +93,7 @@ class FRRZebra(CoreService): cfg += cfgv4 if want_ipv6: ipv6list = filter( - lambda x: netaddr.valid_ipv6(x.split("/")[0]), ifc.addrlist + lambda x: netaddr.valid_ipv6(x.split("/")[0]), iface.addrlist ) cfg += " " cfg += "\n ".join(map(cls.addrstr, ipv6list)) @@ -104,7 +104,7 @@ class FRRZebra(CoreService): for s in node.services: if cls.name not in s.dependencies: continue - cfg += s.generatefrrconfig(node) + cfg += s.generate_frr_config(node) return cfg @staticmethod @@ -237,10 +237,10 @@ bootfrr frr_bin_search, constants.FRR_STATE_DIR, ) - for ifc in node.netifs(): - cfg += f"ip link set dev {ifc.name} down\n" + for iface in node.get_ifaces(): + cfg += f"ip link set dev {iface.name} down\n" cfg += "sleep 1\n" - cfg += f"ip link set dev {ifc.name} up\n" + cfg += f"ip link set dev {iface.name} up\n" return cfg @classmethod @@ -334,10 +334,8 @@ class FrrService(CoreService): """ Helper to return the first IPv4 address of a node as its router ID. """ - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + for a in iface.addrlist: a = a.split("/")[0] if netaddr.valid_ipv4(a): return a @@ -345,16 +343,16 @@ class FrrService(CoreService): return "0.0.0.0" @staticmethod - def rj45check(ifc): + def rj45check(iface): """ Helper to detect whether interface is connected an external RJ45 link. """ - if ifc.net: - for peerifc in ifc.net.netifs(): - if peerifc == ifc: + if iface.net: + for peer_iface in iface.net.get_ifaces(): + if peer_iface == iface: continue - if isinstance(peerifc.node, Rj45Node): + if isinstance(peer_iface.node, Rj45Node): return True return False @@ -363,11 +361,11 @@ class FrrService(CoreService): return "" @classmethod - def generatefrrifcconfig(cls, node, ifc): + def generate_frr_iface_config(cls, node, iface): return "" @classmethod - def generatefrrconfig(cls, node): + def generate_frr_config(cls, node): return "" @@ -385,43 +383,41 @@ class FRROspfv2(FrrService): ipv4_routing = True @staticmethod - def mtucheck(ifc): + def mtucheck(iface): """ Helper to detect MTU mismatch and add the appropriate OSPF mtu-ignore command. This is needed when e.g. a node is linked via a GreTap device. """ - if ifc.mtu != 1500: + if iface.mtu != 1500: # a workaround for PhysicalNode GreTap, which has no knowledge of # the other nodes/nets return " ip ospf mtu-ignore\n" - if not ifc.net: + if not iface.net: return "" - for i in ifc.net.netifs(): - if i.mtu != ifc.mtu: + for iface in iface.net.get_ifaces(): + if iface.mtu != iface.mtu: return " ip ospf mtu-ignore\n" return "" @staticmethod - def ptpcheck(ifc): + def ptpcheck(iface): """ Helper to detect whether interface is connected to a notional point-to-point link. """ - if isinstance(ifc.net, PtpNet): + if isinstance(iface.net, PtpNet): return " ip ospf network point-to-point\n" return "" @classmethod - def generatefrrconfig(cls, node): + def generate_frr_config(cls, node): cfg = "router ospf\n" rtrid = cls.routerid(node) cfg += " router-id %s\n" % rtrid # network 10.0.0.0/24 area 0 - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + for a in iface.addrlist: addr = a.split("/")[0] if not netaddr.valid_ipv4(addr): continue @@ -430,8 +426,8 @@ class FRROspfv2(FrrService): return cfg @classmethod - def generatefrrifcconfig(cls, node, ifc): - return cls.mtucheck(ifc) + def generate_frr_iface_config(cls, node, iface): + return cls.mtucheck(iface) class FRROspfv3(FrrService): @@ -449,57 +445,55 @@ class FRROspfv3(FrrService): ipv6_routing = True @staticmethod - def minmtu(ifc): + def minmtu(iface): """ Helper to discover the minimum MTU of interfaces linked with the given interface. """ - mtu = ifc.mtu - if not ifc.net: + mtu = iface.mtu + if not iface.net: return mtu - for i in ifc.net.netifs(): - if i.mtu < mtu: - mtu = i.mtu + for iface in iface.net.get_ifaces(): + if iface.mtu < mtu: + mtu = iface.mtu return mtu @classmethod - def mtucheck(cls, ifc): + def mtucheck(cls, iface): """ Helper to detect MTU mismatch and add the appropriate OSPFv3 ifmtu command. This is needed when e.g. a node is linked via a GreTap device. """ - minmtu = cls.minmtu(ifc) - if minmtu < ifc.mtu: + minmtu = cls.minmtu(iface) + if minmtu < iface.mtu: return " ipv6 ospf6 ifmtu %d\n" % minmtu else: return "" @staticmethod - def ptpcheck(ifc): + def ptpcheck(iface): """ Helper to detect whether interface is connected to a notional point-to-point link. """ - if isinstance(ifc.net, PtpNet): + if isinstance(iface.net, PtpNet): return " ipv6 ospf6 network point-to-point\n" return "" @classmethod - def generatefrrconfig(cls, node): + def generate_frr_config(cls, node): cfg = "router ospf6\n" rtrid = cls.routerid(node) cfg += " router-id %s\n" % rtrid - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += " interface %s area 0.0.0.0\n" % ifc.name + for iface in node.get_ifaces(control=False): + cfg += " interface %s area 0.0.0.0\n" % iface.name cfg += "!\n" return cfg @classmethod - def generatefrrifcconfig(cls, node, ifc): - return cls.mtucheck(ifc) + def generate_frr_iface_config(cls, node, iface): + return cls.mtucheck(iface) # cfg = cls.mtucheck(ifc) # external RJ45 connections will use default OSPF timers # if cls.rj45check(ifc): @@ -531,7 +525,7 @@ class FRRBgp(FrrService): ipv6_routing = True @classmethod - def generatefrrconfig(cls, node): + def generate_frr_config(cls, node): cfg = "!\n! BGP configuration\n!\n" cfg += "! You should configure the AS number below,\n" cfg += "! along with this router's peers.\n!\n" @@ -555,7 +549,7 @@ class FRRRip(FrrService): ipv4_routing = True @classmethod - def generatefrrconfig(cls, node): + def generate_frr_config(cls, node): cfg = """\ router rip redistribute static @@ -579,7 +573,7 @@ class FRRRipng(FrrService): ipv6_routing = True @classmethod - def generatefrrconfig(cls, node): + def generate_frr_config(cls, node): cfg = """\ router ripng redistribute static @@ -604,18 +598,16 @@ class FRRBabel(FrrService): ipv6_routing = True @classmethod - def generatefrrconfig(cls, node): + def generate_frr_config(cls, node): cfg = "router babel\n" - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += " network %s\n" % ifc.name + for iface in node.get_ifaces(control=False): + cfg += " network %s\n" % iface.name cfg += " redistribute static\n redistribute ipv4 connected\n" return cfg @classmethod - def generatefrrifcconfig(cls, node, ifc): - if ifc.net and isinstance(ifc.net, (EmaneNet, WlanNode)): + def generate_frr_iface_config(cls, node, iface): + if iface.net and isinstance(iface.net, (EmaneNet, WlanNode)): return " babel wireless\n no babel split-horizon\n" else: return " babel wired\n babel split-horizon\n" @@ -633,11 +625,11 @@ class FRRpimd(FrrService): ipv4_routing = True @classmethod - def generatefrrconfig(cls, node): + def generate_frr_config(cls, node): ifname = "eth0" - for ifc in node.netifs(): - if ifc.name != "lo": - ifname = ifc.name + for iface in node.get_ifaces(): + if iface.name != "lo": + ifname = iface.name break cfg = "router mfea\n!\n" cfg += "router igmp\n!\n" @@ -649,7 +641,7 @@ class FRRpimd(FrrService): return cfg @classmethod - def generatefrrifcconfig(cls, node, ifc): + def generate_frr_iface_config(cls, node, iface): return " ip mfea\n ip igmp\n ip pim\n" @@ -668,17 +660,17 @@ class FRRIsis(FrrService): ipv6_routing = True @staticmethod - def ptpcheck(ifc): + def ptpcheck(iface): """ Helper to detect whether interface is connected to a notional point-to-point link. """ - if isinstance(ifc.net, PtpNet): + if isinstance(iface.net, PtpNet): return " isis network point-to-point\n" return "" @classmethod - def generatefrrconfig(cls, node): + def generate_frr_config(cls, node): cfg = "router isis DEFAULT\n" cfg += " net 47.0001.0000.1900.%04x.00\n" % node.id cfg += " metric-style wide\n" @@ -687,9 +679,9 @@ class FRRIsis(FrrService): return cfg @classmethod - def generatefrrifcconfig(cls, node, ifc): + def generate_frr_iface_config(cls, node, iface): cfg = " ip router isis DEFAULT\n" cfg += " ipv6 router isis DEFAULT\n" cfg += " isis circuit-type level-2-only\n" - cfg += cls.ptpcheck(ifc) + cfg += cls.ptpcheck(iface) return cfg diff --git a/daemon/core/services/nrl.py b/daemon/core/services/nrl.py index 3c9f262d..38b90d48 100644 --- a/daemon/core/services/nrl.py +++ b/daemon/core/services/nrl.py @@ -32,10 +32,8 @@ class NrlService(CoreService): prefix of a node, using the supplied prefix length. This ignores the interface's prefix length, so e.g. '/32' can turn into '/24'. """ - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + for a in iface.addrlist: a = a.split("/")[0] if netaddr.valid_ipv4(a): return f"{a}/{prefixlen}" @@ -54,8 +52,8 @@ class MgenSinkService(NrlService): @classmethod def generate_config(cls, node, filename): cfg = "0.0 LISTEN UDP 5000\n" - for ifc in node.netifs(): - name = utils.sysctl_devname(ifc.name) + for iface in node.get_ifaces(): + name = utils.sysctl_devname(iface.name) cfg += "0.0 Join 224.225.1.2 INTERFACE %s\n" % name return cfg @@ -91,11 +89,11 @@ class NrlNhdp(NrlService): cmd += " -flooding ecds" cmd += " -smfClient %s_smf" % node.name - netifs = list(filter(lambda x: not getattr(x, "control", False), node.netifs())) - if len(netifs) > 0: - interfacenames = map(lambda x: x.name, netifs) + ifaces = node.get_ifaces(control=False) + if len(ifaces) > 0: + iface_names = map(lambda x: x.name, ifaces) cmd += " -i " - cmd += " -i ".join(interfacenames) + cmd += " -i ".join(iface_names) return (cmd,) @@ -125,16 +123,16 @@ class NrlSmf(NrlService): cmd = "nrlsmf instance %s_smf" % node.name servicenames = map(lambda x: x.name, node.services) - netifs = list(filter(lambda x: not getattr(x, "control", False), node.netifs())) - if len(netifs) == 0: + ifaces = node.get_ifaces(control=False) + if len(ifaces) == 0: return "" if "arouted" in servicenames: comments += "# arouted service is enabled\n" cmd += " tap %s_tap" % (node.name,) cmd += " unicast %s" % cls.firstipv4prefix(node, 24) - cmd += " push lo,%s resequence on" % netifs[0].name - if len(netifs) > 0: + cmd += " push lo,%s resequence on" % ifaces[0].name + if len(ifaces) > 0: if "NHDP" in servicenames: comments += "# NHDP service is enabled\n" cmd += " ecds " @@ -143,8 +141,8 @@ class NrlSmf(NrlService): cmd += " smpr " else: cmd += " cf " - interfacenames = map(lambda x: x.name, netifs) - cmd += ",".join(interfacenames) + iface_names = map(lambda x: x.name, ifaces) + cmd += ",".join(iface_names) cmd += " hash MD5" cmd += " log /var/log/nrlsmf.log" @@ -171,10 +169,10 @@ class NrlOlsr(NrlService): """ cmd = cls.startup[0] # are multiple interfaces supported? No. - netifs = list(node.netifs()) - if len(netifs) > 0: - ifc = netifs[0] - cmd += " -i %s" % ifc.name + ifaces = node.get_ifaces() + if len(ifaces) > 0: + iface = ifaces[0] + cmd += " -i %s" % iface.name cmd += " -l /var/log/nrlolsrd.log" cmd += " -rpipe %s_olsr" % node.name @@ -215,11 +213,11 @@ class NrlOlsrv2(NrlService): cmd += " -p olsr" - netifs = list(filter(lambda x: not getattr(x, "control", False), node.netifs())) - if len(netifs) > 0: - interfacenames = map(lambda x: x.name, netifs) + ifaces = node.get_ifaces(control=False) + if len(ifaces) > 0: + iface_names = map(lambda x: x.name, ifaces) cmd += " -i " - cmd += " -i ".join(interfacenames) + cmd += " -i ".join(iface_names) return (cmd,) @@ -243,11 +241,11 @@ class OlsrOrg(NrlService): Generate the appropriate command-line based on node interfaces. """ cmd = cls.startup[0] - netifs = list(filter(lambda x: not getattr(x, "control", False), node.netifs())) - if len(netifs) > 0: - interfacenames = map(lambda x: x.name, netifs) + ifaces = node.get_ifaces(control=False) + if len(ifaces) > 0: + iface_names = map(lambda x: x.name, ifaces) cmd += " -i " - cmd += " -i ".join(interfacenames) + cmd += " -i ".join(iface_names) return (cmd,) @@ -607,8 +605,8 @@ class MgenActor(NrlService): comments = "" cmd = "mgenBasicActor.py -n %s -a 0.0.0.0" % node.name - netifs = [x for x in node.netifs() if not getattr(x, "control", False)] - if len(netifs) == 0: + ifaces = node.get_ifaces(control=False) + if len(ifaces) == 0: return "" cfg += comments + cmd + " < /dev/null > /dev/null 2>&1 &\n\n" diff --git a/daemon/core/services/quagga.py b/daemon/core/services/quagga.py index a62cbc5c..41cfa3d8 100644 --- a/daemon/core/services/quagga.py +++ b/daemon/core/services/quagga.py @@ -56,12 +56,12 @@ class Zebra(CoreService): """ # we could verify here that filename == Quagga.conf cfg = "" - for ifc in node.netifs(): - cfg += "interface %s\n" % ifc.name + for iface in node.get_ifaces(): + cfg += "interface %s\n" % iface.name # include control interfaces in addressing but not routing daemons - if hasattr(ifc, "control") and ifc.control is True: + if hasattr(iface, "control") and iface.control is True: cfg += " " - cfg += "\n ".join(map(cls.addrstr, ifc.addrlist)) + cfg += "\n ".join(map(cls.addrstr, iface.addrlist)) cfg += "\n" continue cfgv4 = "" @@ -71,18 +71,18 @@ class Zebra(CoreService): for s in node.services: if cls.name not in s.dependencies: continue - ifccfg = s.generatequaggaifcconfig(node, ifc) + iface_config = s.generate_quagga_iface_config(node, iface) if s.ipv4_routing: want_ipv4 = True if s.ipv6_routing: want_ipv6 = True - cfgv6 += ifccfg + cfgv6 += iface_config else: - cfgv4 += ifccfg + cfgv4 += iface_config if want_ipv4: ipv4list = filter( - lambda x: netaddr.valid_ipv4(x.split("/")[0]), ifc.addrlist + lambda x: netaddr.valid_ipv4(x.split("/")[0]), iface.addrlist ) cfg += " " cfg += "\n ".join(map(cls.addrstr, ipv4list)) @@ -90,7 +90,7 @@ class Zebra(CoreService): cfg += cfgv4 if want_ipv6: ipv6list = filter( - lambda x: netaddr.valid_ipv6(x.split("/")[0]), ifc.addrlist + lambda x: netaddr.valid_ipv6(x.split("/")[0]), iface.addrlist ) cfg += " " cfg += "\n ".join(map(cls.addrstr, ipv6list)) @@ -101,7 +101,7 @@ class Zebra(CoreService): for s in node.services: if cls.name not in s.dependencies: continue - cfg += s.generatequaggaconfig(node) + cfg += s.generate_quagga_config(node) return cfg @staticmethod @@ -252,10 +252,8 @@ class QuaggaService(CoreService): """ Helper to return the first IPv4 address of a node as its router ID. """ - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + for a in iface.addrlist: a = a.split("/")[0] if netaddr.valid_ipv4(a): return a @@ -263,16 +261,16 @@ class QuaggaService(CoreService): return "0.0.0.%d" % node.id @staticmethod - def rj45check(ifc): + def rj45check(iface): """ Helper to detect whether interface is connected an external RJ45 link. """ - if ifc.net: - for peerifc in ifc.net.netifs(): - if peerifc == ifc: + if iface.net: + for peer_iface in iface.net.get_ifaces(): + if peer_iface == iface: continue - if isinstance(peerifc.node, Rj45Node): + if isinstance(peer_iface.node, Rj45Node): return True return False @@ -281,11 +279,11 @@ class QuaggaService(CoreService): return "" @classmethod - def generatequaggaifcconfig(cls, node, ifc): + def generate_quagga_iface_config(cls, node, iface): return "" @classmethod - def generatequaggaconfig(cls, node): + def generate_quagga_config(cls, node): return "" @@ -303,43 +301,41 @@ class Ospfv2(QuaggaService): ipv4_routing = True @staticmethod - def mtucheck(ifc): + def mtucheck(iface): """ Helper to detect MTU mismatch and add the appropriate OSPF mtu-ignore command. This is needed when e.g. a node is linked via a GreTap device. """ - if ifc.mtu != 1500: + if iface.mtu != 1500: # a workaround for PhysicalNode GreTap, which has no knowledge of # the other nodes/nets return " ip ospf mtu-ignore\n" - if not ifc.net: + if not iface.net: return "" - for i in ifc.net.netifs(): - if i.mtu != ifc.mtu: + for iface in iface.net.get_ifaces(): + if iface.mtu != iface.mtu: return " ip ospf mtu-ignore\n" return "" @staticmethod - def ptpcheck(ifc): + def ptpcheck(iface): """ Helper to detect whether interface is connected to a notional point-to-point link. """ - if isinstance(ifc.net, PtpNet): + if isinstance(iface.net, PtpNet): return " ip ospf network point-to-point\n" return "" @classmethod - def generatequaggaconfig(cls, node): + def generate_quagga_config(cls, node): cfg = "router ospf\n" rtrid = cls.routerid(node) cfg += " router-id %s\n" % rtrid # network 10.0.0.0/24 area 0 - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + for a in iface.addrlist: addr = a.split("/")[0] if netaddr.valid_ipv4(addr): cfg += " network %s area 0\n" % a @@ -347,12 +343,12 @@ class Ospfv2(QuaggaService): return cfg @classmethod - def generatequaggaifcconfig(cls, node, ifc): - cfg = cls.mtucheck(ifc) + def generate_quagga_iface_config(cls, node, iface): + cfg = cls.mtucheck(iface) # external RJ45 connections will use default OSPF timers - if cls.rj45check(ifc): + if cls.rj45check(iface): return cfg - cfg += cls.ptpcheck(ifc) + cfg += cls.ptpcheck(iface) return ( cfg + """\ @@ -378,58 +374,56 @@ class Ospfv3(QuaggaService): ipv6_routing = True @staticmethod - def minmtu(ifc): + def minmtu(iface): """ Helper to discover the minimum MTU of interfaces linked with the given interface. """ - mtu = ifc.mtu - if not ifc.net: + mtu = iface.mtu + if not iface.net: return mtu - for i in ifc.net.netifs(): - if i.mtu < mtu: - mtu = i.mtu + for iface in iface.net.get_ifaces(): + if iface.mtu < mtu: + mtu = iface.mtu return mtu @classmethod - def mtucheck(cls, ifc): + def mtucheck(cls, iface): """ Helper to detect MTU mismatch and add the appropriate OSPFv3 ifmtu command. This is needed when e.g. a node is linked via a GreTap device. """ - minmtu = cls.minmtu(ifc) - if minmtu < ifc.mtu: + minmtu = cls.minmtu(iface) + if minmtu < iface.mtu: return " ipv6 ospf6 ifmtu %d\n" % minmtu else: return "" @staticmethod - def ptpcheck(ifc): + def ptpcheck(iface): """ Helper to detect whether interface is connected to a notional point-to-point link. """ - if isinstance(ifc.net, PtpNet): + if isinstance(iface.net, PtpNet): return " ipv6 ospf6 network point-to-point\n" return "" @classmethod - def generatequaggaconfig(cls, node): + def generate_quagga_config(cls, node): cfg = "router ospf6\n" rtrid = cls.routerid(node) cfg += " instance-id 65\n" cfg += " router-id %s\n" % rtrid - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += " interface %s area 0.0.0.0\n" % ifc.name + for iface in node.get_ifaces(control=False): + cfg += " interface %s area 0.0.0.0\n" % iface.name cfg += "!\n" return cfg @classmethod - def generatequaggaifcconfig(cls, node, ifc): - return cls.mtucheck(ifc) + def generate_quagga_iface_config(cls, node, iface): + return cls.mtucheck(iface) class Ospfv3mdr(Ospfv3): @@ -444,9 +438,9 @@ class Ospfv3mdr(Ospfv3): ipv4_routing = True @classmethod - def generatequaggaifcconfig(cls, node, ifc): - cfg = cls.mtucheck(ifc) - if ifc.net is not None and isinstance(ifc.net, (WlanNode, EmaneNet)): + def generate_quagga_iface_config(cls, node, iface): + cfg = cls.mtucheck(iface) + if iface.net is not None and isinstance(iface.net, (WlanNode, EmaneNet)): return ( cfg + """\ @@ -479,7 +473,7 @@ class Bgp(QuaggaService): ipv6_routing = True @classmethod - def generatequaggaconfig(cls, node): + def generate_quagga_config(cls, node): cfg = "!\n! BGP configuration\n!\n" cfg += "! You should configure the AS number below,\n" cfg += "! along with this router's peers.\n!\n" @@ -503,7 +497,7 @@ class Rip(QuaggaService): ipv4_routing = True @classmethod - def generatequaggaconfig(cls, node): + def generate_quagga_config(cls, node): cfg = """\ router rip redistribute static @@ -527,7 +521,7 @@ class Ripng(QuaggaService): ipv6_routing = True @classmethod - def generatequaggaconfig(cls, node): + def generate_quagga_config(cls, node): cfg = """\ router ripng redistribute static @@ -552,18 +546,16 @@ class Babel(QuaggaService): ipv6_routing = True @classmethod - def generatequaggaconfig(cls, node): + def generate_quagga_config(cls, node): cfg = "router babel\n" - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += " network %s\n" % ifc.name + for iface in node.get_ifaces(control=False): + cfg += " network %s\n" % iface.name cfg += " redistribute static\n redistribute connected\n" return cfg @classmethod - def generatequaggaifcconfig(cls, node, ifc): - if ifc.net and ifc.net.linktype == LinkTypes.WIRELESS: + def generate_quagga_iface_config(cls, node, iface): + if iface.net and iface.net.linktype == LinkTypes.WIRELESS: return " babel wireless\n no babel split-horizon\n" else: return " babel wired\n babel split-horizon\n" @@ -581,11 +573,11 @@ class Xpimd(QuaggaService): ipv4_routing = True @classmethod - def generatequaggaconfig(cls, node): + def generate_quagga_config(cls, node): ifname = "eth0" - for ifc in node.netifs(): - if ifc.name != "lo": - ifname = ifc.name + for iface in node.get_ifaces(): + if iface.name != "lo": + ifname = iface.name break cfg = "router mfea\n!\n" cfg += "router igmp\n!\n" @@ -597,5 +589,5 @@ class Xpimd(QuaggaService): return cfg @classmethod - def generatequaggaifcconfig(cls, node, ifc): + def generate_quagga_iface_config(cls, node, iface): return " ip mfea\n ip igmp\n ip pim\n" diff --git a/daemon/core/services/sdn.py b/daemon/core/services/sdn.py index ab46f551..71ab815f 100644 --- a/daemon/core/services/sdn.py +++ b/daemon/core/services/sdn.py @@ -49,10 +49,8 @@ class OvsService(SdnService): cfg += "\n## Now add all our interfaces as ports to the switch\n" portnum = 1 - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - ifnumstr = re.findall(r"\d+", ifc.name) + for iface in node.get_ifaces(control=False): + ifnumstr = re.findall(r"\d+", iface.name) ifnum = ifnumstr[0] # create virtual interfaces @@ -61,18 +59,18 @@ class OvsService(SdnService): # remove ip address of eths because quagga/zebra will assign same IPs to rtr interfaces # or assign them manually to rtr interfaces if zebra is not running - for ifcaddr in ifc.addrlist: - addr = ifcaddr.split("/")[0] + for addr in iface.addrlist: + addr = addr.split("/")[0] if netaddr.valid_ipv4(addr): - cfg += "ip addr del %s dev %s\n" % (ifcaddr, ifc.name) + cfg += "ip addr del %s dev %s\n" % (addr, iface.name) if has_zebra == 0: - cfg += "ip addr add %s dev rtr%s\n" % (ifcaddr, ifnum) + cfg += "ip addr add %s dev rtr%s\n" % (addr, ifnum) elif netaddr.valid_ipv6(addr): - cfg += "ip -6 addr del %s dev %s\n" % (ifcaddr, ifc.name) + cfg += "ip -6 addr del %s dev %s\n" % (addr, iface.name) if has_zebra == 0: - cfg += "ip -6 addr add %s dev rtr%s\n" % (ifcaddr, ifnum) + cfg += "ip -6 addr add %s dev rtr%s\n" % (addr, ifnum) else: - raise ValueError("invalid address: %s" % ifcaddr) + raise ValueError("invalid address: %s" % addr) # add interfaces to bridge # Make port numbers explicit so they're easier to follow in reading the script @@ -102,9 +100,7 @@ class OvsService(SdnService): cfg += "## if the above controller will be present then you probably want to delete them\n" # Setup default flows portnum = 1 - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue + for iface in node.get_ifaces(control=False): cfg += "## Take the data from the CORE interface and put it on the veth and vice versa\n" cfg += ( "ovs-ofctl add-flow ovsbr0 priority=1000,in_port=%d,action=output:%d\n" diff --git a/daemon/core/services/security.py b/daemon/core/services/security.py index eb6545b2..91c942f1 100644 --- a/daemon/core/services/security.py +++ b/daemon/core/services/security.py @@ -131,18 +131,18 @@ class Nat(CoreService): custom_needed = False @classmethod - def generateifcnatrule(cls, ifc, line_prefix=""): + def generate_iface_nat_rule(cls, iface, line_prefix=""): """ Generate a NAT line for one interface. """ cfg = line_prefix + "iptables -t nat -A POSTROUTING -o " - cfg += ifc.name + " -j MASQUERADE\n" + cfg += iface.name + " -j MASQUERADE\n" - cfg += line_prefix + "iptables -A FORWARD -i " + ifc.name + cfg += line_prefix + "iptables -A FORWARD -i " + iface.name cfg += " -m state --state RELATED,ESTABLISHED -j ACCEPT\n" cfg += line_prefix + "iptables -A FORWARD -i " - cfg += ifc.name + " -j DROP\n" + cfg += iface.name + " -j DROP\n" return cfg @classmethod @@ -154,14 +154,12 @@ class Nat(CoreService): cfg += "# generated by security.py\n" cfg += "# NAT out the first interface by default\n" have_nat = False - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue + for iface in node.get_ifaces(control=False): if have_nat: - cfg += cls.generateifcnatrule(ifc, line_prefix="#") + cfg += cls.generate_iface_nat_rule(iface, line_prefix="#") else: have_nat = True - cfg += "# NAT out the " + ifc.name + " interface\n" - cfg += cls.generateifcnatrule(ifc) + cfg += "# NAT out the " + iface.name + " interface\n" + cfg += cls.generate_iface_nat_rule(iface) cfg += "\n" return cfg diff --git a/daemon/core/services/utility.py b/daemon/core/services/utility.py index 8a6e828b..273318e1 100644 --- a/daemon/core/services/utility.py +++ b/daemon/core/services/utility.py @@ -55,8 +55,8 @@ class IPForwardService(UtilService): """ % { "sysctl": constants.SYSCTL_BIN } - for ifc in node.netifs(): - name = utils.sysctl_devname(ifc.name) + for iface in node.get_ifaces(): + name = utils.sysctl_devname(iface.name) cfg += "%s -w net.ipv4.conf.%s.forwarding=1\n" % ( constants.SYSCTL_BIN, name, @@ -77,10 +77,10 @@ class DefaultRouteService(UtilService): @classmethod def generate_config(cls, node, filename): routes = [] - netifs = node.netifs(sort=True) - if netifs: - netif = netifs[0] - for x in netif.addrlist: + ifaces = node.get_ifaces() + if ifaces: + iface = ifaces[0] + for x in iface.addrlist: net = netaddr.IPNetwork(x).cidr if net.size > 1: router = net[1] @@ -104,14 +104,12 @@ class DefaultMulticastRouteService(UtilService): cfg += "# the first interface is chosen below; please change it " cfg += "as needed\n" - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue + for iface in node.get_ifaces(control=False): if os.uname()[0] == "Linux": rtcmd = "ip route add 224.0.0.0/4 dev" else: raise Exception("unknown platform") - cfg += "%s %s\n" % (rtcmd, ifc.name) + cfg += "%s %s\n" % (rtcmd, iface.name) cfg += "\n" break return cfg @@ -129,10 +127,8 @@ class StaticRouteService(UtilService): cfg += "# auto-generated by StaticRoute service (utility.py)\n#\n" cfg += "# NOTE: this service must be customized to be of any use\n" cfg += "# Below are samples that you can uncomment and edit.\n#\n" - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += "\n".join(map(cls.routestr, ifc.addrlist)) + for iface in node.get_ifaces(control=False): + cfg += "\n".join(map(cls.routestr, iface.addrlist)) cfg += "\n" return cfg @@ -259,10 +255,8 @@ max-lease-time 7200; ddns-update-style none; """ - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += "\n".join(map(cls.subnetentry, ifc.addrlist)) + for iface in node.get_ifaces(control=False): + cfg += "\n".join(map(cls.subnetentry, iface.addrlist)) cfg += "\n" return cfg @@ -320,13 +314,11 @@ class DhcpClientService(UtilService): cfg += "side DNS\n# resolution based on the DHCP server response.\n" cfg += "#mkdir -p /var/run/resolvconf/interface\n" - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += "#ln -s /var/run/resolvconf/interface/%s.dhclient" % ifc.name + for iface in node.get_ifaces(control=False): + cfg += "#ln -s /var/run/resolvconf/interface/%s.dhclient" % iface.name cfg += " /var/run/resolvconf/resolv.conf\n" - cfg += "/sbin/dhclient -nw -pf /var/run/dhclient-%s.pid" % ifc.name - cfg += " -lf /var/run/dhclient-%s.lease %s\n" % (ifc.name, ifc.name) + cfg += "/sbin/dhclient -nw -pf /var/run/dhclient-%s.pid" % iface.name + cfg += " -lf /var/run/dhclient-%s.lease %s\n" % (iface.name, iface.name) return cfg @@ -585,10 +577,8 @@ export LANG """ % node.name ) - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - body += "
  • %s - %s
  • \n" % (ifc.name, ifc.addrlist) + for iface in node.get_ifaces(control=False): + body += "
  • %s - %s
  • \n" % (iface.name, iface.addrlist) return "%s" % body @@ -619,14 +609,14 @@ DUMPOPTS="-s 12288 -C 10 -n" if [ "x$1" = "xstart" ]; then """ - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: + for iface in node.get_ifaces(): + if hasattr(iface, "control") and iface.control is True: cfg += "# " redir = "< /dev/null" cfg += "tcpdump ${DUMPOPTS} -w %s.%s.pcap -i %s %s &\n" % ( node.name, - ifc.name, - ifc.name, + iface.name, + iface.name, redir, ) cfg += """ @@ -654,10 +644,8 @@ class RadvdService(UtilService): using the network address of each interface. """ cfg = "# auto-generated by RADVD service (utility.py)\n" - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - prefixes = list(map(cls.subnetentry, ifc.addrlist)) + for iface in node.get_ifaces(control=False): + prefixes = list(map(cls.subnetentry, iface.addrlist)) if len(prefixes) < 1: continue cfg += ( @@ -670,7 +658,7 @@ interface %s AdvDefaultPreference low; AdvHomeAgentFlag off; """ - % ifc.name + % iface.name ) for prefix in prefixes: if prefix == "": diff --git a/daemon/core/services/xorp.py b/daemon/core/services/xorp.py index 2312e6d4..3dfef56a 100644 --- a/daemon/core/services/xorp.py +++ b/daemon/core/services/xorp.py @@ -35,11 +35,11 @@ class XorpRtrmgr(CoreService): invoked here. Filename currently ignored. """ cfg = "interfaces {\n" - for ifc in node.netifs(): - cfg += " interface %s {\n" % ifc.name - cfg += "\tvif %s {\n" % ifc.name - cfg += "".join(map(cls.addrstr, ifc.addrlist)) - cfg += cls.lladdrstr(ifc) + for iface in node.get_ifaces(): + cfg += " interface %s {\n" % iface.name + cfg += "\tvif %s {\n" % iface.name + cfg += "".join(map(cls.addrstr, iface.addrlist)) + cfg += cls.lladdrstr(iface) cfg += "\t}\n" cfg += " }\n" cfg += "}\n\n" @@ -65,11 +65,11 @@ class XorpRtrmgr(CoreService): return cfg @staticmethod - def lladdrstr(ifc): + def lladdrstr(iface): """ helper for adding link-local address entries (required by OSPFv3) """ - cfg = "\t address %s {\n" % ifc.hwaddr.tolinklocal() + cfg = "\t address %s {\n" % iface.hwaddr.tolinklocal() cfg += "\t\tprefix-length: 64\n" cfg += "\t }\n" return cfg @@ -104,15 +104,15 @@ class XorpService(CoreService): return cfg @staticmethod - def mfea(forwarding, ifcs): + def mfea(forwarding, ifaces): """ Helper to add a multicast forwarding engine entry to the config file. """ names = [] - for ifc in ifcs: - if hasattr(ifc, "control") and ifc.control is True: + for iface in ifaces: + if hasattr(iface, "control") and iface.control is True: continue - names.append(ifc.name) + names.append(iface.name) names.append("register_vif") cfg = "plumbing {\n" @@ -148,10 +148,8 @@ class XorpService(CoreService): """ Helper to return the first IPv4 address of a node as its router ID. """ - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + for a in iface.addrlist: a = a.split("/")[0] if netaddr.valid_ipv4(a): return a @@ -184,12 +182,10 @@ class XorpOspfv2(XorpService): cfg += " ospf4 {\n" cfg += "\trouter-id: %s\n" % rtrid cfg += "\tarea 0.0.0.0 {\n" - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += "\t interface %s {\n" % ifc.name - cfg += "\t\tvif %s {\n" % ifc.name - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + cfg += "\t interface %s {\n" % iface.name + cfg += "\t\tvif %s {\n" % iface.name + for a in iface.addrlist: addr = a.split("/")[0] if not netaddr.valid_ipv4(addr): continue @@ -220,11 +216,9 @@ class XorpOspfv3(XorpService): cfg += " ospf6 0 { /* Instance ID 0 */\n" cfg += "\trouter-id: %s\n" % rtrid cfg += "\tarea 0.0.0.0 {\n" - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += "\t interface %s {\n" % ifc.name - cfg += "\t\tvif %s {\n" % ifc.name + for iface in node.get_ifaces(control=False): + cfg += "\t interface %s {\n" % iface.name + cfg += "\t\tvif %s {\n" % iface.name cfg += "\t\t}\n" cfg += "\t }\n" cfg += "\t}\n" @@ -277,12 +271,10 @@ class XorpRip(XorpService): cfg += "\nprotocols {\n" cfg += " rip {\n" cfg += '\texport: "export-connected"\n' - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += "\tinterface %s {\n" % ifc.name - cfg += "\t vif %s {\n" % ifc.name - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + cfg += "\tinterface %s {\n" % iface.name + cfg += "\t vif %s {\n" % iface.name + for a in iface.addrlist: addr = a.split("/")[0] if not netaddr.valid_ipv4(addr): continue @@ -310,12 +302,10 @@ class XorpRipng(XorpService): cfg += "\nprotocols {\n" cfg += " ripng {\n" cfg += '\texport: "export-connected"\n' - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += "\tinterface %s {\n" % ifc.name - cfg += "\t vif %s {\n" % ifc.name - cfg += "\t\taddress %s {\n" % ifc.hwaddr.tolinklocal() + for iface in node.get_ifaces(control=False): + cfg += "\tinterface %s {\n" % iface.name + cfg += "\t vif %s {\n" % iface.name + cfg += "\t\taddress %s {\n" % iface.hwaddr.tolinklocal() cfg += "\t\t disable: false\n" cfg += "\t\t}\n" cfg += "\t }\n" @@ -334,17 +324,15 @@ class XorpPimSm4(XorpService): @classmethod def generatexorpconfig(cls, node): - cfg = cls.mfea("mfea4", node.netifs()) + cfg = cls.mfea("mfea4", node.get_ifaces()) cfg += "\nprotocols {\n" cfg += " igmp {\n" names = [] - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - names.append(ifc.name) - cfg += "\tinterface %s {\n" % ifc.name - cfg += "\t vif %s {\n" % ifc.name + for iface in node.get_ifaces(control=False): + names.append(iface.name) + cfg += "\tinterface %s {\n" % iface.name + cfg += "\t vif %s {\n" % iface.name cfg += "\t\tdisable: false\n" cfg += "\t }\n" cfg += "\t}\n" @@ -394,17 +382,15 @@ class XorpPimSm6(XorpService): @classmethod def generatexorpconfig(cls, node): - cfg = cls.mfea("mfea6", node.netifs()) + cfg = cls.mfea("mfea6", node.get_ifaces()) cfg += "\nprotocols {\n" cfg += " mld {\n" names = [] - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - names.append(ifc.name) - cfg += "\tinterface %s {\n" % ifc.name - cfg += "\t vif %s {\n" % ifc.name + for iface in node.get_ifaces(control=False): + names.append(iface.name) + cfg += "\tinterface %s {\n" % iface.name + cfg += "\t vif %s {\n" % iface.name cfg += "\t\tdisable: false\n" cfg += "\t }\n" cfg += "\t}\n" @@ -459,12 +445,10 @@ class XorpOlsr(XorpService): cfg += "\nprotocols {\n" cfg += " olsr4 {\n" cfg += "\tmain-address: %s\n" % rtrid - for ifc in node.netifs(): - if hasattr(ifc, "control") and ifc.control is True: - continue - cfg += "\tinterface %s {\n" % ifc.name - cfg += "\t vif %s {\n" % ifc.name - for a in ifc.addrlist: + for iface in node.get_ifaces(control=False): + cfg += "\tinterface %s {\n" % iface.name + cfg += "\t vif %s {\n" % iface.name + for a in iface.addrlist: addr = a.split("/")[0] if not netaddr.valid_ipv4(addr): continue diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 759de680..fe596d7a 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -58,16 +58,16 @@ def add_attribute(element: etree.Element, name: str, value: Any) -> None: element.set(name, str(value)) -def create_interface_data(interface_element: etree.Element) -> InterfaceData: - interface_id = int(interface_element.get("id")) - name = interface_element.get("name") - mac = interface_element.get("mac") - ip4 = interface_element.get("ip4") - ip4_mask = get_int(interface_element, "ip4_mask") - ip6 = interface_element.get("ip6") - ip6_mask = get_int(interface_element, "ip6_mask") +def create_iface_data(iface_element: etree.Element) -> InterfaceData: + iface_id = int(iface_element.get("id")) + name = iface_element.get("name") + mac = iface_element.get("mac") + ip4 = iface_element.get("ip4") + ip4_mask = get_int(iface_element, "ip4_mask") + ip6 = iface_element.get("ip6") + ip6_mask = get_int(iface_element, "ip6_mask") return InterfaceData( - id=interface_id, + id=iface_id, name=name, mac=mac, ip4=ip4, @@ -482,7 +482,7 @@ class CoreXmlWriter: # add link data for link_data in links: # skip basic range links - if link_data.interface1_id is None and link_data.interface2_id is None: + if link_data.iface1_id is None and link_data.iface2_id is None: continue link_element = self.create_link_element(link_data) @@ -495,37 +495,37 @@ class CoreXmlWriter: device = DeviceElement(self.session, node) self.devices.append(device.element) - def create_interface_element( + def create_iface_element( self, element_name: str, node_id: int, - interface_id: int, + iface_id: int, mac: str, ip4: str, ip4_mask: int, ip6: str, ip6_mask: int, ) -> etree.Element: - interface = etree.Element(element_name) + iface = etree.Element(element_name) node = self.session.get_node(node_id, NodeBase) - interface_name = None + iface_name = None if isinstance(node, CoreNodeBase): - node_interface = node.netif(interface_id) - interface_name = node_interface.name + node_iface = node.get_iface(iface_id) + iface_name = node_iface.name # check if emane interface - if isinstance(node_interface.net, EmaneNet): - nem = node_interface.net.getnemid(node_interface) - add_attribute(interface, "nem", nem) + if isinstance(node_iface.net, EmaneNet): + nem = node_iface.net.getnemid(node_iface) + add_attribute(iface, "nem", nem) - add_attribute(interface, "id", interface_id) - add_attribute(interface, "name", interface_name) - add_attribute(interface, "mac", mac) - add_attribute(interface, "ip4", ip4) - add_attribute(interface, "ip4_mask", ip4_mask) - add_attribute(interface, "ip6", ip6) - add_attribute(interface, "ip6_mask", ip6_mask) - return interface + add_attribute(iface, "id", iface_id) + add_attribute(iface, "name", iface_name) + add_attribute(iface, "mac", mac) + add_attribute(iface, "ip4", ip4) + add_attribute(iface, "ip4_mask", ip4_mask) + add_attribute(iface, "ip6", ip6) + add_attribute(iface, "ip6_mask", ip6_mask) + return iface def create_link_element(self, link_data: LinkData) -> etree.Element: link_element = etree.Element("link") @@ -533,32 +533,32 @@ class CoreXmlWriter: add_attribute(link_element, "node2", link_data.node2_id) # check for interface one - if link_data.interface1_id is not None: - interface1 = self.create_interface_element( + if link_data.iface1_id is not None: + iface1 = self.create_iface_element( "interface1", link_data.node1_id, - link_data.interface1_id, - link_data.interface1_mac, - link_data.interface1_ip4, - link_data.interface1_ip4_mask, - link_data.interface1_ip6, - link_data.interface1_ip6_mask, + link_data.iface1_id, + link_data.iface1_mac, + link_data.iface1_ip4, + link_data.iface1_ip4_mask, + link_data.iface1_ip6, + link_data.iface1_ip6_mask, ) - link_element.append(interface1) + link_element.append(iface1) # check for interface two - if link_data.interface2_id is not None: - interface2 = self.create_interface_element( + if link_data.iface2_id is not None: + iface2 = self.create_iface_element( "interface2", link_data.node2_id, - link_data.interface2_id, - link_data.interface2_mac, - link_data.interface2_ip4, - link_data.interface2_ip4_mask, - link_data.interface2_ip6, - link_data.interface2_ip6_mask, + link_data.iface2_id, + link_data.iface2_mac, + link_data.iface2_ip4, + link_data.iface2_ip4_mask, + link_data.iface2_ip6, + link_data.iface2_ip6_mask, ) - link_element.append(interface2) + link_element.append(iface2) # check for options, don't write for emane/wlan links node1 = self.session.get_node(link_data.node1_id, NodeBase) @@ -940,19 +940,19 @@ class CoreXmlReader: node2_id = get_int(link_element, "node_two") node_set = frozenset((node1_id, node2_id)) - interface1_element = link_element.find("interface1") - if interface1_element is None: - interface1_element = link_element.find("interface_one") - interface1_data = None - if interface1_element is not None: - interface1_data = create_interface_data(interface1_element) + iface1_element = link_element.find("interface1") + if iface1_element is None: + iface1_element = link_element.find("interface_one") + iface1_data = None + if iface1_element is not None: + iface1_data = create_iface_data(iface1_element) - interface2_element = link_element.find("interface2") - if interface2_element is None: - interface2_element = link_element.find("interface_two") - interface2_data = None - if interface2_element is not None: - interface2_data = create_interface_data(interface2_element) + iface2_element = link_element.find("interface2") + if iface2_element is None: + iface2_element = link_element.find("interface_two") + iface2_data = None + if iface2_element is not None: + iface2_data = create_iface_data(iface2_element) options_element = link_element.find("options") options = LinkOptions() @@ -978,12 +978,12 @@ class CoreXmlReader: if options.unidirectional == 1 and node_set in node_sets: logging.info("updating link node1(%s) node2(%s)", node1_id, node2_id) self.session.update_link( - node1_id, node2_id, interface1_data.id, interface2_data.id, options + node1_id, node2_id, iface1_data.id, iface2_data.id, options ) else: logging.info("adding link node1(%s) node2(%s)", node1_id, node2_id) self.session.add_link( - node1_id, node2_id, interface1_data, interface2_data, options + node1_id, node2_id, iface1_data, iface2_data, options ) node_sets.add(node_set) diff --git a/daemon/core/xml/corexmldeployment.py b/daemon/core/xml/corexmldeployment.py index 04915bf1..7954b71a 100644 --- a/daemon/core/xml/corexmldeployment.py +++ b/daemon/core/xml/corexmldeployment.py @@ -24,25 +24,25 @@ def add_address( parent_element: etree.Element, address_type: str, address: str, - interface_name: str = None, + iface_name: str = None, ) -> None: address_element = etree.SubElement(parent_element, "address", type=address_type) address_element.text = address - if interface_name is not None: - address_element.set("iface", interface_name) + if iface_name is not None: + address_element.set("iface", iface_name) def add_mapping(parent_element: etree.Element, maptype: str, mapref: str) -> None: etree.SubElement(parent_element, "mapping", type=maptype, ref=mapref) -def add_emane_interface( +def add_emane_iface( host_element: etree.Element, - netif: CoreInterface, + iface: CoreInterface, platform_name: str = "p1", transport_name: str = "t1", ) -> etree.Element: - nem_id = netif.net.nemidmap[netif] + nem_id = iface.net.nemidmap[iface] host_id = host_element.get("id") # platform data @@ -89,10 +89,10 @@ def get_ipv4_addresses(hostname: str) -> List[Tuple[str, str]]: split = line.split() if not split: continue - interface_name = split[1] + iface_name = split[1] address = split[3] if not address.startswith("127."): - addresses.append((interface_name, address)) + addresses.append((iface_name, address)) return addresses else: # TODO: handle other hosts @@ -112,11 +112,11 @@ class CoreXmlDeployment: device = self.scenario.find(f"devices/device[@name='{name}']") return device - def find_interface(self, device: NodeBase, name: str) -> etree.Element: - interface = self.scenario.find( + def find_iface(self, device: NodeBase, name: str) -> etree.Element: + iface = self.scenario.find( f"devices/device[@name='{device.name}']/interfaces/interface[@name='{name}']" ) - return interface + return iface def add_deployment(self) -> None: physical_host = self.add_physical_host(socket.gethostname()) @@ -136,8 +136,8 @@ class CoreXmlDeployment: add_type(host_element, "physical") # add ipv4 addresses - for interface_name, address in get_ipv4_addresses("localhost"): - add_address(host_element, "IPv4", address, interface_name) + for iface_name, address in get_ipv4_addresses("localhost"): + add_address(host_element, "IPv4", address, iface_name) return host_element @@ -155,15 +155,15 @@ class CoreXmlDeployment: # add host type add_type(host_element, "virtual") - for netif in node.netifs(): + for iface in node.get_ifaces(): emane_element = None - if isinstance(netif.net, EmaneNet): - emane_element = add_emane_interface(host_element, netif) + if isinstance(iface.net, EmaneNet): + emane_element = add_emane_iface(host_element, iface) parent_element = host_element if emane_element is not None: parent_element = emane_element - for address in netif.addrlist: + for address in iface.addrlist: address_type = get_address_type(address) - add_address(parent_element, address_type, address, netif.name) + add_address(parent_element, address_type, address, iface.name) diff --git a/daemon/core/xml/emanexml.py b/daemon/core/xml/emanexml.py index 2589edd9..4f511476 100644 --- a/daemon/core/xml/emanexml.py +++ b/daemon/core/xml/emanexml.py @@ -158,19 +158,19 @@ def build_node_platform_xml( logging.warning("warning: EMANE network %s has no associated model", node.name) return nem_id - for netif in node.netifs(): + for iface in node.get_ifaces(): logging.debug( - "building platform xml for interface(%s) nem_id(%s)", netif.name, nem_id + "building platform xml for interface(%s) nem_id(%s)", iface.name, nem_id ) # build nem xml - nem_definition = nem_file_name(node.model, netif) + nem_definition = nem_file_name(node.model, iface) nem_element = etree.Element( - "nem", id=str(nem_id), name=netif.localname, definition=nem_definition + "nem", id=str(nem_id), name=iface.localname, definition=nem_definition ) # check if this is an external transport, get default config if an interface # specific one does not exist - config = emane_manager.getifcconfig(node.model.id, netif, node.model.name) + config = emane_manager.get_iface_config(node.model.id, iface, node.model.name) if is_external(config): nem_element.set("transport", "external") @@ -180,9 +180,9 @@ def build_node_platform_xml( add_param(nem_element, transport_endpoint, config[transport_endpoint]) else: # build transport xml - transport_type = netif.transport_type + transport_type = iface.transport_type if not transport_type: - logging.info("warning: %s interface type unsupported!", netif.name) + logging.info("warning: %s interface type unsupported!", iface.name) transport_type = TransportType.RAW transport_file = transport_file_name(node.id, transport_type) transport_element = etree.SubElement( @@ -190,14 +190,14 @@ def build_node_platform_xml( ) # add transport parameter - add_param(transport_element, "device", netif.name) + add_param(transport_element, "device", iface.name) # add nem entry - nem_entries[netif] = nem_element + nem_entries[iface] = nem_element # merging code - key = netif.node.id - if netif.transport_type == TransportType.RAW: + key = iface.node.id + if iface.transport_type == TransportType.RAW: key = "host" otadev = control_net.brname eventdev = control_net.brname @@ -229,10 +229,10 @@ def build_node_platform_xml( platform_element.append(nem_element) - node.setnemid(netif, nem_id) + node.setnemid(iface, nem_id) macstr = _hwaddr_prefix + ":00:00:" macstr += f"{(nem_id >> 8) & 0xFF:02X}:{nem_id & 0xFF:02X}" - netif.sethwaddr(macstr) + iface.sethwaddr(macstr) # increment nem id nem_id += 1 @@ -280,19 +280,19 @@ def build_xml_files(emane_manager: "EmaneManager", node: EmaneNet) -> None: vtype = TransportType.VIRTUAL rtype = TransportType.RAW - for netif in node.netifs(): + for iface in node.get_ifaces(): # check for interface specific emane configuration and write xml files - config = emane_manager.getifcconfig(node.model.id, netif, node.model.name) + config = emane_manager.get_iface_config(node.model.id, iface, node.model.name) if config: - node.model.build_xml_files(config, netif) + node.model.build_xml_files(config, iface) # check transport type needed for interface - if netif.transport_type == TransportType.VIRTUAL: + if iface.transport_type == TransportType.VIRTUAL: need_virtual = True - vtype = netif.transport_type + vtype = iface.transport_type else: need_raw = True - rtype = netif.transport_type + rtype = iface.transport_type if need_virtual: build_transport_xml(emane_manager, node, vtype) @@ -494,70 +494,70 @@ def transport_file_name(node_id: int, transport_type: TransportType) -> str: return f"n{node_id}trans{transport_type.value}.xml" -def _basename(emane_model: "EmaneModel", interface: CoreInterface = None) -> str: +def _basename(emane_model: "EmaneModel", iface: CoreInterface = None) -> str: """ Create name that is leveraged for configuration file creation. :param emane_model: emane model to create name for - :param interface: interface for this model + :param iface: interface for this model :return: basename used for file creation """ name = f"n{emane_model.id}" - if interface: - node_id = interface.node.id - if emane_model.session.emane.getifcconfig(node_id, interface, emane_model.name): - name = interface.localname.replace(".", "_") + if iface: + node_id = iface.node.id + if emane_model.session.emane.get_iface_config(node_id, iface, emane_model.name): + name = iface.localname.replace(".", "_") return f"{name}{emane_model.name}" -def nem_file_name(emane_model: "EmaneModel", interface: CoreInterface = None) -> str: +def nem_file_name(emane_model: "EmaneModel", iface: CoreInterface = None) -> str: """ Return the string name for the NEM XML file, e.g. "n3rfpipenem.xml" :param emane_model: emane model to create file - :param interface: interface for this model + :param iface: interface for this model :return: nem xml filename """ - basename = _basename(emane_model, interface) + basename = _basename(emane_model, iface) append = "" - if interface and interface.transport_type == TransportType.RAW: + if iface and iface.transport_type == TransportType.RAW: append = "_raw" return f"{basename}nem{append}.xml" -def shim_file_name(emane_model: "EmaneModel", interface: CoreInterface = None) -> str: +def shim_file_name(emane_model: "EmaneModel", iface: CoreInterface = None) -> str: """ Return the string name for the SHIM XML file, e.g. "commeffectshim.xml" :param emane_model: emane model to create file - :param interface: interface for this model + :param iface: interface for this model :return: shim xml filename """ - name = _basename(emane_model, interface) + name = _basename(emane_model, iface) return f"{name}shim.xml" -def mac_file_name(emane_model: "EmaneModel", interface: CoreInterface = None) -> str: +def mac_file_name(emane_model: "EmaneModel", iface: CoreInterface = None) -> str: """ Return the string name for the MAC XML file, e.g. "n3rfpipemac.xml" :param emane_model: emane model to create file - :param interface: interface for this model + :param iface: interface for this model :return: mac xml filename """ - name = _basename(emane_model, interface) + name = _basename(emane_model, iface) return f"{name}mac.xml" -def phy_file_name(emane_model: "EmaneModel", interface: CoreInterface = None) -> str: +def phy_file_name(emane_model: "EmaneModel", iface: CoreInterface = None) -> str: """ Return the string name for the PHY XML file, e.g. "n3rfpipephy.xml" :param emane_model: emane model to create file - :param interface: interface for this model + :param iface: interface for this model :return: phy xml filename """ - name = _basename(emane_model, interface) + name = _basename(emane_model, iface) return f"{name}phy.xml" diff --git a/daemon/examples/configservices/testing.py b/daemon/examples/configservices/testing.py index 948ec739..767d0f45 100644 --- a/daemon/examples/configservices/testing.py +++ b/daemon/examples/configservices/testing.py @@ -20,13 +20,13 @@ if __name__ == "__main__": # node one options.config_services = ["DefaultRoute", "IPForward"] node1 = session.add_node(CoreNode, options=options) - interface = prefixes.create_interface(node1) - session.add_link(node1.id, switch.id, interface1_data=interface) + interface = prefixes.create_iface(node1) + session.add_link(node1.id, switch.id, iface1_data=interface) # node two node2 = session.add_node(CoreNode, options=options) - interface = prefixes.create_interface(node2) - session.add_link(node2.id, switch.id, interface1_data=interface) + interface = prefixes.create_iface(node2) + session.add_link(node2.id, switch.id, iface1_data=interface) # start session and run services session.instantiate() diff --git a/daemon/examples/docker/docker2core.py b/daemon/examples/docker/docker2core.py index 8151a590..c38f96af 100644 --- a/daemon/examples/docker/docker2core.py +++ b/daemon/examples/docker/docker2core.py @@ -18,11 +18,11 @@ if __name__ == "__main__": # create node one node1 = session.add_node(DockerNode, options=options) - interface1_data = prefixes.create_interface(node1) + interface1_data = prefixes.create_iface(node1) # create node two node2 = session.add_node(CoreNode) - interface2_data = prefixes.create_interface(node2) + interface2_data = prefixes.create_iface(node2) # add link session.add_link(node1.id, node2.id, interface1_data, interface2_data) diff --git a/daemon/examples/docker/docker2docker.py b/daemon/examples/docker/docker2docker.py index a7a70534..5b62d433 100644 --- a/daemon/examples/docker/docker2docker.py +++ b/daemon/examples/docker/docker2docker.py @@ -19,11 +19,11 @@ if __name__ == "__main__": # create node one node1 = session.add_node(DockerNode, options=options) - interface1_data = prefixes.create_interface(node1) + interface1_data = prefixes.create_iface(node1) # create node two node2 = session.add_node(DockerNode, options=options) - interface2_data = prefixes.create_interface(node2) + interface2_data = prefixes.create_iface(node2) # add link session.add_link(node1.id, node2.id, interface1_data, interface2_data) diff --git a/daemon/examples/docker/switch.py b/daemon/examples/docker/switch.py index ef057945..161cd823 100644 --- a/daemon/examples/docker/switch.py +++ b/daemon/examples/docker/switch.py @@ -23,15 +23,15 @@ if __name__ == "__main__": # node one node1 = session.add_node(DockerNode, options=options) - interface1_data = prefixes.create_interface(node1) + interface1_data = prefixes.create_iface(node1) # node two node2 = session.add_node(DockerNode, options=options) - interface2_data = prefixes.create_interface(node2) + interface2_data = prefixes.create_iface(node2) # node three node_three = session.add_node(CoreNode) - interface_three = prefixes.create_interface(node_three) + interface_three = prefixes.create_iface(node_three) # add links session.add_link(node1.id, switch.id, interface1_data) diff --git a/daemon/examples/grpc/distributed_switch.py b/daemon/examples/grpc/distributed_switch.py index e847016f..0d781c19 100644 --- a/daemon/examples/grpc/distributed_switch.py +++ b/daemon/examples/grpc/distributed_switch.py @@ -47,7 +47,7 @@ def main(args): node1_id = response.node_id # create link - interface1 = interface_helper.create_interface(node1_id, 0) + interface1 = interface_helper.create_iface(node1_id, 0) response = core.add_link(session_id, node1_id, switch_id, interface1) logging.info("created link from node one to switch: %s", response) @@ -59,7 +59,7 @@ def main(args): node2_id = response.node_id # create link - interface1 = interface_helper.create_interface(node2_id, 0) + interface1 = interface_helper.create_iface(node2_id, 0) response = core.add_link(session_id, node2_id, switch_id, interface1) logging.info("created link from node two to switch: %s", response) diff --git a/daemon/examples/grpc/emane80211.py b/daemon/examples/grpc/emane80211.py index 24532266..b8036db0 100644 --- a/daemon/examples/grpc/emane80211.py +++ b/daemon/examples/grpc/emane80211.py @@ -57,10 +57,10 @@ def main(): node2_id = response.node_id # links nodes to switch - interface1 = interface_helper.create_interface(node1_id, 0) + interface1 = interface_helper.create_iface(node1_id, 0) response = core.add_link(session_id, node1_id, emane_id, interface1) logging.info("created link: %s", response) - interface1 = interface_helper.create_interface(node2_id, 0) + interface1 = interface_helper.create_iface(node2_id, 0) response = core.add_link(session_id, node2_id, emane_id, interface1) logging.info("created link: %s", response) diff --git a/daemon/examples/grpc/switch.py b/daemon/examples/grpc/switch.py index 74e315c6..1ed7c684 100644 --- a/daemon/examples/grpc/switch.py +++ b/daemon/examples/grpc/switch.py @@ -53,10 +53,10 @@ def main(): node2_id = response.node_id # links nodes to switch - interface1 = interface_helper.create_interface(node1_id, 0) + interface1 = interface_helper.create_iface(node1_id, 0) response = core.add_link(session_id, node1_id, switch_id, interface1) logging.info("created link: %s", response) - interface1 = interface_helper.create_interface(node2_id, 0) + interface1 = interface_helper.create_iface(node2_id, 0) response = core.add_link(session_id, node2_id, switch_id, interface1) logging.info("created link: %s", response) diff --git a/daemon/examples/grpc/wlan.py b/daemon/examples/grpc/wlan.py index d60ca1be..715d4706 100644 --- a/daemon/examples/grpc/wlan.py +++ b/daemon/examples/grpc/wlan.py @@ -65,10 +65,10 @@ def main(): node2_id = response.node_id # links nodes to switch - interface1 = interface_helper.create_interface(node1_id, 0) + interface1 = interface_helper.create_iface(node1_id, 0) response = core.add_link(session_id, node1_id, wlan_id, interface1) logging.info("created link: %s", response) - interface1 = interface_helper.create_interface(node2_id, 0) + interface1 = interface_helper.create_iface(node2_id, 0) response = core.add_link(session_id, node2_id, wlan_id, interface1) logging.info("created link: %s", response) diff --git a/daemon/examples/lxd/lxd2core.py b/daemon/examples/lxd/lxd2core.py index 49b68943..3d8eef6a 100644 --- a/daemon/examples/lxd/lxd2core.py +++ b/daemon/examples/lxd/lxd2core.py @@ -18,11 +18,11 @@ if __name__ == "__main__": # create node one node1 = session.add_node(LxcNode, options=options) - interface1_data = prefixes.create_interface(node1) + interface1_data = prefixes.create_iface(node1) # create node two node2 = session.add_node(CoreNode) - interface2_data = prefixes.create_interface(node2) + interface2_data = prefixes.create_iface(node2) # add link session.add_link(node1.id, node2.id, interface1_data, interface2_data) diff --git a/daemon/examples/lxd/lxd2lxd.py b/daemon/examples/lxd/lxd2lxd.py index 18af8037..a7209b5c 100644 --- a/daemon/examples/lxd/lxd2lxd.py +++ b/daemon/examples/lxd/lxd2lxd.py @@ -19,11 +19,11 @@ if __name__ == "__main__": # create node one node1 = session.add_node(LxcNode, options=options) - interface1_data = prefixes.create_interface(node1) + interface1_data = prefixes.create_iface(node1) # create node two node2 = session.add_node(LxcNode, options=options) - interface2_data = prefixes.create_interface(node2) + interface2_data = prefixes.create_iface(node2) # add link session.add_link(node1.id, node2.id, interface1_data, interface2_data) diff --git a/daemon/examples/lxd/switch.py b/daemon/examples/lxd/switch.py index 31a79887..9b6801f5 100644 --- a/daemon/examples/lxd/switch.py +++ b/daemon/examples/lxd/switch.py @@ -23,15 +23,15 @@ if __name__ == "__main__": # node one node1 = session.add_node(LxcNode, options=options) - interface1_data = prefixes.create_interface(node1) + interface1_data = prefixes.create_iface(node1) # node two node2 = session.add_node(LxcNode, options=options) - interface2_data = prefixes.create_interface(node2) + interface2_data = prefixes.create_iface(node2) # node three node3 = session.add_node(CoreNode) - interface3_data = prefixes.create_interface(node3) + interface3_data = prefixes.create_iface(node3) # add links session.add_link(node1.id, switch.id, interface1_data) diff --git a/daemon/examples/myservices/sample.py b/daemon/examples/myservices/sample.py index 8c6dbe06..e0c9a232 100644 --- a/daemon/examples/myservices/sample.py +++ b/daemon/examples/myservices/sample.py @@ -80,8 +80,8 @@ class MyService(CoreService): if filename == cls.configs[0]: cfg += "# auto-generated by MyService (sample.py)\n" - for ifc in node.netifs(): - cfg += f'echo "Node {node.name} has interface {ifc.name}"\n' + for iface in node.get_ifaces(): + cfg += f'echo "Node {node.name} has interface {iface.name}"\n' elif filename == cls.configs[1]: cfg += "echo hello" diff --git a/daemon/examples/python/distributed_emane.py b/daemon/examples/python/distributed_emane.py index d9b41ea4..3ee56108 100644 --- a/daemon/examples/python/distributed_emane.py +++ b/daemon/examples/python/distributed_emane.py @@ -59,10 +59,10 @@ def main(args): node2 = session.add_node(CoreNode, options=options) # create node interfaces and link - interface1_data = prefixes.create_interface(node1) - interface2_data = prefixes.create_interface(node2) - session.add_link(node1.id, emane_net.id, interface1_data=interface1_data) - session.add_link(node2.id, emane_net.id, interface1_data=interface2_data) + interface1_data = prefixes.create_iface(node1) + interface2_data = prefixes.create_iface(node2) + session.add_link(node1.id, emane_net.id, iface1_data=interface1_data) + session.add_link(node2.id, emane_net.id, iface1_data=interface2_data) # instantiate session session.instantiate() diff --git a/daemon/examples/python/distributed_lxd.py b/daemon/examples/python/distributed_lxd.py index affb16a8..1573836a 100644 --- a/daemon/examples/python/distributed_lxd.py +++ b/daemon/examples/python/distributed_lxd.py @@ -48,8 +48,8 @@ def main(args): node2 = session.add_node(LxcNode, options=options) # create node interfaces and link - interface1_data = prefixes.create_interface(node1) - interface2_data = prefixes.create_interface(node2) + interface1_data = prefixes.create_iface(node1) + interface2_data = prefixes.create_iface(node2) session.add_link(node1.id, node2.id, interface1_data, interface2_data) # instantiate session diff --git a/daemon/examples/python/distributed_ptp.py b/daemon/examples/python/distributed_ptp.py index 6bf33474..1486c237 100644 --- a/daemon/examples/python/distributed_ptp.py +++ b/daemon/examples/python/distributed_ptp.py @@ -48,8 +48,8 @@ def main(args): node2 = session.add_node(CoreNode, options=options) # create node interfaces and link - interface1_data = prefixes.create_interface(node1) - interface2_data = prefixes.create_interface(node2) + interface1_data = prefixes.create_iface(node1) + interface2_data = prefixes.create_iface(node2) session.add_link(node1.id, node2.id, interface1_data, interface2_data) # instantiate session diff --git a/daemon/examples/python/distributed_switch.py b/daemon/examples/python/distributed_switch.py index 8991161e..e9eb1e81 100644 --- a/daemon/examples/python/distributed_switch.py +++ b/daemon/examples/python/distributed_switch.py @@ -52,10 +52,10 @@ def main(args): node2 = session.add_node(CoreNode, options=options) # create node interfaces and link - interface1_data = prefixes.create_interface(node1) - interface2_data = prefixes.create_interface(node2) - session.add_link(node1.id, switch.id, interface1_data=interface1_data) - session.add_link(node2.id, switch.id, interface1_data=interface2_data) + interface1_data = prefixes.create_iface(node1) + interface2_data = prefixes.create_iface(node2) + session.add_link(node1.id, switch.id, iface1_data=interface1_data) + session.add_link(node2.id, switch.id, iface1_data=interface2_data) # instantiate session session.instantiate() diff --git a/daemon/examples/python/emane80211.py b/daemon/examples/python/emane80211.py index d3f6652a..322e569f 100644 --- a/daemon/examples/python/emane80211.py +++ b/daemon/examples/python/emane80211.py @@ -42,8 +42,8 @@ def main(): for i in range(NODES): node = session.add_node(CoreNode, options=options) node.setposition(x=150 * (i + 1), y=150) - interface = prefixes.create_interface(node) - session.add_link(node.id, emane_network.id, interface1_data=interface) + interface = prefixes.create_iface(node) + session.add_link(node.id, emane_network.id, iface1_data=interface) # instantiate session session.instantiate() diff --git a/daemon/examples/python/switch.py b/daemon/examples/python/switch.py index 1b939cd7..902e79e0 100644 --- a/daemon/examples/python/switch.py +++ b/daemon/examples/python/switch.py @@ -31,8 +31,8 @@ def main(): # create nodes for _ in range(NODES): node = session.add_node(CoreNode) - interface = prefixes.create_interface(node) - session.add_link(node.id, switch.id, interface1_data=interface) + interface = prefixes.create_iface(node) + session.add_link(node.id, switch.id, iface1_data=interface) # instantiate session session.instantiate() diff --git a/daemon/examples/python/switch_inject.py b/daemon/examples/python/switch_inject.py index 59816b19..89f70e05 100644 --- a/daemon/examples/python/switch_inject.py +++ b/daemon/examples/python/switch_inject.py @@ -33,8 +33,8 @@ def main(): # create nodes for _ in range(NODES): node = session.add_node(CoreNode) - interface = prefixes.create_interface(node) - session.add_link(node.id, switch.id, interface1_data=interface) + interface = prefixes.create_iface(node) + session.add_link(node.id, switch.id, iface1_data=interface) # instantiate session session.instantiate() diff --git a/daemon/examples/python/wlan.py b/daemon/examples/python/wlan.py index 0302bbd3..547a5860 100644 --- a/daemon/examples/python/wlan.py +++ b/daemon/examples/python/wlan.py @@ -35,8 +35,8 @@ def main(): options.set_position(0, 0) for _ in range(NODES): node = session.add_node(CoreNode, options=options) - interface = prefixes.create_interface(node) - session.add_link(node.id, wlan.id, interface1_data=interface) + interface = prefixes.create_iface(node) + session.add_link(node.id, wlan.id, iface1_data=interface) # instantiate session session.instantiate() diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 828b41fb..f691621a 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -319,12 +319,12 @@ message ThroughputsRequest { message ThroughputsEvent { int32 session_id = 1; repeated BridgeThroughput bridge_throughputs = 2; - repeated InterfaceThroughput interface_throughputs = 3; + repeated InterfaceThroughput iface_throughputs = 3; } message InterfaceThroughput { int32 node_id = 1; - int32 interface_id = 2; + int32 iface_id = 2; double throughput = 3; } @@ -374,7 +374,7 @@ message ConfigEvent { string bitmap = 8; string possible_values = 9; string groups = 10; - int32 interface = 11; + int32 iface_id = 11; int32 network_id = 12; string opaque = 13; } @@ -416,7 +416,7 @@ message GetNodeRequest { message GetNodeResponse { Node node = 1; - repeated Interface interfaces = 2; + repeated Interface ifaces = 2; } message EditNodeRequest { @@ -492,16 +492,16 @@ message AddLinkRequest { message AddLinkResponse { bool result = 1; - Interface interface1 = 2; - Interface interface2 = 3; + Interface iface1 = 2; + Interface iface2 = 3; } message EditLinkRequest { int32 session_id = 1; int32 node1_id = 2; int32 node2_id = 3; - int32 interface1_id = 4; - int32 interface2_id = 5; + int32 iface1_id = 4; + int32 iface2_id = 5; LinkOptions options = 6; } @@ -513,8 +513,8 @@ message DeleteLinkRequest { int32 session_id = 1; int32 node1_id = 2; int32 node2_id = 3; - int32 interface1_id = 4; - int32 interface2_id = 5; + int32 iface1_id = 4; + int32 iface2_id = 5; } message DeleteLinkResponse { @@ -561,7 +561,7 @@ message GetInterfacesRequest { } message GetInterfacesResponse { - repeated string interfaces = 1; + repeated string ifaces = 1; } message ExecuteScriptRequest { @@ -705,8 +705,8 @@ message Link { int32 node1_id = 1; int32 node2_id = 2; LinkType.Enum type = 3; - Interface interface1 = 4; - Interface interface2 = 5; + Interface iface1 = 4; + Interface iface2 = 5; LinkOptions options = 6; int32 network_id = 7; string label = 8; diff --git a/daemon/proto/core/api/grpc/emane.proto b/daemon/proto/core/api/grpc/emane.proto index e4189700..ac5456fd 100644 --- a/daemon/proto/core/api/grpc/emane.proto +++ b/daemon/proto/core/api/grpc/emane.proto @@ -32,7 +32,7 @@ message GetEmaneModelsResponse { message GetEmaneModelConfigRequest { int32 session_id = 1; int32 node_id = 2; - int32 interface = 3; + int32 iface_id = 3; string model = 4; } @@ -57,7 +57,7 @@ message GetEmaneModelConfigsResponse { message ModelConfig { int32 node_id = 1; string model = 2; - int32 interface = 3; + int32 iface_id = 3; map config = 4; } repeated ModelConfig configs = 1; @@ -86,7 +86,7 @@ message EmaneLinkResponse { message EmaneModelConfig { int32 node_id = 1; - int32 interface_id = 2; + int32 iface_id = 2; string model = 3; map config = 4; } @@ -95,10 +95,10 @@ message EmanePathlossesRequest { int32 session_id = 1; int32 node1_id = 2; float rx1 = 3; - int32 interface1_id = 4; + int32 iface1_id = 4; int32 node2_id = 5; float rx2 = 6; - int32 interface2_id = 7; + int32 iface2_id = 7; } message EmanePathlossesResponse { diff --git a/daemon/scripts/core-route-monitor b/daemon/scripts/core-route-monitor index b12e6205..d644ae1b 100755 --- a/daemon/scripts/core-route-monitor +++ b/daemon/scripts/core-route-monitor @@ -101,8 +101,8 @@ class RouterMonitor: node_map[node.id] = node.channel if self.src_id is None: response = self.core.get_node(self.session, node.id) - for netif in response.interfaces: - if self.src == netif.ip4: + for iface in response.ifaces: + if self.src == iface.ip4: self.src_id = node.id break except grpc.RpcError: diff --git a/daemon/tests/conftest.py b/daemon/tests/conftest.py index 9d54d9c2..c3315e7c 100644 --- a/daemon/tests/conftest.py +++ b/daemon/tests/conftest.py @@ -89,7 +89,7 @@ def ip_prefixes(): @pytest.fixture(scope="session") -def interface_helper(): +def iface_helper(): return InterfaceHelper(ip4_prefix="10.83.0.0/16") diff --git a/daemon/tests/emane/test_emane.py b/daemon/tests/emane/test_emane.py index 15e3d869..e1c7938b 100644 --- a/daemon/tests/emane/test_emane.py +++ b/daemon/tests/emane/test_emane.py @@ -79,8 +79,8 @@ class TestEmane: for i, node in enumerate([node1, node2]): node.setposition(x=150 * (i + 1), y=150) - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, emane_network.id, interface1_data=interface) + iface_data = ip_prefixes.create_iface(node) + session.add_link(node.id, emane_network.id, iface1_data=iface_data) # instantiate session session.instantiate() @@ -119,8 +119,8 @@ class TestEmane: for i, node in enumerate([node1, node2]): node.setposition(x=150 * (i + 1), y=150) - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, emane_network.id, interface1_data=interface) + iface_data = ip_prefixes.create_iface(node) + session.add_link(node.id, emane_network.id, iface1_data=iface_data) # instantiate session session.instantiate() diff --git a/daemon/tests/test_core.py b/daemon/tests/test_core.py index 626f84a7..5771f7ad 100644 --- a/daemon/tests/test_core.py +++ b/daemon/tests/test_core.py @@ -53,8 +53,8 @@ class TestCore: # link nodes to net node for node in [node1, node2]: - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, net_node.id, interface1_data=interface) + iface_data = ip_prefixes.create_iface(node) + session.add_link(node.id, net_node.id, iface1_data=iface_data) # instantiate session session.instantiate() @@ -80,8 +80,8 @@ class TestCore: # link nodes to ptp net for node in [node1, node2]: - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, ptp_node.id, interface1_data=interface) + iface_data = ip_prefixes.create_iface(node) + session.add_link(node.id, ptp_node.id, iface1_data=iface_data) # get node client for testing client = node1.client @@ -96,9 +96,9 @@ class TestCore: if not request.config.getoption("mock"): assert client.check_cmd("echo hello") == "hello" - def test_netif(self, session: Session, ip_prefixes: IpPrefixes): + def test_iface(self, session: Session, ip_prefixes: IpPrefixes): """ - Test netif methods. + Test interface methods. :param session: session for test :param ip_prefixes: generates ip addresses for nodes @@ -113,8 +113,8 @@ class TestCore: # link nodes to ptp net for node in [node1, node2]: - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, ptp_node.id, interface1_data=interface) + iface = ip_prefixes.create_iface(node) + session.add_link(node.id, ptp_node.id, iface1_data=iface) # instantiate session session.instantiate() @@ -126,19 +126,19 @@ class TestCore: assert node1.commonnets(node2) assert node2.commonnets(node1) - # check we can retrieve netif index - assert node1.ifname(0) - assert node2.ifname(0) + # check we can retrieve interface id + assert 0 in node1.ifaces + assert 0 in node2.ifaces # check interface parameters - interface = node1.netif(0) - interface.setparam("test", 1) - assert interface.getparam("test") == 1 - assert interface.getparams() + iface = node1.get_iface(0) + iface.setparam("test", 1) + assert iface.getparam("test") == 1 + assert iface.getparams() - # delete netif and test that if no longer exists - node1.delnetif(0) - assert not node1.netif(0) + # delete interface and test that if no longer exists + node1.delete_iface(0) + assert 0 not in node1.ifaces def test_wlan_ping(self, session: Session, ip_prefixes: IpPrefixes): """ @@ -160,8 +160,8 @@ class TestCore: # link nodes for node in [node1, node2]: - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, wlan_node.id, interface1_data=interface) + iface_id = ip_prefixes.create_iface(node) + session.add_link(node.id, wlan_node.id, iface1_data=iface_id) # instantiate session session.instantiate() @@ -190,8 +190,8 @@ class TestCore: # link nodes for node in [node1, node2]: - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, wlan_node.id, interface1_data=interface) + iface_id = ip_prefixes.create_iface(node) + session.add_link(node.id, wlan_node.id, iface1_data=iface_id) # configure mobility script for session config = { diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 8beb4b9a..23ff0301 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -42,15 +42,17 @@ class TestGrpc: id=3, type=NodeTypes.WIRELESS_LAN.value, position=position ) nodes = [node1, node2, wlan_node] - interface_helper = InterfaceHelper(ip4_prefix="10.83.0.0/16") - interface1 = interface_helper.create_interface(node1.id, 0) - interface2 = interface_helper.create_interface(node2.id, 0) + iface_helper = InterfaceHelper(ip4_prefix="10.83.0.0/16") + iface1_id = 0 + iface1 = iface_helper.create_iface(node1.id, iface1_id) + iface2_id = 0 + iface2 = iface_helper.create_iface(node2.id, iface2_id) link = core_pb2.Link( type=core_pb2.LinkType.WIRED, node1_id=node1.id, node2_id=node2.id, - interface1=interface1, - interface2=interface2, + iface1=iface1, + iface2=iface2, ) links = [link] hook = core_pb2.Hook( @@ -81,7 +83,7 @@ class TestGrpc: model_config_value = "500000" model_config = EmaneModelConfig( node_id=model_node_id, - interface_id=-1, + iface_id=-1, model=EmaneIeee80211abgModel.name, config={model_config_key: model_config_value}, ) @@ -131,8 +133,8 @@ class TestGrpc: assert node1.id in session.nodes assert node2.id in session.nodes assert wlan_node.id in session.nodes - assert session.nodes[node1.id].netif(0) is not None - assert session.nodes[node2.id].netif(0) is not None + assert iface1_id in session.nodes[node1.id].ifaces + assert iface2_id in session.nodes[node2.id].ifaces hook_file, hook_data = session.hooks[EventTypes.RUNTIME_STATE][0] assert hook_file == hook.file assert hook_data == hook.data @@ -522,8 +524,8 @@ class TestGrpc: session = grpc_server.coreemu.create_session() switch = session.add_node(SwitchNode) node = session.add_node(CoreNode) - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, switch.id, interface) + iface_data = ip_prefixes.create_iface(node) + session.add_link(node.id, switch.id, iface_data) # then with client.context_connect(): @@ -540,17 +542,15 @@ class TestGrpc: session = grpc_server.coreemu.create_session() switch = session.add_node(SwitchNode) node = session.add_node(CoreNode) - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, switch.id, interface) + iface_data = ip_prefixes.create_iface(node) + session.add_link(node.id, switch.id, iface_data) # then with pytest.raises(grpc.RpcError): with client.context_connect(): client.get_node_links(session.id, 3) - def test_add_link( - self, grpc_server: CoreGrpcServer, interface_helper: InterfaceHelper - ): + def test_add_link(self, grpc_server: CoreGrpcServer, iface_helper: InterfaceHelper): # given client = CoreGrpcClient() session = grpc_server.coreemu.create_session() @@ -559,16 +559,16 @@ class TestGrpc: assert len(switch.all_link_data()) == 0 # then - interface = interface_helper.create_interface(node.id, 0) + iface = iface_helper.create_iface(node.id, 0) with client.context_connect(): - response = client.add_link(session.id, node.id, switch.id, interface) + response = client.add_link(session.id, node.id, switch.id, iface) # then assert response.result is True assert len(switch.all_link_data()) == 1 def test_add_link_exception( - self, grpc_server: CoreGrpcServer, interface_helper: InterfaceHelper + self, grpc_server: CoreGrpcServer, iface_helper: InterfaceHelper ): # given client = CoreGrpcClient() @@ -576,10 +576,10 @@ class TestGrpc: node = session.add_node(CoreNode) # then - interface = interface_helper.create_interface(node.id, 0) + iface = iface_helper.create_iface(node.id, 0) with pytest.raises(grpc.RpcError): with client.context_connect(): - client.add_link(session.id, 1, 3, interface) + client.add_link(session.id, 1, 3, iface) def test_edit_link(self, grpc_server: CoreGrpcServer, ip_prefixes: IpPrefixes): # given @@ -587,8 +587,8 @@ class TestGrpc: session = grpc_server.coreemu.create_session() switch = session.add_node(SwitchNode) node = session.add_node(CoreNode) - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, switch.id, interface) + iface = ip_prefixes.create_iface(node) + session.add_link(node.id, switch.id, iface) options = core_pb2.LinkOptions(bandwidth=30000) link = switch.all_link_data()[0] assert options.bandwidth != link.bandwidth @@ -596,7 +596,7 @@ class TestGrpc: # then with client.context_connect(): response = client.edit_link( - session.id, node.id, switch.id, options, interface1_id=interface.id + session.id, node.id, switch.id, options, iface1_id=iface.id ) # then @@ -609,10 +609,10 @@ class TestGrpc: client = CoreGrpcClient() session = grpc_server.coreemu.create_session() node1 = session.add_node(CoreNode) - interface1 = ip_prefixes.create_interface(node1) + iface1 = ip_prefixes.create_iface(node1) node2 = session.add_node(CoreNode) - interface2 = ip_prefixes.create_interface(node2) - session.add_link(node1.id, node2.id, interface1, interface2) + iface2 = ip_prefixes.create_iface(node2) + session.add_link(node1.id, node2.id, iface1, iface2) link_node = None for node_id in session.nodes: node = session.nodes[node_id] @@ -624,7 +624,7 @@ class TestGrpc: # then with client.context_connect(): response = client.delete_link( - session.id, node1.id, node2.id, interface1.id, interface2.id + session.id, node1.id, node2.id, iface1.id, iface2.id ) # then @@ -729,7 +729,7 @@ class TestGrpc: assert emane_network.id == model_config.node_id assert model_config.model == EmaneIeee80211abgModel.name assert len(model_config.config) > 0 - assert model_config.interface == -1 + assert model_config.iface_id == -1 def test_set_emane_model_config(self, grpc_server: CoreGrpcServer): # given @@ -1028,8 +1028,8 @@ class TestGrpc: session = grpc_server.coreemu.create_session() wlan = session.add_node(WlanNode) node = session.add_node(CoreNode) - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, wlan.id, interface) + iface = ip_prefixes.create_iface(node) + session.add_link(node.id, wlan.id, iface) link_data = wlan.all_link_data()[0] queue = Queue() diff --git a/daemon/tests/test_gui.py b/daemon/tests/test_gui.py index d3b9362d..c413295a 100644 --- a/daemon/tests/test_gui.py +++ b/daemon/tests/test_gui.py @@ -107,15 +107,15 @@ class TestGui: switch_id = 2 coretlv.session.add_node(SwitchNode, _id=switch_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface1_ip4 = str(ip_prefix[node1_id]) + iface1_ip4 = str(ip_prefix[node1_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ (LinkTlvs.N1_NUMBER, node1_id), (LinkTlvs.N2_NUMBER, switch_id), - (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface1_ip4), - (LinkTlvs.INTERFACE1_IP4_MASK, 24), + (LinkTlvs.IFACE1_NUMBER, 0), + (LinkTlvs.IFACE1_IP4, iface1_ip4), + (LinkTlvs.IFACE1_IP4_MASK, 24), ], ) @@ -131,15 +131,15 @@ class TestGui: switch_id = 2 coretlv.session.add_node(SwitchNode, _id=switch_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface2_ip4 = str(ip_prefix[node1_id]) + iface2_ip4 = str(ip_prefix[node1_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ (LinkTlvs.N1_NUMBER, switch_id), (LinkTlvs.N2_NUMBER, node1_id), - (LinkTlvs.INTERFACE2_NUMBER, 0), - (LinkTlvs.INTERFACE2_IP4, interface2_ip4), - (LinkTlvs.INTERFACE2_IP4_MASK, 24), + (LinkTlvs.IFACE2_NUMBER, 0), + (LinkTlvs.IFACE2_IP4, iface2_ip4), + (LinkTlvs.IFACE2_IP4_MASK, 24), ], ) @@ -155,19 +155,19 @@ class TestGui: node2_id = 2 coretlv.session.add_node(CoreNode, _id=node2_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface1_ip4 = str(ip_prefix[node1_id]) - interface2_ip4 = str(ip_prefix[node2_id]) + iface1_ip4 = str(ip_prefix[node1_id]) + iface2_ip4 = str(ip_prefix[node2_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ (LinkTlvs.N1_NUMBER, node1_id), (LinkTlvs.N2_NUMBER, node2_id), - (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface1_ip4), - (LinkTlvs.INTERFACE1_IP4_MASK, 24), - (LinkTlvs.INTERFACE2_NUMBER, 0), - (LinkTlvs.INTERFACE2_IP4, interface2_ip4), - (LinkTlvs.INTERFACE2_IP4_MASK, 24), + (LinkTlvs.IFACE1_NUMBER, 0), + (LinkTlvs.IFACE1_IP4, iface1_ip4), + (LinkTlvs.IFACE1_IP4_MASK, 24), + (LinkTlvs.IFACE2_NUMBER, 0), + (LinkTlvs.IFACE2_IP4, iface2_ip4), + (LinkTlvs.IFACE2_IP4_MASK, 24), ], ) @@ -185,15 +185,15 @@ class TestGui: switch_id = 2 coretlv.session.add_node(SwitchNode, _id=switch_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface1_ip4 = str(ip_prefix[node1_id]) + iface1_ip4 = str(ip_prefix[node1_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ (LinkTlvs.N1_NUMBER, node1_id), (LinkTlvs.N2_NUMBER, switch_id), - (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface1_ip4), - (LinkTlvs.INTERFACE1_IP4_MASK, 24), + (LinkTlvs.IFACE1_NUMBER, 0), + (LinkTlvs.IFACE1_IP4, iface1_ip4), + (LinkTlvs.IFACE1_IP4_MASK, 24), ], ) coretlv.handle_message(message) @@ -209,7 +209,7 @@ class TestGui: [ (LinkTlvs.N1_NUMBER, node1_id), (LinkTlvs.N2_NUMBER, switch_id), - (LinkTlvs.INTERFACE1_NUMBER, 0), + (LinkTlvs.IFACE1_NUMBER, 0), (LinkTlvs.BANDWIDTH, bandwidth), ], ) @@ -227,18 +227,18 @@ class TestGui: node2_id = 2 coretlv.session.add_node(CoreNode, _id=node2_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface1_ip4 = str(ip_prefix[node1_id]) - interface2_ip4 = str(ip_prefix[node2_id]) + iface1_ip4 = str(ip_prefix[node1_id]) + iface2_ip4 = str(ip_prefix[node2_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ (LinkTlvs.N1_NUMBER, node1_id), (LinkTlvs.N2_NUMBER, node2_id), - (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface1_ip4), - (LinkTlvs.INTERFACE1_IP4_MASK, 24), - (LinkTlvs.INTERFACE2_IP4, interface2_ip4), - (LinkTlvs.INTERFACE2_IP4_MASK, 24), + (LinkTlvs.IFACE1_NUMBER, 0), + (LinkTlvs.IFACE1_IP4, iface1_ip4), + (LinkTlvs.IFACE1_IP4_MASK, 24), + (LinkTlvs.IFACE2_IP4, iface2_ip4), + (LinkTlvs.IFACE2_IP4_MASK, 24), ], ) coretlv.handle_message(message) @@ -253,8 +253,8 @@ class TestGui: [ (LinkTlvs.N1_NUMBER, node1_id), (LinkTlvs.N2_NUMBER, node2_id), - (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE2_NUMBER, 0), + (LinkTlvs.IFACE1_NUMBER, 0), + (LinkTlvs.IFACE2_NUMBER, 0), ], ) coretlv.handle_message(message) @@ -271,15 +271,15 @@ class TestGui: switch_id = 2 coretlv.session.add_node(SwitchNode, _id=switch_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface1_ip4 = str(ip_prefix[node1_id]) + iface1_ip4 = str(ip_prefix[node1_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ (LinkTlvs.N1_NUMBER, node1_id), (LinkTlvs.N2_NUMBER, switch_id), - (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface1_ip4), - (LinkTlvs.INTERFACE1_IP4_MASK, 24), + (LinkTlvs.IFACE1_NUMBER, 0), + (LinkTlvs.IFACE1_IP4, iface1_ip4), + (LinkTlvs.IFACE1_IP4_MASK, 24), ], ) coretlv.handle_message(message) @@ -292,7 +292,7 @@ class TestGui: [ (LinkTlvs.N1_NUMBER, node1_id), (LinkTlvs.N2_NUMBER, switch_id), - (LinkTlvs.INTERFACE1_NUMBER, 0), + (LinkTlvs.IFACE1_NUMBER, 0), ], ) coretlv.handle_message(message) @@ -307,15 +307,15 @@ class TestGui: switch_id = 2 coretlv.session.add_node(SwitchNode, _id=switch_id) ip_prefix = netaddr.IPNetwork("10.0.0.0/24") - interface1_ip4 = str(ip_prefix[node1_id]) + iface1_ip4 = str(ip_prefix[node1_id]) message = coreapi.CoreLinkMessage.create( MessageFlags.ADD.value, [ (LinkTlvs.N1_NUMBER, node1_id), (LinkTlvs.N2_NUMBER, switch_id), - (LinkTlvs.INTERFACE1_NUMBER, 0), - (LinkTlvs.INTERFACE1_IP4, interface1_ip4), - (LinkTlvs.INTERFACE1_IP4_MASK, 24), + (LinkTlvs.IFACE1_NUMBER, 0), + (LinkTlvs.IFACE1_IP4, iface1_ip4), + (LinkTlvs.IFACE1_IP4_MASK, 24), ], ) coretlv.handle_message(message) @@ -328,7 +328,7 @@ class TestGui: [ (LinkTlvs.N1_NUMBER, switch_id), (LinkTlvs.N2_NUMBER, node1_id), - (LinkTlvs.INTERFACE2_NUMBER, 0), + (LinkTlvs.IFACE2_NUMBER, 0), ], ) coretlv.handle_message(message) diff --git a/daemon/tests/test_links.py b/daemon/tests/test_links.py index 819e2be8..fea4f4f8 100644 --- a/daemon/tests/test_links.py +++ b/daemon/tests/test_links.py @@ -14,9 +14,9 @@ def create_ptp_network( node2 = session.add_node(CoreNode) # link nodes to net node - interface1_data = ip_prefixes.create_interface(node1) - interface2_data = ip_prefixes.create_interface(node2) - session.add_link(node1.id, node2.id, interface1_data, interface2_data) + iface1_data = ip_prefixes.create_iface(node1) + iface2_data = ip_prefixes.create_iface(node2) + session.add_link(node1.id, node2.id, iface1_data, iface2_data) # instantiate session session.instantiate() @@ -29,41 +29,41 @@ class TestLinks: # given node1 = session.add_node(CoreNode) node2 = session.add_node(CoreNode) - interface1_data = ip_prefixes.create_interface(node1) - interface2_data = ip_prefixes.create_interface(node2) + iface1_data = ip_prefixes.create_iface(node1) + iface2_data = ip_prefixes.create_iface(node2) # when - session.add_link(node1.id, node2.id, interface1_data, interface2_data) + session.add_link(node1.id, node2.id, iface1_data, iface2_data) # then - assert node1.netif(interface1_data.id) - assert node2.netif(interface2_data.id) + assert node1.get_iface(iface1_data.id) + assert node2.get_iface(iface2_data.id) def test_add_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): # given node1 = session.add_node(CoreNode) node2 = session.add_node(SwitchNode) - interface1_data = ip_prefixes.create_interface(node1) + iface1_data = ip_prefixes.create_iface(node1) # when - session.add_link(node1.id, node2.id, interface1_data=interface1_data) + session.add_link(node1.id, node2.id, iface1_data=iface1_data) # then assert node2.all_link_data() - assert node1.netif(interface1_data.id) + assert node1.get_iface(iface1_data.id) def test_add_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): # given node1 = session.add_node(SwitchNode) node2 = session.add_node(CoreNode) - interface2_data = ip_prefixes.create_interface(node2) + iface2_data = ip_prefixes.create_iface(node2) # when - session.add_link(node1.id, node2.id, interface2_data=interface2_data) + session.add_link(node1.id, node2.id, iface2_data=iface2_data) # then assert node1.all_link_data() - assert node2.netif(interface2_data.id) + assert node2.get_iface(iface2_data.id) def test_add_net_to_net(self, session): # given @@ -85,29 +85,29 @@ class TestLinks: jitter = 10 node1 = session.add_node(CoreNode) node2 = session.add_node(SwitchNode) - interface1_data = ip_prefixes.create_interface(node1) - session.add_link(node1.id, node2.id, interface1_data) - interface1 = node1.netif(interface1_data.id) - assert interface1.getparam("delay") != delay - assert interface1.getparam("bw") != bandwidth - assert interface1.getparam("loss") != loss - assert interface1.getparam("duplicate") != dup - assert interface1.getparam("jitter") != jitter + iface1_data = ip_prefixes.create_iface(node1) + session.add_link(node1.id, node2.id, iface1_data) + iface1 = node1.get_iface(iface1_data.id) + assert iface1.getparam("delay") != delay + assert iface1.getparam("bw") != bandwidth + assert iface1.getparam("loss") != loss + assert iface1.getparam("duplicate") != dup + assert iface1.getparam("jitter") != jitter # when options = LinkOptions( delay=delay, bandwidth=bandwidth, loss=loss, dup=dup, jitter=jitter ) session.update_link( - node1.id, node2.id, interface1_id=interface1_data.id, options=options + node1.id, node2.id, iface1_id=iface1_data.id, options=options ) # then - assert interface1.getparam("delay") == delay - assert interface1.getparam("bw") == bandwidth - assert interface1.getparam("loss") == loss - assert interface1.getparam("duplicate") == dup - assert interface1.getparam("jitter") == jitter + assert iface1.getparam("delay") == delay + assert iface1.getparam("bw") == bandwidth + assert iface1.getparam("loss") == loss + assert iface1.getparam("duplicate") == dup + assert iface1.getparam("jitter") == jitter def test_update_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): # given @@ -118,29 +118,29 @@ class TestLinks: jitter = 10 node1 = session.add_node(SwitchNode) node2 = session.add_node(CoreNode) - interface2_data = ip_prefixes.create_interface(node2) - session.add_link(node1.id, node2.id, interface2_data=interface2_data) - interface2 = node2.netif(interface2_data.id) - assert interface2.getparam("delay") != delay - assert interface2.getparam("bw") != bandwidth - assert interface2.getparam("loss") != loss - assert interface2.getparam("duplicate") != dup - assert interface2.getparam("jitter") != jitter + iface2_data = ip_prefixes.create_iface(node2) + session.add_link(node1.id, node2.id, iface2_data=iface2_data) + iface2 = node2.get_iface(iface2_data.id) + assert iface2.getparam("delay") != delay + assert iface2.getparam("bw") != bandwidth + assert iface2.getparam("loss") != loss + assert iface2.getparam("duplicate") != dup + assert iface2.getparam("jitter") != jitter # when options = LinkOptions( delay=delay, bandwidth=bandwidth, loss=loss, dup=dup, jitter=jitter ) session.update_link( - node1.id, node2.id, interface2_id=interface2_data.id, options=options + node1.id, node2.id, iface2_id=iface2_data.id, options=options ) # then - assert interface2.getparam("delay") == delay - assert interface2.getparam("bw") == bandwidth - assert interface2.getparam("loss") == loss - assert interface2.getparam("duplicate") == dup - assert interface2.getparam("jitter") == jitter + assert iface2.getparam("delay") == delay + assert iface2.getparam("bw") == bandwidth + assert iface2.getparam("loss") == loss + assert iface2.getparam("duplicate") == dup + assert iface2.getparam("jitter") == jitter def test_update_ptp(self, session: Session, ip_prefixes: IpPrefixes): # given @@ -151,83 +151,81 @@ class TestLinks: jitter = 10 node1 = session.add_node(CoreNode) node2 = session.add_node(CoreNode) - interface1_data = ip_prefixes.create_interface(node1) - interface2_data = ip_prefixes.create_interface(node2) - session.add_link(node1.id, node2.id, interface1_data, interface2_data) - interface1 = node1.netif(interface1_data.id) - interface2 = node2.netif(interface2_data.id) - assert interface1.getparam("delay") != delay - assert interface1.getparam("bw") != bandwidth - assert interface1.getparam("loss") != loss - assert interface1.getparam("duplicate") != dup - assert interface1.getparam("jitter") != jitter - assert interface2.getparam("delay") != delay - assert interface2.getparam("bw") != bandwidth - assert interface2.getparam("loss") != loss - assert interface2.getparam("duplicate") != dup - assert interface2.getparam("jitter") != jitter + iface1_data = ip_prefixes.create_iface(node1) + iface2_data = ip_prefixes.create_iface(node2) + session.add_link(node1.id, node2.id, iface1_data, iface2_data) + iface1 = node1.get_iface(iface1_data.id) + iface2 = node2.get_iface(iface2_data.id) + assert iface1.getparam("delay") != delay + assert iface1.getparam("bw") != bandwidth + assert iface1.getparam("loss") != loss + assert iface1.getparam("duplicate") != dup + assert iface1.getparam("jitter") != jitter + assert iface2.getparam("delay") != delay + assert iface2.getparam("bw") != bandwidth + assert iface2.getparam("loss") != loss + assert iface2.getparam("duplicate") != dup + assert iface2.getparam("jitter") != jitter # when options = LinkOptions( delay=delay, bandwidth=bandwidth, loss=loss, dup=dup, jitter=jitter ) - session.update_link( - node1.id, node2.id, interface1_data.id, interface2_data.id, options - ) + session.update_link(node1.id, node2.id, iface1_data.id, iface2_data.id, options) # then - assert interface1.getparam("delay") == delay - assert interface1.getparam("bw") == bandwidth - assert interface1.getparam("loss") == loss - assert interface1.getparam("duplicate") == dup - assert interface1.getparam("jitter") == jitter - assert interface2.getparam("delay") == delay - assert interface2.getparam("bw") == bandwidth - assert interface2.getparam("loss") == loss - assert interface2.getparam("duplicate") == dup - assert interface2.getparam("jitter") == jitter + assert iface1.getparam("delay") == delay + assert iface1.getparam("bw") == bandwidth + assert iface1.getparam("loss") == loss + assert iface1.getparam("duplicate") == dup + assert iface1.getparam("jitter") == jitter + assert iface2.getparam("delay") == delay + assert iface2.getparam("bw") == bandwidth + assert iface2.getparam("loss") == loss + assert iface2.getparam("duplicate") == dup + assert iface2.getparam("jitter") == jitter def test_delete_ptp(self, session: Session, ip_prefixes: IpPrefixes): # given node1 = session.add_node(CoreNode) node2 = session.add_node(CoreNode) - interface1_data = ip_prefixes.create_interface(node1) - interface2_data = ip_prefixes.create_interface(node2) - session.add_link(node1.id, node2.id, interface1_data, interface2_data) - assert node1.netif(interface1_data.id) - assert node2.netif(interface2_data.id) + iface1_data = ip_prefixes.create_iface(node1) + iface2_data = ip_prefixes.create_iface(node2) + session.add_link(node1.id, node2.id, iface1_data, iface2_data) + assert node1.get_iface(iface1_data.id) + assert node2.get_iface(iface2_data.id) # when - session.delete_link(node1.id, node2.id, interface1_data.id, interface2_data.id) + session.delete_link(node1.id, node2.id, iface1_data.id, iface2_data.id) # then - assert not node1.netif(interface1_data.id) - assert not node2.netif(interface2_data.id) + assert iface1_data.id not in node1.ifaces + assert iface2_data.id not in node2.ifaces def test_delete_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): # given node1 = session.add_node(CoreNode) node2 = session.add_node(SwitchNode) - interface1_data = ip_prefixes.create_interface(node1) - session.add_link(node1.id, node2.id, interface1_data) - assert node1.netif(interface1_data.id) + iface1_data = ip_prefixes.create_iface(node1) + session.add_link(node1.id, node2.id, iface1_data) + assert node1.get_iface(iface1_data.id) # when - session.delete_link(node1.id, node2.id, interface1_id=interface1_data.id) + session.delete_link(node1.id, node2.id, iface1_id=iface1_data.id) # then - assert not node1.netif(interface1_data.id) + assert iface1_data.id not in node1.ifaces def test_delete_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): # given node1 = session.add_node(SwitchNode) node2 = session.add_node(CoreNode) - interface2_data = ip_prefixes.create_interface(node2) - session.add_link(node1.id, node2.id, interface2_data=interface2_data) - assert node2.netif(interface2_data.id) + iface2_data = ip_prefixes.create_iface(node2) + session.add_link(node1.id, node2.id, iface2_data=iface2_data) + assert node2.get_iface(iface2_data.id) # when - session.delete_link(node1.id, node2.id, interface2_id=interface2_data.id) + session.delete_link(node1.id, node2.id, iface2_id=iface2_data.id) # then - assert not node2.netif(interface2_data.id) + assert iface2_data.id not in node2.ifaces diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index 0cbdb8ae..d7e435ab 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -53,53 +53,53 @@ class TestNodes: # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) - interface_data = InterfaceData() - interface = node.newnetif(switch, interface_data) + iface_data = InterfaceData() + iface = node.new_iface(switch, iface_data) mac = "aa:aa:aa:ff:ff:ff" # when - node.sethwaddr(interface.netindex, mac) + node.sethwaddr(iface.node_id, mac) # then - assert interface.hwaddr == mac + assert iface.hwaddr == mac def test_node_sethwaddr_exception(self, session: Session): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) - interface_data = InterfaceData() - interface = node.newnetif(switch, interface_data) + iface_data = InterfaceData() + iface = node.new_iface(switch, iface_data) mac = "aa:aa:aa:ff:ff:fff" # when with pytest.raises(CoreError): - node.sethwaddr(interface.netindex, mac) + node.sethwaddr(iface.node_id, mac) def test_node_addaddr(self, session: Session): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) - interface_data = InterfaceData() - interface = node.newnetif(switch, interface_data) + iface_data = InterfaceData() + iface = node.new_iface(switch, iface_data) addr = "192.168.0.1/24" # when - node.addaddr(interface.netindex, addr) + node.addaddr(iface.node_id, addr) # then - assert interface.addrlist[0] == addr + assert iface.addrlist[0] == addr def test_node_addaddr_exception(self, session): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) - interface_data = InterfaceData() - interface = node.newnetif(switch, interface_data) + iface_data = InterfaceData() + iface = node.new_iface(switch, iface_data) addr = "256.168.0.1/24" # when with pytest.raises(CoreError): - node.addaddr(interface.netindex, addr) + node.addaddr(iface.node_id, addr) @pytest.mark.parametrize("net_type", NET_TYPES) def test_net(self, session, net_type): diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index 0b44a354..55f5a2ab 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -73,8 +73,8 @@ class TestXml: # link nodes to ptp net for node in [node1, node2]: - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, ptp_node.id, interface1_data=interface) + iface_data = ip_prefixes.create_iface(node) + session.add_link(node.id, ptp_node.id, iface1_data=iface_data) # instantiate session session.instantiate() @@ -128,8 +128,8 @@ class TestXml: # link nodes to ptp net for node in [node1, node2]: - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, ptp_node.id, interface1_data=interface) + iface_data = ip_prefixes.create_iface(node) + session.add_link(node.id, ptp_node.id, iface1_data=iface_data) # set custom values for node service session.services.set_service(node1.id, SshService.name) @@ -197,8 +197,8 @@ class TestXml: # link nodes for node in [node1, node2]: - interface = ip_prefixes.create_interface(node) - session.add_link(node.id, wlan_node.id, interface1_data=interface) + iface_data = ip_prefixes.create_iface(node) + session.add_link(node.id, wlan_node.id, iface1_data=iface_data) # instantiate session session.instantiate() @@ -299,7 +299,7 @@ class TestXml: """ # create nodes node1 = session.add_node(CoreNode) - interface1_data = ip_prefixes.create_interface(node1) + iface1_data = ip_prefixes.create_iface(node1) switch = session.add_node(SwitchNode) # create link @@ -309,7 +309,7 @@ class TestXml: options.jitter = 10 options.delay = 30 options.dup = 5 - session.add_link(node1.id, switch.id, interface1_data, options=options) + session.add_link(node1.id, switch.id, iface1_data, options=options) # instantiate session session.instantiate() @@ -365,9 +365,9 @@ class TestXml: """ # create nodes node1 = session.add_node(CoreNode) - interface1_data = ip_prefixes.create_interface(node1) + iface1_data = ip_prefixes.create_iface(node1) node2 = session.add_node(CoreNode) - interface2_data = ip_prefixes.create_interface(node2) + iface2_data = ip_prefixes.create_iface(node2) # create link options = LinkOptions() @@ -376,7 +376,7 @@ class TestXml: options.jitter = 10 options.delay = 30 options.dup = 5 - session.add_link(node1.id, node2.id, interface1_data, interface2_data, options) + session.add_link(node1.id, node2.id, iface1_data, iface2_data, options) # instantiate session session.instantiate() @@ -432,9 +432,9 @@ class TestXml: """ # create nodes node1 = session.add_node(CoreNode) - interface1_data = ip_prefixes.create_interface(node1) + iface1_data = ip_prefixes.create_iface(node1) node2 = session.add_node(CoreNode) - interface2_data = ip_prefixes.create_interface(node2) + iface2_data = ip_prefixes.create_iface(node2) # create link options1 = LinkOptions() @@ -444,7 +444,7 @@ class TestXml: options1.loss = 10.5 options1.dup = 5 options1.jitter = 5 - session.add_link(node1.id, node2.id, interface1_data, interface2_data, options1) + session.add_link(node1.id, node2.id, iface1_data, iface2_data, options1) options2 = LinkOptions() options2.unidirectional = 1 options2.bandwidth = 10000 @@ -453,7 +453,7 @@ class TestXml: options2.dup = 10 options2.jitter = 10 session.update_link( - node2.id, node1.id, interface2_data.id, interface1_data.id, options2 + node2.id, node1.id, iface2_data.id, iface1_data.id, options2 ) # instantiate session diff --git a/docs/scripting.md b/docs/scripting.md index 59bc02ae..18666a9a 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -61,8 +61,8 @@ def main(): # create nodes for _ in range(NODES): node = session.add_node(CoreNode) - interface = prefixes.create_interface(node) - session.add_link(node.id, switch.id, interface1_data=interface) + interface = prefixes.create_iface(node) + session.add_link(node.id, switch.id, iface1_data=interface) # instantiate session session.instantiate() From eeca33e72240aa83e355f584753c8f1e2038f91a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 16 Jun 2020 12:50:24 -0700 Subject: [PATCH 157/929] combined core.emulator.data and core.emulator.emudata, updated LinkData to leverage InterfaceData, instead of repeated interface fields, removed session from LinkData and LinkOptions --- daemon/core/api/grpc/client.py | 2 +- daemon/core/api/grpc/grpcutils.py | 39 ++-- daemon/core/api/grpc/server.py | 3 +- daemon/core/api/tlv/corehandlers.py | 43 ++-- daemon/core/emane/commeffect.py | 2 +- daemon/core/emane/emanemodel.py | 2 +- daemon/core/emane/nodes.py | 3 +- daemon/core/emulator/data.py | 221 +++++++++++++++++-- daemon/core/emulator/emudata.py | 206 ----------------- daemon/core/emulator/session.py | 4 +- daemon/core/location/mobility.py | 3 +- daemon/core/nodes/base.py | 26 +-- daemon/core/nodes/network.py | 55 ++--- daemon/core/nodes/physical.py | 2 +- daemon/core/xml/corexml.py | 71 ++---- daemon/examples/configservices/testing.py | 2 +- daemon/examples/docker/docker2core.py | 2 +- daemon/examples/docker/docker2docker.py | 2 +- daemon/examples/docker/switch.py | 2 +- daemon/examples/lxd/lxd2core.py | 2 +- daemon/examples/lxd/lxd2lxd.py | 2 +- daemon/examples/lxd/switch.py | 2 +- daemon/examples/python/distributed_emane.py | 2 +- daemon/examples/python/distributed_lxd.py | 2 +- daemon/examples/python/distributed_ptp.py | 2 +- daemon/examples/python/distributed_switch.py | 2 +- daemon/examples/python/emane80211.py | 2 +- daemon/examples/python/switch.py | 2 +- daemon/examples/python/switch_inject.py | 2 +- daemon/examples/python/wlan.py | 2 +- daemon/tests/conftest.py | 2 +- daemon/tests/emane/test_emane.py | 2 +- daemon/tests/test_core.py | 2 +- daemon/tests/test_distributed.py | 2 +- daemon/tests/test_grpc.py | 3 +- daemon/tests/test_links.py | 2 +- daemon/tests/test_nodes.py | 2 +- daemon/tests/test_xml.py | 2 +- docs/scripting.md | 2 +- 39 files changed, 332 insertions(+), 399 deletions(-) delete mode 100644 daemon/core/emulator/emudata.py diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 47aaef63..68bfc502 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -92,7 +92,7 @@ from core.api.grpc.wlan_pb2 import ( WlanLinkRequest, WlanLinkResponse, ) -from core.emulator.emudata import IpPrefixes +from core.emulator.data import IpPrefixes class InterfaceHelper: diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index f2f85798..095c4d0c 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -11,8 +11,7 @@ from core.api.grpc import common_pb2, core_pb2 from core.api.grpc.services_pb2 import NodeServiceData, ServiceConfig from core.config import ConfigurableOptions from core.emane.nodes import EmaneNet -from core.emulator.data import LinkData -from core.emulator.emudata import InterfaceData, LinkOptions, NodeOptions +from core.emulator.data import InterfaceData, LinkData, LinkOptions, NodeOptions from core.emulator.enumerations import LinkTypes, NodeTypes from core.emulator.session import Session from core.nodes.base import CoreNode, NodeBase @@ -308,6 +307,18 @@ def parse_emane_model_id(_id: int) -> Tuple[int, int]: return node_id, iface_id +def convert_iface(iface_data: InterfaceData) -> core_pb2.Interface: + return core_pb2.Interface( + id=iface_data.id, + name=iface_data.name, + mac=iface_data.mac, + ip4=iface_data.ip4, + ip4mask=iface_data.ip4_mask, + ip6=iface_data.ip6, + ip6mask=iface_data.ip6_mask, + ) + + def convert_link(link_data: LinkData) -> core_pb2.Link: """ Convert link_data into core protobuf link. @@ -316,27 +327,11 @@ def convert_link(link_data: LinkData) -> core_pb2.Link: :return: core protobuf Link """ iface1 = None - if link_data.iface1_id is not None: - iface1 = core_pb2.Interface( - id=link_data.iface1_id, - name=link_data.iface1_name, - mac=convert_value(link_data.iface1_mac), - ip4=convert_value(link_data.iface1_ip4), - ip4mask=link_data.iface1_ip4_mask, - ip6=convert_value(link_data.iface1_ip6), - ip6mask=link_data.iface1_ip6_mask, - ) + if link_data.iface1 is not None: + iface1 = convert_iface(link_data.iface1) iface2 = None - if link_data.iface2_id is not None: - iface2 = core_pb2.Interface( - id=link_data.iface2_id, - name=link_data.iface2_name, - mac=convert_value(link_data.iface2_mac), - ip4=convert_value(link_data.iface2_ip4), - ip4mask=link_data.iface2_ip4_mask, - ip6=convert_value(link_data.iface2_ip6), - ip6mask=link_data.iface2_ip6_mask, - ) + if link_data.iface2 is not None: + iface2 = convert_iface(link_data.iface2) options = core_pb2.LinkOptions( opaque=link_data.opaque, jitter=link_data.jitter, diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 87b69a77..1be60116 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -108,8 +108,7 @@ from core.api.grpc.wlan_pb2 import ( WlanLinkResponse, ) from core.emulator.coreemu import CoreEmu -from core.emulator.data import LinkData -from core.emulator.emudata import LinkOptions, NodeOptions +from core.emulator.data import LinkData, LinkOptions, NodeOptions from core.emulator.enumerations import EventTypes, LinkTypes, MessageFlags from core.emulator.session import NT, Session from core.errors import CoreCommandError, CoreError diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index b09a37fe..88906e0c 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -29,8 +29,15 @@ from core.api.tlv.enumerations import ( NodeTlvs, SessionTlvs, ) -from core.emulator.data import ConfigData, EventData, ExceptionData, FileData -from core.emulator.emudata import InterfaceData, LinkOptions, NodeOptions +from core.emulator.data import ( + ConfigData, + EventData, + ExceptionData, + FileData, + InterfaceData, + LinkOptions, + NodeOptions, +) from core.emulator.enumerations import ( ConfigDataTypes, EventTypes, @@ -342,6 +349,12 @@ class CoreHandler(socketserver.BaseRequestHandler): dup = "" if link_data.dup is not None: dup = str(link_data.dup) + iface1 = link_data.iface1 + if iface1 is None: + iface1 = InterfaceData() + iface2 = link_data.iface2 + if iface2 is None: + iface2 = InterfaceData() tlv_data = structutils.pack_values( coreapi.CoreLinkTlv, @@ -355,7 +368,6 @@ class CoreHandler(socketserver.BaseRequestHandler): (LinkTlvs.JITTER, link_data.jitter), (LinkTlvs.MER, link_data.mer), (LinkTlvs.BURST, link_data.burst), - (LinkTlvs.SESSION, link_data.session), (LinkTlvs.MBURST, link_data.mburst), (LinkTlvs.TYPE, link_data.link_type.value), (LinkTlvs.GUI_ATTRIBUTES, link_data.gui_attributes), @@ -363,18 +375,18 @@ class CoreHandler(socketserver.BaseRequestHandler): (LinkTlvs.EMULATION_ID, link_data.emulation_id), (LinkTlvs.NETWORK_ID, link_data.network_id), (LinkTlvs.KEY, link_data.key), - (LinkTlvs.IFACE1_NUMBER, link_data.iface1_id), - (LinkTlvs.IFACE1_IP4, link_data.iface1_ip4), - (LinkTlvs.IFACE1_IP4_MASK, link_data.iface1_ip4_mask), - (LinkTlvs.IFACE1_MAC, link_data.iface1_mac), - (LinkTlvs.IFACE1_IP6, link_data.iface1_ip6), - (LinkTlvs.IFACE1_IP6_MASK, link_data.iface1_ip6_mask), - (LinkTlvs.IFACE2_NUMBER, link_data.iface2_id), - (LinkTlvs.IFACE2_IP4, link_data.iface2_ip4), - (LinkTlvs.IFACE2_IP4_MASK, link_data.iface2_ip4_mask), - (LinkTlvs.IFACE2_MAC, link_data.iface2_mac), - (LinkTlvs.IFACE2_IP6, link_data.iface2_ip6), - (LinkTlvs.IFACE2_IP6_MASK, link_data.iface2_ip6_mask), + (LinkTlvs.IFACE1_NUMBER, iface1.id), + (LinkTlvs.IFACE1_IP4, iface1.ip4), + (LinkTlvs.IFACE1_IP4_MASK, iface1.ip4_mask), + (LinkTlvs.IFACE1_MAC, iface1.mac), + (LinkTlvs.IFACE1_IP6, iface1.ip6), + (LinkTlvs.IFACE1_IP6_MASK, iface1.ip6_mask), + (LinkTlvs.IFACE2_NUMBER, iface2.id), + (LinkTlvs.IFACE2_IP4, iface2.ip4), + (LinkTlvs.IFACE2_IP4_MASK, iface2.ip4_mask), + (LinkTlvs.IFACE2_MAC, iface2.mac), + (LinkTlvs.IFACE2_IP6, iface2.ip6), + (LinkTlvs.IFACE2_IP6_MASK, iface2.ip6_mask), (LinkTlvs.OPAQUE, link_data.opaque), ], ) @@ -774,7 +786,6 @@ class CoreHandler(socketserver.BaseRequestHandler): options = LinkOptions(type=link_type) options.delay = message.get_tlv(LinkTlvs.DELAY.value) options.bandwidth = message.get_tlv(LinkTlvs.BANDWIDTH.value) - options.session = message.get_tlv(LinkTlvs.SESSION.value) options.loss = message.get_tlv(LinkTlvs.LOSS.value) options.dup = message.get_tlv(LinkTlvs.DUP.value) options.jitter = message.get_tlv(LinkTlvs.JITTER.value) diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index 0f441d76..610099f1 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -11,7 +11,7 @@ from lxml import etree from core.config import ConfigGroup, Configuration from core.emane import emanemanifest, emanemodel from core.emane.nodes import EmaneNet -from core.emulator.emudata import LinkOptions +from core.emulator.data import LinkOptions from core.emulator.enumerations import TransportType from core.nodes.interface import CoreInterface from core.xml import emanexml diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 1a14011a..43fbc0fb 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -8,7 +8,7 @@ from typing import Dict, List, Optional, Set from core.config import ConfigGroup, Configuration from core.emane import emanemanifest from core.emane.nodes import EmaneNet -from core.emulator.emudata import LinkOptions +from core.emulator.data import LinkOptions from core.emulator.enumerations import ConfigDataTypes, TransportType from core.errors import CoreError from core.location.mobility import WirelessModel diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index eed51ff2..c28f1382 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -6,9 +6,8 @@ share the same MAC+PHY model. import logging from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type -from core.emulator.data import LinkData +from core.emulator.data import LinkData, LinkOptions from core.emulator.distributed import DistributedServer -from core.emulator.emudata import LinkOptions from core.emulator.enumerations import ( LinkTypes, MessageFlags, diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index 47f45820..c08a70f0 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -1,10 +1,12 @@ """ CORE data objects. """ +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, List, Optional, Tuple -from dataclasses import dataclass -from typing import List, Tuple +import netaddr +from core import utils from core.emulator.enumerations import ( EventTypes, ExceptionLevels, @@ -13,6 +15,9 @@ from core.emulator.enumerations import ( NodeTypes, ) +if TYPE_CHECKING: + from core.nodes.base import CoreNode + @dataclass class ConfigData: @@ -93,6 +98,57 @@ class NodeData: source: str = None +@dataclass +class InterfaceData: + """ + Convenience class for storing interface data. + """ + + id: int = None + name: str = None + mac: str = None + ip4: str = None + ip4_mask: int = None + ip6: str = None + ip6_mask: int = None + + def get_addresses(self) -> List[str]: + """ + Returns a list of ip4 and ip6 addresses when present. + + :return: list of addresses + """ + addresses = [] + if self.ip4 and self.ip4_mask: + addresses.append(f"{self.ip4}/{self.ip4_mask}") + if self.ip6 and self.ip6_mask: + addresses.append(f"{self.ip6}/{self.ip6_mask}") + return addresses + + +@dataclass +class LinkOptions: + """ + Options for creating and updating links within core. + """ + + type: LinkTypes = LinkTypes.WIRED + delay: int = None + bandwidth: int = None + loss: float = None + dup: int = None + jitter: int = None + mer: int = None + burst: int = None + mburst: int = None + gui_attributes: str = None + unidirectional: bool = None + emulation_id: int = None + network_id: int = None + key: int = None + opaque: str = None + + @dataclass class LinkData: message_type: MessageFlags = None @@ -106,7 +162,6 @@ class LinkData: jitter: float = None mer: float = None burst: float = None - session: int = None mburst: float = None link_type: LinkTypes = None gui_attributes: str = None @@ -114,19 +169,151 @@ class LinkData: emulation_id: int = None network_id: int = None key: int = None - iface1_id: int = None - iface1_name: str = None - iface1_ip4: str = None - iface1_ip4_mask: int = None - iface1_mac: str = None - iface1_ip6: str = None - iface1_ip6_mask: int = None - iface2_id: int = None - iface2_name: str = None - iface2_ip4: str = None - iface2_ip4_mask: int = None - iface2_mac: str = None - iface2_ip6: str = None - iface2_ip6_mask: int = None + iface1: InterfaceData = None + iface2: InterfaceData = None opaque: str = None color: str = None + + +class IpPrefixes: + """ + Convenience class to help generate IP4 and IP6 addresses for nodes within CORE. + """ + + def __init__(self, ip4_prefix: str = None, ip6_prefix: str = None) -> None: + """ + Creates an IpPrefixes object. + + :param ip4_prefix: ip4 prefix to use for generation + :param ip6_prefix: ip6 prefix to use for generation + :raises ValueError: when both ip4 and ip6 prefixes have not been provided + """ + if not ip4_prefix and not ip6_prefix: + raise ValueError("ip4 or ip6 must be provided") + + self.ip4 = None + if ip4_prefix: + self.ip4 = netaddr.IPNetwork(ip4_prefix) + self.ip6 = None + if ip6_prefix: + self.ip6 = netaddr.IPNetwork(ip6_prefix) + + def ip4_address(self, node_id: int) -> str: + """ + Convenience method to return the IP4 address for a node. + + :param node_id: node id to get IP4 address for + :return: IP4 address or None + """ + if not self.ip4: + raise ValueError("ip4 prefixes have not been set") + return str(self.ip4[node_id]) + + def ip6_address(self, node_id: int) -> str: + """ + Convenience method to return the IP6 address for a node. + + :param node_id: node id to get IP6 address for + :return: IP4 address or None + """ + if not self.ip6: + raise ValueError("ip6 prefixes have not been set") + return str(self.ip6[node_id]) + + def gen_iface(self, node_id: int, name: str = None, mac: str = None): + """ + Creates interface data for linking nodes, using the nodes unique id for + generation, along with a random mac address, unless provided. + + :param node_id: node id to create an interface for + :param name: name to set for interface, default is eth{id} + :param mac: mac address to use for this interface, default is random + generation + :return: new interface data for the provided node + """ + # generate ip4 data + ip4 = None + ip4_mask = None + if self.ip4: + ip4 = self.ip4_address(node_id) + ip4_mask = self.ip4.prefixlen + + # generate ip6 data + ip6 = None + ip6_mask = None + if self.ip6: + ip6 = self.ip6_address(node_id) + ip6_mask = self.ip6.prefixlen + + # random mac + if not mac: + mac = utils.random_mac() + + return InterfaceData( + name=name, ip4=ip4, ip4_mask=ip4_mask, ip6=ip6, ip6_mask=ip6_mask, mac=mac + ) + + def create_iface( + self, node: "CoreNode", name: str = None, mac: str = None + ) -> InterfaceData: + """ + Creates interface data for linking nodes, using the nodes unique id for + generation, along with a random mac address, unless provided. + + :param node: node to create interface for + :param name: name to set for interface, default is eth{id} + :param mac: mac address to use for this interface, default is random + generation + :return: new interface data for the provided node + """ + iface_data = self.gen_iface(node.id, name, mac) + iface_data.id = node.next_iface_id() + return iface_data + + +@dataclass +class NodeOptions: + """ + Options for creating and updating nodes within core. + """ + + name: str = None + model: Optional[str] = "PC" + canvas: int = None + icon: str = None + opaque: str = None + services: List[str] = field(default_factory=list) + config_services: List[str] = field(default_factory=list) + x: float = None + y: float = None + lat: float = None + lon: float = None + alt: float = None + emulation_id: int = None + server: str = None + image: str = None + emane: str = None + + def set_position(self, x: float, y: float) -> None: + """ + Convenience method for setting position. + + :param x: x position + :param y: y position + :return: nothing + """ + self.x = x + self.y = y + + def set_location(self, lat: float, lon: float, alt: float) -> None: + """ + Convenience method for setting location. + + :param lat: latitude + :param lon: longitude + :param alt: altitude + :return: nothing + """ + self.lat = lat + self.lon = lon + self.alt = alt diff --git a/daemon/core/emulator/emudata.py b/daemon/core/emulator/emudata.py deleted file mode 100644 index 25ce71ac..00000000 --- a/daemon/core/emulator/emudata.py +++ /dev/null @@ -1,206 +0,0 @@ -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, List, Optional - -import netaddr - -from core import utils -from core.emulator.enumerations import LinkTypes - -if TYPE_CHECKING: - from core.nodes.base import CoreNode - - -@dataclass -class NodeOptions: - """ - Options for creating and updating nodes within core. - """ - - name: str = None - model: Optional[str] = "PC" - canvas: int = None - icon: str = None - opaque: str = None - services: List[str] = field(default_factory=list) - config_services: List[str] = field(default_factory=list) - x: float = None - y: float = None - lat: float = None - lon: float = None - alt: float = None - emulation_id: int = None - server: str = None - image: str = None - emane: str = None - - def set_position(self, x: float, y: float) -> None: - """ - Convenience method for setting position. - - :param x: x position - :param y: y position - :return: nothing - """ - self.x = x - self.y = y - - def set_location(self, lat: float, lon: float, alt: float) -> None: - """ - Convenience method for setting location. - - :param lat: latitude - :param lon: longitude - :param alt: altitude - :return: nothing - """ - self.lat = lat - self.lon = lon - self.alt = alt - - -@dataclass -class LinkOptions: - """ - Options for creating and updating links within core. - """ - - type: LinkTypes = LinkTypes.WIRED - session: int = None - delay: int = None - bandwidth: int = None - loss: float = None - dup: int = None - jitter: int = None - mer: int = None - burst: int = None - mburst: int = None - gui_attributes: str = None - unidirectional: bool = None - emulation_id: int = None - network_id: int = None - key: int = None - opaque: str = None - - -@dataclass -class InterfaceData: - """ - Convenience class for storing interface data. - """ - - id: int = None - name: str = None - mac: str = None - ip4: str = None - ip4_mask: int = None - ip6: str = None - ip6_mask: int = None - - def get_addresses(self) -> List[str]: - """ - Returns a list of ip4 and ip6 addresses when present. - - :return: list of addresses - """ - addresses = [] - if self.ip4 and self.ip4_mask: - addresses.append(f"{self.ip4}/{self.ip4_mask}") - if self.ip6 and self.ip6_mask: - addresses.append(f"{self.ip6}/{self.ip6_mask}") - return addresses - - -class IpPrefixes: - """ - Convenience class to help generate IP4 and IP6 addresses for nodes within CORE. - """ - - def __init__(self, ip4_prefix: str = None, ip6_prefix: str = None) -> None: - """ - Creates an IpPrefixes object. - - :param ip4_prefix: ip4 prefix to use for generation - :param ip6_prefix: ip6 prefix to use for generation - :raises ValueError: when both ip4 and ip6 prefixes have not been provided - """ - if not ip4_prefix and not ip6_prefix: - raise ValueError("ip4 or ip6 must be provided") - - self.ip4 = None - if ip4_prefix: - self.ip4 = netaddr.IPNetwork(ip4_prefix) - self.ip6 = None - if ip6_prefix: - self.ip6 = netaddr.IPNetwork(ip6_prefix) - - def ip4_address(self, node_id: int) -> str: - """ - Convenience method to return the IP4 address for a node. - - :param node_id: node id to get IP4 address for - :return: IP4 address or None - """ - if not self.ip4: - raise ValueError("ip4 prefixes have not been set") - return str(self.ip4[node_id]) - - def ip6_address(self, node_id: int) -> str: - """ - Convenience method to return the IP6 address for a node. - - :param node_id: node id to get IP6 address for - :return: IP4 address or None - """ - if not self.ip6: - raise ValueError("ip6 prefixes have not been set") - return str(self.ip6[node_id]) - - def gen_iface(self, node_id: int, name: str = None, mac: str = None): - """ - Creates interface data for linking nodes, using the nodes unique id for - generation, along with a random mac address, unless provided. - - :param node_id: node id to create an interface for - :param name: name to set for interface, default is eth{id} - :param mac: mac address to use for this interface, default is random - generation - :return: new interface data for the provided node - """ - # generate ip4 data - ip4 = None - ip4_mask = None - if self.ip4: - ip4 = self.ip4_address(node_id) - ip4_mask = self.ip4.prefixlen - - # generate ip6 data - ip6 = None - ip6_mask = None - if self.ip6: - ip6 = self.ip6_address(node_id) - ip6_mask = self.ip6.prefixlen - - # random mac - if not mac: - mac = utils.random_mac() - - return InterfaceData( - name=name, ip4=ip4, ip4_mask=ip4_mask, ip6=ip6, ip6_mask=ip6_mask, mac=mac - ) - - def create_iface( - self, node: "CoreNode", name: str = None, mac: str = None - ) -> InterfaceData: - """ - Creates interface data for linking nodes, using the nodes unique id for - generation, along with a random mac address, unless provided. - - :param node: node to create interface for - :param name: name to set for interface, default is eth{id} - :param mac: mac address to use for this interface, default is random - generation - :return: new interface data for the provided node - """ - iface_data = self.gen_iface(node.id, name, mac) - iface_data.id = node.next_iface_id() - return iface_data diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 2dc5ad12..f2514e67 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -22,11 +22,13 @@ from core.emulator.data import ( EventData, ExceptionData, FileData, + InterfaceData, LinkData, + LinkOptions, NodeData, + NodeOptions, ) from core.emulator.distributed import DistributedController -from core.emulator.emudata import InterfaceData, LinkOptions, NodeOptions from core.emulator.enumerations import ( EventTypes, ExceptionLevels, diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index d56c40aa..91a8baae 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -13,8 +13,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple from core import utils from core.config import ConfigGroup, ConfigurableOptions, Configuration, ModelManager -from core.emulator.data import EventData, LinkData -from core.emulator.emudata import LinkOptions +from core.emulator.data import EventData, LinkData, LinkOptions from core.emulator.enumerations import ( ConfigDataTypes, EventTypes, diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 40aae6a8..3c754aa2 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -14,8 +14,7 @@ import netaddr from core import utils from core.configservice.dependencies import ConfigServiceDependencies from core.constants import MOUNT_BIN, VNODED_BIN -from core.emulator.data import LinkData, NodeData -from core.emulator.emudata import InterfaceData, LinkOptions +from core.emulator.data import InterfaceData, LinkData, LinkOptions, NodeData from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes from core.errors import CoreCommandError, CoreError from core.nodes.client import VnodeClient @@ -1096,19 +1095,18 @@ class CoreNetworkBase(NodeBase): if uni: unidirectional = 1 - iface2_ip4 = None - iface2_ip4_mask = None - iface2_ip6 = None - iface2_ip6_mask = None + iface2 = InterfaceData( + id=linked_node.get_iface_id(iface), name=iface.name, mac=iface.hwaddr + ) for address in iface.addrlist: ip, _sep, mask = address.partition("/") mask = int(mask) if netaddr.valid_ipv4(ip): - iface2_ip4 = ip - iface2_ip4_mask = mask + iface2.ip4 = ip + iface2.ip4_mask = mask else: - iface2_ip6 = ip - iface2_ip6_mask = mask + iface2.ip6 = ip + iface2.ip6_mask = mask link_data = LinkData( message_type=flags, @@ -1116,13 +1114,7 @@ class CoreNetworkBase(NodeBase): node2_id=linked_node.id, link_type=self.linktype, unidirectional=unidirectional, - iface2_id=linked_node.get_iface_id(iface), - iface2_name=iface.name, - iface2_mac=iface.hwaddr, - iface2_ip4=iface2_ip4, - iface2_ip4_mask=iface2_ip4_mask, - iface2_ip6=iface2_ip6, - iface2_ip6_mask=iface2_ip6_mask, + iface2=iface2, delay=iface.getparam("delay"), bandwidth=iface.getparam("bw"), dup=iface.getparam("duplicate"), diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 85e3e488..b2f6bbf3 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -11,8 +11,7 @@ import netaddr from core import utils from core.constants import EBTABLES_BIN, TC_BIN -from core.emulator.data import LinkData, NodeData -from core.emulator.emudata import InterfaceData, LinkOptions +from core.emulator.data import InterfaceData, LinkData, LinkOptions, NodeData from core.emulator.enumerations import ( LinkTypes, MessageFlags, @@ -894,33 +893,31 @@ class PtpNet(CoreNetwork): if iface1.getparams() != iface2.getparams(): unidirectional = 1 - iface1_ip4 = None - iface1_ip4_mask = None - iface1_ip6 = None - iface1_ip6_mask = None + iface1_data = InterfaceData( + id=iface1.node.get_iface_id(iface1), name=iface1.name, mac=iface1.hwaddr + ) for address in iface1.addrlist: ip, _sep, mask = address.partition("/") mask = int(mask) if netaddr.valid_ipv4(ip): - iface1_ip4 = ip - iface1_ip4_mask = mask + iface1.ip4 = ip + iface1.ip4_mask = mask else: - iface1_ip6 = ip - iface1_ip6_mask = mask + iface1.ip6 = ip + iface1.ip6_mask = mask - iface2_ip4 = None - iface2_ip4_mask = None - iface2_ip6 = None - iface2_ip6_mask = None + iface2_data = InterfaceData( + id=iface2.node.get_iface_id(iface2), name=iface2.name, mac=iface2.hwaddr + ) for address in iface2.addrlist: ip, _sep, mask = address.partition("/") mask = int(mask) if netaddr.valid_ipv4(ip): - iface2_ip4 = ip - iface2_ip4_mask = mask + iface2.ip4 = ip + iface2.ip4_mask = mask else: - iface2_ip6 = ip - iface2_ip6_mask = mask + iface2.ip6 = ip + iface2.ip6_mask = mask link_data = LinkData( message_type=flags, @@ -933,26 +930,16 @@ class PtpNet(CoreNetwork): loss=iface1.getparam("loss"), dup=iface1.getparam("duplicate"), jitter=iface1.getparam("jitter"), - iface1_id=iface1.node.get_iface_id(iface1), - iface1_name=iface1.name, - iface1_mac=iface1.hwaddr, - iface1_ip4=iface1_ip4, - iface1_ip4_mask=iface1_ip4_mask, - iface1_ip6=iface1_ip6, - iface1_ip6_mask=iface1_ip6_mask, - iface2_id=iface2.node.get_iface_id(iface2), - iface2_name=iface2.name, - iface2_mac=iface2.hwaddr, - iface2_ip4=iface2_ip4, - iface2_ip4_mask=iface2_ip4_mask, - iface2_ip6=iface2_ip6, - iface2_ip6_mask=iface2_ip6_mask, + iface1=iface1_data, + iface2=iface2_data, ) all_links.append(link_data) # build a 2nd link message for the upstream link parameters # (swap if1 and if2) if unidirectional: + iface1_data = InterfaceData(id=iface2.node.get_iface_id(iface2)) + iface2_data = InterfaceData(id=iface1.node.get_iface_id(iface1)) link_data = LinkData( message_type=MessageFlags.NONE, link_type=self.linktype, @@ -964,8 +951,8 @@ class PtpNet(CoreNetwork): dup=iface2.getparam("duplicate"), jitter=iface2.getparam("jitter"), unidirectional=1, - iface1_id=iface2.node.get_iface_id(iface2), - iface2_id=iface1.node.get_iface_id(iface1), + iface1=iface1_data, + iface2=iface2_data, ) all_links.append(link_data) return all_links diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 555e0ec9..36bcb267 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -9,8 +9,8 @@ from typing import IO, TYPE_CHECKING, List, Optional, Tuple from core import utils from core.constants import MOUNT_BIN, UMOUNT_BIN +from core.emulator.data import InterfaceData, LinkOptions from core.emulator.distributed import DistributedServer -from core.emulator.emudata import InterfaceData, LinkOptions from core.emulator.enumerations import NodeTypes, TransportType from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNetworkBase, CoreNodeBase diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index fe596d7a..1f92502c 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -6,8 +6,7 @@ from lxml import etree import core.nodes.base import core.nodes.physical from core.emane.nodes import EmaneNet -from core.emulator.data import LinkData -from core.emulator.emudata import InterfaceData, LinkOptions, NodeOptions +from core.emulator.data import InterfaceData, LinkData, LinkOptions, NodeOptions from core.emulator.enumerations import EventTypes, NodeTypes from core.errors import CoreXmlError from core.nodes.base import CoreNodeBase, NodeBase @@ -482,12 +481,10 @@ class CoreXmlWriter: # add link data for link_data in links: # skip basic range links - if link_data.iface1_id is None and link_data.iface2_id is None: + if link_data.iface1 is None and link_data.iface2 is None: continue - link_element = self.create_link_element(link_data) link_elements.append(link_element) - if link_elements.getchildren(): self.scenario.append(link_elements) @@ -496,36 +493,24 @@ class CoreXmlWriter: self.devices.append(device.element) def create_iface_element( - self, - element_name: str, - node_id: int, - iface_id: int, - mac: str, - ip4: str, - ip4_mask: int, - ip6: str, - ip6_mask: int, + self, element_name: str, node_id: int, iface_data: InterfaceData ) -> etree.Element: - iface = etree.Element(element_name) + iface_element = etree.Element(element_name) node = self.session.get_node(node_id, NodeBase) - iface_name = None if isinstance(node, CoreNodeBase): - node_iface = node.get_iface(iface_id) - iface_name = node_iface.name - + iface = node.get_iface(iface_data.id) # check if emane interface - if isinstance(node_iface.net, EmaneNet): - nem = node_iface.net.getnemid(node_iface) - add_attribute(iface, "nem", nem) - - add_attribute(iface, "id", iface_id) - add_attribute(iface, "name", iface_name) - add_attribute(iface, "mac", mac) - add_attribute(iface, "ip4", ip4) - add_attribute(iface, "ip4_mask", ip4_mask) - add_attribute(iface, "ip6", ip6) - add_attribute(iface, "ip6_mask", ip6_mask) - return iface + if isinstance(iface.net, EmaneNet): + nem = iface.net.getnemid(iface) + add_attribute(iface_element, "nem", nem) + add_attribute(iface_element, "id", iface_data.id) + add_attribute(iface_element, "name", iface_data.name) + add_attribute(iface_element, "mac", iface_data.mac) + add_attribute(iface_element, "ip4", iface_data.ip4) + add_attribute(iface_element, "ip4_mask", iface_data.ip4_mask) + add_attribute(iface_element, "ip6", iface_data.ip6) + add_attribute(iface_element, "ip6_mask", iface_data.ip6_mask) + return iface_element def create_link_element(self, link_data: LinkData) -> etree.Element: link_element = etree.Element("link") @@ -533,30 +518,16 @@ class CoreXmlWriter: add_attribute(link_element, "node2", link_data.node2_id) # check for interface one - if link_data.iface1_id is not None: + if link_data.iface1 is not None: iface1 = self.create_iface_element( - "interface1", - link_data.node1_id, - link_data.iface1_id, - link_data.iface1_mac, - link_data.iface1_ip4, - link_data.iface1_ip4_mask, - link_data.iface1_ip6, - link_data.iface1_ip6_mask, + "interface1", link_data.node1_id, link_data.iface1 ) link_element.append(iface1) # check for interface two - if link_data.iface2_id is not None: + if link_data.iface2 is not None: iface2 = self.create_iface_element( - "interface2", - link_data.node2_id, - link_data.iface2_id, - link_data.iface2_mac, - link_data.iface2_ip4, - link_data.iface2_ip4_mask, - link_data.iface2_ip6, - link_data.iface2_ip6_mask, + "interface2", link_data.node2_id, link_data.iface2 ) link_element.append(iface2) @@ -582,7 +553,6 @@ class CoreXmlWriter: add_attribute(options, "network_id", link_data.network_id) add_attribute(options, "key", link_data.key) add_attribute(options, "opaque", link_data.opaque) - add_attribute(options, "session", link_data.session) if options.items(): link_element.append(options) @@ -969,7 +939,6 @@ class CoreXmlReader: if options.loss is None: options.loss = get_float(options_element, "per") options.unidirectional = get_int(options_element, "unidirectional") - options.session = options_element.get("session") options.emulation_id = get_int(options_element, "emulation_id") options.network_id = get_int(options_element, "network_id") options.opaque = options_element.get("opaque") diff --git a/daemon/examples/configservices/testing.py b/daemon/examples/configservices/testing.py index 767d0f45..9706f2c9 100644 --- a/daemon/examples/configservices/testing.py +++ b/daemon/examples/configservices/testing.py @@ -1,7 +1,7 @@ import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode from core.nodes.network import SwitchNode diff --git a/daemon/examples/docker/docker2core.py b/daemon/examples/docker/docker2core.py index c38f96af..ae7dae79 100644 --- a/daemon/examples/docker/docker2core.py +++ b/daemon/examples/docker/docker2core.py @@ -1,7 +1,7 @@ import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode from core.nodes.docker import DockerNode diff --git a/daemon/examples/docker/docker2docker.py b/daemon/examples/docker/docker2docker.py index 5b62d433..308fd00f 100644 --- a/daemon/examples/docker/docker2docker.py +++ b/daemon/examples/docker/docker2docker.py @@ -1,7 +1,7 @@ import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.docker import DockerNode diff --git a/daemon/examples/docker/switch.py b/daemon/examples/docker/switch.py index 161cd823..fa9e4e40 100644 --- a/daemon/examples/docker/switch.py +++ b/daemon/examples/docker/switch.py @@ -1,7 +1,7 @@ import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode from core.nodes.docker import DockerNode diff --git a/daemon/examples/lxd/lxd2core.py b/daemon/examples/lxd/lxd2core.py index 3d8eef6a..b41520d8 100644 --- a/daemon/examples/lxd/lxd2core.py +++ b/daemon/examples/lxd/lxd2core.py @@ -1,7 +1,7 @@ import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode from core.nodes.lxd import LxcNode diff --git a/daemon/examples/lxd/lxd2lxd.py b/daemon/examples/lxd/lxd2lxd.py index a7209b5c..3a55e2e1 100644 --- a/daemon/examples/lxd/lxd2lxd.py +++ b/daemon/examples/lxd/lxd2lxd.py @@ -1,7 +1,7 @@ import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.lxd import LxcNode diff --git a/daemon/examples/lxd/switch.py b/daemon/examples/lxd/switch.py index 9b6801f5..12767e71 100644 --- a/daemon/examples/lxd/switch.py +++ b/daemon/examples/lxd/switch.py @@ -1,7 +1,7 @@ import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode from core.nodes.lxd import LxcNode diff --git a/daemon/examples/python/distributed_emane.py b/daemon/examples/python/distributed_emane.py index 3ee56108..4421283f 100644 --- a/daemon/examples/python/distributed_emane.py +++ b/daemon/examples/python/distributed_emane.py @@ -9,7 +9,7 @@ import logging from core.emane.ieee80211abg import EmaneIeee80211abgModel from core.emane.nodes import EmaneNet from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode diff --git a/daemon/examples/python/distributed_lxd.py b/daemon/examples/python/distributed_lxd.py index 1573836a..26f7caa6 100644 --- a/daemon/examples/python/distributed_lxd.py +++ b/daemon/examples/python/distributed_lxd.py @@ -7,7 +7,7 @@ import argparse import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.lxd import LxcNode diff --git a/daemon/examples/python/distributed_ptp.py b/daemon/examples/python/distributed_ptp.py index 1486c237..fe714e1d 100644 --- a/daemon/examples/python/distributed_ptp.py +++ b/daemon/examples/python/distributed_ptp.py @@ -7,7 +7,7 @@ import argparse import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode diff --git a/daemon/examples/python/distributed_switch.py b/daemon/examples/python/distributed_switch.py index e9eb1e81..35de1cad 100644 --- a/daemon/examples/python/distributed_switch.py +++ b/daemon/examples/python/distributed_switch.py @@ -7,7 +7,7 @@ import argparse import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode from core.nodes.network import SwitchNode diff --git a/daemon/examples/python/emane80211.py b/daemon/examples/python/emane80211.py index 322e569f..9d6def4a 100644 --- a/daemon/examples/python/emane80211.py +++ b/daemon/examples/python/emane80211.py @@ -10,7 +10,7 @@ import time from core.emane.ieee80211abg import EmaneIeee80211abgModel from core.emane.nodes import EmaneNet from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode diff --git a/daemon/examples/python/switch.py b/daemon/examples/python/switch.py index 902e79e0..f05176a3 100644 --- a/daemon/examples/python/switch.py +++ b/daemon/examples/python/switch.py @@ -6,7 +6,7 @@ interact with the GUI. import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes +from core.emulator.data import IpPrefixes from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode from core.nodes.network import SwitchNode diff --git a/daemon/examples/python/switch_inject.py b/daemon/examples/python/switch_inject.py index 89f70e05..18a75a49 100644 --- a/daemon/examples/python/switch_inject.py +++ b/daemon/examples/python/switch_inject.py @@ -8,7 +8,7 @@ same CoreEmu instance the GUI is using. import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes +from core.emulator.data import IpPrefixes from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode from core.nodes.network import SwitchNode diff --git a/daemon/examples/python/wlan.py b/daemon/examples/python/wlan.py index 547a5860..de26ab97 100644 --- a/daemon/examples/python/wlan.py +++ b/daemon/examples/python/wlan.py @@ -6,7 +6,7 @@ interact with the GUI. import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import EventTypes from core.location.mobility import BasicRangeModel from core.nodes.base import CoreNode diff --git a/daemon/tests/conftest.py b/daemon/tests/conftest.py index c3315e7c..be62fc03 100644 --- a/daemon/tests/conftest.py +++ b/daemon/tests/conftest.py @@ -14,8 +14,8 @@ from core.api.grpc.server import CoreGrpcServer from core.api.tlv.corehandlers import CoreHandler from core.emane.emanemanager import EmaneManager from core.emulator.coreemu import CoreEmu +from core.emulator.data import IpPrefixes from core.emulator.distributed import DistributedServer -from core.emulator.emudata import IpPrefixes from core.emulator.enumerations import EventTypes from core.emulator.session import Session from core.nodes.base import CoreNode diff --git a/daemon/tests/emane/test_emane.py b/daemon/tests/emane/test_emane.py index e1c7938b..f51e30b9 100644 --- a/daemon/tests/emane/test_emane.py +++ b/daemon/tests/emane/test_emane.py @@ -15,7 +15,7 @@ from core.emane.ieee80211abg import EmaneIeee80211abgModel from core.emane.nodes import EmaneNet from core.emane.rfpipe import EmaneRfPipeModel from core.emane.tdma import EmaneTdmaModel -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.session import Session from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNode diff --git a/daemon/tests/test_core.py b/daemon/tests/test_core.py index 5771f7ad..2623b0df 100644 --- a/daemon/tests/test_core.py +++ b/daemon/tests/test_core.py @@ -8,7 +8,7 @@ from typing import Type import pytest -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import IpPrefixes, NodeOptions from core.emulator.enumerations import MessageFlags from core.emulator.session import Session from core.errors import CoreCommandError diff --git a/daemon/tests/test_distributed.py b/daemon/tests/test_distributed.py index 0f4b1731..01362cae 100644 --- a/daemon/tests/test_distributed.py +++ b/daemon/tests/test_distributed.py @@ -1,4 +1,4 @@ -from core.emulator.emudata import NodeOptions +from core.emulator.data import NodeOptions from core.emulator.session import Session from core.nodes.base import CoreNode from core.nodes.network import HubNode diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 23ff0301..b2a1c312 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -18,8 +18,7 @@ from core.api.tlv.dataconversion import ConfigShim from core.api.tlv.enumerations import ConfigFlags from core.emane.ieee80211abg import EmaneIeee80211abgModel from core.emane.nodes import EmaneNet -from core.emulator.data import EventData, NodeData -from core.emulator.emudata import IpPrefixes, NodeOptions +from core.emulator.data import EventData, IpPrefixes, NodeData, NodeOptions from core.emulator.enumerations import EventTypes, ExceptionLevels, NodeTypes from core.errors import CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility diff --git a/daemon/tests/test_links.py b/daemon/tests/test_links.py index fea4f4f8..4078d8bc 100644 --- a/daemon/tests/test_links.py +++ b/daemon/tests/test_links.py @@ -1,6 +1,6 @@ from typing import Tuple -from core.emulator.emudata import IpPrefixes, LinkOptions +from core.emulator.data import IpPrefixes, LinkOptions from core.emulator.session import Session from core.nodes.base import CoreNode from core.nodes.network import SwitchNode diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index d7e435ab..327137d2 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -1,6 +1,6 @@ import pytest -from core.emulator.emudata import InterfaceData, NodeOptions +from core.emulator.data import InterfaceData, NodeOptions from core.emulator.session import Session from core.errors import CoreError from core.nodes.base import CoreNode diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index 55f5a2ab..d81fe471 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -3,7 +3,7 @@ from xml.etree import ElementTree import pytest -from core.emulator.emudata import IpPrefixes, LinkOptions, NodeOptions +from core.emulator.data import IpPrefixes, LinkOptions, NodeOptions from core.emulator.enumerations import EventTypes from core.emulator.session import Session from core.errors import CoreError diff --git a/docs/scripting.md b/docs/scripting.md index 18666a9a..f65d66a3 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -36,7 +36,7 @@ interact with the GUI. import logging from core.emulator.coreemu import CoreEmu -from core.emulator.emudata import IpPrefixes +from core.emulator.data import IpPrefixes from core.emulator.enumerations import EventTypes from core.nodes.base import CoreNode from core.nodes.network import SwitchNode From a29a7a558277e9438e288dbeaa1bbd6839bc7dcf Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 16 Jun 2020 14:18:19 -0700 Subject: [PATCH 158/929] refactored LinkOptions to be used within LinkData, instead of duplicating data, removed session from LinkOptions and LinkData --- daemon/core/api/grpc/grpcutils.py | 30 ++++++++++++---------- daemon/core/api/tlv/corehandlers.py | 31 +++++++++++----------- daemon/core/emulator/data.py | 25 ++++++------------ daemon/core/nodes/base.py | 20 ++++----------- daemon/core/nodes/interface.py | 29 +++++++++++++++++++++ daemon/core/nodes/network.py | 21 +++++---------- daemon/core/xml/corexml.py | 29 ++++++++++----------- daemon/tests/test_grpc.py | 4 +-- daemon/tests/test_gui.py | 4 +-- daemon/tests/test_xml.py | 40 ++++++++++++++--------------- 10 files changed, 120 insertions(+), 113 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 095c4d0c..5213a835 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -319,6 +319,22 @@ def convert_iface(iface_data: InterfaceData) -> core_pb2.Interface: ) +def convert_link_options(options_data: LinkOptions) -> core_pb2.LinkOptions: + return core_pb2.LinkOptions( + opaque=options_data.opaque, + jitter=options_data.jitter, + key=options_data.key, + mburst=options_data.mburst, + mer=options_data.mer, + loss=options_data.loss, + bandwidth=options_data.bandwidth, + burst=options_data.burst, + delay=options_data.delay, + dup=options_data.dup, + unidirectional=options_data.unidirectional, + ) + + def convert_link(link_data: LinkData) -> core_pb2.Link: """ Convert link_data into core protobuf link. @@ -332,19 +348,7 @@ def convert_link(link_data: LinkData) -> core_pb2.Link: iface2 = None if link_data.iface2 is not None: iface2 = convert_iface(link_data.iface2) - options = core_pb2.LinkOptions( - opaque=link_data.opaque, - jitter=link_data.jitter, - key=link_data.key, - mburst=link_data.mburst, - mer=link_data.mer, - loss=link_data.loss, - bandwidth=link_data.bandwidth, - burst=link_data.burst, - delay=link_data.delay, - dup=link_data.dup, - unidirectional=link_data.unidirectional, - ) + options = convert_link_options(link_data.options) return core_pb2.Link( type=link_data.link_type.value, node1_id=link_data.node1_id, diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 88906e0c..3a4351f1 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -343,12 +343,13 @@ class CoreHandler(socketserver.BaseRequestHandler): :return: nothing """ logging.debug("handling broadcast link: %s", link_data) + options_data = link_data.options loss = "" - if link_data.loss is not None: - loss = str(link_data.loss) + if options_data.loss is not None: + loss = str(options_data.loss) dup = "" - if link_data.dup is not None: - dup = str(link_data.dup) + if options_data.dup is not None: + dup = str(options_data.dup) iface1 = link_data.iface1 if iface1 is None: iface1 = InterfaceData() @@ -361,20 +362,20 @@ class CoreHandler(socketserver.BaseRequestHandler): [ (LinkTlvs.N1_NUMBER, link_data.node1_id), (LinkTlvs.N2_NUMBER, link_data.node2_id), - (LinkTlvs.DELAY, link_data.delay), - (LinkTlvs.BANDWIDTH, link_data.bandwidth), + (LinkTlvs.DELAY, options_data.delay), + (LinkTlvs.BANDWIDTH, options_data.bandwidth), (LinkTlvs.LOSS, loss), (LinkTlvs.DUP, dup), - (LinkTlvs.JITTER, link_data.jitter), - (LinkTlvs.MER, link_data.mer), - (LinkTlvs.BURST, link_data.burst), - (LinkTlvs.MBURST, link_data.mburst), + (LinkTlvs.JITTER, options_data.jitter), + (LinkTlvs.MER, options_data.mer), + (LinkTlvs.BURST, options_data.burst), + (LinkTlvs.MBURST, options_data.mburst), (LinkTlvs.TYPE, link_data.link_type.value), - (LinkTlvs.GUI_ATTRIBUTES, link_data.gui_attributes), - (LinkTlvs.UNIDIRECTIONAL, link_data.unidirectional), - (LinkTlvs.EMULATION_ID, link_data.emulation_id), + (LinkTlvs.GUI_ATTRIBUTES, options_data.gui_attributes), + (LinkTlvs.UNIDIRECTIONAL, options_data.unidirectional), + (LinkTlvs.EMULATION_ID, options_data.emulation_id), (LinkTlvs.NETWORK_ID, link_data.network_id), - (LinkTlvs.KEY, link_data.key), + (LinkTlvs.KEY, options_data.key), (LinkTlvs.IFACE1_NUMBER, iface1.id), (LinkTlvs.IFACE1_IP4, iface1.ip4), (LinkTlvs.IFACE1_IP4_MASK, iface1.ip4_mask), @@ -387,7 +388,7 @@ class CoreHandler(socketserver.BaseRequestHandler): (LinkTlvs.IFACE2_MAC, iface2.mac), (LinkTlvs.IFACE2_IP6, iface2.ip6), (LinkTlvs.IFACE2_IP6_MASK, iface2.ip6_mask), - (LinkTlvs.OPAQUE, link_data.opaque), + (LinkTlvs.OPAQUE, options_data.opaque), ], ) diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index c08a70f0..0c263135 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -142,36 +142,27 @@ class LinkOptions: burst: int = None mburst: int = None gui_attributes: str = None - unidirectional: bool = None + unidirectional: int = None emulation_id: int = None - network_id: int = None key: int = None opaque: str = None @dataclass class LinkData: + """ + Represents all data associated with a link. + """ + message_type: MessageFlags = None + link_type: LinkTypes = None label: str = None node1_id: int = None node2_id: int = None - delay: float = None - bandwidth: float = None - loss: float = None - dup: float = None - jitter: float = None - mer: float = None - burst: float = None - mburst: float = None - link_type: LinkTypes = None - gui_attributes: str = None - unidirectional: int = None - emulation_id: int = None - network_id: int = None - key: int = None iface1: InterfaceData = None iface2: InterfaceData = None - opaque: str = None + options: LinkOptions = LinkOptions() + network_id: int = None color: str = None diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 3c754aa2..a6e4f147 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -224,9 +224,7 @@ class NodeBase(abc.ABC): def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ - Build CORE Link data for this object. There is no default - method for PyCoreObjs as PyCoreNodes do not implement this but - PyCoreNets do. + Build link data for this node. :param flags: message flags :return: list of link data @@ -1108,35 +1106,27 @@ class CoreNetworkBase(NodeBase): iface2.ip6 = ip iface2.ip6_mask = mask + options_data = iface.get_link_options(unidirectional) link_data = LinkData( message_type=flags, node1_id=self.id, node2_id=linked_node.id, link_type=self.linktype, - unidirectional=unidirectional, iface2=iface2, - delay=iface.getparam("delay"), - bandwidth=iface.getparam("bw"), - dup=iface.getparam("duplicate"), - jitter=iface.getparam("jitter"), - loss=iface.getparam("loss"), + options=options_data, ) all_links.append(link_data) if not uni: continue iface.swapparams("_params_up") + options_data = iface.get_link_options(unidirectional) link_data = LinkData( message_type=MessageFlags.NONE, node1_id=linked_node.id, node2_id=self.id, link_type=self.linktype, - unidirectional=1, - delay=iface.getparam("delay"), - bandwidth=iface.getparam("bw"), - dup=iface.getparam("duplicate"), - jitter=iface.getparam("jitter"), - loss=iface.getparam("loss"), + options=options_data, ) iface.swapparams("_params_up") all_links.append(link_data) diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index dc16517f..1fb8b894 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -7,6 +7,7 @@ import time from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple from core import utils +from core.emulator.data import LinkOptions from core.emulator.enumerations import MessageFlags, TransportType from core.errors import CoreCommandError from core.nodes.netclient import LinuxNetClient, get_net_client @@ -169,6 +170,34 @@ class CoreInterface: """ return self._params.get(key) + def get_link_options(self, unidirectional: int) -> LinkOptions: + """ + Get currently set params as link options. + + :param unidirectional: unidirectional setting + :return: link options + """ + delay = self.getparam("delay") + if delay is not None: + delay = int(delay) + bandwidth = self.getparam("bw") + if bandwidth is not None: + bandwidth = int(bandwidth) + dup = self.getparam("duplicate") + if dup is not None: + dup = int(dup) + jitter = self.getparam("jitter") + if jitter is not None: + jitter = int(jitter) + return LinkOptions( + delay=delay, + bandwidth=bandwidth, + dup=dup, + jitter=jitter, + loss=self.getparam("loss"), + unidirectional=unidirectional, + ) + def getparams(self) -> List[Tuple[str, float]]: """ Return (key, value) pairs for parameters. diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index b2f6bbf3..972d54f9 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -884,11 +884,12 @@ class PtpNet(CoreNetwork): :return: list of link data """ all_links = [] - if len(self.ifaces) != 2: return all_links - iface1, iface2 = self.get_ifaces() + ifaces = self.get_ifaces() + iface1 = ifaces[0] + iface2 = ifaces[1] unidirectional = 0 if iface1.getparams() != iface2.getparams(): unidirectional = 1 @@ -919,19 +920,15 @@ class PtpNet(CoreNetwork): iface2.ip6 = ip iface2.ip6_mask = mask + options_data = iface1.get_link_options(unidirectional) link_data = LinkData( message_type=flags, node1_id=iface1.node.id, node2_id=iface2.node.id, link_type=self.linktype, - unidirectional=unidirectional, - delay=iface1.getparam("delay"), - bandwidth=iface1.getparam("bw"), - loss=iface1.getparam("loss"), - dup=iface1.getparam("duplicate"), - jitter=iface1.getparam("jitter"), iface1=iface1_data, iface2=iface2_data, + options=options_data, ) all_links.append(link_data) @@ -940,19 +937,15 @@ class PtpNet(CoreNetwork): if unidirectional: iface1_data = InterfaceData(id=iface2.node.get_iface_id(iface2)) iface2_data = InterfaceData(id=iface1.node.get_iface_id(iface1)) + options_data = iface2.get_link_options(unidirectional) link_data = LinkData( message_type=MessageFlags.NONE, link_type=self.linktype, node1_id=iface2.node.id, node2_id=iface1.node.id, - delay=iface2.getparam("delay"), - bandwidth=iface2.getparam("bw"), - loss=iface2.getparam("loss"), - dup=iface2.getparam("duplicate"), - jitter=iface2.getparam("jitter"), - unidirectional=1, iface1=iface1_data, iface2=iface2_data, + options=options_data, ) all_links.append(link_data) return all_links diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 1f92502c..4febe71f 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -537,22 +537,22 @@ class CoreXmlWriter: is_node1_wireless = isinstance(node1, (WlanNode, EmaneNet)) is_node2_wireless = isinstance(node2, (WlanNode, EmaneNet)) if not any([is_node1_wireless, is_node2_wireless]): + options_data = link_data.options options = etree.Element("options") - add_attribute(options, "delay", link_data.delay) - add_attribute(options, "bandwidth", link_data.bandwidth) - add_attribute(options, "loss", link_data.loss) - add_attribute(options, "dup", link_data.dup) - add_attribute(options, "jitter", link_data.jitter) - add_attribute(options, "mer", link_data.mer) - add_attribute(options, "burst", link_data.burst) - add_attribute(options, "mburst", link_data.mburst) - add_attribute(options, "type", link_data.link_type) - add_attribute(options, "gui_attributes", link_data.gui_attributes) - add_attribute(options, "unidirectional", link_data.unidirectional) - add_attribute(options, "emulation_id", link_data.emulation_id) + add_attribute(options, "delay", options_data.delay) + add_attribute(options, "bandwidth", options_data.bandwidth) + add_attribute(options, "loss", options_data.loss) + add_attribute(options, "dup", options_data.dup) + add_attribute(options, "jitter", options_data.jitter) + add_attribute(options, "mer", options_data.mer) + add_attribute(options, "burst", options_data.burst) + add_attribute(options, "mburst", options_data.mburst) + add_attribute(options, "gui_attributes", options_data.gui_attributes) + add_attribute(options, "unidirectional", options_data.unidirectional) + add_attribute(options, "emulation_id", options_data.emulation_id) add_attribute(options, "network_id", link_data.network_id) - add_attribute(options, "key", link_data.key) - add_attribute(options, "opaque", link_data.opaque) + add_attribute(options, "key", options_data.key) + add_attribute(options, "opaque", options_data.opaque) if options.items(): link_element.append(options) @@ -940,7 +940,6 @@ class CoreXmlReader: options.loss = get_float(options_element, "per") options.unidirectional = get_int(options_element, "unidirectional") options.emulation_id = get_int(options_element, "emulation_id") - options.network_id = get_int(options_element, "network_id") options.opaque = options_element.get("opaque") options.gui_attributes = options_element.get("gui_attributes") diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index b2a1c312..cff7cd85 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -590,7 +590,7 @@ class TestGrpc: session.add_link(node.id, switch.id, iface) options = core_pb2.LinkOptions(bandwidth=30000) link = switch.all_link_data()[0] - assert options.bandwidth != link.bandwidth + assert options.bandwidth != link.options.bandwidth # then with client.context_connect(): @@ -601,7 +601,7 @@ class TestGrpc: # then assert response.result is True link = switch.all_link_data()[0] - assert options.bandwidth == link.bandwidth + assert options.bandwidth == link.options.bandwidth def test_delete_link(self, grpc_server: CoreGrpcServer, ip_prefixes: IpPrefixes): # given diff --git a/daemon/tests/test_gui.py b/daemon/tests/test_gui.py index c413295a..8f01a2bf 100644 --- a/daemon/tests/test_gui.py +++ b/daemon/tests/test_gui.py @@ -201,7 +201,7 @@ class TestGui: all_links = switch_node.all_link_data() assert len(all_links) == 1 link = all_links[0] - assert link.bandwidth is None + assert link.options.bandwidth is None bandwidth = 50000 message = coreapi.CoreLinkMessage.create( @@ -219,7 +219,7 @@ class TestGui: all_links = switch_node.all_link_data() assert len(all_links) == 1 link = all_links[0] - assert link.bandwidth == bandwidth + assert link.options.bandwidth == bandwidth def test_link_delete_node_to_node(self, coretlv: CoreHandler): node1_id = 1 diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index d81fe471..91b598f3 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -347,11 +347,11 @@ class TestXml: node = session.nodes[node_id] links += node.all_link_data() link = links[0] - assert options.loss == link.loss - assert options.bandwidth == link.bandwidth - assert options.jitter == link.jitter - assert options.delay == link.delay - assert options.dup == link.dup + assert options.loss == link.options.loss + assert options.bandwidth == link.options.bandwidth + assert options.jitter == link.options.jitter + assert options.delay == link.options.delay + assert options.dup == link.options.dup def test_link_options_ptp( self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes @@ -414,11 +414,11 @@ class TestXml: node = session.nodes[node_id] links += node.all_link_data() link = links[0] - assert options.loss == link.loss - assert options.bandwidth == link.bandwidth - assert options.jitter == link.jitter - assert options.delay == link.delay - assert options.dup == link.dup + assert options.loss == link.options.loss + assert options.bandwidth == link.options.bandwidth + assert options.jitter == link.options.jitter + assert options.delay == link.options.delay + assert options.dup == link.options.dup def test_link_options_bidirectional( self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes @@ -494,13 +494,13 @@ class TestXml: assert len(links) == 2 link1 = links[0] link2 = links[1] - assert options1.bandwidth == link1.bandwidth - assert options1.delay == link1.delay - assert options1.loss == link1.loss - assert options1.dup == link1.dup - assert options1.jitter == link1.jitter - assert options2.bandwidth == link2.bandwidth - assert options2.delay == link2.delay - assert options2.loss == link2.loss - assert options2.dup == link2.dup - assert options2.jitter == link2.jitter + assert options1.bandwidth == link1.options.bandwidth + assert options1.delay == link1.options.delay + assert options1.loss == link1.options.loss + assert options1.dup == link1.options.dup + assert options1.jitter == link1.options.jitter + assert options2.bandwidth == link2.options.bandwidth + assert options2.delay == link2.options.delay + assert options2.loss == link2.options.loss + assert options2.dup == link2.options.dup + assert options2.jitter == link2.options.jitter From 351b99aae003b3852240dd9effb825677252574d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 16 Jun 2020 21:53:12 -0700 Subject: [PATCH 159/929] daemon: renamed LinkData.link_type to LinkData.type and removed LinkOptions.type to remove redundant information, link_type param added to session.add_link, delete_link, and update_link functions --- daemon/core/api/grpc/grpcutils.py | 16 ++++++++-------- daemon/core/api/grpc/server.py | 8 +++++--- daemon/core/api/tlv/corehandlers.py | 14 +++++++++----- daemon/core/emane/emanemanager.py | 2 +- daemon/core/emane/linkmonitor.py | 2 +- daemon/core/emulator/data.py | 3 +-- daemon/core/emulator/session.py | 10 +++++++--- daemon/core/location/mobility.py | 2 +- daemon/core/nodes/base.py | 4 ++-- daemon/core/nodes/network.py | 4 ++-- 10 files changed, 37 insertions(+), 28 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 5213a835..a8e0a792 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -78,7 +78,7 @@ def link_iface(iface_proto: core_pb2.Interface) -> InterfaceData: def add_link_data( link_proto: core_pb2.Link -) -> Tuple[InterfaceData, InterfaceData, LinkOptions]: +) -> Tuple[InterfaceData, InterfaceData, LinkOptions, LinkTypes]: """ Convert link proto to link interfaces and options data. @@ -88,7 +88,7 @@ def add_link_data( iface1_data = link_iface(link_proto.iface1) iface2_data = link_iface(link_proto.iface2) link_type = LinkTypes(link_proto.type) - options = LinkOptions(type=link_type) + options = LinkOptions() options_data = link_proto.options if options_data: options.delay = options_data.delay @@ -102,7 +102,7 @@ def add_link_data( options.unidirectional = options_data.unidirectional options.key = options_data.key options.opaque = options_data.opaque - return iface1_data, iface2_data, options + return iface1_data, iface2_data, options, link_type def create_nodes( @@ -142,8 +142,8 @@ def create_links( for link_proto in link_protos: node1_id = link_proto.node1_id node2_id = link_proto.node2_id - iface1, iface2, options = add_link_data(link_proto) - args = (node1_id, node2_id, iface1, iface2, options) + iface1, iface2, options, link_type = add_link_data(link_proto) + args = (node1_id, node2_id, iface1, iface2, options, link_type) funcs.append((session.add_link, args, {})) start = time.monotonic() results, exceptions = utils.threadpool(funcs) @@ -166,8 +166,8 @@ def edit_links( for link_proto in link_protos: node1_id = link_proto.node1_id node2_id = link_proto.node2_id - iface1, iface2, options = add_link_data(link_proto) - args = (node1_id, node2_id, iface1.id, iface2.id, options) + iface1, iface2, options, link_type = add_link_data(link_proto) + args = (node1_id, node2_id, iface1.id, iface2.id, options, link_type) funcs.append((session.update_link, args, {})) start = time.monotonic() results, exceptions = utils.threadpool(funcs) @@ -350,7 +350,7 @@ def convert_link(link_data: LinkData) -> core_pb2.Link: iface2 = convert_iface(link_data.iface2) options = convert_link_options(link_data.options) return core_pb2.Link( - type=link_data.link_type.value, + type=link_data.type.value, node1_id=link_data.node1_id, node2_id=link_data.node2_id, iface1=iface1, diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 1be60116..b9e0e0aa 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -847,9 +847,11 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): node2_id = request.link.node2_id self.get_node(session, node1_id, context, NodeBase) self.get_node(session, node2_id, context, NodeBase) - iface1_data, iface2_data, options = grpcutils.add_link_data(request.link) + iface1_data, iface2_data, options, link_type = grpcutils.add_link_data( + request.link + ) node1_iface, node2_iface = session.add_link( - node1_id, node2_id, iface1_data, iface2_data, options=options + node1_id, node2_id, iface1_data, iface2_data, options, link_type ) iface1_proto = None iface2_proto = None @@ -1522,7 +1524,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): color = session.get_link_color(emane1.id) link = LinkData( message_type=flag, - link_type=LinkTypes.WIRELESS, + type=LinkTypes.WIRELESS, node1_id=node1.id, node2_id=node2.id, network_id=emane1.id, diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 3a4351f1..631cd538 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -370,7 +370,7 @@ class CoreHandler(socketserver.BaseRequestHandler): (LinkTlvs.MER, options_data.mer), (LinkTlvs.BURST, options_data.burst), (LinkTlvs.MBURST, options_data.mburst), - (LinkTlvs.TYPE, link_data.link_type.value), + (LinkTlvs.TYPE, link_data.type.value), (LinkTlvs.GUI_ATTRIBUTES, options_data.gui_attributes), (LinkTlvs.UNIDIRECTIONAL, options_data.unidirectional), (LinkTlvs.EMULATION_ID, options_data.emulation_id), @@ -784,7 +784,7 @@ class CoreHandler(socketserver.BaseRequestHandler): link_type_value = message.get_tlv(LinkTlvs.TYPE.value) if link_type_value is not None: link_type = LinkTypes(link_type_value) - options = LinkOptions(type=link_type) + options = LinkOptions() options.delay = message.get_tlv(LinkTlvs.DELAY.value) options.bandwidth = message.get_tlv(LinkTlvs.BANDWIDTH.value) options.loss = message.get_tlv(LinkTlvs.LOSS.value) @@ -801,12 +801,16 @@ class CoreHandler(socketserver.BaseRequestHandler): options.opaque = message.get_tlv(LinkTlvs.OPAQUE.value) if message.flags & MessageFlags.ADD.value: - self.session.add_link(node1_id, node2_id, iface1_data, iface2_data, options) + self.session.add_link( + node1_id, node2_id, iface1_data, iface2_data, options, link_type + ) elif message.flags & MessageFlags.DELETE.value: - self.session.delete_link(node1_id, node2_id, iface1_data.id, iface2_data.id) + self.session.delete_link( + node1_id, node2_id, iface1_data.id, iface2_data.id, link_type + ) else: self.session.update_link( - node1_id, node2_id, iface1_data.id, iface2_data.id, options + node1_id, node2_id, iface1_data.id, iface2_data.id, options, link_type ) return () diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 58b85080..fc561b5f 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -500,10 +500,10 @@ class EmaneManager(ModelManager): color = self.session.get_link_color(emane1.id) return LinkData( message_type=flags, + type=LinkTypes.WIRELESS, node1_id=node1.id, node2_id=node2.id, network_id=emane1.id, - link_type=LinkTypes.WIRELESS, color=color, ) diff --git a/daemon/core/emane/linkmonitor.py b/daemon/core/emane/linkmonitor.py index 097080c3..1a9ac41a 100644 --- a/daemon/core/emane/linkmonitor.py +++ b/daemon/core/emane/linkmonitor.py @@ -305,11 +305,11 @@ class EmaneLinkMonitor: color = self.emane_manager.session.get_link_color(emane_id) link_data = LinkData( message_type=message_type, + type=LinkTypes.WIRELESS, label=label, node1_id=node1, node2_id=node2, network_id=emane_id, - link_type=LinkTypes.WIRELESS, color=color, ) self.emane_manager.session.broadcast_link(link_data) diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index 0c263135..899d32ae 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -132,7 +132,6 @@ class LinkOptions: Options for creating and updating links within core. """ - type: LinkTypes = LinkTypes.WIRED delay: int = None bandwidth: int = None loss: float = None @@ -155,7 +154,7 @@ class LinkData: """ message_type: MessageFlags = None - link_type: LinkTypes = None + type: LinkTypes = LinkTypes.WIRED label: str = None node1_id: int = None node2_id: int = None diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index f2514e67..814c89d9 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -224,6 +224,7 @@ class Session: iface1_data: InterfaceData = None, iface2_data: InterfaceData = None, options: LinkOptions = None, + link_type: LinkTypes = LinkTypes.WIRED, ) -> Tuple[CoreInterface, CoreInterface]: """ Add a link between nodes. @@ -236,6 +237,7 @@ class Session: data, defaults to none :param options: data for creating link, defaults to no options + :param link_type: type of link to add :return: tuple of created core interfaces, depending on link """ if not options: @@ -246,7 +248,7 @@ class Session: iface2 = None # wireless link - if options.type == LinkTypes.WIRELESS: + if link_type == LinkTypes.WIRELESS: if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): self._link_wireless(node1, node2, connect=True) else: @@ -371,6 +373,7 @@ class Session: iface1_id: int = None, iface2_id: int = None, options: LinkOptions = None, + link_type: LinkTypes = LinkTypes.WIRED, ) -> None: """ Update link information between nodes. @@ -380,6 +383,7 @@ class Session: :param iface1_id: interface id for node one :param iface2_id: interface id for node two :param options: data to update link with + :param link_type: type of link to update :return: nothing :raises core.CoreError: when updating a wireless type link, when there is a unknown link between networks @@ -390,7 +394,7 @@ class Session: node2 = self.get_node(node2_id, NodeBase) logging.info( "update link(%s) node(%s):interface(%s) node(%s):interface(%s)", - options.type.name, + link_type.name, node1.name, iface1_id, node2.name, @@ -398,7 +402,7 @@ class Session: ) # wireless link - if options.type == LinkTypes.WIRELESS: + if link_type == LinkTypes.WIRELESS: raise CoreError("cannot update wireless link") else: if isinstance(node1, CoreNodeBase) and isinstance(node2, CoreNodeBase): diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index 91a8baae..9bb2966e 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -487,10 +487,10 @@ class BasicRangeModel(WirelessModel): color = self.session.get_link_color(self.wlan.id) return LinkData( message_type=message_type, + type=LinkTypes.WIRELESS, node1_id=iface1.node.id, node2_id=iface2.node.id, network_id=self.wlan.id, - link_type=LinkTypes.WIRELESS, color=color, ) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index a6e4f147..97164cb6 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -1109,9 +1109,9 @@ class CoreNetworkBase(NodeBase): options_data = iface.get_link_options(unidirectional) link_data = LinkData( message_type=flags, + type=self.linktype, node1_id=self.id, node2_id=linked_node.id, - link_type=self.linktype, iface2=iface2, options=options_data, ) @@ -1123,9 +1123,9 @@ class CoreNetworkBase(NodeBase): options_data = iface.get_link_options(unidirectional) link_data = LinkData( message_type=MessageFlags.NONE, + type=self.linktype, node1_id=linked_node.id, node2_id=self.id, - link_type=self.linktype, options=options_data, ) iface.swapparams("_params_up") diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 972d54f9..04d4e8f8 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -923,9 +923,9 @@ class PtpNet(CoreNetwork): options_data = iface1.get_link_options(unidirectional) link_data = LinkData( message_type=flags, + type=self.linktype, node1_id=iface1.node.id, node2_id=iface2.node.id, - link_type=self.linktype, iface1=iface1_data, iface2=iface2_data, options=options_data, @@ -940,7 +940,7 @@ class PtpNet(CoreNetwork): options_data = iface2.get_link_options(unidirectional) link_data = LinkData( message_type=MessageFlags.NONE, - link_type=self.linktype, + type=self.linktype, node1_id=iface2.node.id, node2_id=iface1.node.id, iface1=iface1_data, From a1734c3bc0cb8d4eaa17c414db72a3835d464ab7 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 16 Jun 2020 22:05:36 -0700 Subject: [PATCH 160/929] grpc: updated Interface proto fields to be more consistent with code, ip4mask to ip4_mask, ip6mask to ip6_mask, netid to net_id, flowid to flow_id --- daemon/core/api/grpc/client.py | 4 ++-- daemon/core/api/grpc/grpcutils.py | 26 +++++++++++++------------- daemon/core/gui/coreclient.py | 7 ++++++- daemon/core/gui/dialogs/nodeconfig.py | 20 ++++++++++---------- daemon/core/gui/graph/edges.py | 4 ++-- daemon/core/gui/interface.py | 6 +++--- daemon/proto/core/api/grpc/core.proto | 8 ++++---- 7 files changed, 40 insertions(+), 35 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 68bfc502..db908e05 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -127,9 +127,9 @@ class InterfaceHelper: id=iface_id, name=iface_data.name, ip4=iface_data.ip4, - ip4mask=iface_data.ip4_mask, + ip4_mask=iface_data.ip4_mask, ip6=iface_data.ip6, - ip6mask=iface_data.ip6_mask, + ip6_mask=iface_data.ip6_mask, mac=iface_data.mac, ) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index a8e0a792..9d26e4cf 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -69,9 +69,9 @@ def link_iface(iface_proto: core_pb2.Interface) -> InterfaceData: name=name, mac=mac, ip4=ip4, - ip4_mask=iface_proto.ip4mask, + ip4_mask=iface_proto.ip4_mask, ip6=ip6, - ip6_mask=iface_proto.ip6mask, + ip6_mask=iface_proto.ip6_mask, ) return iface_data @@ -313,9 +313,9 @@ def convert_iface(iface_data: InterfaceData) -> core_pb2.Interface: name=iface_data.name, mac=iface_data.mac, ip4=iface_data.ip4, - ip4mask=iface_data.ip4_mask, + ip4_mask=iface_data.ip4_mask, ip6=iface_data.ip6, - ip6mask=iface_data.ip6_mask, + ip6_mask=iface_data.ip6_mask, ) @@ -449,30 +449,30 @@ def iface_to_proto(iface: CoreInterface) -> core_pb2.Interface: if iface.net: net_id = iface.net.id ip4 = None - ip4mask = None + ip4_mask = None ip6 = None - ip6mask = None + ip6_mask = None for addr in iface.addrlist: network = netaddr.IPNetwork(addr) mask = network.prefixlen ip = str(network.ip) if netaddr.valid_ipv4(ip) and not ip4: ip4 = ip - ip4mask = mask + ip4_mask = mask elif netaddr.valid_ipv6(ip) and not ip6: ip6 = ip - ip6mask = mask + ip6_mask = mask return core_pb2.Interface( id=iface.node_id, - netid=net_id, + net_id=net_id, name=iface.name, - mac=str(iface.hwaddr), + mac=iface.hwaddr, mtu=iface.mtu, - flowid=iface.flow_id, + flow_id=iface.flow_id, ip4=ip4, - ip4mask=ip4mask, + ip4_mask=ip4_mask, ip6=ip6, - ip6mask=ip6mask, + ip6_mask=ip6_mask, ) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 3be58e17..8b0c423c 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -834,7 +834,12 @@ class CoreClient: iface_id = canvas_node.next_iface_id() name = f"eth{iface_id}" iface = core_pb2.Interface( - id=iface_id, name=name, ip4=ip4, ip4mask=ip4_mask, ip6=ip6, ip6mask=ip6_mask + id=iface_id, + name=name, + ip4=ip4, + ip4_mask=ip4_mask, + ip6=ip6, + ip6_mask=ip6_mask, ) logging.info( "create node(%s) interface(%s) IPv4(%s) IPv6(%s)", diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index 29ce2010..cec9e9f9 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -248,7 +248,7 @@ class NodeConfigDialog(Dialog): label.grid(row=row, column=0, padx=PADX, pady=PADY) ip4_net = "" if iface.ip4: - ip4_net = f"{iface.ip4}/{iface.ip4mask}" + ip4_net = f"{iface.ip4}/{iface.ip4_mask}" ip4 = tk.StringVar(value=ip4_net) entry = ttk.Entry(tab, textvariable=ip4, state=state) entry.grid(row=row, column=1, columnspan=2, sticky="ew") @@ -258,7 +258,7 @@ class NodeConfigDialog(Dialog): label.grid(row=row, column=0, padx=PADX, pady=PADY) ip6_net = "" if iface.ip6: - ip6_net = f"{iface.ip6}/{iface.ip6mask}" + ip6_net = f"{iface.ip6}/{iface.ip6_mask}" ip6 = tk.StringVar(value=ip6_net) entry = ttk.Entry(tab, textvariable=ip6, state=state) entry.grid(row=row, column=1, columnspan=2, sticky="ew") @@ -318,12 +318,12 @@ class NodeConfigDialog(Dialog): error = True break if ip4_net: - ip4, ip4mask = ip4_net.split("/") - ip4mask = int(ip4mask) + ip4, ip4_mask = ip4_net.split("/") + ip4_mask = int(ip4_mask) else: - ip4, ip4mask = "", 0 + ip4, ip4_mask = "", 0 iface.ip4 = ip4 - iface.ip4mask = ip4mask + iface.ip4_mask = ip4_mask # validate ip6 ip6_net = data.ip6.get() @@ -331,12 +331,12 @@ class NodeConfigDialog(Dialog): error = True break if ip6_net: - ip6, ip6mask = ip6_net.split("/") - ip6mask = int(ip6mask) + ip6, ip6_mask = ip6_net.split("/") + ip6_mask = int(ip6_mask) else: - ip6, ip6mask = "", 0 + ip6, ip6_mask = "", 0 iface.ip6 = ip6 - iface.ip6mask = ip6mask + iface.ip6_mask = ip6_mask mac = data.mac.get() auto_mac = data.is_auto.get() diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index 152e1a2f..ac637b28 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -289,10 +289,10 @@ class CanvasEdge(Edge): label = f"{iface.name}" if iface.ip4 and self.canvas.show_ip4s.get(): label = f"{label}\n" if label else "" - label += f"{iface.ip4}/{iface.ip4mask}" + label += f"{iface.ip4}/{iface.ip4_mask}" if iface.ip6 and self.canvas.show_ip6s.get(): label = f"{label}\n" if label else "" - label += f"{iface.ip6}/{iface.ip6mask}" + label += f"{iface.ip6}/{iface.ip6_mask}" return label def create_node_labels(self) -> Tuple[str, str]: diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 14cba024..6c82ca51 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: def get_index(iface: "core_pb2.Interface") -> Optional[int]: if not iface.ip4: return None - net = netaddr.IPNetwork(f"{iface.ip4}/{iface.ip4mask}") + net = netaddr.IPNetwork(f"{iface.ip4}/{iface.ip4_mask}") ip_value = net.value cidr_value = net.cidr.value return ip_value - cidr_value @@ -153,10 +153,10 @@ class InterfaceManager: def get_subnets(self, iface: "core_pb2.Interface") -> Subnets: ip4_subnet = self.ip4_subnets if iface.ip4: - ip4_subnet = IPNetwork(f"{iface.ip4}/{iface.ip4mask}").cidr + ip4_subnet = IPNetwork(f"{iface.ip4}/{iface.ip4_mask}").cidr ip6_subnet = self.ip6_subnets if iface.ip6: - ip6_subnet = IPNetwork(f"{iface.ip6}/{iface.ip6mask}").cidr + ip6_subnet = IPNetwork(f"{iface.ip6}/{iface.ip6_mask}").cidr subnets = Subnets(ip4_subnet, ip6_subnet) return self.used_subnets.get(subnets.key(), subnets) diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index f691621a..2819c5ea 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -732,11 +732,11 @@ message Interface { string name = 2; string mac = 3; string ip4 = 4; - int32 ip4mask = 5; + int32 ip4_mask = 5; string ip6 = 6; - int32 ip6mask = 7; - int32 netid = 8; - int32 flowid = 9; + int32 ip6_mask = 7; + int32 net_id = 8; + int32 flow_id = 9; int32 mtu = 10; } From f4671ab2b894c82693484e5f2bcb52a9c707529e Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 16 Jun 2020 23:25:26 -0700 Subject: [PATCH 161/929] daemon: refactored usages of hwaddr to mac and be consistent everywhere --- daemon/core/api/grpc/grpcutils.py | 2 +- daemon/core/nodes/base.py | 22 +++++++++++----------- daemon/core/nodes/interface.py | 14 +++++++------- daemon/core/nodes/network.py | 4 ++-- daemon/core/nodes/physical.py | 18 +++++++++--------- daemon/core/services/xorp.py | 4 ++-- daemon/core/xml/emanexml.py | 6 +++--- daemon/tests/test_nodes.py | 10 +++++----- 8 files changed, 40 insertions(+), 40 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 9d26e4cf..6f2911a4 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -466,7 +466,7 @@ def iface_to_proto(iface: CoreInterface) -> core_pb2.Interface: id=iface.node_id, net_id=net_id, name=iface.name, - mac=iface.hwaddr, + mac=iface.mac, mtu=iface.mtu, flow_id=iface.flow_id, ip4=ip4, diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 97164cb6..97da63a4 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -731,9 +731,9 @@ class CoreNode(CoreNodeBase): flow_id = self.node_net_client.get_ifindex(veth.name) veth.flow_id = int(flow_id) logging.debug("interface flow index: %s - %s", veth.name, veth.flow_id) - hwaddr = self.node_net_client.get_mac(veth.name) - logging.debug("interface mac: %s - %s", veth.name, hwaddr) - veth.sethwaddr(hwaddr) + mac = self.node_net_client.get_mac(veth.name) + logging.debug("interface mac: %s - %s", veth.name, mac) + veth.set_mac(mac) try: # add network interface to the node. If unsuccessful, destroy the @@ -775,20 +775,20 @@ class CoreNode(CoreNodeBase): return iface_id - def sethwaddr(self, iface_id: int, addr: str) -> None: + def set_mac(self, iface_id: int, mac: str) -> None: """ Set hardware address for an interface. :param iface_id: id of interface to set hardware address for - :param addr: hardware address to set + :param mac: mac address to set :return: nothing :raises CoreCommandError: when a non-zero exit status occurs """ - addr = utils.validate_mac(addr) + mac = utils.validate_mac(mac) iface = self.get_iface(iface_id) - iface.sethwaddr(addr) + iface.set_mac(mac) if self.up: - self.node_net_client.device_mac(iface.name, addr) + self.node_net_client.device_mac(iface.name, mac) def addaddr(self, iface_id: int, addr: str) -> None: """ @@ -857,14 +857,14 @@ class CoreNode(CoreNodeBase): # save addresses with the interface now self.attachnet(iface_id, net) iface = self.get_iface(iface_id) - iface.sethwaddr(iface_data.mac) + iface.set_mac(iface_data.mac) for address in addresses: iface.addaddr(address) else: iface_id = self.newveth(iface_data.id, iface_data.name) self.attachnet(iface_id, net) if iface_data.mac: - self.sethwaddr(iface_id, iface_data.mac) + self.set_mac(iface_id, iface_data.mac) for address in addresses: self.addaddr(iface_id, address) self.ifup(iface_id) @@ -1094,7 +1094,7 @@ class CoreNetworkBase(NodeBase): unidirectional = 1 iface2 = InterfaceData( - id=linked_node.get_iface_id(iface), name=iface.name, mac=iface.hwaddr + id=linked_node.get_iface_id(iface), name=iface.name, mac=iface.mac ) for address in iface.addrlist: ip, _sep, mask = address.partition("/") diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index 1fb8b894..287723a7 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -53,7 +53,7 @@ class CoreInterface: self.othernet: Optional[CoreNetworkBase] = None self._params: Dict[str, float] = {} self.addrlist: List[str] = [] - self.hwaddr: Optional[str] = None + self.mac: Optional[str] = None # placeholder position hook self.poshook: Callable[[CoreInterface], None] = lambda x: None # used with EMANE @@ -150,16 +150,16 @@ class CoreInterface: """ self.addrlist.remove(addr) - def sethwaddr(self, addr: str) -> None: + def set_mac(self, mac: str) -> None: """ - Set hardware address. + Set mac address. - :param addr: hardware address to set to. + :param mac: mac address to set :return: nothing """ - if addr is not None: - addr = utils.validate_mac(addr) - self.hwaddr = addr + if mac is not None: + mac = utils.validate_mac(mac) + self.mac = mac def getparam(self, key: str) -> float: """ diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 04d4e8f8..ef9456db 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -895,7 +895,7 @@ class PtpNet(CoreNetwork): unidirectional = 1 iface1_data = InterfaceData( - id=iface1.node.get_iface_id(iface1), name=iface1.name, mac=iface1.hwaddr + id=iface1.node.get_iface_id(iface1), name=iface1.name, mac=iface1.mac ) for address in iface1.addrlist: ip, _sep, mask = address.partition("/") @@ -908,7 +908,7 @@ class PtpNet(CoreNetwork): iface1.ip6_mask = mask iface2_data = InterfaceData( - id=iface2.node.get_iface_id(iface2), name=iface2.name, mac=iface2.hwaddr + id=iface2.node.get_iface_id(iface2), name=iface2.name, mac=iface2.mac ) for address in iface2.addrlist: ip, _sep, mask = address.partition("/") diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 36bcb267..0ce8946a 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -65,20 +65,20 @@ class PhysicalNode(CoreNodeBase): """ return sh - def sethwaddr(self, iface_id: int, addr: str) -> None: + def set_mac(self, iface_id: int, mac: str) -> None: """ - Set hardware address for an interface. + Set mac address for an interface. :param iface_id: index of interface to set hardware address for - :param addr: hardware address to set + :param mac: mac address to set :return: nothing :raises CoreCommandError: when a non-zero exit status occurs """ - addr = utils.validate_mac(addr) + mac = utils.validate_mac(mac) iface = self.ifaces[iface_id] - iface.sethwaddr(addr) + iface.set_mac(mac) if self.up: - self.net_client.device_mac(iface.name, addr) + self.net_client.device_mac(iface.name, mac) def addaddr(self, iface_id: int, addr: str) -> None: """ @@ -111,7 +111,7 @@ class PhysicalNode(CoreNodeBase): self.net_client.delete_address(iface.name, addr) def adopt_iface( - self, iface: CoreInterface, iface_id: int, hwaddr: str, addrlist: List[str] + self, iface: CoreInterface, iface_id: int, mac: str, addrlist: List[str] ) -> None: """ When a link message is received linking this node to another part of @@ -126,8 +126,8 @@ class PhysicalNode(CoreNodeBase): self.net_client.device_down(iface.localname) self.net_client.device_name(iface.localname, iface.name) iface.localname = iface.name - if hwaddr: - self.sethwaddr(iface_id, hwaddr) + if mac: + self.set_mac(iface_id, mac) for addr in addrlist: self.addaddr(iface_id, addr) if self.up: diff --git a/daemon/core/services/xorp.py b/daemon/core/services/xorp.py index 3dfef56a..10b4fd9f 100644 --- a/daemon/core/services/xorp.py +++ b/daemon/core/services/xorp.py @@ -69,7 +69,7 @@ class XorpRtrmgr(CoreService): """ helper for adding link-local address entries (required by OSPFv3) """ - cfg = "\t address %s {\n" % iface.hwaddr.tolinklocal() + cfg = "\t address %s {\n" % iface.mac.tolinklocal() cfg += "\t\tprefix-length: 64\n" cfg += "\t }\n" return cfg @@ -305,7 +305,7 @@ class XorpRipng(XorpService): for iface in node.get_ifaces(control=False): cfg += "\tinterface %s {\n" % iface.name cfg += "\t vif %s {\n" % iface.name - cfg += "\t\taddress %s {\n" % iface.hwaddr.tolinklocal() + cfg += "\t\taddress %s {\n" % iface.mac.tolinklocal() cfg += "\t\t disable: false\n" cfg += "\t\t}\n" cfg += "\t }\n" diff --git a/daemon/core/xml/emanexml.py b/daemon/core/xml/emanexml.py index 4f511476..d716777b 100644 --- a/daemon/core/xml/emanexml.py +++ b/daemon/core/xml/emanexml.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from core.emane.emanemanager import EmaneManager from core.emane.emanemodel import EmaneModel -_hwaddr_prefix = "02:02" +_MAC_PREFIX = "02:02" def is_external(config: Dict[str, str]) -> bool: @@ -230,9 +230,9 @@ def build_node_platform_xml( platform_element.append(nem_element) node.setnemid(iface, nem_id) - macstr = _hwaddr_prefix + ":00:00:" + macstr = _MAC_PREFIX + ":00:00:" macstr += f"{(nem_id >> 8) & 0xFF:02X}:{nem_id & 0xFF:02X}" - iface.sethwaddr(macstr) + iface.set_mac(macstr) # increment nem id nem_id += 1 diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index 327137d2..8af2e895 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -49,7 +49,7 @@ class TestNodes: with pytest.raises(CoreError): session.get_node(node.id, CoreNode) - def test_node_sethwaddr(self, session: Session): + def test_node_set_mac(self, session: Session): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) @@ -58,12 +58,12 @@ class TestNodes: mac = "aa:aa:aa:ff:ff:ff" # when - node.sethwaddr(iface.node_id, mac) + node.set_mac(iface.node_id, mac) # then - assert iface.hwaddr == mac + assert iface.mac == mac - def test_node_sethwaddr_exception(self, session: Session): + def test_node_set_mac_exception(self, session: Session): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) @@ -73,7 +73,7 @@ class TestNodes: # when with pytest.raises(CoreError): - node.sethwaddr(iface.node_id, mac) + node.set_mac(iface.node_id, mac) def test_node_addaddr(self, session: Session): # given From a64047e221cb83d3f27d9c3c75f4d81afbea4036 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 16 Jun 2020 23:27:17 -0700 Subject: [PATCH 162/929] fixed issue with xorp service depending on old MacAddress class --- daemon/core/services/xorp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/daemon/core/services/xorp.py b/daemon/core/services/xorp.py index 10b4fd9f..776b1d16 100644 --- a/daemon/core/services/xorp.py +++ b/daemon/core/services/xorp.py @@ -69,7 +69,7 @@ class XorpRtrmgr(CoreService): """ helper for adding link-local address entries (required by OSPFv3) """ - cfg = "\t address %s {\n" % iface.mac.tolinklocal() + cfg = "\t address %s {\n" % netaddr.EUI(iface.mac).eui64() cfg += "\t\tprefix-length: 64\n" cfg += "\t }\n" return cfg @@ -305,7 +305,7 @@ class XorpRipng(XorpService): for iface in node.get_ifaces(control=False): cfg += "\tinterface %s {\n" % iface.name cfg += "\t vif %s {\n" % iface.name - cfg += "\t\taddress %s {\n" % iface.mac.tolinklocal() + cfg += "\t\taddress %s {\n" % netaddr.EUI(iface.mac).eui64() cfg += "\t\t disable: false\n" cfg += "\t\t}\n" cfg += "\t }\n" From b92ff0586a6e04d1dae8a36f159e9f6449df7489 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 17 Jun 2020 22:43:13 -0700 Subject: [PATCH 163/929] daemon: renamed NodeData.node_type to type, removed NodeData/NodeOptions fields that were not being used for clarity --- daemon/core/api/grpc/grpcutils.py | 1 - daemon/core/api/tlv/corehandlers.py | 1 - daemon/core/api/tlv/dataconversion.py | 12 +-- daemon/core/emulator/data.py | 123 ++++++++++++-------------- daemon/core/emulator/session.py | 1 - daemon/core/nodes/base.py | 11 +-- daemon/proto/core/api/grpc/core.proto | 13 ++- 7 files changed, 70 insertions(+), 92 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 6f2911a4..7c517caf 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -34,7 +34,6 @@ def add_node_data(node_proto: core_pb2.Node) -> Tuple[NodeTypes, int, NodeOption name=node_proto.name, model=node_proto.model, icon=node_proto.icon, - opaque=node_proto.opaque, image=node_proto.image, services=node_proto.services, config_services=node_proto.config_services, diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 631cd538..981bdb15 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -722,7 +722,6 @@ class CoreHandler(socketserver.BaseRequestHandler): options.icon = message.get_tlv(NodeTlvs.ICON.value) options.canvas = message.get_tlv(NodeTlvs.CANVAS.value) - options.opaque = message.get_tlv(NodeTlvs.OPAQUE.value) options.server = message.get_tlv(NodeTlvs.EMULATION_SERVER.value) services = message.get_tlv(NodeTlvs.SERVICES.value) diff --git a/daemon/core/api/tlv/dataconversion.py b/daemon/core/api/tlv/dataconversion.py index cd10ef04..62b51d39 100644 --- a/daemon/core/api/tlv/dataconversion.py +++ b/daemon/core/api/tlv/dataconversion.py @@ -18,9 +18,6 @@ def convert_node(node_data): :param core.emulator.data.NodeData node_data: node data to convert :return: packed node message """ - session = None - if node_data.session is not None: - session = str(node_data.session) services = None if node_data.services is not None: services = "|".join([x for x in node_data.services]) @@ -28,25 +25,18 @@ def convert_node(node_data): coreapi.CoreNodeTlv, [ (NodeTlvs.NUMBER, node_data.id), - (NodeTlvs.TYPE, node_data.node_type.value), + (NodeTlvs.TYPE, node_data.type.value), (NodeTlvs.NAME, node_data.name), - (NodeTlvs.IP_ADDRESS, node_data.ip_address), - (NodeTlvs.MAC_ADDRESS, node_data.mac_address), - (NodeTlvs.IP6_ADDRESS, node_data.ip6_address), (NodeTlvs.MODEL, node_data.model), - (NodeTlvs.EMULATION_ID, node_data.emulation_id), (NodeTlvs.EMULATION_SERVER, node_data.server), - (NodeTlvs.SESSION, session), (NodeTlvs.X_POSITION, int(node_data.x_position)), (NodeTlvs.Y_POSITION, int(node_data.y_position)), (NodeTlvs.CANVAS, node_data.canvas), - (NodeTlvs.NETWORK_ID, node_data.network_id), (NodeTlvs.SERVICES, services), (NodeTlvs.LATITUDE, str(node_data.latitude)), (NodeTlvs.LONGITUDE, str(node_data.longitude)), (NodeTlvs.ALTITUDE, str(node_data.altitude)), (NodeTlvs.ICON, node_data.icon), - (NodeTlvs.OPAQUE, node_data.opaque), ], ) return coreapi.CoreNodeMessage.pack(node_data.message_type.value, tlv_data) diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index 899d32ae..1a7a6096 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -73,28 +73,71 @@ class FileData: @dataclass -class NodeData: - message_type: MessageFlags = None - id: int = None - node_type: NodeTypes = None +class NodeOptions: + """ + Options for creating and updating nodes within core. + """ + name: str = None - ip_address: str = None - mac_address: str = None - ip6_address: str = None - model: str = None - emulation_id: int = None + model: Optional[str] = "PC" + canvas: int = None + icon: str = None + services: List[str] = field(default_factory=list) + config_services: List[str] = field(default_factory=list) + x: float = None + y: float = None + lat: float = None + lon: float = None + alt: float = None server: str = None - session: int = None + image: str = None + emane: str = None + + def set_position(self, x: float, y: float) -> None: + """ + Convenience method for setting position. + + :param x: x position + :param y: y position + :return: nothing + """ + self.x = x + self.y = y + + def set_location(self, lat: float, lon: float, alt: float) -> None: + """ + Convenience method for setting location. + + :param lat: latitude + :param lon: longitude + :param alt: altitude + :return: nothing + """ + self.lat = lat + self.lon = lon + self.alt = alt + + +@dataclass +class NodeData: + """ + Used to represent nodes being broadcasted. + """ + + message_type: MessageFlags = None + type: NodeTypes = None + id: int = None + name: str = None + model: str = None + server: str = None + icon: str = None + canvas: int = None + services: List[str] = None x_position: float = None y_position: float = None - canvas: int = None - network_id: int = None - services: List[str] = None latitude: float = None longitude: float = None altitude: float = None - icon: str = None - opaque: str = None source: str = None @@ -158,10 +201,10 @@ class LinkData: label: str = None node1_id: int = None node2_id: int = None + network_id: int = None iface1: InterfaceData = None iface2: InterfaceData = None options: LinkOptions = LinkOptions() - network_id: int = None color: str = None @@ -259,51 +302,3 @@ class IpPrefixes: iface_data = self.gen_iface(node.id, name, mac) iface_data.id = node.next_iface_id() return iface_data - - -@dataclass -class NodeOptions: - """ - Options for creating and updating nodes within core. - """ - - name: str = None - model: Optional[str] = "PC" - canvas: int = None - icon: str = None - opaque: str = None - services: List[str] = field(default_factory=list) - config_services: List[str] = field(default_factory=list) - x: float = None - y: float = None - lat: float = None - lon: float = None - alt: float = None - emulation_id: int = None - server: str = None - image: str = None - emane: str = None - - def set_position(self, x: float, y: float) -> None: - """ - Convenience method for setting position. - - :param x: x position - :param y: y position - :return: nothing - """ - self.x = x - self.y = y - - def set_location(self, lat: float, lon: float, alt: float) -> None: - """ - Convenience method for setting location. - - :param lat: latitude - :param lon: longitude - :param alt: altitude - :return: nothing - """ - self.lat = lat - self.lon = lon - self.alt = alt diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 814c89d9..ccabeddb 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -523,7 +523,6 @@ class Session: # set node attributes node.icon = options.icon node.canvas = options.canvas - node.opaque = options.opaque # set node position and broadcast it self.set_node_position(node, options) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 97da63a4..0ecc9085 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -195,29 +195,26 @@ class NodeBase(abc.ABC): """ if self.apitype is None: return None - x, y, _ = self.getposition() model = self.type server = None if self.server is not None: server = self.server.name - services = [service.name for service in self.services] + services = [x.name for x in self.services] return NodeData( message_type=message_type, + type=self.apitype, id=self.id, - node_type=self.apitype, name=self.name, - emulation_id=self.id, + model=model, + server=server, canvas=self.canvas, icon=self.icon, - opaque=self.opaque, x_position=x, y_position=y, latitude=self.position.lat, longitude=self.position.lon, altitude=self.position.alt, - model=model, - server=server, services=services, source=source, ) diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 2819c5ea..46e1da91 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -692,13 +692,12 @@ message Node { repeated string services = 6; string emane = 7; string icon = 8; - string opaque = 9; - string image = 10; - string server = 11; - repeated string config_services = 12; - Geo geo = 13; - string dir = 14; - string channel = 15; + string image = 9; + string server = 10; + repeated string config_services = 11; + Geo geo = 12; + string dir = 13; + string channel = 14; } message Link { From 5d34a2b752631580e4d5ca01daaf0714fc707e44 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 17 Jun 2020 22:59:50 -0700 Subject: [PATCH 164/929] daemon: removed opaque from NodeBase, since it is not used --- daemon/core/nodes/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 0ecc9085..378549ab 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -71,7 +71,6 @@ class NodeBase(abc.ABC): self.iface_id: int = 0 self.canvas: Optional[int] = None self.icon: Optional[str] = None - self.opaque: Optional[str] = None self.position: Position = Position() self.up: bool = False use_ovs = session.options.get_config("ovs") == "True" From 3d7d775bfbf5c673837baeecd25faa078e3906a6 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 18 Jun 2020 00:15:44 -0700 Subject: [PATCH 165/929] daemon: removed unused variables from LinkOptions --- daemon/core/api/grpc/grpcutils.py | 26 ++++++++++++-------------- daemon/core/api/grpc/server.py | 23 +++++++++++------------ daemon/core/api/tlv/corehandlers.py | 7 ------- daemon/core/emulator/data.py | 3 --- daemon/core/xml/corexml.py | 6 ------ 5 files changed, 23 insertions(+), 42 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 7c517caf..d95b7555 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -88,19 +88,18 @@ def add_link_data( iface2_data = link_iface(link_proto.iface2) link_type = LinkTypes(link_proto.type) options = LinkOptions() - options_data = link_proto.options - if options_data: - options.delay = options_data.delay - options.bandwidth = options_data.bandwidth - options.loss = options_data.loss - options.dup = options_data.dup - options.jitter = options_data.jitter - options.mer = options_data.mer - options.burst = options_data.burst - options.mburst = options_data.mburst - options.unidirectional = options_data.unidirectional - options.key = options_data.key - options.opaque = options_data.opaque + options_proto = link_proto.options + if options_proto: + options.delay = options_proto.delay + options.bandwidth = options_proto.bandwidth + options.loss = options_proto.loss + options.dup = options_proto.dup + options.jitter = options_proto.jitter + options.mer = options_proto.mer + options.burst = options_proto.burst + options.mburst = options_proto.mburst + options.unidirectional = options_proto.unidirectional + options.key = options_proto.key return iface1_data, iface2_data, options, link_type @@ -320,7 +319,6 @@ def convert_iface(iface_data: InterfaceData) -> core_pb2.Interface: def convert_link_options(options_data: LinkOptions) -> core_pb2.LinkOptions: return core_pb2.LinkOptions( - opaque=options_data.opaque, jitter=options_data.jitter, key=options_data.key, mburst=options_data.mburst, diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index b9e0e0aa..1964b6e8 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -879,19 +879,18 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): node2_id = request.node2_id iface1_id = request.iface1_id iface2_id = request.iface2_id - options_data = request.options + options_proto = request.options options = LinkOptions( - delay=options_data.delay, - bandwidth=options_data.bandwidth, - loss=options_data.loss, - dup=options_data.dup, - jitter=options_data.jitter, - mer=options_data.mer, - burst=options_data.burst, - mburst=options_data.mburst, - unidirectional=options_data.unidirectional, - key=options_data.key, - opaque=options_data.opaque, + delay=options_proto.delay, + bandwidth=options_proto.bandwidth, + loss=options_proto.loss, + dup=options_proto.dup, + jitter=options_proto.jitter, + mer=options_proto.mer, + burst=options_proto.burst, + mburst=options_proto.mburst, + unidirectional=options_proto.unidirectional, + key=options_proto.key, ) session.update_link(node1_id, node2_id, iface1_id, iface2_id, options) return core_pb2.EditLinkResponse(result=True) diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 981bdb15..379b739e 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -371,9 +371,7 @@ class CoreHandler(socketserver.BaseRequestHandler): (LinkTlvs.BURST, options_data.burst), (LinkTlvs.MBURST, options_data.mburst), (LinkTlvs.TYPE, link_data.type.value), - (LinkTlvs.GUI_ATTRIBUTES, options_data.gui_attributes), (LinkTlvs.UNIDIRECTIONAL, options_data.unidirectional), - (LinkTlvs.EMULATION_ID, options_data.emulation_id), (LinkTlvs.NETWORK_ID, link_data.network_id), (LinkTlvs.KEY, options_data.key), (LinkTlvs.IFACE1_NUMBER, iface1.id), @@ -388,7 +386,6 @@ class CoreHandler(socketserver.BaseRequestHandler): (LinkTlvs.IFACE2_MAC, iface2.mac), (LinkTlvs.IFACE2_IP6, iface2.ip6), (LinkTlvs.IFACE2_IP6_MASK, iface2.ip6_mask), - (LinkTlvs.OPAQUE, options_data.opaque), ], ) @@ -792,12 +789,8 @@ class CoreHandler(socketserver.BaseRequestHandler): options.mer = message.get_tlv(LinkTlvs.MER.value) options.burst = message.get_tlv(LinkTlvs.BURST.value) options.mburst = message.get_tlv(LinkTlvs.MBURST.value) - options.gui_attributes = message.get_tlv(LinkTlvs.GUI_ATTRIBUTES.value) options.unidirectional = message.get_tlv(LinkTlvs.UNIDIRECTIONAL.value) - options.emulation_id = message.get_tlv(LinkTlvs.EMULATION_ID.value) - options.network_id = message.get_tlv(LinkTlvs.NETWORK_ID.value) options.key = message.get_tlv(LinkTlvs.KEY.value) - options.opaque = message.get_tlv(LinkTlvs.OPAQUE.value) if message.flags & MessageFlags.ADD.value: self.session.add_link( diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index 1a7a6096..c57f8b24 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -183,11 +183,8 @@ class LinkOptions: mer: int = None burst: int = None mburst: int = None - gui_attributes: str = None unidirectional: int = None - emulation_id: int = None key: int = None - opaque: str = None @dataclass diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 4febe71f..190cf8f7 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -547,12 +547,9 @@ class CoreXmlWriter: add_attribute(options, "mer", options_data.mer) add_attribute(options, "burst", options_data.burst) add_attribute(options, "mburst", options_data.mburst) - add_attribute(options, "gui_attributes", options_data.gui_attributes) add_attribute(options, "unidirectional", options_data.unidirectional) - add_attribute(options, "emulation_id", options_data.emulation_id) add_attribute(options, "network_id", link_data.network_id) add_attribute(options, "key", options_data.key) - add_attribute(options, "opaque", options_data.opaque) if options.items(): link_element.append(options) @@ -939,9 +936,6 @@ class CoreXmlReader: if options.loss is None: options.loss = get_float(options_element, "per") options.unidirectional = get_int(options_element, "unidirectional") - options.emulation_id = get_int(options_element, "emulation_id") - options.opaque = options_element.get("opaque") - options.gui_attributes = options_element.get("gui_attributes") if options.unidirectional == 1 and node_set in node_sets: logging.info("updating link node1(%s) node2(%s)", node1_id, node2_id) From 1702fe256f2ae153e65baabcb963c0d6638f1397 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 18 Jun 2020 00:30:39 -0700 Subject: [PATCH 166/929] doc: updated refactored example in documentation --- docs/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/services.md b/docs/services.md index 9f47ae48..2ce52e99 100644 --- a/docs/services.md +++ b/docs/services.md @@ -263,7 +263,7 @@ class MyService(CoreService): if filename == cls.configs[0]: cfg += "# auto-generated by MyService (sample.py)\n" - for ifc in node.netifs(): + for ifc in node.get_ifaces(): cfg += f'echo "Node {node.name} has interface {ifc.name}"\n' elif filename == cls.configs[1]: cfg += "echo hello" From ecc3eb1c891b8458e944158b9a14b0f43f9348d8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 18 Jun 2020 09:06:31 -0700 Subject: [PATCH 167/929] daemon: refactored NodeData to reference a node instead of replicating fields as an intermediate passthrough, removed data() functions from nodes due to this change --- daemon/core/api/grpc/events.py | 22 +++++++++------- daemon/core/api/tlv/corehandlers.py | 1 - daemon/core/api/tlv/dataconversion.py | 36 ++++++++++++++----------- daemon/core/emulator/data.py | 19 +++----------- daemon/core/emulator/session.py | 4 +-- daemon/core/nodes/base.py | 38 +-------------------------- daemon/core/nodes/interface.py | 21 +-------------- daemon/core/nodes/network.py | 15 +---------- daemon/core/plugins/sdt.py | 18 +++++-------- daemon/tests/test_grpc.py | 7 ++--- 10 files changed, 52 insertions(+), 129 deletions(-) diff --git a/daemon/core/api/grpc/events.py b/daemon/core/api/grpc/events.py index ff65142d..75f9eb2e 100644 --- a/daemon/core/api/grpc/events.py +++ b/daemon/core/api/grpc/events.py @@ -15,24 +15,28 @@ from core.emulator.data import ( from core.emulator.session import Session -def handle_node_event(event: NodeData) -> core_pb2.NodeEvent: +def handle_node_event(node_data: NodeData) -> core_pb2.NodeEvent: """ Handle node event when there is a node event - :param event: node data + :param node_data: node data :return: node event that contains node id, name, model, position, and services """ - position = core_pb2.Position(x=event.x_position, y=event.y_position) - geo = core_pb2.Geo(lat=event.latitude, lon=event.longitude, alt=event.altitude) + node = node_data.node + x, y, _ = node.position.get() + position = core_pb2.Position(x=x, y=y) + lon, lat, alt = node.position.get_geo() + geo = core_pb2.Geo(lon=lon, lat=lat, alt=alt) + services = [x.name for x in node.services] node_proto = core_pb2.Node( - id=event.id, - name=event.name, - model=event.model, + id=node.id, + name=node.name, + model=node.type, position=position, geo=geo, - services=event.services, + services=services, ) - return core_pb2.NodeEvent(node=node_proto, source=event.source) + return core_pb2.NodeEvent(node=node_proto, source=node_data.source) def handle_link_event(event: LinkData) -> core_pb2.LinkEvent: diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index 379b739e..d01f15a3 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -329,7 +329,6 @@ class CoreHandler(socketserver.BaseRequestHandler): """ logging.debug("handling broadcast node: %s", node_data) message = dataconversion.convert_node(node_data) - try: self.sendall(message) except IOError: diff --git a/daemon/core/api/tlv/dataconversion.py b/daemon/core/api/tlv/dataconversion.py index 62b51d39..8a26300a 100644 --- a/daemon/core/api/tlv/dataconversion.py +++ b/daemon/core/api/tlv/dataconversion.py @@ -8,35 +8,39 @@ from typing import Dict, List from core.api.tlv import coreapi, structutils from core.api.tlv.enumerations import ConfigTlvs, NodeTlvs from core.config import ConfigGroup, ConfigurableOptions -from core.emulator.data import ConfigData +from core.emulator.data import ConfigData, NodeData -def convert_node(node_data): +def convert_node(node_data: NodeData): """ Convenience method for converting NodeData to a packed TLV message. :param core.emulator.data.NodeData node_data: node data to convert :return: packed node message """ + node = node_data.node services = None - if node_data.services is not None: - services = "|".join([x for x in node_data.services]) + if node.services is not None: + services = "|".join([x.name for x in node.services]) + server = None + if node.server is not None: + server = node.server.name tlv_data = structutils.pack_values( coreapi.CoreNodeTlv, [ - (NodeTlvs.NUMBER, node_data.id), - (NodeTlvs.TYPE, node_data.type.value), - (NodeTlvs.NAME, node_data.name), - (NodeTlvs.MODEL, node_data.model), - (NodeTlvs.EMULATION_SERVER, node_data.server), - (NodeTlvs.X_POSITION, int(node_data.x_position)), - (NodeTlvs.Y_POSITION, int(node_data.y_position)), - (NodeTlvs.CANVAS, node_data.canvas), + (NodeTlvs.NUMBER, node.id), + (NodeTlvs.TYPE, node.apitype.value), + (NodeTlvs.NAME, node.name), + (NodeTlvs.MODEL, node.type), + (NodeTlvs.EMULATION_SERVER, server), + (NodeTlvs.X_POSITION, int(node.position.x)), + (NodeTlvs.Y_POSITION, int(node.position.y)), + (NodeTlvs.CANVAS, node.canvas), (NodeTlvs.SERVICES, services), - (NodeTlvs.LATITUDE, str(node_data.latitude)), - (NodeTlvs.LONGITUDE, str(node_data.longitude)), - (NodeTlvs.ALTITUDE, str(node_data.altitude)), - (NodeTlvs.ICON, node_data.icon), + (NodeTlvs.LATITUDE, str(node.position.lat)), + (NodeTlvs.LONGITUDE, str(node.position.lon)), + (NodeTlvs.ALTITUDE, str(node.position.alt)), + (NodeTlvs.ICON, node.icon), ], ) return coreapi.CoreNodeMessage.pack(node_data.message_type.value, tlv_data) diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index c57f8b24..5b6479ae 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -12,11 +12,10 @@ from core.emulator.enumerations import ( ExceptionLevels, LinkTypes, MessageFlags, - NodeTypes, ) if TYPE_CHECKING: - from core.nodes.base import CoreNode + from core.nodes.base import CoreNode, NodeBase @dataclass @@ -121,23 +120,11 @@ class NodeOptions: @dataclass class NodeData: """ - Used to represent nodes being broadcasted. + Node to broadcast. """ + node: "NodeBase" message_type: MessageFlags = None - type: NodeTypes = None - id: int = None - name: str = None - model: str = None - server: str = None - icon: str = None - canvas: int = None - services: List[str] = None - x_position: float = None - y_position: float = None - latitude: float = None - longitude: float = None - altitude: float = None source: str = None diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index ccabeddb..0b97da93 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -807,9 +807,9 @@ class Session: :param source: source of broadcast, None by default :return: nothing """ - node_data = node.data(message_type, source) - if not node_data: + if not node.apitype: return + node_data = NodeData(node=node, message_type=message_type, source=source) for handler in self.node_handlers: handler(node_data) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 378549ab..8a5c579a 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -14,7 +14,7 @@ import netaddr from core import utils from core.configservice.dependencies import ConfigServiceDependencies from core.constants import MOUNT_BIN, VNODED_BIN -from core.emulator.data import InterfaceData, LinkData, LinkOptions, NodeData +from core.emulator.data import InterfaceData, LinkData, LinkOptions from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes from core.errors import CoreCommandError, CoreError from core.nodes.client import VnodeClient @@ -182,42 +182,6 @@ class NodeBase(abc.ABC): self.iface_id += 1 return iface_id - def data( - self, message_type: MessageFlags = MessageFlags.NONE, source: str = None - ) -> Optional[NodeData]: - """ - Build a data object for this node. - - :param message_type: purpose for the data object we are creating - :param source: source of node data - :return: node data object - """ - if self.apitype is None: - return None - x, y, _ = self.getposition() - model = self.type - server = None - if self.server is not None: - server = self.server.name - services = [x.name for x in self.services] - return NodeData( - message_type=message_type, - type=self.apitype, - id=self.id, - name=self.name, - model=model, - server=server, - canvas=self.canvas, - icon=self.icon, - x_position=x, - y_position=y, - latitude=self.position.lat, - longitude=self.position.lon, - altitude=self.position.alt, - services=services, - source=source, - ) - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ Build link data for this node. diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index 287723a7..680def1b 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple from core import utils from core.emulator.data import LinkOptions -from core.emulator.enumerations import MessageFlags, TransportType +from core.emulator.enumerations import TransportType from core.errors import CoreCommandError from core.nodes.netclient import LinuxNetClient, get_net_client @@ -561,23 +561,4 @@ class GreTap(CoreInterface): self.net_client.delete_device(self.localname) except CoreCommandError: logging.exception("error during shutdown") - self.localname = None - - def data(self, message_type: int) -> None: - """ - Data for a gre tap. - - :param message_type: message type for data - :return: None - """ - return None - - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List: - """ - Retrieve link data. - - :param flags: link flags - :return: link data - """ - return [] diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index ef9456db..f5baf326 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -11,7 +11,7 @@ import netaddr from core import utils from core.constants import EBTABLES_BIN, TC_BIN -from core.emulator.data import InterfaceData, LinkData, LinkOptions, NodeData +from core.emulator.data import InterfaceData, LinkData, LinkOptions from core.emulator.enumerations import ( LinkTypes, MessageFlags, @@ -862,19 +862,6 @@ class PtpNet(CoreNetwork): ) super().attach(iface) - def data( - self, message_type: MessageFlags = MessageFlags.NONE, source: str = None - ) -> Optional[NodeData]: - """ - Do not generate a Node Message for point-to-point links. They are - built using a link message instead. - - :param message_type: purpose for the data object we are creating - :param source: source of node data - :return: node data object - """ - return None - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ Build CORE API TLVs for a point-to-point link. One Link message diff --git a/daemon/core/plugins/sdt.py b/daemon/core/plugins/sdt.py index 04fff3e4..84c90730 100644 --- a/daemon/core/plugins/sdt.py +++ b/daemon/core/plugins/sdt.py @@ -314,26 +314,22 @@ class Sdt: :param node_data: node data being updated :return: nothing """ - logging.debug("sdt handle node update: %s - %s", node_data.id, node_data.name) if not self.connect(): return - - # delete node + node = node_data.node + logging.debug("sdt handle node update: %s - %s", node.id, node.name) if node_data.message_type == MessageFlags.DELETE: - self.cmd(f"delete node,{node_data.id}") + self.cmd(f"delete node,{node.id}") else: - x = node_data.x_position - y = node_data.y_position - lat = node_data.latitude - lon = node_data.longitude - alt = node_data.altitude + x, y, _ = node.position.get() + lon, lat, alt = node.position.get_geo() if all([lat is not None, lon is not None, alt is not None]): pos = f"pos {lon:.6f},{lat:.6f},{alt:.6f}" - self.cmd(f"node {node_data.id} {pos}") + self.cmd(f"node {node.id} {pos}") elif node_data.message_type == 0: lat, lon, alt = self.session.location.getgeo(x, y, 0) pos = f"pos {lon:.6f},{lat:.6f},{alt:.6f}" - self.cmd(f"node {node_data.id} {pos}") + self.cmd(f"node {node.id} {pos}") def wireless_net_check(self, node_id: int) -> bool: """ diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index cff7cd85..8abf33aa 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -1198,9 +1198,10 @@ class TestGrpc: queue = Queue() def node_handler(node_data: NodeData): - assert node_data.longitude == lon - assert node_data.latitude == lat - assert node_data.altitude == alt + n = node_data.node + assert n.position.lon == lon + assert n.position.lat == lat + assert n.position.alt == alt queue.put(node_data) session.node_handlers.append(node_handler) From e46a072f744c198e702e5fce5347538aa0ac1456 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 18 Jun 2020 09:33:54 -0700 Subject: [PATCH 168/929] daemon: removed missing params from python docs, updated node ValueErrors to CoreErrors --- daemon/core/nodes/interface.py | 4 ++-- daemon/core/nodes/network.py | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index 680def1b..42522362 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple from core import utils from core.emulator.data import LinkOptions from core.emulator.enumerations import TransportType -from core.errors import CoreCommandError +from core.errors import CoreCommandError, CoreError from core.nodes.netclient import LinuxNetClient, get_net_client if TYPE_CHECKING: @@ -544,7 +544,7 @@ class GreTap(CoreInterface): if not start: return if remoteip is None: - raise ValueError("missing remote IP required for GRE TAP device") + raise CoreError("missing remote IP required for GRE TAP device") self.net_client.create_gretap(self.localname, remoteip, localip, ttl, key) self.net_client.device_up(self.localname) self.up = True diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index f5baf326..f20b6dfb 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -618,7 +618,6 @@ class GreTapBridge(CoreNetwork): :param localip: local address :param ttl: ttl value :param key: gre tap key - :param start: start flag :param server: remote server node will run on, default is None for localhost """ @@ -857,9 +856,7 @@ class PtpNet(CoreNetwork): :return: nothing """ if len(self.ifaces) >= 2: - raise ValueError( - "Point-to-point links support at most 2 network interfaces" - ) + raise CoreError("ptp links support at most 2 network interfaces") super().attach(iface) def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: @@ -992,7 +989,6 @@ class WlanNode(CoreNetwork): :param session: core session instance :param _id: node id :param name: node name - :param start: start flag :param server: remote server node will run on, default is None for localhost :param policy: wlan policy From cd74a44558596d259e0b878b49ac605edf389665 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 18 Jun 2020 12:54:36 -0700 Subject: [PATCH 169/929] daemon: added type hinting throughout all services and made small tweaks/fixes that were ran across --- daemon/core/nodes/base.py | 4 +- daemon/core/services/bird.py | 86 +++++------ daemon/core/services/emaneservices.py | 25 ++-- daemon/core/services/frr.py | 202 ++++++++++++------------- daemon/core/services/nrl.py | 154 +++++++++---------- daemon/core/services/quagga.py | 196 ++++++++++++------------ daemon/core/services/sdn.py | 44 +++--- daemon/core/services/security.py | 112 +++++++------- daemon/core/services/ucarp.py | 42 +++--- daemon/core/services/utility.py | 206 ++++++++++++-------------- daemon/core/services/xorp.py | 125 +++++++--------- 11 files changed, 560 insertions(+), 636 deletions(-) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 8a5c579a..4fc6b873 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -7,7 +7,7 @@ import os import shutil import threading from threading import RLock -from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type, Union import netaddr @@ -27,7 +27,7 @@ if TYPE_CHECKING: from core.configservice.base import ConfigService from core.services.coreservices import CoreService - CoreServices = List[CoreService] + CoreServices = List[Union[CoreService, Type[CoreService]]] ConfigServiceType = Type[ConfigService] _DEFAULT_MTU = 1500 diff --git a/daemon/core/services/bird.py b/daemon/core/services/bird.py index 16f0bb84..a5052942 100644 --- a/daemon/core/services/bird.py +++ b/daemon/core/services/bird.py @@ -1,8 +1,11 @@ """ bird.py: defines routing services provided by the BIRD Internet Routing Daemon. """ +from typing import Optional, Tuple + import netaddr +from core.nodes.base import CoreNode from core.services.coreservices import CoreService @@ -11,27 +14,27 @@ class Bird(CoreService): Bird router support """ - name = "bird" - executables = ("bird",) - group = "BIRD" - dirs = ("/etc/bird",) - configs = ("/etc/bird/bird.conf",) - startup = ("bird -c %s" % (configs[0]),) - shutdown = ("killall bird",) - validate = ("pidof bird",) + name: str = "bird" + group: str = "BIRD" + executables: Tuple[str, ...] = ("bird",) + dirs: Tuple[str, ...] = ("/etc/bird",) + configs: Tuple[str, ...] = ("/etc/bird/bird.conf",) + startup: Tuple[str, ...] = ("bird -c %s" % (configs[0]),) + shutdown: Tuple[str, ...] = ("killall bird",) + validate: Tuple[str, ...] = ("pidof bird",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Return the bird.conf file contents. """ if filename == cls.configs[0]: - return cls.generateBirdConf(node) + return cls.generate_bird_config(node) else: raise ValueError @staticmethod - def routerid(node): + def router_id(node: CoreNode) -> str: """ Helper to return the first IPv4 address of a node as its router ID. """ @@ -40,15 +43,13 @@ class Bird(CoreService): a = a.split("/")[0] if netaddr.valid_ipv4(a): return a - # raise ValueError, "no IPv4 address found for router ID" return "0.0.0.0" @classmethod - def generateBirdConf(cls, node): + def generate_bird_config(cls, node: CoreNode) -> str: """ Returns configuration file text. Other services that depend on bird - will have generatebirdifcconfig() and generatebirdconfig() - hooks that are invoked here. + will have hooks that are invoked here. """ cfg = """\ /* Main configuration file for BIRD. This is ony a template, @@ -75,15 +76,16 @@ protocol device { """ % ( cls.name, - cls.routerid(node), + cls.router_id(node), ) - # Generate protocol specific configurations + # generate protocol specific configurations for s in node.services: if cls.name not in s.dependencies: continue + if not (isinstance(s, BirdService) or issubclass(s, BirdService)): + continue cfg += s.generate_bird_config(node) - return cfg @@ -93,32 +95,26 @@ class BirdService(CoreService): common to Bird's routing daemons. """ - name = None - executables = ("bird",) - group = "BIRD" - dependencies = ("bird",) - dirs = () - configs = () - startup = () - shutdown = () - meta = "The config file for this service can be found in the bird service." + name: Optional[str] = None + group: str = "BIRD" + executables: Tuple[str, ...] = ("bird",) + dependencies: Tuple[str, ...] = ("bird",) + meta: str = "The config file for this service can be found in the bird service." @classmethod - def generate_bird_config(cls, node): + def generate_bird_config(cls, node: CoreNode) -> str: return "" @classmethod - def generate_bird_iface_config(cls, node): + def generate_bird_iface_config(cls, node: CoreNode) -> str: """ Use only bare interfaces descriptions in generated protocol configurations. This has the slight advantage of being the same everywhere. """ cfg = "" - for iface in node.get_ifaces(control=False): cfg += ' interface "%s";\n' % iface.name - return cfg @@ -127,11 +123,11 @@ class BirdBgp(BirdService): BGP BIRD Service (configuration generation) """ - name = "BIRD_BGP" - custom_needed = True + name: str = "BIRD_BGP" + custom_needed: bool = True @classmethod - def generate_bird_config(cls, node): + def generate_bird_config(cls, node: CoreNode) -> str: return """ /* This is a sample config that should be customized with appropriate AS numbers * and peers; add one section like this for each neighbor */ @@ -158,10 +154,10 @@ class BirdOspf(BirdService): OSPF BIRD Service (configuration generation) """ - name = "BIRD_OSPFv2" + name: str = "BIRD_OSPFv2" @classmethod - def generate_bird_config(cls, node): + def generate_bird_config(cls, node: CoreNode) -> str: cfg = "protocol ospf {\n" cfg += " export filter {\n" cfg += " if source = RTS_BGP then {\n" @@ -174,7 +170,6 @@ class BirdOspf(BirdService): cfg += cls.generate_bird_iface_config(node) cfg += " };\n" cfg += "}\n\n" - return cfg @@ -183,12 +178,11 @@ class BirdRadv(BirdService): RADV BIRD Service (configuration generation) """ - name = "BIRD_RADV" + name: str = "BIRD_RADV" @classmethod - def generate_bird_config(cls, node): + def generate_bird_config(cls, node: CoreNode) -> str: cfg = "/* This is a sample config that must be customized */\n" - cfg += "protocol radv {\n" cfg += " # auto configuration on all interfaces\n" cfg += cls.generate_bird_iface_config(node) @@ -202,7 +196,6 @@ class BirdRadv(BirdService): cfg += "# ns 2001:0DB8:1234::12;\n" cfg += " };\n" cfg += "}\n\n" - return cfg @@ -211,10 +204,10 @@ class BirdRip(BirdService): RIP BIRD Service (configuration generation) """ - name = "BIRD_RIP" + name: str = "BIRD_RIP" @classmethod - def generate_bird_config(cls, node): + def generate_bird_config(cls, node: CoreNode) -> str: cfg = "protocol rip {\n" cfg += " period 10;\n" cfg += " garbage time 60;\n" @@ -224,7 +217,6 @@ class BirdRip(BirdService): cfg += " import all;\n" cfg += " export all;\n" cfg += "}\n\n" - return cfg @@ -233,11 +225,11 @@ class BirdStatic(BirdService): Static Bird Service (configuration generation) """ - name = "BIRD_static" - custom_needed = True + name: str = "BIRD_static" + custom_needed: bool = True @classmethod - def generate_bird_config(cls, node): + def generate_bird_config(cls, node: CoreNode) -> str: cfg = "/* This is a sample config that must be customized */\n" cfg += "protocol static {\n" cfg += "# route 0.0.0.0/0 via 198.51.100.130; # Default route. Do NOT advertise on BGP !\n" diff --git a/daemon/core/services/emaneservices.py b/daemon/core/services/emaneservices.py index da438bab..ef188fab 100644 --- a/daemon/core/services/emaneservices.py +++ b/daemon/core/services/emaneservices.py @@ -1,23 +1,26 @@ +from typing import Tuple + from core.emane.nodes import EmaneNet from core.errors import CoreError +from core.nodes.base import CoreNode from core.services.coreservices import CoreService from core.xml import emanexml class EmaneTransportService(CoreService): - name = "transportd" - executables = ("emanetransportd", "emanegentransportxml") - group = "EMANE" - dependencies = () - dirs = () - configs = ("emanetransport.sh",) - startup = ("sh %s" % configs[0],) - validate = ("pidof %s" % executables[0],) - validation_timer = 0.5 - shutdown = ("killall %s" % executables[0],) + name: str = "transportd" + group: str = "EMANE" + executables: Tuple[str, ...] = ("emanetransportd", "emanegentransportxml") + dependencies: Tuple[str, ...] = () + dirs: Tuple[str, ...] = () + configs: Tuple[str, ...] = ("emanetransport.sh",) + startup: Tuple[str, ...] = ("sh %s" % configs[0],) + validate: Tuple[str, ...] = ("pidof %s" % executables[0],) + validation_timer: float = 0.5 + shutdown: Tuple[str, ...] = ("killall %s" % executables[0],) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: if filename == cls.configs[0]: transport_commands = [] for iface in node.get_ifaces(): diff --git a/daemon/core/services/frr.py b/daemon/core/services/frr.py index 97a8b334..e75d8f56 100644 --- a/daemon/core/services/frr.py +++ b/daemon/core/services/frr.py @@ -2,60 +2,63 @@ frr.py: defines routing services provided by FRRouting. Assumes installation of FRR via https://deb.frrouting.org/ """ +from typing import Optional, Tuple + import netaddr from core import constants from core.emane.nodes import EmaneNet +from core.nodes.base import CoreNode +from core.nodes.interface import CoreInterface from core.nodes.network import PtpNet, WlanNode from core.nodes.physical import Rj45Node from core.services.coreservices import CoreService class FRRZebra(CoreService): - name = "FRRzebra" - group = "FRR" - dirs = ("/usr/local/etc/frr", "/var/run/frr", "/var/log/frr") - configs = ( + name: str = "FRRzebra" + group: str = "FRR" + dirs: Tuple[str, ...] = ("/usr/local/etc/frr", "/var/run/frr", "/var/log/frr") + configs: Tuple[str, ...] = ( "/usr/local/etc/frr/frr.conf", "frrboot.sh", "/usr/local/etc/frr/vtysh.conf", "/usr/local/etc/frr/daemons", ) - startup = ("sh frrboot.sh zebra",) - shutdown = ("killall zebra",) - validate = ("pidof zebra",) + startup: Tuple[str, ...] = ("sh frrboot.sh zebra",) + shutdown: Tuple[str, ...] = ("killall zebra",) + validate: Tuple[str, ...] = ("pidof zebra",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Return the frr.conf or frrboot.sh file contents. """ if filename == cls.configs[0]: - return cls.generateFrrConf(node) + return cls.generate_frr_conf(node) elif filename == cls.configs[1]: - return cls.generateFrrBoot(node) + return cls.generate_frr_boot(node) elif filename == cls.configs[2]: - return cls.generateVtyshConf(node) + return cls.generate_vtysh_conf(node) elif filename == cls.configs[3]: - return cls.generateFrrDaemons(node) + return cls.generate_frr_daemons(node) else: raise ValueError( "file name (%s) is not a known configuration: %s", filename, cls.configs ) @classmethod - def generateVtyshConf(cls, node): + def generate_vtysh_conf(cls, node: CoreNode) -> str: """ Returns configuration file text. """ return "service integrated-vtysh-config\n" @classmethod - def generateFrrConf(cls, node): + def generate_frr_conf(cls, node: CoreNode) -> str: """ Returns configuration file text. Other services that depend on zebra - will have generatefrrifcconfig() and generatefrrconfig() - hooks that are invoked here. + will have hooks that are invoked here. """ # we could verify here that filename == frr.conf cfg = "" @@ -108,7 +111,7 @@ class FRRZebra(CoreService): return cfg @staticmethod - def addrstr(x): + def addrstr(x: str) -> str: """ helper for mapping IP addresses to zebra config statements """ @@ -121,7 +124,7 @@ class FRRZebra(CoreService): raise ValueError("invalid address: %s", x) @classmethod - def generateFrrBoot(cls, node): + def generate_frr_boot(cls, node: CoreNode) -> str: """ Generate a shell script used to boot the FRR daemons. """ @@ -244,7 +247,7 @@ bootfrr return cfg @classmethod - def generateFrrDaemons(cls, node): + def generate_frr_daemons(cls, node: CoreNode) -> str: """ Returns configuration file text. """ @@ -317,20 +320,15 @@ class FrrService(CoreService): common to FRR's routing daemons. """ - name = None - group = "FRR" - dependencies = ("FRRzebra",) - dirs = () - configs = () - startup = () - shutdown = () - meta = "The config file for this service can be found in the Zebra service." - - ipv4_routing = False - ipv6_routing = False + name: Optional[str] = None + group: str = "FRR" + dependencies: Tuple[str, ...] = ("FRRzebra",) + meta: str = "The config file for this service can be found in the Zebra service." + ipv4_routing: bool = False + ipv6_routing: bool = False @staticmethod - def routerid(node): + def router_id(node: CoreNode) -> str: """ Helper to return the first IPv4 address of a node as its router ID. """ @@ -339,11 +337,10 @@ class FrrService(CoreService): a = a.split("/")[0] if netaddr.valid_ipv4(a): return a - # raise ValueError, "no IPv4 address found for router ID" return "0.0.0.0" @staticmethod - def rj45check(iface): + def rj45check(iface: CoreInterface) -> bool: """ Helper to detect whether interface is connected an external RJ45 link. @@ -357,15 +354,15 @@ class FrrService(CoreService): return False @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: return "" @classmethod - def generate_frr_iface_config(cls, node, iface): + def generate_frr_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: return "" @classmethod - def generate_frr_config(cls, node): + def generate_frr_config(cls, node: CoreNode) -> str: return "" @@ -376,14 +373,13 @@ class FRROspfv2(FrrService): unified frr.conf file. """ - name = "FRROSPFv2" - startup = () - shutdown = ("killall ospfd",) - validate = ("pidof ospfd",) - ipv4_routing = True + name: str = "FRROSPFv2" + shutdown: Tuple[str, ...] = ("killall ospfd",) + validate: Tuple[str, ...] = ("pidof ospfd",) + ipv4_routing: bool = True @staticmethod - def mtucheck(iface): + def mtu_check(iface: CoreInterface) -> str: """ Helper to detect MTU mismatch and add the appropriate OSPF mtu-ignore command. This is needed when e.g. a node is linked via a @@ -401,7 +397,7 @@ class FRROspfv2(FrrService): return "" @staticmethod - def ptpcheck(iface): + def ptp_check(iface: CoreInterface) -> str: """ Helper to detect whether interface is connected to a notional point-to-point link. @@ -411,9 +407,9 @@ class FRROspfv2(FrrService): return "" @classmethod - def generate_frr_config(cls, node): + def generate_frr_config(cls, node: CoreNode) -> str: cfg = "router ospf\n" - rtrid = cls.routerid(node) + rtrid = cls.router_id(node) cfg += " router-id %s\n" % rtrid # network 10.0.0.0/24 area 0 for iface in node.get_ifaces(control=False): @@ -426,8 +422,8 @@ class FRROspfv2(FrrService): return cfg @classmethod - def generate_frr_iface_config(cls, node, iface): - return cls.mtucheck(iface) + def generate_frr_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: + return cls.mtu_check(iface) class FRROspfv3(FrrService): @@ -437,15 +433,14 @@ class FRROspfv3(FrrService): unified frr.conf file. """ - name = "FRROSPFv3" - startup = () - shutdown = ("killall ospf6d",) - validate = ("pidof ospf6d",) - ipv4_routing = True - ipv6_routing = True + name: str = "FRROSPFv3" + shutdown: Tuple[str, ...] = ("killall ospf6d",) + validate: Tuple[str, ...] = ("pidof ospf6d",) + ipv4_routing: bool = True + ipv6_routing: bool = True @staticmethod - def minmtu(iface): + def min_mtu(iface: CoreInterface) -> int: """ Helper to discover the minimum MTU of interfaces linked with the given interface. @@ -459,20 +454,20 @@ class FRROspfv3(FrrService): return mtu @classmethod - def mtucheck(cls, iface): + def mtu_check(cls, iface: CoreInterface) -> str: """ Helper to detect MTU mismatch and add the appropriate OSPFv3 ifmtu command. This is needed when e.g. a node is linked via a GreTap device. """ - minmtu = cls.minmtu(iface) + minmtu = cls.min_mtu(iface) if minmtu < iface.mtu: return " ipv6 ospf6 ifmtu %d\n" % minmtu else: return "" @staticmethod - def ptpcheck(iface): + def ptp_check(iface: CoreInterface) -> str: """ Helper to detect whether interface is connected to a notional point-to-point link. @@ -482,9 +477,9 @@ class FRROspfv3(FrrService): return "" @classmethod - def generate_frr_config(cls, node): + def generate_frr_config(cls, node: CoreNode) -> str: cfg = "router ospf6\n" - rtrid = cls.routerid(node) + rtrid = cls.router_id(node) cfg += " router-id %s\n" % rtrid for iface in node.get_ifaces(control=False): cfg += " interface %s area 0.0.0.0\n" % iface.name @@ -492,14 +487,13 @@ class FRROspfv3(FrrService): return cfg @classmethod - def generate_frr_iface_config(cls, node, iface): - return cls.mtucheck(iface) + def generate_frr_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: + return cls.mtu_check(iface) # cfg = cls.mtucheck(ifc) # external RJ45 connections will use default OSPF timers # if cls.rj45check(ifc): # return cfg # cfg += cls.ptpcheck(ifc) - # return cfg + """\ @@ -516,21 +510,20 @@ class FRRBgp(FrrService): having the same AS number. """ - name = "FRRBGP" - startup = () - shutdown = ("killall bgpd",) - validate = ("pidof bgpd",) - custom_needed = True - ipv4_routing = True - ipv6_routing = True + name: str = "FRRBGP" + shutdown: Tuple[str, ...] = ("killall bgpd",) + validate: Tuple[str, ...] = ("pidof bgpd",) + custom_needed: bool = True + ipv4_routing: bool = True + ipv6_routing: bool = True @classmethod - def generate_frr_config(cls, node): + def generate_frr_config(cls, node: CoreNode) -> str: cfg = "!\n! BGP configuration\n!\n" cfg += "! You should configure the AS number below,\n" cfg += "! along with this router's peers.\n!\n" cfg += "router bgp %s\n" % node.id - rtrid = cls.routerid(node) + rtrid = cls.router_id(node) cfg += " bgp router-id %s\n" % rtrid cfg += " redistribute connected\n" cfg += "! neighbor 1.2.3.4 remote-as 555\n!\n" @@ -542,14 +535,13 @@ class FRRRip(FrrService): The RIP service provides IPv4 routing for wired networks. """ - name = "FRRRIP" - startup = () - shutdown = ("killall ripd",) - validate = ("pidof ripd",) - ipv4_routing = True + name: str = "FRRRIP" + shutdown: Tuple[str, ...] = ("killall ripd",) + validate: Tuple[str, ...] = ("pidof ripd",) + ipv4_routing: bool = True @classmethod - def generate_frr_config(cls, node): + def generate_frr_config(cls, node: CoreNode) -> str: cfg = """\ router rip redistribute static @@ -566,14 +558,13 @@ class FRRRipng(FrrService): The RIP NG service provides IPv6 routing for wired networks. """ - name = "FRRRIPNG" - startup = () - shutdown = ("killall ripngd",) - validate = ("pidof ripngd",) - ipv6_routing = True + name: str = "FRRRIPNG" + shutdown: Tuple[str, ...] = ("killall ripngd",) + validate: Tuple[str, ...] = ("pidof ripngd",) + ipv6_routing: bool = True @classmethod - def generate_frr_config(cls, node): + def generate_frr_config(cls, node: CoreNode) -> str: cfg = """\ router ripng redistribute static @@ -591,14 +582,13 @@ class FRRBabel(FrrService): protocol for IPv6 and IPv4 with fast convergence properties. """ - name = "FRRBabel" - startup = () - shutdown = ("killall babeld",) - validate = ("pidof babeld",) - ipv6_routing = True + name: str = "FRRBabel" + shutdown: Tuple[str, ...] = ("killall babeld",) + validate: Tuple[str, ...] = ("pidof babeld",) + ipv6_routing: bool = True @classmethod - def generate_frr_config(cls, node): + def generate_frr_config(cls, node: CoreNode) -> str: cfg = "router babel\n" for iface in node.get_ifaces(control=False): cfg += " network %s\n" % iface.name @@ -606,7 +596,7 @@ class FRRBabel(FrrService): return cfg @classmethod - def generate_frr_iface_config(cls, node, iface): + def generate_frr_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: if iface.net and isinstance(iface.net, (EmaneNet, WlanNode)): return " babel wireless\n no babel split-horizon\n" else: @@ -618,14 +608,13 @@ class FRRpimd(FrrService): PIM multicast routing based on XORP. """ - name = "FRRpimd" - startup = () - shutdown = ("killall pimd",) - validate = ("pidof pimd",) - ipv4_routing = True + name: str = "FRRpimd" + shutdown: Tuple[str, ...] = ("killall pimd",) + validate: Tuple[str, ...] = ("pidof pimd",) + ipv4_routing: bool = True @classmethod - def generate_frr_config(cls, node): + def generate_frr_config(cls, node: CoreNode) -> str: ifname = "eth0" for iface in node.get_ifaces(): if iface.name != "lo": @@ -641,7 +630,7 @@ class FRRpimd(FrrService): return cfg @classmethod - def generate_frr_iface_config(cls, node, iface): + def generate_frr_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: return " ip mfea\n ip igmp\n ip pim\n" @@ -652,15 +641,14 @@ class FRRIsis(FrrService): unified frr.conf file. """ - name = "FRRISIS" - startup = () - shutdown = ("killall isisd",) - validate = ("pidof isisd",) - ipv4_routing = True - ipv6_routing = True + name: str = "FRRISIS" + shutdown: Tuple[str, ...] = ("killall isisd",) + validate: Tuple[str, ...] = ("pidof isisd",) + ipv4_routing: bool = True + ipv6_routing: bool = True @staticmethod - def ptpcheck(iface): + def ptp_check(iface: CoreInterface) -> str: """ Helper to detect whether interface is connected to a notional point-to-point link. @@ -670,7 +658,7 @@ class FRRIsis(FrrService): return "" @classmethod - def generate_frr_config(cls, node): + def generate_frr_config(cls, node: CoreNode) -> str: cfg = "router isis DEFAULT\n" cfg += " net 47.0001.0000.1900.%04x.00\n" % node.id cfg += " metric-style wide\n" @@ -679,9 +667,9 @@ class FRRIsis(FrrService): return cfg @classmethod - def generate_frr_iface_config(cls, node, iface): + def generate_frr_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: cfg = " ip router isis DEFAULT\n" cfg += " ipv6 router isis DEFAULT\n" cfg += " isis circuit-type level-2-only\n" - cfg += cls.ptpcheck(iface) + cfg += cls.ptp_check(iface) return cfg diff --git a/daemon/core/services/nrl.py b/daemon/core/services/nrl.py index 38b90d48..9933b130 100644 --- a/daemon/core/services/nrl.py +++ b/daemon/core/services/nrl.py @@ -2,9 +2,12 @@ nrl.py: defines services provided by NRL protolib tools hosted here: http://www.nrl.navy.mil/itd/ncs/products """ +from typing import Optional, Tuple + import netaddr from core import utils +from core.nodes.base import CoreNode from core.services.coreservices import CoreService @@ -14,19 +17,15 @@ class NrlService(CoreService): common to NRL's routing daemons. """ - name = None - group = "ProtoSvc" - dirs = () - configs = () - startup = () - shutdown = () + name: Optional[str] = None + group: str = "ProtoSvc" @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: return "" @staticmethod - def firstipv4prefix(node, prefixlen=24): + def firstipv4prefix(node: CoreNode, prefixlen: int = 24) -> str: """ Similar to QuaggaService.routerid(). Helper to return the first IPv4 prefix of a node, using the supplied prefix length. This ignores the @@ -37,20 +36,19 @@ class NrlService(CoreService): a = a.split("/")[0] if netaddr.valid_ipv4(a): return f"{a}/{prefixlen}" - # raise ValueError, "no IPv4 address found" return "0.0.0.0/%s" % prefixlen class MgenSinkService(NrlService): - name = "MGEN_Sink" - executables = ("mgen",) - configs = ("sink.mgen",) - startup = ("mgen input sink.mgen",) - validate = ("pidof mgen",) - shutdown = ("killall mgen",) + name: str = "MGEN_Sink" + executables: Tuple[str, ...] = ("mgen",) + configs: Tuple[str, ...] = ("sink.mgen",) + startup: Tuple[str, ...] = ("mgen input sink.mgen",) + validate: Tuple[str, ...] = ("pidof mgen",) + shutdown: Tuple[str, ...] = ("killall mgen",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: cfg = "0.0 LISTEN UDP 5000\n" for iface in node.get_ifaces(): name = utils.sysctl_devname(iface.name) @@ -58,7 +56,7 @@ class MgenSinkService(NrlService): return cfg @classmethod - def get_startup(cls, node): + def get_startup(cls, node: CoreNode) -> Tuple[str, ...]: cmd = cls.startup[0] cmd += " output /tmp/mgen_%s.log" % node.name return (cmd,) @@ -69,32 +67,29 @@ class NrlNhdp(NrlService): NeighborHood Discovery Protocol for MANET networks. """ - name = "NHDP" - executables = ("nrlnhdp",) - startup = ("nrlnhdp",) - shutdown = ("killall nrlnhdp",) - validate = ("pidof nrlnhdp",) + name: str = "NHDP" + executables: Tuple[str, ...] = ("nrlnhdp",) + startup: Tuple[str, ...] = ("nrlnhdp",) + shutdown: Tuple[str, ...] = ("killall nrlnhdp",) + validate: Tuple[str, ...] = ("pidof nrlnhdp",) @classmethod - def get_startup(cls, node): + def get_startup(cls, node: CoreNode) -> Tuple[str, ...]: """ Generate the appropriate command-line based on node interfaces. """ cmd = cls.startup[0] cmd += " -l /var/log/nrlnhdp.log" cmd += " -rpipe %s_nhdp" % node.name - servicenames = map(lambda x: x.name, node.services) if "SMF" in servicenames: cmd += " -flooding ecds" cmd += " -smfClient %s_smf" % node.name - ifaces = node.get_ifaces(control=False) if len(ifaces) > 0: iface_names = map(lambda x: x.name, ifaces) cmd += " -i " cmd += " -i ".join(iface_names) - return (cmd,) @@ -103,15 +98,15 @@ class NrlSmf(NrlService): Simplified Multicast Forwarding for MANET networks. """ - name = "SMF" - executables = ("nrlsmf",) - startup = ("sh startsmf.sh",) - shutdown = ("killall nrlsmf",) - validate = ("pidof nrlsmf",) - configs = ("startsmf.sh",) + name: str = "SMF" + executables: Tuple[str, ...] = ("nrlsmf",) + startup: Tuple[str, ...] = ("sh startsmf.sh",) + shutdown: Tuple[str, ...] = ("killall nrlsmf",) + validate: Tuple[str, ...] = ("pidof nrlsmf",) + configs: Tuple[str, ...] = ("startsmf.sh",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Generate a startup script for SMF. Because nrlsmf does not daemonize, it can cause problems in some situations when launched @@ -146,7 +141,6 @@ class NrlSmf(NrlService): cmd += " hash MD5" cmd += " log /var/log/nrlsmf.log" - cfg += comments + cmd + " < /dev/null > /dev/null 2>&1 &\n\n" return cfg @@ -156,14 +150,14 @@ class NrlOlsr(NrlService): Optimized Link State Routing protocol for MANET networks. """ - name = "OLSR" - executables = ("nrlolsrd",) - startup = ("nrlolsrd",) - shutdown = ("killall nrlolsrd",) - validate = ("pidof nrlolsrd",) + name: str = "OLSR" + executables: Tuple[str, ...] = ("nrlolsrd",) + startup: Tuple[str, ...] = ("nrlolsrd",) + shutdown: Tuple[str, ...] = ("killall nrlolsrd",) + validate: Tuple[str, ...] = ("pidof nrlolsrd",) @classmethod - def get_startup(cls, node): + def get_startup(cls, node: CoreNode) -> Tuple[str, ...]: """ Generate the appropriate command-line based on node interfaces. """ @@ -175,14 +169,12 @@ class NrlOlsr(NrlService): cmd += " -i %s" % iface.name cmd += " -l /var/log/nrlolsrd.log" cmd += " -rpipe %s_olsr" % node.name - servicenames = map(lambda x: x.name, node.services) if "SMF" in servicenames and "NHDP" not in servicenames: cmd += " -flooding s-mpr" cmd += " -smfClient %s_smf" % node.name if "zebra" in servicenames: cmd += " -z" - return (cmd,) @@ -191,34 +183,30 @@ class NrlOlsrv2(NrlService): Optimized Link State Routing protocol version 2 for MANET networks. """ - name = "OLSRv2" - executables = ("nrlolsrv2",) - startup = ("nrlolsrv2",) - shutdown = ("killall nrlolsrv2",) - validate = ("pidof nrlolsrv2",) + name: str = "OLSRv2" + executables: Tuple[str, ...] = ("nrlolsrv2",) + startup: Tuple[str, ...] = ("nrlolsrv2",) + shutdown: Tuple[str, ...] = ("killall nrlolsrv2",) + validate: Tuple[str, ...] = ("pidof nrlolsrv2",) @classmethod - def get_startup(cls, node): + def get_startup(cls, node: CoreNode) -> Tuple[str, ...]: """ Generate the appropriate command-line based on node interfaces. """ cmd = cls.startup[0] cmd += " -l /var/log/nrlolsrv2.log" cmd += " -rpipe %s_olsrv2" % node.name - servicenames = map(lambda x: x.name, node.services) if "SMF" in servicenames: cmd += " -flooding ecds" cmd += " -smfClient %s_smf" % node.name - cmd += " -p olsr" - ifaces = node.get_ifaces(control=False) if len(ifaces) > 0: iface_names = map(lambda x: x.name, ifaces) cmd += " -i " cmd += " -i ".join(iface_names) - return (cmd,) @@ -227,16 +215,16 @@ class OlsrOrg(NrlService): Optimized Link State Routing protocol from olsr.org for MANET networks. """ - name = "OLSRORG" - executables = ("olsrd",) - configs = ("/etc/olsrd/olsrd.conf",) - dirs = ("/etc/olsrd",) - startup = ("olsrd",) - shutdown = ("killall olsrd",) - validate = ("pidof olsrd",) + name: str = "OLSRORG" + executables: Tuple[str, ...] = ("olsrd",) + configs: Tuple[str, ...] = ("/etc/olsrd/olsrd.conf",) + dirs: Tuple[str, ...] = ("/etc/olsrd",) + startup: Tuple[str, ...] = ("olsrd",) + shutdown: Tuple[str, ...] = ("killall olsrd",) + validate: Tuple[str, ...] = ("pidof olsrd",) @classmethod - def get_startup(cls, node): + def get_startup(cls, node: CoreNode) -> Tuple[str, ...]: """ Generate the appropriate command-line based on node interfaces. """ @@ -246,13 +234,13 @@ class OlsrOrg(NrlService): iface_names = map(lambda x: x.name, ifaces) cmd += " -i " cmd += " -i ".join(iface_names) - return (cmd,) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ - Generate a default olsrd config file to use the broadcast address of 255.255.255.255. + Generate a default olsrd config file to use the broadcast address of + 255.255.255.255. """ cfg = """\ # @@ -577,24 +565,16 @@ class MgenActor(NrlService): """ # a unique name is required, without spaces - name = "MgenActor" - executables = ("mgen",) - # you can create your own group here - group = "ProtoSvc" - # per-node directories - dirs = () - # generated files (without a full path this file goes in the node's dir, - # e.g. /tmp/pycore.12345/n1.conf/) - configs = ("start_mgen_actor.sh",) - # list of startup commands, also may be generated during startup - startup = ("sh start_mgen_actor.sh",) - # list of validation commands - validate = ("pidof mgen",) - # list of shutdown commands - shutdown = ("killall mgen",) + name: str = "MgenActor" + group: str = "ProtoSvc" + executables: Tuple[str, ...] = ("mgen",) + configs: Tuple[str, ...] = ("start_mgen_actor.sh",) + startup: Tuple[str, ...] = ("sh start_mgen_actor.sh",) + validate: Tuple[str, ...] = ("pidof mgen",) + shutdown: Tuple[str, ...] = ("killall mgen",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Generate a startup script for MgenActor. Because mgenActor does not daemonize, it can cause problems in some situations when launched @@ -604,11 +584,9 @@ class MgenActor(NrlService): cfg += "# auto-generated by nrl.py:MgenActor.generateconfig()\n" comments = "" cmd = "mgenBasicActor.py -n %s -a 0.0.0.0" % node.name - ifaces = node.get_ifaces(control=False) if len(ifaces) == 0: return "" - cfg += comments + cmd + " < /dev/null > /dev/null 2>&1 &\n\n" return cfg @@ -618,15 +596,15 @@ class Arouted(NrlService): Adaptive Routing """ - name = "arouted" - executables = ("arouted",) - configs = ("startarouted.sh",) - startup = ("sh startarouted.sh",) - shutdown = ("pkill arouted",) - validate = ("pidof arouted",) + name: str = "arouted" + executables: Tuple[str, ...] = ("arouted",) + configs: Tuple[str, ...] = ("startarouted.sh",) + startup: Tuple[str, ...] = ("sh startarouted.sh",) + shutdown: Tuple[str, ...] = ("pkill arouted",) + validate: Tuple[str, ...] = ("pidof arouted",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Return the Quagga.conf or quaggaboot.sh file contents. """ diff --git a/daemon/core/services/quagga.py b/daemon/core/services/quagga.py index 41cfa3d8..30d14353 100644 --- a/daemon/core/services/quagga.py +++ b/daemon/core/services/quagga.py @@ -1,65 +1,68 @@ """ quagga.py: defines routing services provided by Quagga. """ +from typing import Optional, Tuple + import netaddr from core import constants from core.emane.nodes import EmaneNet from core.emulator.enumerations import LinkTypes +from core.nodes.base import CoreNode +from core.nodes.interface import CoreInterface from core.nodes.network import PtpNet, WlanNode from core.nodes.physical import Rj45Node from core.services.coreservices import CoreService class Zebra(CoreService): - name = "zebra" - group = "Quagga" - dirs = ("/usr/local/etc/quagga", "/var/run/quagga") - configs = ( + name: str = "zebra" + group: str = "Quagga" + dirs: Tuple[str, ...] = ("/usr/local/etc/quagga", "/var/run/quagga") + configs: Tuple[str, ...] = ( "/usr/local/etc/quagga/Quagga.conf", "quaggaboot.sh", "/usr/local/etc/quagga/vtysh.conf", ) - startup = ("sh quaggaboot.sh zebra",) - shutdown = ("killall zebra",) - validate = ("pidof zebra",) + startup: Tuple[str, ...] = ("sh quaggaboot.sh zebra",) + shutdown: Tuple[str, ...] = ("killall zebra",) + validate: Tuple[str, ...] = ("pidof zebra",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Return the Quagga.conf or quaggaboot.sh file contents. """ if filename == cls.configs[0]: - return cls.generateQuaggaConf(node) + return cls.generate_quagga_conf(node) elif filename == cls.configs[1]: - return cls.generateQuaggaBoot(node) + return cls.generate_quagga_boot(node) elif filename == cls.configs[2]: - return cls.generateVtyshConf(node) + return cls.generate_vtysh_conf(node) else: raise ValueError( "file name (%s) is not a known configuration: %s", filename, cls.configs ) @classmethod - def generateVtyshConf(cls, node): + def generate_vtysh_conf(cls, node: CoreNode) -> str: """ Returns configuration file text. """ return "service integrated-vtysh-config\n" @classmethod - def generateQuaggaConf(cls, node): + def generate_quagga_conf(cls, node: CoreNode) -> str: """ Returns configuration file text. Other services that depend on zebra - will have generatequaggaifcconfig() and generatequaggaconfig() - hooks that are invoked here. + will have hooks that are invoked here. """ # we could verify here that filename == Quagga.conf cfg = "" for iface in node.get_ifaces(): cfg += "interface %s\n" % iface.name # include control interfaces in addressing but not routing daemons - if hasattr(iface, "control") and iface.control is True: + if getattr(iface, "control", False): cfg += " " cfg += "\n ".join(map(cls.addrstr, iface.addrlist)) cfg += "\n" @@ -71,6 +74,8 @@ class Zebra(CoreService): for s in node.services: if cls.name not in s.dependencies: continue + if not (isinstance(s, QuaggaService) or issubclass(s, QuaggaService)): + continue iface_config = s.generate_quagga_iface_config(node, iface) if s.ipv4_routing: want_ipv4 = True @@ -101,11 +106,13 @@ class Zebra(CoreService): for s in node.services: if cls.name not in s.dependencies: continue + if not (isinstance(s, QuaggaService) or issubclass(s, QuaggaService)): + continue cfg += s.generate_quagga_config(node) return cfg @staticmethod - def addrstr(x): + def addrstr(x: str) -> str: """ helper for mapping IP addresses to zebra config statements """ @@ -118,7 +125,7 @@ class Zebra(CoreService): raise ValueError("invalid address: %s", x) @classmethod - def generateQuaggaBoot(cls, node): + def generate_quagga_boot(cls, node: CoreNode) -> str: """ Generate a shell script used to boot the Quagga daemons. """ @@ -235,20 +242,15 @@ class QuaggaService(CoreService): common to Quagga's routing daemons. """ - name = None - group = "Quagga" - dependencies = ("zebra",) - dirs = () - configs = () - startup = () - shutdown = () - meta = "The config file for this service can be found in the Zebra service." - - ipv4_routing = False - ipv6_routing = False + name: Optional[str] = None + group: str = "Quagga" + dependencies: Tuple[str, ...] = (Zebra.name,) + meta: str = "The config file for this service can be found in the Zebra service." + ipv4_routing: bool = False + ipv6_routing: bool = False @staticmethod - def routerid(node): + def router_id(node: CoreNode) -> str: """ Helper to return the first IPv4 address of a node as its router ID. """ @@ -257,11 +259,10 @@ class QuaggaService(CoreService): a = a.split("/")[0] if netaddr.valid_ipv4(a): return a - # raise ValueError, "no IPv4 address found for router ID" - return "0.0.0.%d" % node.id + return f"0.0.0.{node.id:d}" @staticmethod - def rj45check(iface): + def rj45check(iface: CoreInterface) -> bool: """ Helper to detect whether interface is connected an external RJ45 link. @@ -275,15 +276,15 @@ class QuaggaService(CoreService): return False @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: return "" @classmethod - def generate_quagga_iface_config(cls, node, iface): + def generate_quagga_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: return "" @classmethod - def generate_quagga_config(cls, node): + def generate_quagga_config(cls, node: CoreNode) -> str: return "" @@ -294,14 +295,13 @@ class Ospfv2(QuaggaService): unified Quagga.conf file. """ - name = "OSPFv2" - startup = () - shutdown = ("killall ospfd",) - validate = ("pidof ospfd",) - ipv4_routing = True + name: str = "OSPFv2" + shutdown: Tuple[str, ...] = ("killall ospfd",) + validate: Tuple[str, ...] = ("pidof ospfd",) + ipv4_routing: bool = True @staticmethod - def mtucheck(iface): + def mtu_check(iface: CoreInterface) -> str: """ Helper to detect MTU mismatch and add the appropriate OSPF mtu-ignore command. This is needed when e.g. a node is linked via a @@ -319,7 +319,7 @@ class Ospfv2(QuaggaService): return "" @staticmethod - def ptpcheck(iface): + def ptp_check(iface: CoreInterface) -> str: """ Helper to detect whether interface is connected to a notional point-to-point link. @@ -329,9 +329,9 @@ class Ospfv2(QuaggaService): return "" @classmethod - def generate_quagga_config(cls, node): + def generate_quagga_config(cls, node: CoreNode) -> str: cfg = "router ospf\n" - rtrid = cls.routerid(node) + rtrid = cls.router_id(node) cfg += " router-id %s\n" % rtrid # network 10.0.0.0/24 area 0 for iface in node.get_ifaces(control=False): @@ -343,12 +343,12 @@ class Ospfv2(QuaggaService): return cfg @classmethod - def generate_quagga_iface_config(cls, node, iface): - cfg = cls.mtucheck(iface) + def generate_quagga_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: + cfg = cls.mtu_check(iface) # external RJ45 connections will use default OSPF timers if cls.rj45check(iface): return cfg - cfg += cls.ptpcheck(iface) + cfg += cls.ptp_check(iface) return ( cfg + """\ @@ -366,15 +366,14 @@ class Ospfv3(QuaggaService): unified Quagga.conf file. """ - name = "OSPFv3" - startup = () - shutdown = ("killall ospf6d",) - validate = ("pidof ospf6d",) - ipv4_routing = True - ipv6_routing = True + name: str = "OSPFv3" + shutdown: Tuple[str, ...] = ("killall ospf6d",) + validate: Tuple[str, ...] = ("pidof ospf6d",) + ipv4_routing: bool = True + ipv6_routing: bool = True @staticmethod - def minmtu(iface): + def min_mtu(iface: CoreInterface) -> int: """ Helper to discover the minimum MTU of interfaces linked with the given interface. @@ -388,20 +387,20 @@ class Ospfv3(QuaggaService): return mtu @classmethod - def mtucheck(cls, iface): + def mtu_check(cls, iface: CoreInterface) -> str: """ Helper to detect MTU mismatch and add the appropriate OSPFv3 ifmtu command. This is needed when e.g. a node is linked via a GreTap device. """ - minmtu = cls.minmtu(iface) + minmtu = cls.min_mtu(iface) if minmtu < iface.mtu: return " ipv6 ospf6 ifmtu %d\n" % minmtu else: return "" @staticmethod - def ptpcheck(iface): + def ptp_check(iface: CoreInterface) -> str: """ Helper to detect whether interface is connected to a notional point-to-point link. @@ -411,9 +410,9 @@ class Ospfv3(QuaggaService): return "" @classmethod - def generate_quagga_config(cls, node): + def generate_quagga_config(cls, node: CoreNode) -> str: cfg = "router ospf6\n" - rtrid = cls.routerid(node) + rtrid = cls.router_id(node) cfg += " instance-id 65\n" cfg += " router-id %s\n" % rtrid for iface in node.get_ifaces(control=False): @@ -422,8 +421,8 @@ class Ospfv3(QuaggaService): return cfg @classmethod - def generate_quagga_iface_config(cls, node, iface): - return cls.mtucheck(iface) + def generate_quagga_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: + return cls.mtu_check(iface) class Ospfv3mdr(Ospfv3): @@ -434,12 +433,12 @@ class Ospfv3mdr(Ospfv3): unified Quagga.conf file. """ - name = "OSPFv3MDR" - ipv4_routing = True + name: str = "OSPFv3MDR" + ipv4_routing: bool = True @classmethod - def generate_quagga_iface_config(cls, node, iface): - cfg = cls.mtucheck(iface) + def generate_quagga_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: + cfg = cls.mtu_check(iface) if iface.net is not None and isinstance(iface.net, (WlanNode, EmaneNet)): return ( cfg @@ -464,21 +463,20 @@ class Bgp(QuaggaService): having the same AS number. """ - name = "BGP" - startup = () - shutdown = ("killall bgpd",) - validate = ("pidof bgpd",) - custom_needed = True - ipv4_routing = True - ipv6_routing = True + name: str = "BGP" + shutdown: Tuple[str, ...] = ("killall bgpd",) + validate: Tuple[str, ...] = ("pidof bgpd",) + custom_needed: bool = True + ipv4_routing: bool = True + ipv6_routing: bool = True @classmethod - def generate_quagga_config(cls, node): + def generate_quagga_config(cls, node: CoreNode) -> str: cfg = "!\n! BGP configuration\n!\n" cfg += "! You should configure the AS number below,\n" cfg += "! along with this router's peers.\n!\n" cfg += "router bgp %s\n" % node.id - rtrid = cls.routerid(node) + rtrid = cls.router_id(node) cfg += " bgp router-id %s\n" % rtrid cfg += " redistribute connected\n" cfg += "! neighbor 1.2.3.4 remote-as 555\n!\n" @@ -490,14 +488,13 @@ class Rip(QuaggaService): The RIP service provides IPv4 routing for wired networks. """ - name = "RIP" - startup = () - shutdown = ("killall ripd",) - validate = ("pidof ripd",) - ipv4_routing = True + name: str = "RIP" + shutdown: Tuple[str, ...] = ("killall ripd",) + validate: Tuple[str, ...] = ("pidof ripd",) + ipv4_routing: bool = True @classmethod - def generate_quagga_config(cls, node): + def generate_quagga_config(cls, node: CoreNode) -> str: cfg = """\ router rip redistribute static @@ -514,14 +511,13 @@ class Ripng(QuaggaService): The RIP NG service provides IPv6 routing for wired networks. """ - name = "RIPNG" - startup = () - shutdown = ("killall ripngd",) - validate = ("pidof ripngd",) - ipv6_routing = True + name: str = "RIPNG" + shutdown: Tuple[str, ...] = ("killall ripngd",) + validate: Tuple[str, ...] = ("pidof ripngd",) + ipv6_routing: bool = True @classmethod - def generate_quagga_config(cls, node): + def generate_quagga_config(cls, node: CoreNode) -> str: cfg = """\ router ripng redistribute static @@ -539,14 +535,13 @@ class Babel(QuaggaService): protocol for IPv6 and IPv4 with fast convergence properties. """ - name = "Babel" - startup = () - shutdown = ("killall babeld",) - validate = ("pidof babeld",) - ipv6_routing = True + name: str = "Babel" + shutdown: Tuple[str, ...] = ("killall babeld",) + validate: Tuple[str, ...] = ("pidof babeld",) + ipv6_routing: bool = True @classmethod - def generate_quagga_config(cls, node): + def generate_quagga_config(cls, node: CoreNode) -> str: cfg = "router babel\n" for iface in node.get_ifaces(control=False): cfg += " network %s\n" % iface.name @@ -554,7 +549,7 @@ class Babel(QuaggaService): return cfg @classmethod - def generate_quagga_iface_config(cls, node, iface): + def generate_quagga_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: if iface.net and iface.net.linktype == LinkTypes.WIRELESS: return " babel wireless\n no babel split-horizon\n" else: @@ -566,14 +561,13 @@ class Xpimd(QuaggaService): PIM multicast routing based on XORP. """ - name = "Xpimd" - startup = () - shutdown = ("killall xpimd",) - validate = ("pidof xpimd",) - ipv4_routing = True + name: str = "Xpimd" + shutdown: Tuple[str, ...] = ("killall xpimd",) + validate: Tuple[str, ...] = ("pidof xpimd",) + ipv4_routing: bool = True @classmethod - def generate_quagga_config(cls, node): + def generate_quagga_config(cls, node: CoreNode) -> str: ifname = "eth0" for iface in node.get_ifaces(): if iface.name != "lo": @@ -589,5 +583,5 @@ class Xpimd(QuaggaService): return cfg @classmethod - def generate_quagga_iface_config(cls, node, iface): + def generate_quagga_iface_config(cls, node: CoreNode, iface: CoreInterface) -> str: return " ip mfea\n ip igmp\n ip pim\n" diff --git a/daemon/core/services/sdn.py b/daemon/core/services/sdn.py index 71ab815f..1f17201d 100644 --- a/daemon/core/services/sdn.py +++ b/daemon/core/services/sdn.py @@ -3,9 +3,11 @@ sdn.py defines services to start Open vSwitch and the Ryu SDN Controller. """ import re +from typing import Tuple import netaddr +from core.nodes.base import CoreNode from core.services.coreservices import CoreService @@ -14,24 +16,28 @@ class SdnService(CoreService): Parent class for SDN services. """ - group = "SDN" + group: str = "SDN" @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: return "" class OvsService(SdnService): - name = "OvsService" - executables = ("ovs-ofctl", "ovs-vsctl") - group = "SDN" - dirs = ("/etc/openvswitch", "/var/run/openvswitch", "/var/log/openvswitch") - configs = ("OvsService.sh",) - startup = ("sh OvsService.sh",) - shutdown = ("killall ovs-vswitchd", "killall ovsdb-server") + name: str = "OvsService" + group: str = "SDN" + executables: Tuple[str, ...] = ("ovs-ofctl", "ovs-vsctl") + dirs: Tuple[str, ...] = ( + "/etc/openvswitch", + "/var/run/openvswitch", + "/var/log/openvswitch", + ) + configs: Tuple[str, ...] = ("OvsService.sh",) + startup: Tuple[str, ...] = ("sh OvsService.sh",) + shutdown: Tuple[str, ...] = ("killall ovs-vswitchd", "killall ovsdb-server") @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: # Check whether the node is running zebra has_zebra = 0 for s in node.services: @@ -46,8 +52,8 @@ class OvsService(SdnService): cfg += "## this stops it from routing traffic without defined flows.\n" cfg += "## remove the -- and everything after if you want it to act as a regular switch\n" cfg += "ovs-vsctl add-br ovsbr0 -- set Bridge ovsbr0 fail-mode=secure\n" - cfg += "\n## Now add all our interfaces as ports to the switch\n" + portnum = 1 for iface in node.get_ifaces(control=False): ifnumstr = re.findall(r"\d+", iface.name) @@ -111,21 +117,19 @@ class OvsService(SdnService): % (portnum + 1, portnum) ) portnum += 2 - return cfg class RyuService(SdnService): - name = "ryuService" - executables = ("ryu-manager",) - group = "SDN" - dirs = () - configs = ("ryuService.sh",) - startup = ("sh ryuService.sh",) - shutdown = ("killall ryu-manager",) + name: str = "ryuService" + group: str = "SDN" + executables: Tuple[str, ...] = ("ryu-manager",) + configs: Tuple[str, ...] = ("ryuService.sh",) + startup: Tuple[str, ...] = ("sh ryuService.sh",) + shutdown: Tuple[str, ...] = ("killall ryu-manager",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Return a string that will be written to filename, or sent to the GUI for user customization. diff --git a/daemon/core/services/security.py b/daemon/core/services/security.py index 91c942f1..b813579e 100644 --- a/daemon/core/services/security.py +++ b/daemon/core/services/security.py @@ -4,78 +4,79 @@ firewall) """ import logging +from typing import Tuple from core import constants +from core.nodes.base import CoreNode +from core.nodes.interface import CoreInterface from core.services.coreservices import CoreService class VPNClient(CoreService): - name = "VPNClient" - group = "Security" - configs = ("vpnclient.sh",) - startup = ("sh vpnclient.sh",) - shutdown = ("killall openvpn",) - validate = ("pidof openvpn",) - custom_needed = True + name: str = "VPNClient" + group: str = "Security" + configs: Tuple[str, ...] = ("vpnclient.sh",) + startup: Tuple[str, ...] = ("sh vpnclient.sh",) + shutdown: Tuple[str, ...] = ("killall openvpn",) + validate: Tuple[str, ...] = ("pidof openvpn",) + custom_needed: bool = True @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Return the client.conf and vpnclient.sh file contents to """ cfg = "#!/bin/sh\n" cfg += "# custom VPN Client configuration for service (security.py)\n" - fname = "%s/examples/services/sampleVPNClient" % constants.CORE_DATA_DIR - + fname = f"{constants.CORE_DATA_DIR}/examples/services/sampleVPNClient" try: - cfg += open(fname, "rb").read() + with open(fname, "r") as f: + cfg += f.read() except IOError: logging.exception( - "Error opening VPN client configuration template (%s)", fname + "error opening VPN client configuration template (%s)", fname ) - return cfg class VPNServer(CoreService): - name = "VPNServer" - group = "Security" - configs = ("vpnserver.sh",) - startup = ("sh vpnserver.sh",) - shutdown = ("killall openvpn",) - validate = ("pidof openvpn",) - custom_needed = True + name: str = "VPNServer" + group: str = "Security" + configs: Tuple[str, ...] = ("vpnserver.sh",) + startup: Tuple[str, ...] = ("sh vpnserver.sh",) + shutdown: Tuple[str, ...] = ("killall openvpn",) + validate: Tuple[str, ...] = ("pidof openvpn",) + custom_needed: bool = True @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Return the sample server.conf and vpnserver.sh file contents to GUI for user customization. """ cfg = "#!/bin/sh\n" cfg += "# custom VPN Server Configuration for service (security.py)\n" - fname = "%s/examples/services/sampleVPNServer" % constants.CORE_DATA_DIR - + fname = f"{constants.CORE_DATA_DIR}/examples/services/sampleVPNServer" try: - cfg += open(fname, "rb").read() + with open(fname, "r") as f: + cfg += f.read() except IOError: logging.exception( "Error opening VPN server configuration template (%s)", fname ) - return cfg class IPsec(CoreService): - name = "IPsec" - group = "Security" - configs = ("ipsec.sh",) - startup = ("sh ipsec.sh",) - shutdown = ("killall racoon",) - custom_needed = True + name: str = "IPsec" + group: str = "Security" + configs: Tuple[str, ...] = ("ipsec.sh",) + startup: Tuple[str, ...] = ("sh ipsec.sh",) + shutdown: Tuple[str, ...] = ("killall racoon",) + custom_needed: bool = True @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Return the ipsec.conf and racoon.conf file contents to GUI for user customization. @@ -83,7 +84,7 @@ class IPsec(CoreService): cfg = "#!/bin/sh\n" cfg += "# set up static tunnel mode security assocation for service " cfg += "(security.py)\n" - fname = "%s/examples/services/sampleIPsec" % constants.CORE_DATA_DIR + fname = f"{constants.CORE_DATA_DIR}/examples/services/sampleIPsec" try: with open(fname, "r") as f: cfg += f.read() @@ -93,28 +94,27 @@ class IPsec(CoreService): class Firewall(CoreService): - name = "Firewall" - group = "Security" - configs = ("firewall.sh",) - startup = ("sh firewall.sh",) - custom_needed = True + name: str = "Firewall" + group: str = "Security" + configs: Tuple[str, ...] = ("firewall.sh",) + startup: Tuple[str, ...] = ("sh firewall.sh",) + custom_needed: bool = True @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Return the firewall rule examples to GUI for user customization. """ cfg = "#!/bin/sh\n" cfg += "# custom node firewall rules for service (security.py)\n" - fname = "%s/examples/services/sampleFirewall" % constants.CORE_DATA_DIR - + fname = f"{constants.CORE_DATA_DIR}/examples/services/sampleFirewall" try: - cfg += open(fname, "rb").read() + with open(fname, "r") as f: + cfg += f.read() except IOError: logging.exception( "Error opening Firewall configuration template (%s)", fname ) - return cfg @@ -123,30 +123,28 @@ class Nat(CoreService): IPv4 source NAT service. """ - name = "NAT" - executables = ("iptables",) - group = "Security" - configs = ("nat.sh",) - startup = ("sh nat.sh",) - custom_needed = False + name: str = "NAT" + group: str = "Security" + executables: Tuple[str, ...] = ("iptables",) + configs: Tuple[str, ...] = ("nat.sh",) + startup: Tuple[str, ...] = ("sh nat.sh",) + custom_needed: bool = False @classmethod - def generate_iface_nat_rule(cls, iface, line_prefix=""): + def generate_iface_nat_rule(cls, iface: CoreInterface, prefix: str = "") -> str: """ Generate a NAT line for one interface. """ - cfg = line_prefix + "iptables -t nat -A POSTROUTING -o " + cfg = prefix + "iptables -t nat -A POSTROUTING -o " cfg += iface.name + " -j MASQUERADE\n" - - cfg += line_prefix + "iptables -A FORWARD -i " + iface.name + cfg += prefix + "iptables -A FORWARD -i " + iface.name cfg += " -m state --state RELATED,ESTABLISHED -j ACCEPT\n" - - cfg += line_prefix + "iptables -A FORWARD -i " + cfg += prefix + "iptables -A FORWARD -i " cfg += iface.name + " -j DROP\n" return cfg @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ NAT out the first interface """ @@ -156,7 +154,7 @@ class Nat(CoreService): have_nat = False for iface in node.get_ifaces(control=False): if have_nat: - cfg += cls.generate_iface_nat_rule(iface, line_prefix="#") + cfg += cls.generate_iface_nat_rule(iface, prefix="#") else: have_nat = True cfg += "# NAT out the " + iface.name + " interface\n" diff --git a/daemon/core/services/ucarp.py b/daemon/core/services/ucarp.py index 1eb80179..8ac92dd3 100644 --- a/daemon/core/services/ucarp.py +++ b/daemon/core/services/ucarp.py @@ -1,52 +1,52 @@ """ ucarp.py: defines high-availability IP address controlled by ucarp """ +from typing import Tuple +from core.nodes.base import CoreNode from core.services.coreservices import CoreService UCARP_ETC = "/usr/local/etc/ucarp" class Ucarp(CoreService): - name = "ucarp" - group = "Utility" - dirs = (UCARP_ETC,) - configs = ( + name: str = "ucarp" + group: str = "Utility" + dirs: Tuple[str, ...] = (UCARP_ETC,) + configs: Tuple[str, ...] = ( UCARP_ETC + "/default.sh", UCARP_ETC + "/default-up.sh", UCARP_ETC + "/default-down.sh", "ucarpboot.sh", ) - startup = ("sh ucarpboot.sh",) - shutdown = ("killall ucarp",) - validate = ("pidof ucarp",) + startup: Tuple[str, ...] = ("sh ucarpboot.sh",) + shutdown: Tuple[str, ...] = ("killall ucarp",) + validate: Tuple[str, ...] = ("pidof ucarp",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Return the default file contents """ if filename == cls.configs[0]: - return cls.generateUcarpConf(node) + return cls.generate_ucarp_conf(node) elif filename == cls.configs[1]: - return cls.generateVipUp(node) + return cls.generate_vip_up(node) elif filename == cls.configs[2]: - return cls.generateVipDown(node) + return cls.generate_vip_down(node) elif filename == cls.configs[3]: - return cls.generateUcarpBoot(node) + return cls.generate_ucarp_boot(node) else: raise ValueError @classmethod - def generateUcarpConf(cls, node): + def generate_ucarp_conf(cls, node: CoreNode) -> str: """ Returns configuration file text. """ - try: - ucarp_bin = node.session.cfg["ucarp_bin"] - except KeyError: - ucarp_bin = "/usr/sbin/ucarp" - + ucarp_bin = node.session.options.get_config( + "ucarp_bin", default="/usr/sbin/ucarp" + ) return """\ #!/bin/sh # Location of UCARP executable @@ -110,7 +110,7 @@ ${UCARP_EXEC} -B ${UCARP_OPTS} ) @classmethod - def generateUcarpBoot(cls, node): + def generate_ucarp_boot(cls, node: CoreNode) -> str: """ Generate a shell script used to boot the Ucarp daemons. """ @@ -130,7 +130,7 @@ ${UCARP_CFGDIR}/default.sh ) @classmethod - def generateVipUp(cls, node): + def generate_vip_up(cls, node: CoreNode) -> str: """ Generate a shell script used to start the virtual ip """ @@ -152,7 +152,7 @@ fi """ @classmethod - def generateVipDown(cls, node): + def generate_vip_down(cls, node: CoreNode) -> str: """ Generate a shell script used to stop the virtual ip """ diff --git a/daemon/core/services/utility.py b/daemon/core/services/utility.py index 273318e1..a44037f6 100644 --- a/daemon/core/services/utility.py +++ b/daemon/core/services/utility.py @@ -1,12 +1,13 @@ """ utility.py: defines miscellaneous utility services. """ -import os +from typing import Optional, Tuple import netaddr from core import constants, utils from core.errors import CoreCommandError +from core.nodes.base import CoreNode from core.services.coreservices import CoreService, ServiceMode @@ -15,32 +16,25 @@ class UtilService(CoreService): Parent class for utility services. """ - name = None - group = "Utility" - dirs = () - configs = () - startup = () - shutdown = () + name: Optional[str] = None + group: str = "Utility" @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: return "" class IPForwardService(UtilService): - name = "IPForward" - configs = ("ipforward.sh",) - startup = ("sh ipforward.sh",) + name: str = "IPForward" + configs: Tuple[str, ...] = ("ipforward.sh",) + startup: Tuple[str, ...] = ("sh ipforward.sh",) @classmethod - def generate_config(cls, node, filename): - if os.uname()[0] == "Linux": - return cls.generateconfiglinux(node, filename) - else: - raise Exception("unknown platform") + def generate_config(cls, node: CoreNode, filename: str) -> str: + return cls.generateconfiglinux(node, filename) @classmethod - def generateconfiglinux(cls, node, filename): + def generateconfiglinux(cls, node: CoreNode, filename: str) -> str: cfg = """\ #!/bin/sh # auto-generated by IPForward service (utility.py) @@ -70,12 +64,12 @@ class IPForwardService(UtilService): class DefaultRouteService(UtilService): - name = "DefaultRoute" - configs = ("defaultroute.sh",) - startup = ("sh defaultroute.sh",) + name: str = "DefaultRoute" + configs: Tuple[str, ...] = ("defaultroute.sh",) + startup: Tuple[str, ...] = ("sh defaultroute.sh",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: routes = [] ifaces = node.get_ifaces() if ifaces: @@ -93,22 +87,18 @@ class DefaultRouteService(UtilService): class DefaultMulticastRouteService(UtilService): - name = "DefaultMulticastRoute" - configs = ("defaultmroute.sh",) - startup = ("sh defaultmroute.sh",) + name: str = "DefaultMulticastRoute" + configs: Tuple[str, ...] = ("defaultmroute.sh",) + startup: Tuple[str, ...] = ("sh defaultmroute.sh",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: cfg = "#!/bin/sh\n" cfg += "# auto-generated by DefaultMulticastRoute service (utility.py)\n" cfg += "# the first interface is chosen below; please change it " cfg += "as needed\n" - for iface in node.get_ifaces(control=False): - if os.uname()[0] == "Linux": - rtcmd = "ip route add 224.0.0.0/4 dev" - else: - raise Exception("unknown platform") + rtcmd = "ip route add 224.0.0.0/4 dev" cfg += "%s %s\n" % (rtcmd, iface.name) cfg += "\n" break @@ -116,13 +106,13 @@ class DefaultMulticastRouteService(UtilService): class StaticRouteService(UtilService): - name = "StaticRoute" - configs = ("staticroute.sh",) - startup = ("sh staticroute.sh",) - custom_needed = True + name: str = "StaticRoute" + configs: Tuple[str, ...] = ("staticroute.sh",) + startup: Tuple[str, ...] = ("sh staticroute.sh",) + custom_needed: bool = True @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: cfg = "#!/bin/sh\n" cfg += "# auto-generated by StaticRoute service (utility.py)\n#\n" cfg += "# NOTE: this service must be customized to be of any use\n" @@ -133,7 +123,7 @@ class StaticRouteService(UtilService): return cfg @staticmethod - def routestr(x): + def routestr(x: str) -> str: addr = x.split("/")[0] if netaddr.valid_ipv6(addr): dst = "3ffe:4::/64" @@ -143,24 +133,20 @@ class StaticRouteService(UtilService): if net[-2] == net[1]: return "" else: - if os.uname()[0] == "Linux": - rtcmd = "#/sbin/ip route add %s via" % dst - else: - raise Exception("unknown platform") + rtcmd = "#/sbin/ip route add %s via" % dst return "%s %s" % (rtcmd, net[1]) class SshService(UtilService): - name = "SSH" - configs = ("startsshd.sh", "/etc/ssh/sshd_config") - dirs = ("/etc/ssh", "/var/run/sshd") - startup = ("sh startsshd.sh",) - shutdown = ("killall sshd",) - validate = () - validation_mode = ServiceMode.BLOCKING + name: str = "SSH" + configs: Tuple[str, ...] = ("startsshd.sh", "/etc/ssh/sshd_config") + dirs: Tuple[str, ...] = ("/etc/ssh", "/var/run/sshd") + startup: Tuple[str, ...] = ("sh startsshd.sh",) + shutdown: Tuple[str, ...] = ("killall sshd",) + validation_mode: ServiceMode = ServiceMode.BLOCKING @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Use a startup script for launching sshd in order to wait for host key generation. @@ -228,15 +214,15 @@ UseDNS no class DhcpService(UtilService): - name = "DHCP" - configs = ("/etc/dhcp/dhcpd.conf",) - dirs = ("/etc/dhcp", "/var/lib/dhcp") - startup = ("touch /var/lib/dhcp/dhcpd.leases", "dhcpd") - shutdown = ("killall dhcpd",) - validate = ("pidof dhcpd",) + name: str = "DHCP" + configs: Tuple[str, ...] = ("/etc/dhcp/dhcpd.conf",) + dirs: Tuple[str, ...] = ("/etc/dhcp", "/var/lib/dhcp") + startup: Tuple[str, ...] = ("touch /var/lib/dhcp/dhcpd.leases", "dhcpd") + shutdown: Tuple[str, ...] = ("killall dhcpd",) + validate: Tuple[str, ...] = ("pidof dhcpd",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Generate a dhcpd config file using the network address of each interface. @@ -261,7 +247,7 @@ ddns-update-style none; return cfg @staticmethod - def subnetentry(x): + def subnetentry(x: str) -> str: """ Generate a subnet declaration block given an IPv4 prefix string for inclusion in the dhcpd3 config file. @@ -297,14 +283,14 @@ class DhcpClientService(UtilService): Use a DHCP client for all interfaces for addressing. """ - name = "DHCPClient" - configs = ("startdhcpclient.sh",) - startup = ("sh startdhcpclient.sh",) - shutdown = ("killall dhclient",) - validate = ("pidof dhclient",) + name: str = "DHCPClient" + configs: Tuple[str, ...] = ("startdhcpclient.sh",) + startup: Tuple[str, ...] = ("sh startdhcpclient.sh",) + shutdown: Tuple[str, ...] = ("killall dhclient",) + validate: Tuple[str, ...] = ("pidof dhclient",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Generate a script to invoke dhclient on all interfaces. """ @@ -313,7 +299,6 @@ class DhcpClientService(UtilService): cfg += "# uncomment this mkdir line and symlink line to enable client-" cfg += "side DNS\n# resolution based on the DHCP server response.\n" cfg += "#mkdir -p /var/run/resolvconf/interface\n" - for iface in node.get_ifaces(control=False): cfg += "#ln -s /var/run/resolvconf/interface/%s.dhclient" % iface.name cfg += " /var/run/resolvconf/resolv.conf\n" @@ -327,15 +312,15 @@ class FtpService(UtilService): Start a vsftpd server. """ - name = "FTP" - configs = ("vsftpd.conf",) - dirs = ("/var/run/vsftpd/empty", "/var/ftp") - startup = ("vsftpd ./vsftpd.conf",) - shutdown = ("killall vsftpd",) - validate = ("pidof vsftpd",) + name: str = "FTP" + configs: Tuple[str, ...] = ("vsftpd.conf",) + dirs: Tuple[str, ...] = ("/var/run/vsftpd/empty", "/var/ftp") + startup: Tuple[str, ...] = ("vsftpd ./vsftpd.conf",) + shutdown: Tuple[str, ...] = ("killall vsftpd",) + validate: Tuple[str, ...] = ("pidof vsftpd",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Generate a vsftpd.conf configuration file. """ @@ -360,13 +345,13 @@ class HttpService(UtilService): Start an apache server. """ - name = "HTTP" - configs = ( + name: str = "HTTP" + configs: Tuple[str, ...] = ( "/etc/apache2/apache2.conf", "/etc/apache2/envvars", "/var/www/index.html", ) - dirs = ( + dirs: Tuple[str, ...] = ( "/etc/apache2", "/var/run/apache2", "/var/log/apache2", @@ -374,14 +359,14 @@ class HttpService(UtilService): "/var/lock/apache2", "/var/www", ) - startup = ("chown www-data /var/lock/apache2", "apache2ctl start") - shutdown = ("apache2ctl stop",) - validate = ("pidof apache2",) - - APACHEVER22, APACHEVER24 = (22, 24) + startup: Tuple[str, ...] = ("chown www-data /var/lock/apache2", "apache2ctl start") + shutdown: Tuple[str, ...] = ("apache2ctl stop",) + validate: Tuple[str, ...] = ("pidof apache2",) + APACHEVER22: int = 22 + APACHEVER24: int = 24 @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Generate an apache2.conf configuration file. """ @@ -395,7 +380,7 @@ class HttpService(UtilService): return "" @classmethod - def detectversionfromcmd(cls): + def detectversionfromcmd(cls) -> int: """ Detect the apache2 version using the 'a2query' command. """ @@ -405,14 +390,12 @@ class HttpService(UtilService): except CoreCommandError as e: status = e.returncode result = e.stderr - if status == 0 and result[:3] == "2.4": return cls.APACHEVER24 - return cls.APACHEVER22 @classmethod - def generateapache2conf(cls, node, filename): + def generateapache2conf(cls, node: CoreNode, filename: str) -> str: lockstr = { cls.APACHEVER22: "LockFile ${APACHE_LOCK_DIR}/accept.lock\n", cls.APACHEVER24: "Mutex file:${APACHE_LOCK_DIR} default\n", @@ -421,22 +404,18 @@ class HttpService(UtilService): cls.APACHEVER22: "", cls.APACHEVER24: "LoadModule mpm_worker_module /usr/lib/apache2/modules/mod_mpm_worker.so\n", } - permstr = { cls.APACHEVER22: " Order allow,deny\n Deny from all\n Satisfy all\n", cls.APACHEVER24: " Require all denied\n", } - authstr = { cls.APACHEVER22: "LoadModule authz_default_module /usr/lib/apache2/modules/mod_authz_default.so\n", cls.APACHEVER24: "LoadModule authz_core_module /usr/lib/apache2/modules/mod_authz_core.so\n", } - permstr2 = { cls.APACHEVER22: "\t\tOrder allow,deny\n\t\tallow from all\n", cls.APACHEVER24: "\t\tRequire all granted\n", } - version = cls.detectversionfromcmd() cfg = "# apache2.conf generated by utility.py:HttpService\n" cfg += lockstr[version] @@ -552,7 +531,7 @@ TraceEnable Off return cfg @classmethod - def generateenvvars(cls, node, filename): + def generateenvvars(cls, node: CoreNode, filename: str) -> str: return """\ # this file is used by apache2ctl - generated by utility.py:HttpService # these settings come from a default Ubuntu apache2 installation @@ -567,7 +546,7 @@ export LANG """ @classmethod - def generatehtml(cls, node, filename): + def generatehtml(cls, node: CoreNode, filename: str) -> str: body = ( """\ @@ -587,16 +566,15 @@ class PcapService(UtilService): Pcap service for logging packets. """ - name = "pcap" - configs = ("pcap.sh",) - dirs = () - startup = ("sh pcap.sh start",) - shutdown = ("sh pcap.sh stop",) - validate = ("pidof tcpdump",) - meta = "logs network traffic to pcap packet capture files" + name: str = "pcap" + configs: Tuple[str, ...] = ("pcap.sh",) + startup: Tuple[str, ...] = ("sh pcap.sh start",) + shutdown: Tuple[str, ...] = ("sh pcap.sh stop",) + validate: Tuple[str, ...] = ("pidof tcpdump",) + meta: str = "logs network traffic to pcap packet capture files" @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Generate a startpcap.sh traffic logging script. """ @@ -630,15 +608,17 @@ fi; class RadvdService(UtilService): - name = "radvd" - configs = ("/etc/radvd/radvd.conf",) - dirs = ("/etc/radvd",) - startup = ("radvd -C /etc/radvd/radvd.conf -m logfile -l /var/log/radvd.log",) - shutdown = ("pkill radvd",) - validate = ("pidof radvd",) + name: str = "radvd" + configs: Tuple[str, ...] = ("/etc/radvd/radvd.conf",) + dirs: Tuple[str, ...] = ("/etc/radvd",) + startup: Tuple[str, ...] = ( + "radvd -C /etc/radvd/radvd.conf -m logfile -l /var/log/radvd.log", + ) + shutdown: Tuple[str, ...] = ("pkill radvd",) + validate: Tuple[str, ...] = ("pidof radvd",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Generate a RADVD router advertisement daemon config file using the network address of each interface. @@ -678,7 +658,7 @@ interface %s return cfg @staticmethod - def subnetentry(x): + def subnetentry(x: str) -> str: """ Generate a subnet declaration block given an IPv6 prefix string for inclusion in the RADVD config file. @@ -695,14 +675,14 @@ class AtdService(UtilService): Atd service for scheduling at jobs """ - name = "atd" - configs = ("startatd.sh",) - dirs = ("/var/spool/cron/atjobs", "/var/spool/cron/atspool") - startup = ("sh startatd.sh",) - shutdown = ("pkill atd",) + name: str = "atd" + configs: Tuple[str, ...] = ("startatd.sh",) + dirs: Tuple[str, ...] = ("/var/spool/cron/atjobs", "/var/spool/cron/atspool") + startup: Tuple[str, ...] = ("sh startatd.sh",) + shutdown: Tuple[str, ...] = ("pkill atd",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: return """ #!/bin/sh echo 00001 > /var/spool/cron/atjobs/.SEQ @@ -717,5 +697,5 @@ class UserDefinedService(UtilService): Dummy service allowing customization of anything. """ - name = "UserDefined" - meta = "Customize this service to do anything upon startup." + name: str = "UserDefined" + meta: str = "Customize this service to do anything upon startup." diff --git a/daemon/core/services/xorp.py b/daemon/core/services/xorp.py index 776b1d16..42082377 100644 --- a/daemon/core/services/xorp.py +++ b/daemon/core/services/xorp.py @@ -2,10 +2,12 @@ xorp.py: defines routing services provided by the XORP routing suite. """ -import logging +from typing import Optional, Tuple import netaddr +from core.nodes.base import CoreNode +from core.nodes.interface import CoreInterface from core.services.coreservices import CoreService @@ -15,20 +17,20 @@ class XorpRtrmgr(CoreService): enabled XORP services, and launches necessary daemons upon startup. """ - name = "xorp_rtrmgr" - executables = ("xorp_rtrmgr",) - group = "XORP" - dirs = ("/etc/xorp",) - configs = ("/etc/xorp/config.boot",) - startup = ( + name: str = "xorp_rtrmgr" + group: str = "XORP" + executables: Tuple[str, ...] = ("xorp_rtrmgr",) + dirs: Tuple[str, ...] = ("/etc/xorp",) + configs: Tuple[str, ...] = ("/etc/xorp/config.boot",) + startup: Tuple[str, ...] = ( "xorp_rtrmgr -d -b %s -l /var/log/%s.log -P /var/run/%s.pid" % (configs[0], name, name), ) - shutdown = ("killall xorp_rtrmgr",) - validate = ("pidof xorp_rtrmgr",) + shutdown: Tuple[str, ...] = ("killall xorp_rtrmgr",) + validate: Tuple[str, ...] = ("pidof xorp_rtrmgr",) @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: """ Returns config.boot configuration file text. Other services that depend on this will have generatexorpconfig() hooks that are @@ -45,16 +47,15 @@ class XorpRtrmgr(CoreService): cfg += "}\n\n" for s in node.services: - try: - s.dependencies.index(cls.name) - cfg += s.generatexorpconfig(node) - except ValueError: - logging.exception("error getting value from service: %s", cls.name) - + if cls.name not in s.dependencies: + continue + if not (isinstance(s, XorpService) or issubclass(s, XorpService)): + continue + cfg += s.generate_xorp_config(node) return cfg @staticmethod - def addrstr(x): + def addrstr(x: str) -> str: """ helper for mapping IP addresses to XORP config statements """ @@ -65,7 +66,7 @@ class XorpRtrmgr(CoreService): return cfg @staticmethod - def lladdrstr(iface): + def lladdrstr(iface: CoreInterface) -> str: """ helper for adding link-local address entries (required by OSPFv3) """ @@ -81,18 +82,16 @@ class XorpService(CoreService): common to XORP's routing daemons. """ - name = None - executables = ("xorp_rtrmgr",) - group = "XORP" - dependencies = ("xorp_rtrmgr",) - dirs = () - configs = () - startup = () - shutdown = () - meta = "The config file for this service can be found in the xorp_rtrmgr service." + name: Optional[str] = None + group: str = "XORP" + executables: Tuple[str, ...] = ("xorp_rtrmgr",) + dependencies: Tuple[str, ...] = ("xorp_rtrmgr",) + meta: str = ( + "The config file for this service can be found in the xorp_rtrmgr service." + ) @staticmethod - def fea(forwarding): + def fea(forwarding: str) -> str: """ Helper to add a forwarding engine entry to the config file. """ @@ -104,17 +103,14 @@ class XorpService(CoreService): return cfg @staticmethod - def mfea(forwarding, ifaces): + def mfea(forwarding, node: CoreNode) -> str: """ Helper to add a multicast forwarding engine entry to the config file. """ names = [] - for iface in ifaces: - if hasattr(iface, "control") and iface.control is True: - continue + for iface in node.get_ifaces(control=False): names.append(iface.name) names.append("register_vif") - cfg = "plumbing {\n" cfg += " %s {\n" % forwarding for name in names: @@ -128,7 +124,7 @@ class XorpService(CoreService): return cfg @staticmethod - def policyexportconnected(): + def policyexportconnected() -> str: """ Helper to add a policy statement for exporting connected routes. """ @@ -144,7 +140,7 @@ class XorpService(CoreService): return cfg @staticmethod - def routerid(node): + def router_id(node: CoreNode) -> str: """ Helper to return the first IPv4 address of a node as its router ID. """ @@ -153,15 +149,14 @@ class XorpService(CoreService): a = a.split("/")[0] if netaddr.valid_ipv4(a): return a - # raise ValueError, "no IPv4 address found for router ID" return "0.0.0.0" @classmethod - def generate_config(cls, node, filename): + def generate_config(cls, node: CoreNode, filename: str) -> str: return "" @classmethod - def generatexorpconfig(cls, node): + def generate_xorp_config(cls, node: CoreNode) -> str: return "" @@ -172,12 +167,12 @@ class XorpOspfv2(XorpService): unified XORP configuration file. """ - name = "XORP_OSPFv2" + name: str = "XORP_OSPFv2" @classmethod - def generatexorpconfig(cls, node): + def generate_xorp_config(cls, node: CoreNode) -> str: cfg = cls.fea("unicast-forwarding4") - rtrid = cls.routerid(node) + rtrid = cls.router_id(node) cfg += "\nprotocols {\n" cfg += " ospf4 {\n" cfg += "\trouter-id: %s\n" % rtrid @@ -206,12 +201,12 @@ class XorpOspfv3(XorpService): unified XORP configuration file. """ - name = "XORP_OSPFv3" + name: str = "XORP_OSPFv3" @classmethod - def generatexorpconfig(cls, node): + def generate_xorp_config(cls, node: CoreNode) -> str: cfg = cls.fea("unicast-forwarding6") - rtrid = cls.routerid(node) + rtrid = cls.router_id(node) cfg += "\nprotocols {\n" cfg += " ospf6 0 { /* Instance ID 0 */\n" cfg += "\trouter-id: %s\n" % rtrid @@ -232,16 +227,16 @@ class XorpBgp(XorpService): IPv4 inter-domain routing. AS numbers and peers must be customized. """ - name = "XORP_BGP" - custom_needed = True + name: str = "XORP_BGP" + custom_needed: bool = True @classmethod - def generatexorpconfig(cls, node): + def generate_xorp_config(cls, node: CoreNode) -> str: cfg = "/* This is a sample config that should be customized with\n" cfg += " appropriate AS numbers and peers */\n" cfg += cls.fea("unicast-forwarding4") cfg += cls.policyexportconnected() - rtrid = cls.routerid(node) + rtrid = cls.router_id(node) cfg += "\nprotocols {\n" cfg += " bgp {\n" cfg += "\tbgp-id: %s\n" % rtrid @@ -262,10 +257,10 @@ class XorpRip(XorpService): RIP IPv4 unicast routing. """ - name = "XORP_RIP" + name: str = "XORP_RIP" @classmethod - def generatexorpconfig(cls, node): + def generate_xorp_config(cls, node: CoreNode) -> str: cfg = cls.fea("unicast-forwarding4") cfg += cls.policyexportconnected() cfg += "\nprotocols {\n" @@ -293,10 +288,10 @@ class XorpRipng(XorpService): RIP NG IPv6 unicast routing. """ - name = "XORP_RIPNG" + name: str = "XORP_RIPNG" @classmethod - def generatexorpconfig(cls, node): + def generate_xorp_config(cls, node: CoreNode) -> str: cfg = cls.fea("unicast-forwarding6") cfg += cls.policyexportconnected() cfg += "\nprotocols {\n" @@ -320,12 +315,11 @@ class XorpPimSm4(XorpService): PIM Sparse Mode IPv4 multicast routing. """ - name = "XORP_PIMSM4" + name: str = "XORP_PIMSM4" @classmethod - def generatexorpconfig(cls, node): - cfg = cls.mfea("mfea4", node.get_ifaces()) - + def generate_xorp_config(cls, node: CoreNode) -> str: + cfg = cls.mfea("mfea4", node) cfg += "\nprotocols {\n" cfg += " igmp {\n" names = [] @@ -338,7 +332,6 @@ class XorpPimSm4(XorpService): cfg += "\t}\n" cfg += " }\n" cfg += "}\n" - cfg += "\nprotocols {\n" cfg += " pimsm4 {\n" @@ -361,10 +354,8 @@ class XorpPimSm4(XorpService): cfg += "\t\t}\n" cfg += "\t }\n" cfg += "\t}\n" - cfg += " }\n" cfg += "}\n" - cfg += "\nprotocols {\n" cfg += " fib2mrib {\n" cfg += "\tdisable: false\n" @@ -378,12 +369,11 @@ class XorpPimSm6(XorpService): PIM Sparse Mode IPv6 multicast routing. """ - name = "XORP_PIMSM6" + name: str = "XORP_PIMSM6" @classmethod - def generatexorpconfig(cls, node): - cfg = cls.mfea("mfea6", node.get_ifaces()) - + def generate_xorp_config(cls, node: CoreNode) -> str: + cfg = cls.mfea("mfea6", node) cfg += "\nprotocols {\n" cfg += " mld {\n" names = [] @@ -396,7 +386,6 @@ class XorpPimSm6(XorpService): cfg += "\t}\n" cfg += " }\n" cfg += "}\n" - cfg += "\nprotocols {\n" cfg += " pimsm6 {\n" @@ -419,10 +408,8 @@ class XorpPimSm6(XorpService): cfg += "\t\t}\n" cfg += "\t }\n" cfg += "\t}\n" - cfg += " }\n" cfg += "}\n" - cfg += "\nprotocols {\n" cfg += " fib2mrib {\n" cfg += "\tdisable: false\n" @@ -436,12 +423,12 @@ class XorpOlsr(XorpService): OLSR IPv4 unicast MANET routing. """ - name = "XORP_OLSR" + name: str = "XORP_OLSR" @classmethod - def generatexorpconfig(cls, node): + def generate_xorp_config(cls, node: CoreNode) -> str: cfg = cls.fea("unicast-forwarding4") - rtrid = cls.routerid(node) + rtrid = cls.router_id(node) cfg += "\nprotocols {\n" cfg += " olsr4 {\n" cfg += "\tmain-address: %s\n" % rtrid From b2ea8cbbf65c316ef2f06cd48f478df82028eb6d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 18 Jun 2020 14:15:45 -0700 Subject: [PATCH 170/929] daemon: added type hinting throughout config services --- daemon/core/configservice/base.py | 16 +- daemon/core/configservice/dependencies.py | 14 +- daemon/core/configservice/manager.py | 11 +- .../configservices/frrservices/services.py | 122 +++---- .../configservices/nrlservices/services.py | 197 ++++++------ .../configservices/quaggaservices/services.py | 119 +++---- .../sercurityservices/services.py | 124 ++++---- daemon/core/configservices/simpleservice.py | 26 +- .../configservices/utilservices/services.py | 297 +++++++++--------- 9 files changed, 471 insertions(+), 455 deletions(-) diff --git a/daemon/core/configservice/base.py b/daemon/core/configservice/base.py index 82598988..bb97e321 100644 --- a/daemon/core/configservice/base.py +++ b/daemon/core/configservice/base.py @@ -14,7 +14,7 @@ from core.config import Configuration from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNode -TEMPLATES_DIR = "templates" +TEMPLATES_DIR: str = "templates" class ConfigServiceMode(enum.Enum): @@ -33,10 +33,10 @@ class ConfigService(abc.ABC): """ # validation period in seconds, how frequent validation is attempted - validation_period = 0.5 + validation_period: float = 0.5 # time to wait in seconds for determining if service started successfully - validation_timer = 5 + validation_timer: int = 5 def __init__(self, node: CoreNode) -> None: """ @@ -44,13 +44,13 @@ class ConfigService(abc.ABC): :param node: node this service is assigned to """ - self.node = node + self.node: CoreNode = node class_file = inspect.getfile(self.__class__) templates_path = pathlib.Path(class_file).parent.joinpath(TEMPLATES_DIR) - self.templates = TemplateLookup(directories=templates_path) - self.config = {} - self.custom_templates = {} - self.custom_config = {} + self.templates: TemplateLookup = TemplateLookup(directories=templates_path) + self.config: Dict[str, Configuration] = {} + self.custom_templates: Dict[str, str] = {} + self.custom_config: Dict[str, str] = {} configs = self.default_configs[:] self._define_config(configs) diff --git a/daemon/core/configservice/dependencies.py b/daemon/core/configservice/dependencies.py index 92eede79..be1c45e7 100644 --- a/daemon/core/configservice/dependencies.py +++ b/daemon/core/configservice/dependencies.py @@ -1,5 +1,5 @@ import logging -from typing import TYPE_CHECKING, Dict, List +from typing import TYPE_CHECKING, Dict, List, Set if TYPE_CHECKING: from core.configservice.base import ConfigService @@ -17,9 +17,9 @@ class ConfigServiceDependencies: :param services: services for determining dependency sets """ # helpers to check validity - self.dependents = {} - self.started = set() - self.node_services = {} + self.dependents: Dict[str, Set[str]] = {} + self.started: Set[str] = set() + self.node_services: Dict[str, "ConfigService"] = {} for service in services.values(): self.node_services[service.name] = service for dependency in service.dependencies: @@ -27,9 +27,9 @@ class ConfigServiceDependencies: dependents.add(service.name) # used to find paths - self.path = [] - self.visited = set() - self.visiting = set() + self.path: List["ConfigService"] = [] + self.visited: Set[str] = set() + self.visiting: Set[str] = set() def startup_paths(self) -> List[List["ConfigService"]]: """ diff --git a/daemon/core/configservice/manager.py b/daemon/core/configservice/manager.py index 1f806f7b..ecea6e68 100644 --- a/daemon/core/configservice/manager.py +++ b/daemon/core/configservice/manager.py @@ -1,6 +1,6 @@ import logging import pathlib -from typing import List, Type +from typing import Dict, List, Type from core import utils from core.configservice.base import ConfigService @@ -16,7 +16,7 @@ class ConfigServiceManager: """ Create a ConfigServiceManager instance. """ - self.services = {} + self.services: Dict[str, Type[ConfigService]] = {} def get_service(self, name: str) -> Type[ConfigService]: """ @@ -31,7 +31,7 @@ class ConfigServiceManager: raise CoreError(f"service does not exit {name}") return service_class - def add(self, service: ConfigService) -> None: + def add(self, service: Type[ConfigService]) -> None: """ Add service to manager, checking service requirements have been met. @@ -40,7 +40,9 @@ class ConfigServiceManager: :raises CoreError: when service is a duplicate or has unmet executables """ name = service.name - logging.debug("loading service: class(%s) name(%s)", service.__class__, name) + logging.debug( + "loading service: class(%s) name(%s)", service.__class__.__name__, name + ) # avoid duplicate services if name in self.services: @@ -73,7 +75,6 @@ class ConfigServiceManager: logging.debug("loading config services from: %s", subdir) services = utils.load_classes(str(subdir), ConfigService) for service in services: - logging.debug("found service: %s", service) try: self.add(service) except CoreError as e: diff --git a/daemon/core/configservices/frrservices/services.py b/daemon/core/configservices/frrservices/services.py index 8764e32c..2e24b40a 100644 --- a/daemon/core/configservices/frrservices/services.py +++ b/daemon/core/configservices/frrservices/services.py @@ -1,16 +1,17 @@ import abc -from typing import Any, Dict +from typing import Any, Dict, List import netaddr from core import constants +from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode from core.emane.nodes import EmaneNet from core.nodes.base import CoreNodeBase from core.nodes.interface import CoreInterface from core.nodes.network import WlanNode -GROUP = "FRR" +GROUP: str = "FRR" def has_mtu_mismatch(iface: CoreInterface) -> bool: @@ -29,7 +30,7 @@ def has_mtu_mismatch(iface: CoreInterface) -> bool: return False -def get_min_mtu(iface): +def get_min_mtu(iface: CoreInterface) -> int: """ Helper to discover the minimum MTU of interfaces linked with the given interface. @@ -56,23 +57,23 @@ def get_router_id(node: CoreNodeBase) -> str: class FRRZebra(ConfigService): - name = "FRRzebra" - group = GROUP - directories = ["/usr/local/etc/frr", "/var/run/frr", "/var/log/frr"] - files = [ + name: str = "FRRzebra" + group: str = GROUP + directories: List[str] = ["/usr/local/etc/frr", "/var/run/frr", "/var/log/frr"] + files: List[str] = [ "/usr/local/etc/frr/frr.conf", "frrboot.sh", "/usr/local/etc/frr/vtysh.conf", "/usr/local/etc/frr/daemons", ] - executables = ["zebra"] - dependencies = [] - startup = ["sh frrboot.sh zebra"] - validate = ["pidof zebra"] - shutdown = ["killall zebra"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + executables: List[str] = ["zebra"] + dependencies: List[str] = [] + startup: List[str] = ["sh frrboot.sh zebra"] + validate: List[str] = ["pidof zebra"] + shutdown: List[str] = ["killall zebra"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: frr_conf = self.files[0] @@ -89,6 +90,8 @@ class FRRZebra(ConfigService): for service in self.node.config_services.values(): if self.name not in service.dependencies: continue + if not isinstance(service, FrrService): + continue if service.ipv4_routing: want_ip4 = True if service.ipv6_routing: @@ -121,19 +124,19 @@ class FRRZebra(ConfigService): class FrrService(abc.ABC): - group = GROUP - directories = [] - files = [] - executables = [] - dependencies = ["FRRzebra"] - startup = [] - validate = [] - shutdown = [] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} - ipv4_routing = False - ipv6_routing = False + group: str = GROUP + directories: List[str] = [] + files: List[str] = [] + executables: List[str] = [] + dependencies: List[str] = ["FRRzebra"] + startup: List[str] = [] + validate: List[str] = [] + shutdown: List[str] = [] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} + ipv4_routing: bool = False + ipv6_routing: bool = False @abc.abstractmethod def frr_iface_config(self, iface: CoreInterface) -> str: @@ -151,11 +154,10 @@ class FRROspfv2(FrrService, ConfigService): unified frr.conf file. """ - name = "FRROSPFv2" - startup = () - shutdown = ["killall ospfd"] - validate = ["pidof ospfd"] - ipv4_routing = True + name: str = "FRROSPFv2" + shutdown: List[str] = ["killall ospfd"] + validate: List[str] = ["pidof ospfd"] + ipv4_routing: bool = True def frr_config(self) -> str: router_id = get_router_id(self.node) @@ -190,11 +192,11 @@ class FRROspfv3(FrrService, ConfigService): unified frr.conf file. """ - name = "FRROSPFv3" - shutdown = ["killall ospf6d"] - validate = ["pidof ospf6d"] - ipv4_routing = True - ipv6_routing = True + name: str = "FRROSPFv3" + shutdown: List[str] = ["killall ospf6d"] + validate: List[str] = ["pidof ospf6d"] + ipv4_routing: bool = True + ipv6_routing: bool = True def frr_config(self) -> str: router_id = get_router_id(self.node) @@ -227,12 +229,12 @@ class FRRBgp(FrrService, ConfigService): having the same AS number. """ - name = "FRRBGP" - shutdown = ["killall bgpd"] - validate = ["pidof bgpd"] - custom_needed = True - ipv4_routing = True - ipv6_routing = True + name: str = "FRRBGP" + shutdown: List[str] = ["killall bgpd"] + validate: List[str] = ["pidof bgpd"] + custom_needed: bool = True + ipv4_routing: bool = True + ipv6_routing: bool = True def frr_config(self) -> str: router_id = get_router_id(self.node) @@ -257,10 +259,10 @@ class FRRRip(FrrService, ConfigService): The RIP service provides IPv4 routing for wired networks. """ - name = "FRRRIP" - shutdown = ["killall ripd"] - validate = ["pidof ripd"] - ipv4_routing = True + name: str = "FRRRIP" + shutdown: List[str] = ["killall ripd"] + validate: List[str] = ["pidof ripd"] + ipv4_routing: bool = True def frr_config(self) -> str: text = """ @@ -282,10 +284,10 @@ class FRRRipng(FrrService, ConfigService): The RIP NG service provides IPv6 routing for wired networks. """ - name = "FRRRIPNG" - shutdown = ["killall ripngd"] - validate = ["pidof ripngd"] - ipv6_routing = True + name: str = "FRRRIPNG" + shutdown: List[str] = ["killall ripngd"] + validate: List[str] = ["pidof ripngd"] + ipv6_routing: bool = True def frr_config(self) -> str: text = """ @@ -308,10 +310,10 @@ class FRRBabel(FrrService, ConfigService): protocol for IPv6 and IPv4 with fast convergence properties. """ - name = "FRRBabel" - shutdown = ["killall babeld"] - validate = ["pidof babeld"] - ipv6_routing = True + name: str = "FRRBabel" + shutdown: List[str] = ["killall babeld"] + validate: List[str] = ["pidof babeld"] + ipv6_routing: bool = True def frr_config(self) -> str: ifnames = [] @@ -348,10 +350,10 @@ class FRRpimd(FrrService, ConfigService): PIM multicast routing based on XORP. """ - name = "FRRpimd" - shutdown = ["killall pimd"] - validate = ["pidof pimd"] - ipv4_routing = True + name: str = "FRRpimd" + shutdown: List[str] = ["killall pimd"] + validate: List[str] = ["pidof pimd"] + ipv4_routing: bool = True def frr_config(self) -> str: ifname = "eth0" diff --git a/daemon/core/configservices/nrlservices/services.py b/daemon/core/configservices/nrlservices/services.py index ca95b8f6..0a5e8baf 100644 --- a/daemon/core/configservices/nrlservices/services.py +++ b/daemon/core/configservices/nrlservices/services.py @@ -1,26 +1,27 @@ -from typing import Any, Dict +from typing import Any, Dict, List import netaddr from core import utils +from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode -GROUP = "ProtoSvc" +GROUP: str = "ProtoSvc" class MgenSinkService(ConfigService): - name = "MGEN_Sink" - group = GROUP - directories = [] - files = ["mgensink.sh", "sink.mgen"] - executables = ["mgen"] - dependencies = [] - startup = ["sh mgensink.sh"] - validate = ["pidof mgen"] - shutdown = ["killall mgen"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "MGEN_Sink" + group: str = GROUP + directories: List[str] = [] + files: List[str] = ["mgensink.sh", "sink.mgen"] + executables: List[str] = ["mgen"] + dependencies: List[str] = [] + startup: List[str] = ["sh mgensink.sh"] + validate: List[str] = ["pidof mgen"] + shutdown: List[str] = ["killall mgen"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: ifnames = [] @@ -31,18 +32,18 @@ class MgenSinkService(ConfigService): class NrlNhdp(ConfigService): - name = "NHDP" - group = GROUP - directories = [] - files = ["nrlnhdp.sh"] - executables = ["nrlnhdp"] - dependencies = [] - startup = ["sh nrlnhdp.sh"] - validate = ["pidof nrlnhdp"] - shutdown = ["killall nrlnhdp"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "NHDP" + group: str = GROUP + directories: List[str] = [] + files: List[str] = ["nrlnhdp.sh"] + executables: List[str] = ["nrlnhdp"] + dependencies: List[str] = [] + startup: List[str] = ["sh nrlnhdp.sh"] + validate: List[str] = ["pidof nrlnhdp"] + shutdown: List[str] = ["killall nrlnhdp"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: has_smf = "SMF" in self.node.config_services @@ -53,18 +54,18 @@ class NrlNhdp(ConfigService): class NrlSmf(ConfigService): - name = "SMF" - group = GROUP - directories = [] - files = ["startsmf.sh"] - executables = ["nrlsmf", "killall"] - dependencies = [] - startup = ["sh startsmf.sh"] - validate = ["pidof nrlsmf"] - shutdown = ["killall nrlsmf"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "SMF" + group: str = GROUP + directories: List[str] = [] + files: List[str] = ["startsmf.sh"] + executables: List[str] = ["nrlsmf", "killall"] + dependencies: List[str] = [] + startup: List[str] = ["sh startsmf.sh"] + validate: List[str] = ["pidof nrlsmf"] + shutdown: List[str] = ["killall nrlsmf"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: has_arouted = "arouted" in self.node.config_services @@ -91,18 +92,18 @@ class NrlSmf(ConfigService): class NrlOlsr(ConfigService): - name = "OLSR" - group = GROUP - directories = [] - files = ["nrlolsrd.sh"] - executables = ["nrlolsrd"] - dependencies = [] - startup = ["sh nrlolsrd.sh"] - validate = ["pidof nrlolsrd"] - shutdown = ["killall nrlolsrd"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "OLSR" + group: str = GROUP + directories: List[str] = [] + files: List[str] = ["nrlolsrd.sh"] + executables: List[str] = ["nrlolsrd"] + dependencies: List[str] = [] + startup: List[str] = ["sh nrlolsrd.sh"] + validate: List[str] = ["pidof nrlolsrd"] + shutdown: List[str] = ["killall nrlolsrd"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: has_smf = "SMF" in self.node.config_services @@ -115,18 +116,18 @@ class NrlOlsr(ConfigService): class NrlOlsrv2(ConfigService): - name = "OLSRv2" - group = GROUP - directories = [] - files = ["nrlolsrv2.sh"] - executables = ["nrlolsrv2"] - dependencies = [] - startup = ["sh nrlolsrv2.sh"] - validate = ["pidof nrlolsrv2"] - shutdown = ["killall nrlolsrv2"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "OLSRv2" + group: str = GROUP + directories: List[str] = [] + files: List[str] = ["nrlolsrv2.sh"] + executables: List[str] = ["nrlolsrv2"] + dependencies: List[str] = [] + startup: List[str] = ["sh nrlolsrv2.sh"] + validate: List[str] = ["pidof nrlolsrv2"] + shutdown: List[str] = ["killall nrlolsrv2"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: has_smf = "SMF" in self.node.config_services @@ -137,18 +138,18 @@ class NrlOlsrv2(ConfigService): class OlsrOrg(ConfigService): - name = "OLSRORG" - group = GROUP - directories = ["/etc/olsrd"] - files = ["olsrd.sh", "/etc/olsrd/olsrd.conf"] - executables = ["olsrd"] - dependencies = [] - startup = ["sh olsrd.sh"] - validate = ["pidof olsrd"] - shutdown = ["killall olsrd"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "OLSRORG" + group: str = GROUP + directories: List[str] = ["/etc/olsrd"] + files: List[str] = ["olsrd.sh", "/etc/olsrd/olsrd.conf"] + executables: List[str] = ["olsrd"] + dependencies: List[str] = [] + startup: List[str] = ["sh olsrd.sh"] + validate: List[str] = ["pidof olsrd"] + shutdown: List[str] = ["killall olsrd"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: has_smf = "SMF" in self.node.config_services @@ -159,33 +160,33 @@ class OlsrOrg(ConfigService): class MgenActor(ConfigService): - name = "MgenActor" - group = GROUP - directories = [] - files = ["start_mgen_actor.sh"] - executables = ["mgen"] - dependencies = [] - startup = ["sh start_mgen_actor.sh"] - validate = ["pidof mgen"] - shutdown = ["killall mgen"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "MgenActor" + group: str = GROUP + directories: List[str] = [] + files: List[str] = ["start_mgen_actor.sh"] + executables: List[str] = ["mgen"] + dependencies: List[str] = [] + startup: List[str] = ["sh start_mgen_actor.sh"] + validate: List[str] = ["pidof mgen"] + shutdown: List[str] = ["killall mgen"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} class Arouted(ConfigService): - name = "arouted" - group = GROUP - directories = [] - files = ["startarouted.sh"] - executables = ["arouted"] - dependencies = [] - startup = ["sh startarouted.sh"] - validate = ["pidof arouted"] - shutdown = ["pkill arouted"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "arouted" + group: str = GROUP + directories: List[str] = [] + files: List[str] = ["startarouted.sh"] + executables: List[str] = ["arouted"] + dependencies: List[str] = [] + startup: List[str] = ["sh startarouted.sh"] + validate: List[str] = ["pidof arouted"] + shutdown: List[str] = ["pkill arouted"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: ip4_prefix = None diff --git a/daemon/core/configservices/quaggaservices/services.py b/daemon/core/configservices/quaggaservices/services.py index 19e21476..40a1d7d3 100644 --- a/daemon/core/configservices/quaggaservices/services.py +++ b/daemon/core/configservices/quaggaservices/services.py @@ -1,17 +1,18 @@ import abc import logging -from typing import Any, Dict +from typing import Any, Dict, List import netaddr from core import constants +from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode from core.emane.nodes import EmaneNet from core.nodes.base import CoreNodeBase from core.nodes.interface import CoreInterface from core.nodes.network import WlanNode -GROUP = "Quagga" +GROUP: str = "Quagga" def has_mtu_mismatch(iface: CoreInterface) -> bool: @@ -57,22 +58,22 @@ def get_router_id(node: CoreNodeBase) -> str: class Zebra(ConfigService): - name = "zebra" - group = GROUP - directories = ["/usr/local/etc/quagga", "/var/run/quagga"] - files = [ + name: str = "zebra" + group: str = GROUP + directories: List[str] = ["/usr/local/etc/quagga", "/var/run/quagga"] + files: List[str] = [ "/usr/local/etc/quagga/Quagga.conf", "quaggaboot.sh", "/usr/local/etc/quagga/vtysh.conf", ] - executables = ["zebra"] - dependencies = [] - startup = ["sh quaggaboot.sh zebra"] - validate = ["pidof zebra"] - shutdown = ["killall zebra"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + executables: List[str] = ["zebra"] + dependencies: List[str] = [] + startup: List[str] = ["sh quaggaboot.sh zebra"] + validate: List[str] = ["pidof zebra"] + shutdown: List[str] = ["killall zebra"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: quagga_bin_search = self.node.session.options.get_config( @@ -90,6 +91,8 @@ class Zebra(ConfigService): for service in self.node.config_services.values(): if self.name not in service.dependencies: continue + if not isinstance(service, QuaggaService): + continue if service.ipv4_routing: want_ip4 = True if service.ipv6_routing: @@ -122,19 +125,19 @@ class Zebra(ConfigService): class QuaggaService(abc.ABC): - group = GROUP - directories = [] - files = [] - executables = [] - dependencies = ["zebra"] - startup = [] - validate = [] - shutdown = [] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} - ipv4_routing = False - ipv6_routing = False + group: str = GROUP + directories: List[str] = [] + files: List[str] = [] + executables: List[str] = [] + dependencies: List[str] = ["zebra"] + startup: List[str] = [] + validate: List[str] = [] + shutdown: List[str] = [] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} + ipv4_routing: bool = False + ipv6_routing: bool = False @abc.abstractmethod def quagga_iface_config(self, iface: CoreInterface) -> str: @@ -152,10 +155,10 @@ class Ospfv2(QuaggaService, ConfigService): unified Quagga.conf file. """ - name = "OSPFv2" - validate = ["pidof ospfd"] - shutdown = ["killall ospfd"] - ipv4_routing = True + name: str = "OSPFv2" + validate: List[str] = ["pidof ospfd"] + shutdown: List[str] = ["killall ospfd"] + ipv4_routing: bool = True def quagga_iface_config(self, iface: CoreInterface) -> str: if has_mtu_mismatch(iface): @@ -190,11 +193,11 @@ class Ospfv3(QuaggaService, ConfigService): unified Quagga.conf file. """ - name = "OSPFv3" - shutdown = ("killall ospf6d",) - validate = ("pidof ospf6d",) - ipv4_routing = True - ipv6_routing = True + name: str = "OSPFv3" + shutdown: List[str] = ["killall ospf6d"] + validate: List[str] = ["pidof ospf6d"] + ipv4_routing: bool = True + ipv6_routing: bool = True def quagga_iface_config(self, iface: CoreInterface) -> str: mtu = get_min_mtu(iface) @@ -229,7 +232,7 @@ class Ospfv3mdr(Ospfv3): unified Quagga.conf file. """ - name = "OSPFv3MDR" + name: str = "OSPFv3MDR" def data(self) -> Dict[str, Any]: for iface in self.node.get_ifaces(): @@ -262,11 +265,11 @@ class Bgp(QuaggaService, ConfigService): having the same AS number. """ - name = "BGP" - shutdown = ["killall bgpd"] - validate = ["pidof bgpd"] - ipv4_routing = True - ipv6_routing = True + name: str = "BGP" + shutdown: List[str] = ["killall bgpd"] + validate: List[str] = ["pidof bgpd"] + ipv4_routing: bool = True + ipv6_routing: bool = True def quagga_config(self) -> str: return "" @@ -291,10 +294,10 @@ class Rip(QuaggaService, ConfigService): The RIP service provides IPv4 routing for wired networks. """ - name = "RIP" - shutdown = ["killall ripd"] - validate = ["pidof ripd"] - ipv4_routing = True + name: str = "RIP" + shutdown: List[str] = ["killall ripd"] + validate: List[str] = ["pidof ripd"] + ipv4_routing: bool = True def quagga_config(self) -> str: text = """ @@ -316,10 +319,10 @@ class Ripng(QuaggaService, ConfigService): The RIP NG service provides IPv6 routing for wired networks. """ - name = "RIPNG" - shutdown = ["killall ripngd"] - validate = ["pidof ripngd"] - ipv6_routing = True + name: str = "RIPNG" + shutdown: List[str] = ["killall ripngd"] + validate: List[str] = ["pidof ripngd"] + ipv6_routing: bool = True def quagga_config(self) -> str: text = """ @@ -342,10 +345,10 @@ class Babel(QuaggaService, ConfigService): protocol for IPv6 and IPv4 with fast convergence properties. """ - name = "Babel" - shutdown = ["killall babeld"] - validate = ["pidof babeld"] - ipv6_routing = True + name: str = "Babel" + shutdown: List[str] = ["killall babeld"] + validate: List[str] = ["pidof babeld"] + ipv6_routing: bool = True def quagga_config(self) -> str: ifnames = [] @@ -382,10 +385,10 @@ class Xpimd(QuaggaService, ConfigService): PIM multicast routing based on XORP. """ - name = "Xpimd" - shutdown = ["killall xpimd"] - validate = ["pidof xpimd"] - ipv4_routing = True + name: str = "Xpimd" + shutdown: List[str] = ["killall xpimd"] + validate: List[str] = ["pidof xpimd"] + ipv4_routing: bool = True def quagga_config(self) -> str: ifname = "eth0" diff --git a/daemon/core/configservices/sercurityservices/services.py b/daemon/core/configservices/sercurityservices/services.py index 6e92bf62..5766b0db 100644 --- a/daemon/core/configservices/sercurityservices/services.py +++ b/daemon/core/configservices/sercurityservices/services.py @@ -1,4 +1,4 @@ -from typing import Any, Dict +from typing import Any, Dict, List import netaddr @@ -6,21 +6,21 @@ from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode from core.emulator.enumerations import ConfigDataTypes -GROUP_NAME = "Security" +GROUP_NAME: str = "Security" class VpnClient(ConfigService): - name = "VPNClient" - group = GROUP_NAME - directories = [] - files = ["vpnclient.sh"] - executables = ["openvpn", "ip", "killall"] - dependencies = [] - startup = ["sh vpnclient.sh"] - validate = ["pidof openvpn"] - shutdown = ["killall openvpn"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [ + name: str = "VPNClient" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["vpnclient.sh"] + executables: List[str] = ["openvpn", "ip", "killall"] + dependencies: List[str] = [] + startup: List[str] = ["sh vpnclient.sh"] + validate: List[str] = ["pidof openvpn"] + shutdown: List[str] = ["killall openvpn"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [ Configuration( _id="keydir", _type=ConfigDataTypes.STRING, @@ -40,21 +40,21 @@ class VpnClient(ConfigService): default="10.0.2.10", ), ] - modes = {} + modes: Dict[str, Dict[str, str]] = {} class VpnServer(ConfigService): - name = "VPNServer" - group = GROUP_NAME - directories = [] - files = ["vpnserver.sh"] - executables = ["openvpn", "ip", "killall"] - dependencies = [] - startup = ["sh vpnserver.sh"] - validate = ["pidof openvpn"] - shutdown = ["killall openvpn"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [ + name: str = "VPNServer" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["vpnserver.sh"] + executables: List[str] = ["openvpn", "ip", "killall"] + dependencies: List[str] = [] + startup: List[str] = ["sh vpnserver.sh"] + validate: List[str] = ["pidof openvpn"] + shutdown: List[str] = ["killall openvpn"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [ Configuration( _id="keydir", _type=ConfigDataTypes.STRING, @@ -74,7 +74,7 @@ class VpnServer(ConfigService): default="10.0.200.0", ), ] - modes = {} + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: address = None @@ -87,48 +87,48 @@ class VpnServer(ConfigService): class IPsec(ConfigService): - name = "IPsec" - group = GROUP_NAME - directories = [] - files = ["ipsec.sh"] - executables = ["racoon", "ip", "setkey", "killall"] - dependencies = [] - startup = ["sh ipsec.sh"] - validate = ["pidof racoon"] - shutdown = ["killall racoon"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "IPsec" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["ipsec.sh"] + executables: List[str] = ["racoon", "ip", "setkey", "killall"] + dependencies: List[str] = [] + startup: List[str] = ["sh ipsec.sh"] + validate: List[str] = ["pidof racoon"] + shutdown: List[str] = ["killall racoon"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} class Firewall(ConfigService): - name = "Firewall" - group = GROUP_NAME - directories = [] - files = ["firewall.sh"] - executables = ["iptables"] - dependencies = [] - startup = ["sh firewall.sh"] - validate = [] - shutdown = [] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "Firewall" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["firewall.sh"] + executables: List[str] = ["iptables"] + dependencies: List[str] = [] + startup: List[str] = ["sh firewall.sh"] + validate: List[str] = [] + shutdown: List[str] = [] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} class Nat(ConfigService): - name = "NAT" - group = GROUP_NAME - directories = [] - files = ["nat.sh"] - executables = ["iptables"] - dependencies = [] - startup = ["sh nat.sh"] - validate = [] - shutdown = [] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "NAT" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["nat.sh"] + executables: List[str] = ["iptables"] + dependencies: List[str] = [] + startup: List[str] = ["sh nat.sh"] + validate: List[str] = [] + shutdown: List[str] = [] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: ifnames = [] diff --git a/daemon/core/configservices/simpleservice.py b/daemon/core/configservices/simpleservice.py index e727fe82..c2e7242f 100644 --- a/daemon/core/configservices/simpleservice.py +++ b/daemon/core/configservices/simpleservice.py @@ -1,20 +1,22 @@ +from typing import Dict, List + from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode from core.emulator.enumerations import ConfigDataTypes class SimpleService(ConfigService): - name = "Simple" - group = "SimpleGroup" - directories = ["/etc/quagga", "/usr/local/lib"] - files = ["test1.sh", "test2.sh"] - executables = [] - dependencies = [] - startup = [] - validate = [] - shutdown = [] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [ + name: str = "Simple" + group: str = "SimpleGroup" + directories: List[str] = ["/etc/quagga", "/usr/local/lib"] + files: List[str] = ["test1.sh", "test2.sh"] + executables: List[str] = [] + dependencies: List[str] = [] + startup: List[str] = [] + validate: List[str] = [] + shutdown: List[str] = [] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [ Configuration(_id="value1", _type=ConfigDataTypes.STRING, label="Text"), Configuration(_id="value2", _type=ConfigDataTypes.BOOL, label="Boolean"), Configuration( @@ -24,7 +26,7 @@ class SimpleService(ConfigService): options=["value1", "value2", "value3"], ), ] - modes = { + modes: Dict[str, Dict[str, str]] = { "mode1": {"value1": "value1", "value2": "0", "value3": "value2"}, "mode2": {"value1": "value2", "value2": "1", "value3": "value3"}, "mode3": {"value1": "value3", "value2": "0", "value3": "value1"}, diff --git a/daemon/core/configservices/utilservices/services.py b/daemon/core/configservices/utilservices/services.py index 5aa3bb54..983f6cff 100644 --- a/daemon/core/configservices/utilservices/services.py +++ b/daemon/core/configservices/utilservices/services.py @@ -1,26 +1,27 @@ -from typing import Any, Dict +from typing import Any, Dict, List import netaddr from core import utils +from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode GROUP_NAME = "Utility" class DefaultRouteService(ConfigService): - name = "DefaultRoute" - group = GROUP_NAME - directories = [] - files = ["defaultroute.sh"] - executables = ["ip"] - dependencies = [] - startup = ["sh defaultroute.sh"] - validate = [] - shutdown = [] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "DefaultRoute" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["defaultroute.sh"] + executables: List[str] = ["ip"] + dependencies: List[str] = [] + startup: List[str] = ["sh defaultroute.sh"] + validate: List[str] = [] + shutdown: List[str] = [] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: # only add default routes for linked routing nodes @@ -37,18 +38,18 @@ class DefaultRouteService(ConfigService): class DefaultMulticastRouteService(ConfigService): - name = "DefaultMulticastRoute" - group = GROUP_NAME - directories = [] - files = ["defaultmroute.sh"] - executables = [] - dependencies = [] - startup = ["sh defaultmroute.sh"] - validate = [] - shutdown = [] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "DefaultMulticastRoute" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["defaultmroute.sh"] + executables: List[str] = [] + dependencies: List[str] = [] + startup: List[str] = ["sh defaultmroute.sh"] + validate: List[str] = [] + shutdown: List[str] = [] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: ifname = None @@ -59,18 +60,18 @@ class DefaultMulticastRouteService(ConfigService): class StaticRouteService(ConfigService): - name = "StaticRoute" - group = GROUP_NAME - directories = [] - files = ["staticroute.sh"] - executables = [] - dependencies = [] - startup = ["sh staticroute.sh"] - validate = [] - shutdown = [] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "StaticRoute" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["staticroute.sh"] + executables: List[str] = [] + dependencies: List[str] = [] + startup: List[str] = ["sh staticroute.sh"] + validate: List[str] = [] + shutdown: List[str] = [] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: routes = [] @@ -88,18 +89,18 @@ class StaticRouteService(ConfigService): class IpForwardService(ConfigService): - name = "IPForward" - group = GROUP_NAME - directories = [] - files = ["ipforward.sh"] - executables = ["sysctl"] - dependencies = [] - startup = ["sh ipforward.sh"] - validate = [] - shutdown = [] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "IPForward" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["ipforward.sh"] + executables: List[str] = ["sysctl"] + dependencies: List[str] = [] + startup: List[str] = ["sh ipforward.sh"] + validate: List[str] = [] + shutdown: List[str] = [] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: devnames = [] @@ -110,18 +111,18 @@ class IpForwardService(ConfigService): class SshService(ConfigService): - name = "SSH" - group = GROUP_NAME - directories = ["/etc/ssh", "/var/run/sshd"] - files = ["startsshd.sh", "/etc/ssh/sshd_config"] - executables = ["sshd"] - dependencies = [] - startup = ["sh startsshd.sh"] - validate = [] - shutdown = ["killall sshd"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "SSH" + group: str = GROUP_NAME + directories: List[str] = ["/etc/ssh", "/var/run/sshd"] + files: List[str] = ["startsshd.sh", "/etc/ssh/sshd_config"] + executables: List[str] = ["sshd"] + dependencies: List[str] = [] + startup: List[str] = ["sh startsshd.sh"] + validate: List[str] = [] + shutdown: List[str] = ["killall sshd"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: return dict( @@ -132,18 +133,18 @@ class SshService(ConfigService): class DhcpService(ConfigService): - name = "DHCP" - group = GROUP_NAME - directories = ["/etc/dhcp", "/var/lib/dhcp"] - files = ["/etc/dhcp/dhcpd.conf"] - executables = ["dhcpd"] - dependencies = [] - startup = ["touch /var/lib/dhcp/dhcpd.leases", "dhcpd"] - validate = ["pidof dhcpd"] - shutdown = ["killall dhcpd"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "DHCP" + group: str = GROUP_NAME + directories: List[str] = ["/etc/dhcp", "/var/lib/dhcp"] + files: List[str] = ["/etc/dhcp/dhcpd.conf"] + executables: List[str] = ["dhcpd"] + dependencies: List[str] = [] + startup: List[str] = ["touch /var/lib/dhcp/dhcpd.leases", "dhcpd"] + validate: List[str] = ["pidof dhcpd"] + shutdown: List[str] = ["killall dhcpd"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: subnets = [] @@ -161,18 +162,18 @@ class DhcpService(ConfigService): class DhcpClientService(ConfigService): - name = "DHCPClient" - group = GROUP_NAME - directories = [] - files = ["startdhcpclient.sh"] - executables = ["dhclient"] - dependencies = [] - startup = ["sh startdhcpclient.sh"] - validate = ["pidof dhclient"] - shutdown = ["killall dhclient"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "DHCPClient" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["startdhcpclient.sh"] + executables: List[str] = ["dhclient"] + dependencies: List[str] = [] + startup: List[str] = ["sh startdhcpclient.sh"] + validate: List[str] = ["pidof dhclient"] + shutdown: List[str] = ["killall dhclient"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: ifnames = [] @@ -182,33 +183,33 @@ class DhcpClientService(ConfigService): class FtpService(ConfigService): - name = "FTP" - group = GROUP_NAME - directories = ["/var/run/vsftpd/empty", "/var/ftp"] - files = ["vsftpd.conf"] - executables = ["vsftpd"] - dependencies = [] - startup = ["vsftpd ./vsftpd.conf"] - validate = ["pidof vsftpd"] - shutdown = ["killall vsftpd"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "FTP" + group: str = GROUP_NAME + directories: List[str] = ["/var/run/vsftpd/empty", "/var/ftp"] + files: List[str] = ["vsftpd.conf"] + executables: List[str] = ["vsftpd"] + dependencies: List[str] = [] + startup: List[str] = ["vsftpd ./vsftpd.conf"] + validate: List[str] = ["pidof vsftpd"] + shutdown: List[str] = ["killall vsftpd"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} class PcapService(ConfigService): - name = "pcap" - group = GROUP_NAME - directories = [] - files = ["pcap.sh"] - executables = ["tcpdump"] - dependencies = [] - startup = ["sh pcap.sh start"] - validate = ["pidof tcpdump"] - shutdown = ["sh pcap.sh stop"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "pcap" + group: str = GROUP_NAME + directories: List[str] = [] + files: List[str] = ["pcap.sh"] + executables: List[str] = ["tcpdump"] + dependencies: List[str] = [] + startup: List[str] = ["sh pcap.sh start"] + validate: List[str] = ["pidof tcpdump"] + shutdown: List[str] = ["sh pcap.sh stop"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: ifnames = [] @@ -218,18 +219,20 @@ class PcapService(ConfigService): class RadvdService(ConfigService): - name = "radvd" - group = GROUP_NAME - directories = ["/etc/radvd"] - files = ["/etc/radvd/radvd.conf"] - executables = ["radvd"] - dependencies = [] - startup = ["radvd -C /etc/radvd/radvd.conf -m logfile -l /var/log/radvd.log"] - validate = ["pidof radvd"] - shutdown = ["pkill radvd"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "radvd" + group: str = GROUP_NAME + directories: List[str] = ["/etc/radvd"] + files: List[str] = ["/etc/radvd/radvd.conf"] + executables: List[str] = ["radvd"] + dependencies: List[str] = [] + startup: List[str] = [ + "radvd -C /etc/radvd/radvd.conf -m logfile -l /var/log/radvd.log" + ] + validate: List[str] = ["pidof radvd"] + shutdown: List[str] = ["pkill radvd"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: ifaces = [] @@ -246,24 +249,24 @@ class RadvdService(ConfigService): class AtdService(ConfigService): - name = "atd" - group = GROUP_NAME - directories = ["/var/spool/cron/atjobs", "/var/spool/cron/atspool"] - files = ["startatd.sh"] - executables = ["atd"] - dependencies = [] - startup = ["sh startatd.sh"] - validate = ["pidof atd"] - shutdown = ["pkill atd"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + name: str = "atd" + group: str = GROUP_NAME + directories: List[str] = ["/var/spool/cron/atjobs", "/var/spool/cron/atspool"] + files: List[str] = ["startatd.sh"] + executables: List[str] = ["atd"] + dependencies: List[str] = [] + startup: List[str] = ["sh startatd.sh"] + validate: List[str] = ["pidof atd"] + shutdown: List[str] = ["pkill atd"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} class HttpService(ConfigService): - name = "HTTP" - group = GROUP_NAME - directories = [ + name: str = "HTTP" + group: str = GROUP_NAME + directories: List[str] = [ "/etc/apache2", "/var/run/apache2", "/var/log/apache2", @@ -271,15 +274,19 @@ class HttpService(ConfigService): "/var/lock/apache2", "/var/www", ] - files = ["/etc/apache2/apache2.conf", "/etc/apache2/envvars", "/var/www/index.html"] - executables = ["apache2ctl"] - dependencies = [] - startup = ["chown www-data /var/lock/apache2", "apache2ctl start"] - validate = ["pidof apache2"] - shutdown = ["apache2ctl stop"] - validation_mode = ConfigServiceMode.BLOCKING - default_configs = [] - modes = {} + files: List[str] = [ + "/etc/apache2/apache2.conf", + "/etc/apache2/envvars", + "/var/www/index.html", + ] + executables: List[str] = ["apache2ctl"] + dependencies: List[str] = [] + startup: List[str] = ["chown www-data /var/lock/apache2", "apache2ctl start"] + validate: List[str] = ["pidof apache2"] + shutdown: List[str] = ["apache2ctl stop"] + validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING + default_configs: List[Configuration] = [] + modes: Dict[str, Dict[str, str]] = {} def data(self) -> Dict[str, Any]: ifaces = [] From ca2b1c9e4cb90f82380492bbea61dd59a06f987a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 18 Jun 2020 21:33:28 -0700 Subject: [PATCH 171/929] daemon: refactored all_link_data to links --- daemon/core/api/grpc/grpcutils.py | 6 +++--- daemon/core/api/tlv/corehandlers.py | 12 ++++++------ daemon/core/emane/nodes.py | 4 ++-- daemon/core/location/mobility.py | 4 ++-- daemon/core/nodes/base.py | 4 ++-- daemon/core/nodes/network.py | 12 ++++++------ daemon/core/plugins/sdt.py | 2 +- daemon/core/xml/corexml.py | 2 +- daemon/tests/test_core.py | 2 +- daemon/tests/test_grpc.py | 14 +++++++------- daemon/tests/test_gui.py | 22 +++++++++++----------- daemon/tests/test_links.py | 6 +++--- daemon/tests/test_xml.py | 8 ++++---- 13 files changed, 49 insertions(+), 49 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index d95b7555..2c13315c 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -270,9 +270,9 @@ def get_links(node: NodeBase): :return: protobuf links """ links = [] - for link_data in node.all_link_data(): - link = convert_link(link_data) - links.append(link) + for link in node.links(): + link_proto = convert_link(link) + links.append(link_proto) return links diff --git a/daemon/core/api/tlv/corehandlers.py b/daemon/core/api/tlv/corehandlers.py index d01f15a3..bb4f2ecd 100644 --- a/daemon/core/api/tlv/corehandlers.py +++ b/daemon/core/api/tlv/corehandlers.py @@ -1824,16 +1824,16 @@ class CoreHandler(socketserver.BaseRequestHandler): Return API messages that describe the current session. """ # find all nodes and links - links_data = [] + all_links = [] with self.session.nodes_lock: for node_id in self.session.nodes: node = self.session.nodes[node_id] self.session.broadcast_node(node, MessageFlags.ADD) - node_links = node.all_link_data(flags=MessageFlags.ADD) - links_data.extend(node_links) + links = node.links(flags=MessageFlags.ADD) + all_links.extend(links) - for link_data in links_data: - self.session.broadcast_link(link_data) + for link in all_links: + self.session.broadcast_link(link) # send mobility model info for node_id in self.session.mobility.nodes(): @@ -1940,7 +1940,7 @@ class CoreHandler(socketserver.BaseRequestHandler): node_count = self.session.get_node_count() logging.info( - "informed GUI about %d nodes and %d links", node_count, len(links_data) + "informed GUI about %d nodes and %d links", node_count, len(all_links) ) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index c28f1382..9173fbfc 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -241,8 +241,8 @@ class EmaneNet(CoreNetworkBase): event.append(nemid, latitude=lat, longitude=lon, altitude=alt) self.session.emane.service.publish(0, event) - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: - links = super().all_link_data(flags) + def links(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: + links = super().links(flags) # gather current emane links nem_ids = set(self.nemidmap.values()) emane_manager = self.session.emane diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index 9bb2966e..f2e0f470 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -217,7 +217,7 @@ class WirelessModel(ConfigurableOptions): self.session: "Session" = session self.id: int = _id - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: + def links(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ May be used if the model can populate the GUI with wireless (green) link lines. @@ -509,7 +509,7 @@ class BasicRangeModel(WirelessModel): link_data = self.create_link_data(iface, iface2, message_type) self.session.broadcast_link(link_data) - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: + def links(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ Return a list of wireless link messages for when the GUI reconnects. diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 4fc6b873..2c8ca06c 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -182,7 +182,7 @@ class NodeBase(abc.ABC): self.iface_id += 1 return iface_id - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: + def links(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ Build link data for this node. @@ -1021,7 +1021,7 @@ class CoreNetworkBase(NodeBase): with self._linked_lock: del self._linked[iface] - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: + def links(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ Build link data objects for this network. Each link object describes a link between this network and a node. diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index f20b6dfb..62443fb8 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -831,7 +831,7 @@ class CtrlNet(CoreNetwork): super().shutdown() - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: + def links(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ Do not include CtrlNet in link messages describing this session. @@ -859,7 +859,7 @@ class PtpNet(CoreNetwork): raise CoreError("ptp links support at most 2 network interfaces") super().attach(iface) - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: + def links(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ Build CORE API TLVs for a point-to-point link. One Link message describes this network. @@ -1054,17 +1054,17 @@ class WlanNode(CoreNetwork): for iface in self.get_ifaces(): iface.setposition() - def all_link_data(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: + def links(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: """ Retrieve all link data. :param flags: message flags :return: list of link data """ - all_links = super().all_link_data(flags) + links = super().links(flags) if self.model: - all_links.extend(self.model.all_link_data(flags)) - return all_links + links.extend(self.model.links(flags)) + return links class TunnelNode(GreTapBridge): diff --git a/daemon/core/plugins/sdt.py b/daemon/core/plugins/sdt.py index 84c90730..ef36b0a4 100644 --- a/daemon/core/plugins/sdt.py +++ b/daemon/core/plugins/sdt.py @@ -225,7 +225,7 @@ class Sdt: self.add_node(node) for net in nets: - all_links = net.all_link_data(flags=MessageFlags.ADD) + all_links = net.links(flags=MessageFlags.ADD) for link_data in all_links: is_wireless = isinstance(net, (WlanNode, EmaneNet)) if is_wireless and link_data.node1_id == net.id: diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 190cf8f7..d3cc85d8 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -465,7 +465,7 @@ class CoreXmlWriter: self.write_device(node) # add known links - links.extend(node.all_link_data()) + links.extend(node.links()) return links def write_network(self, node: NodeBase) -> None: diff --git a/daemon/tests/test_core.py b/daemon/tests/test_core.py index 2623b0df..c4465863 100644 --- a/daemon/tests/test_core.py +++ b/daemon/tests/test_core.py @@ -120,7 +120,7 @@ class TestCore: session.instantiate() # check link data gets generated - assert ptp_node.all_link_data(MessageFlags.ADD) + assert ptp_node.links(MessageFlags.ADD) # check common nets exist between linked nodes assert node1.commonnets(node2) diff --git a/daemon/tests/test_grpc.py b/daemon/tests/test_grpc.py index 8abf33aa..a4efd6d9 100644 --- a/daemon/tests/test_grpc.py +++ b/daemon/tests/test_grpc.py @@ -555,7 +555,7 @@ class TestGrpc: session = grpc_server.coreemu.create_session() switch = session.add_node(SwitchNode) node = session.add_node(CoreNode) - assert len(switch.all_link_data()) == 0 + assert len(switch.links()) == 0 # then iface = iface_helper.create_iface(node.id, 0) @@ -564,7 +564,7 @@ class TestGrpc: # then assert response.result is True - assert len(switch.all_link_data()) == 1 + assert len(switch.links()) == 1 def test_add_link_exception( self, grpc_server: CoreGrpcServer, iface_helper: InterfaceHelper @@ -589,7 +589,7 @@ class TestGrpc: iface = ip_prefixes.create_iface(node) session.add_link(node.id, switch.id, iface) options = core_pb2.LinkOptions(bandwidth=30000) - link = switch.all_link_data()[0] + link = switch.links()[0] assert options.bandwidth != link.options.bandwidth # then @@ -600,7 +600,7 @@ class TestGrpc: # then assert response.result is True - link = switch.all_link_data()[0] + link = switch.links()[0] assert options.bandwidth == link.options.bandwidth def test_delete_link(self, grpc_server: CoreGrpcServer, ip_prefixes: IpPrefixes): @@ -618,7 +618,7 @@ class TestGrpc: if node.id not in {node1.id, node2.id}: link_node = node break - assert len(link_node.all_link_data()) == 1 + assert len(link_node.links()) == 1 # then with client.context_connect(): @@ -628,7 +628,7 @@ class TestGrpc: # then assert response.result is True - assert len(link_node.all_link_data()) == 0 + assert len(link_node.links()) == 0 def test_get_wlan_config(self, grpc_server: CoreGrpcServer): # given @@ -1029,7 +1029,7 @@ class TestGrpc: node = session.add_node(CoreNode) iface = ip_prefixes.create_iface(node) session.add_link(node.id, wlan.id, iface) - link_data = wlan.all_link_data()[0] + link_data = wlan.links()[0] queue = Queue() def handle_event(event_data): diff --git a/daemon/tests/test_gui.py b/daemon/tests/test_gui.py index 8f01a2bf..a0b3bd8a 100644 --- a/daemon/tests/test_gui.py +++ b/daemon/tests/test_gui.py @@ -122,7 +122,7 @@ class TestGui: coretlv.handle_message(message) switch_node = coretlv.session.get_node(switch_id, SwitchNode) - all_links = switch_node.all_link_data() + all_links = switch_node.links() assert len(all_links) == 1 def test_link_add_net_to_node(self, coretlv: CoreHandler): @@ -146,7 +146,7 @@ class TestGui: coretlv.handle_message(message) switch_node = coretlv.session.get_node(switch_id, SwitchNode) - all_links = switch_node.all_link_data() + all_links = switch_node.links() assert len(all_links) == 1 def test_link_add_node_to_node(self, coretlv: CoreHandler): @@ -176,7 +176,7 @@ class TestGui: all_links = [] for node_id in coretlv.session.nodes: node = coretlv.session.nodes[node_id] - all_links += node.all_link_data() + all_links += node.links() assert len(all_links) == 1 def test_link_update(self, coretlv: CoreHandler): @@ -198,7 +198,7 @@ class TestGui: ) coretlv.handle_message(message) switch_node = coretlv.session.get_node(switch_id, SwitchNode) - all_links = switch_node.all_link_data() + all_links = switch_node.links() assert len(all_links) == 1 link = all_links[0] assert link.options.bandwidth is None @@ -216,7 +216,7 @@ class TestGui: coretlv.handle_message(message) switch_node = coretlv.session.get_node(switch_id, SwitchNode) - all_links = switch_node.all_link_data() + all_links = switch_node.links() assert len(all_links) == 1 link = all_links[0] assert link.options.bandwidth == bandwidth @@ -245,7 +245,7 @@ class TestGui: all_links = [] for node_id in coretlv.session.nodes: node = coretlv.session.nodes[node_id] - all_links += node.all_link_data() + all_links += node.links() assert len(all_links) == 1 message = coreapi.CoreLinkMessage.create( @@ -262,7 +262,7 @@ class TestGui: all_links = [] for node_id in coretlv.session.nodes: node = coretlv.session.nodes[node_id] - all_links += node.all_link_data() + all_links += node.links() assert len(all_links) == 0 def test_link_delete_node_to_net(self, coretlv: CoreHandler): @@ -284,7 +284,7 @@ class TestGui: ) coretlv.handle_message(message) switch_node = coretlv.session.get_node(switch_id, SwitchNode) - all_links = switch_node.all_link_data() + all_links = switch_node.links() assert len(all_links) == 1 message = coreapi.CoreLinkMessage.create( @@ -298,7 +298,7 @@ class TestGui: coretlv.handle_message(message) switch_node = coretlv.session.get_node(switch_id, SwitchNode) - all_links = switch_node.all_link_data() + all_links = switch_node.links() assert len(all_links) == 0 def test_link_delete_net_to_node(self, coretlv: CoreHandler): @@ -320,7 +320,7 @@ class TestGui: ) coretlv.handle_message(message) switch_node = coretlv.session.get_node(switch_id, SwitchNode) - all_links = switch_node.all_link_data() + all_links = switch_node.links() assert len(all_links) == 1 message = coreapi.CoreLinkMessage.create( @@ -334,7 +334,7 @@ class TestGui: coretlv.handle_message(message) switch_node = coretlv.session.get_node(switch_id, SwitchNode) - all_links = switch_node.all_link_data() + all_links = switch_node.links() assert len(all_links) == 0 def test_session_update(self, coretlv: CoreHandler): diff --git a/daemon/tests/test_links.py b/daemon/tests/test_links.py index 4078d8bc..535ad837 100644 --- a/daemon/tests/test_links.py +++ b/daemon/tests/test_links.py @@ -49,7 +49,7 @@ class TestLinks: session.add_link(node1.id, node2.id, iface1_data=iface1_data) # then - assert node2.all_link_data() + assert node2.links() assert node1.get_iface(iface1_data.id) def test_add_net_to_node(self, session: Session, ip_prefixes: IpPrefixes): @@ -62,7 +62,7 @@ class TestLinks: session.add_link(node1.id, node2.id, iface2_data=iface2_data) # then - assert node1.all_link_data() + assert node1.links() assert node2.get_iface(iface2_data.id) def test_add_net_to_net(self, session): @@ -74,7 +74,7 @@ class TestLinks: session.add_link(node1.id, node2.id) # then - assert node1.all_link_data() + assert node1.links() def test_update_node_to_net(self, session: Session, ip_prefixes: IpPrefixes): # given diff --git a/daemon/tests/test_xml.py b/daemon/tests/test_xml.py index 91b598f3..fb8bc4d9 100644 --- a/daemon/tests/test_xml.py +++ b/daemon/tests/test_xml.py @@ -285,7 +285,7 @@ class TestXml: switch2 = session.get_node(node2_id, SwitchNode) assert switch1 assert switch2 - assert len(switch1.all_link_data() + switch2.all_link_data()) == 1 + assert len(switch1.links() + switch2.links()) == 1 def test_link_options( self, session: Session, tmpdir: TemporaryFile, ip_prefixes: IpPrefixes @@ -345,7 +345,7 @@ class TestXml: links = [] for node_id in session.nodes: node = session.nodes[node_id] - links += node.all_link_data() + links += node.links() link = links[0] assert options.loss == link.options.loss assert options.bandwidth == link.options.bandwidth @@ -412,7 +412,7 @@ class TestXml: links = [] for node_id in session.nodes: node = session.nodes[node_id] - links += node.all_link_data() + links += node.links() link = links[0] assert options.loss == link.options.loss assert options.bandwidth == link.options.bandwidth @@ -490,7 +490,7 @@ class TestXml: links = [] for node_id in session.nodes: node = session.nodes[node_id] - links += node.all_link_data() + links += node.links() assert len(links) == 2 link1 = links[0] link2 = links[1] From d88f3a253548ff08f9204488b2c36f32b6d35a97 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 08:50:36 -0700 Subject: [PATCH 172/929] daemon: refactored CoreInterface.addrlist storing strings into CoreInterface.ip4s and ip6s, stored as netaddr.IPNetwork objects --- daemon/core/api/grpc/grpcutils.py | 19 +++---- .../configservices/frrservices/services.py | 25 ++++----- .../configservices/nrlservices/services.py | 24 +++------ .../configservices/quaggaservices/services.py | 25 ++++----- .../sercurityservices/services.py | 10 ++-- .../configservices/utilservices/services.py | 36 ++++++------- daemon/core/emane/linkmonitor.py | 12 ++--- daemon/core/emulator/session.py | 5 +- daemon/core/nodes/base.py | 21 ++++---- daemon/core/nodes/interface.py | 46 ++++++++++++---- daemon/core/nodes/network.py | 34 ++++++------ daemon/core/services/bird.py | 9 ++-- daemon/core/services/frr.py | 40 ++++++-------- daemon/core/services/nrl.py | 9 ++-- daemon/core/services/quagga.py | 39 ++++++-------- daemon/core/services/sdn.py | 22 +++----- daemon/core/services/utility.py | 53 ++++++++++--------- daemon/core/services/xorp.py | 37 +++++-------- daemon/core/xml/corexmldeployment.py | 3 +- daemon/tests/test_nodes.py | 2 +- 20 files changed, 209 insertions(+), 262 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 2c13315c..adaf2549 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -3,7 +3,6 @@ import time from typing import Any, Dict, List, Tuple, Type, Union import grpc -import netaddr from grpc import ServicerContext from core import utils @@ -447,18 +446,16 @@ def iface_to_proto(iface: CoreInterface) -> core_pb2.Interface: net_id = iface.net.id ip4 = None ip4_mask = None + ip4_net = iface.get_ip4() + if ip4_net: + ip4 = str(ip4_net.ip) + ip4_mask = ip4_net.prefixlen ip6 = None ip6_mask = None - for addr in iface.addrlist: - network = netaddr.IPNetwork(addr) - mask = network.prefixlen - ip = str(network.ip) - if netaddr.valid_ipv4(ip) and not ip4: - ip4 = ip - ip4_mask = mask - elif netaddr.valid_ipv6(ip) and not ip6: - ip6 = ip - ip6_mask = mask + ip6_net = iface.get_ip6() + if ip6_net: + ip6 = str(ip6_net.ip) + ip6_mask = ip6_net.prefixlen return core_pb2.Interface( id=iface.node_id, net_id=net_id, diff --git a/daemon/core/configservices/frrservices/services.py b/daemon/core/configservices/frrservices/services.py index 2e24b40a..ce8c305c 100644 --- a/daemon/core/configservices/frrservices/services.py +++ b/daemon/core/configservices/frrservices/services.py @@ -1,8 +1,6 @@ import abc from typing import Any, Dict, List -import netaddr - from core import constants from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode @@ -49,10 +47,9 @@ def get_router_id(node: CoreNodeBase) -> str: Helper to return the first IPv4 address of a node as its router ID. """ for iface in node.get_ifaces(control=False): - for a in iface.addrlist: - a = a.split("/")[0] - if netaddr.valid_ipv4(a): - return a + ip4 = iface.get_ip4() + if ip4: + return str(ip4.ip) return "0.0.0.0" @@ -102,12 +99,10 @@ class FRRZebra(ConfigService): for iface in self.node.get_ifaces(): ip4s = [] ip6s = [] - for x in iface.addrlist: - addr = x.split("/")[0] - if netaddr.valid_ipv4(addr): - ip4s.append(x) - else: - ip6s.append(x) + for ip4 in iface.ip4s: + ip4s.append(str(ip4.ip)) + for ip6 in iface.ip6s: + ip6s.append(str(ip6.ip)) is_control = getattr(iface, "control", False) ifaces.append((iface, ip4s, ip6s, is_control)) @@ -163,10 +158,8 @@ class FRROspfv2(FrrService, ConfigService): router_id = get_router_id(self.node) addresses = [] for iface in self.node.get_ifaces(control=False): - for a in iface.addrlist: - addr = a.split("/")[0] - if netaddr.valid_ipv4(addr): - addresses.append(a) + for ip4 in iface.ip4s: + addresses.append(str(ip4.ip)) data = dict(router_id=router_id, addresses=addresses) text = """ router ospf diff --git a/daemon/core/configservices/nrlservices/services.py b/daemon/core/configservices/nrlservices/services.py index 0a5e8baf..cf9b4c88 100644 --- a/daemon/core/configservices/nrlservices/services.py +++ b/daemon/core/configservices/nrlservices/services.py @@ -1,7 +1,5 @@ from typing import Any, Dict, List -import netaddr - from core import utils from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode @@ -75,13 +73,10 @@ class NrlSmf(ConfigService): ip4_prefix = None for iface in self.node.get_ifaces(control=False): ifnames.append(iface.name) - if ip4_prefix: - continue - for a in iface.addrlist: - a = a.split("/")[0] - if netaddr.valid_ipv4(a): - ip4_prefix = f"{a}/{24}" - break + ip4 = iface.get_ip4() + if ip4: + ip4_prefix = f"{ip4.ip}/{24}" + break return dict( has_arouted=has_arouted, has_nhdp=has_nhdp, @@ -191,11 +186,8 @@ class Arouted(ConfigService): def data(self) -> Dict[str, Any]: ip4_prefix = None for iface in self.node.get_ifaces(control=False): - if ip4_prefix: - continue - for a in iface.addrlist: - a = a.split("/")[0] - if netaddr.valid_ipv4(a): - ip4_prefix = f"{a}/{24}" - break + ip4 = iface.get_ip4() + if ip4: + ip4_prefix = f"{ip4.ip}/{24}" + break return dict(ip4_prefix=ip4_prefix) diff --git a/daemon/core/configservices/quaggaservices/services.py b/daemon/core/configservices/quaggaservices/services.py index 40a1d7d3..e18e8a1a 100644 --- a/daemon/core/configservices/quaggaservices/services.py +++ b/daemon/core/configservices/quaggaservices/services.py @@ -2,8 +2,6 @@ import abc import logging from typing import Any, Dict, List -import netaddr - from core import constants from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode @@ -50,10 +48,9 @@ def get_router_id(node: CoreNodeBase) -> str: Helper to return the first IPv4 address of a node as its router ID. """ for iface in node.get_ifaces(control=False): - for a in iface.addrlist: - a = a.split("/")[0] - if netaddr.valid_ipv4(a): - return a + ip4 = iface.get_ip4() + if ip4: + return str(ip4.ip) return "0.0.0.0" @@ -103,12 +100,10 @@ class Zebra(ConfigService): for iface in self.node.get_ifaces(): ip4s = [] ip6s = [] - for x in iface.addrlist: - addr = x.split("/")[0] - if netaddr.valid_ipv4(addr): - ip4s.append(x) - else: - ip6s.append(x) + for ip4 in iface.ip4s: + ip4s.append(str(ip4.ip)) + for ip6 in iface.ip6s: + ip6s.append(str(ip6.ip)) is_control = getattr(iface, "control", False) ifaces.append((iface, ip4s, ip6s, is_control)) @@ -170,10 +165,8 @@ class Ospfv2(QuaggaService, ConfigService): router_id = get_router_id(self.node) addresses = [] for iface in self.node.get_ifaces(control=False): - for a in iface.addrlist: - addr = a.split("/")[0] - if netaddr.valid_ipv4(addr): - addresses.append(a) + for ip4 in iface.ip4s: + addresses.append(str(ip4.ip)) data = dict(router_id=router_id, addresses=addresses) text = """ router ospf diff --git a/daemon/core/configservices/sercurityservices/services.py b/daemon/core/configservices/sercurityservices/services.py index 5766b0db..4a58fd8c 100644 --- a/daemon/core/configservices/sercurityservices/services.py +++ b/daemon/core/configservices/sercurityservices/services.py @@ -1,7 +1,5 @@ from typing import Any, Dict, List -import netaddr - from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode from core.emulator.enumerations import ConfigDataTypes @@ -79,10 +77,10 @@ class VpnServer(ConfigService): def data(self) -> Dict[str, Any]: address = None for iface in self.node.get_ifaces(control=False): - for x in iface.addrlist: - addr = x.split("/")[0] - if netaddr.valid_ipv4(addr): - address = addr + ip4 = iface.get_ip4() + if ip4: + address = str(ip4.ip) + break return dict(address=address) diff --git a/daemon/core/configservices/utilservices/services.py b/daemon/core/configservices/utilservices/services.py index 983f6cff..8013bc41c 100644 --- a/daemon/core/configservices/utilservices/services.py +++ b/daemon/core/configservices/utilservices/services.py @@ -29,8 +29,8 @@ class DefaultRouteService(ConfigService): ifaces = self.node.get_ifaces() if ifaces: iface = ifaces[0] - for x in iface.addrlist: - net = netaddr.IPNetwork(x).cidr + for ip in iface.all_ips(): + net = ip.cidr if net.size > 1: router = net[1] routes.append(str(router)) @@ -76,15 +76,14 @@ class StaticRouteService(ConfigService): def data(self) -> Dict[str, Any]: routes = [] for iface in self.node.get_ifaces(control=False): - for x in iface.addrlist: - addr = x.split("/")[0] - if netaddr.valid_ipv6(addr): + for ip in iface.all_ips(): + address = str(ip.ip) + if netaddr.valid_ipv6(address): dst = "3ffe:4::/64" else: dst = "10.9.8.0/24" - net = netaddr.IPNetwork(x) - if net[-2] != net[1]: - routes.append((dst, net[1])) + if ip[-2] != ip[1]: + routes.append((dst, ip[1])) return dict(routes=routes) @@ -149,15 +148,12 @@ class DhcpService(ConfigService): def data(self) -> Dict[str, Any]: subnets = [] for iface in self.node.get_ifaces(control=False): - for x in iface.addrlist: - addr = x.split("/")[0] - if netaddr.valid_ipv4(addr): - net = netaddr.IPNetwork(x) - # divide the address space in half - index = (net.size - 2) / 2 - rangelow = net[index] - rangehigh = net[-2] - subnets.append((net.ip, net.netmask, rangelow, rangehigh, addr)) + for ip4 in iface.ip4s: + # divide the address space in half + index = (ip4.size - 2) / 2 + rangelow = ip4[index] + rangehigh = ip4[-2] + subnets.append((ip4.ip, ip4.netmask, rangelow, rangehigh, str(ip4.ip))) return dict(subnets=subnets) @@ -238,10 +234,8 @@ class RadvdService(ConfigService): ifaces = [] for iface in self.node.get_ifaces(control=False): prefixes = [] - for x in iface.addrlist: - addr = x.split("/")[0] - if netaddr.valid_ipv6(addr): - prefixes.append(x) + for ip6 in iface.ip6s: + prefixes.append(str(ip6)) if not prefixes: continue ifaces.append((iface.name, prefixes)) diff --git a/daemon/core/emane/linkmonitor.py b/daemon/core/emane/linkmonitor.py index 1a9ac41a..295aaa1e 100644 --- a/daemon/core/emane/linkmonitor.py +++ b/daemon/core/emane/linkmonitor.py @@ -4,7 +4,6 @@ import threading import time from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple -import netaddr from lxml import etree from core.emulator.data import LinkData @@ -214,13 +213,12 @@ class EmaneLinkMonitor: for node in nodes: for iface in node.get_ifaces(): if isinstance(iface.net, CtrlNet): - ip4 = None - for x in iface.addrlist: - address, prefix = x.split("/") - if netaddr.valid_ipv4(address): - ip4 = address + address = None + ip4 = iface.get_ip4() if ip4: - addresses.append(ip4) + address = str(ip4.ip) + if address: + addresses.append(address) break return addresses diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 0b97da93..b0507269 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -1548,9 +1548,8 @@ class Session: entries = [] for iface in control_net.get_ifaces(): name = iface.node.name - for address in iface.addrlist: - address = address.split("/")[0] - entries.append(f"{address} {name}") + for ip in iface.all_ips(): + entries.append(f"{ip.ip} {name}") logging.info("Adding %d /etc/hosts file entries.", len(entries)) utils.file_munge("/etc/hosts", header, "\n".join(entries) + "\n") diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 2c8ca06c..90be59af 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -1053,18 +1053,17 @@ class CoreNetworkBase(NodeBase): if uni: unidirectional = 1 - iface2 = InterfaceData( + iface2_data = InterfaceData( id=linked_node.get_iface_id(iface), name=iface.name, mac=iface.mac ) - for address in iface.addrlist: - ip, _sep, mask = address.partition("/") - mask = int(mask) - if netaddr.valid_ipv4(ip): - iface2.ip4 = ip - iface2.ip4_mask = mask - else: - iface2.ip6 = ip - iface2.ip6_mask = mask + ip4 = iface.get_ip4() + if ip4: + iface2_data.ip4 = str(ip4.ip) + iface2_data.ip4_mask = ip4.prefixlen + ip6 = iface.get_ip6() + if ip6: + iface2_data.ip6 = str(ip6.ip) + iface2_data.ip6_mask = ip6.prefixlen options_data = iface.get_link_options(unidirectional) link_data = LinkData( @@ -1072,7 +1071,7 @@ class CoreNetworkBase(NodeBase): type=self.linktype, node1_id=self.id, node2_id=linked_node.id, - iface2=iface2, + iface2=iface2_data, options=options_data, ) all_links.append(link_data) diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index 42522362..c1603a21 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -6,6 +6,8 @@ import logging import time from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple +import netaddr + from core import utils from core.emulator.data import LinkOptions from core.emulator.enumerations import TransportType @@ -52,7 +54,8 @@ class CoreInterface: self.net: Optional[CoreNetworkBase] = None self.othernet: Optional[CoreNetworkBase] = None self._params: Dict[str, float] = {} - self.addrlist: List[str] = [] + self.ip4s: List[netaddr.IPNetwork] = [] + self.ip6s: List[netaddr.IPNetwork] = [] self.mac: Optional[str] = None # placeholder position hook self.poshook: Callable[[CoreInterface], None] = lambda x: None @@ -131,15 +134,22 @@ class CoreInterface: if self.net is not None: self.net.detach(self) - def addaddr(self, addr: str) -> None: + def addaddr(self, address: str) -> None: """ - Add address. + Add ip address in the format "10.0.0.1/24". - :param addr: address to add + :param address: address to add :return: nothing """ - addr = utils.validate_ip(addr) - self.addrlist.append(addr) + try: + ip = netaddr.IPNetwork(address) + value = str(ip.ip) + if netaddr.valid_ipv4(value): + self.ip4s.append(ip) + else: + self.ip6s.append(ip) + except netaddr.AddrFormatError: + raise CoreError(f"adding invalid address {address}") def deladdr(self, addr: str) -> None: """ @@ -148,7 +158,23 @@ class CoreInterface: :param addr: address to delete :return: nothing """ - self.addrlist.remove(addr) + if netaddr.valid_ipv4(addr): + ip4 = netaddr.IPNetwork(addr) + self.ip4s.remove(ip4) + elif netaddr.valid_ipv6(addr): + ip6 = netaddr.IPNetwork(addr) + self.ip6s.remove(ip6) + else: + raise CoreError(f"deleting invalid address {addr}") + + def get_ip4(self) -> Optional[netaddr.IPNetwork]: + return next(iter(self.ip4s), None) + + def get_ip6(self) -> Optional[netaddr.IPNetwork]: + return next(iter(self.ip6s), None) + + def all_ips(self) -> List[netaddr.IPNetwork]: + return self.ip4s + self.ip6s def set_mac(self, mac: str) -> None: """ @@ -487,13 +513,13 @@ class TunTap(CoreInterface): def setaddrs(self) -> None: """ - Set interface addresses based on self.addrlist. + Set interface addresses. :return: nothing """ self.waitfordevicenode() - for addr in self.addrlist: - self.node.node_net_client.create_address(self.name, str(addr)) + for ip in self.all_ips(): + self.node.node_net_client.create_address(self.name, str(ip)) class GreTap(CoreInterface): diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 62443fb8..3f4ebfba 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -881,28 +881,26 @@ class PtpNet(CoreNetwork): iface1_data = InterfaceData( id=iface1.node.get_iface_id(iface1), name=iface1.name, mac=iface1.mac ) - for address in iface1.addrlist: - ip, _sep, mask = address.partition("/") - mask = int(mask) - if netaddr.valid_ipv4(ip): - iface1.ip4 = ip - iface1.ip4_mask = mask - else: - iface1.ip6 = ip - iface1.ip6_mask = mask + ip4 = iface1.get_ip4() + if ip4: + iface1_data.ip4 = str(ip4.ip) + iface1_data.ip4_mask = ip4.prefixlen + ip6 = iface1.get_ip6() + if ip6: + iface1_data.ip6 = str(ip6.ip) + iface1_data.ip6_mask = ip6.prefixlen iface2_data = InterfaceData( id=iface2.node.get_iface_id(iface2), name=iface2.name, mac=iface2.mac ) - for address in iface2.addrlist: - ip, _sep, mask = address.partition("/") - mask = int(mask) - if netaddr.valid_ipv4(ip): - iface2.ip4 = ip - iface2.ip4_mask = mask - else: - iface2.ip6 = ip - iface2.ip6_mask = mask + ip4 = iface2.get_ip4() + if ip4: + iface2_data.ip4 = str(ip4.ip) + iface2_data.ip4_mask = ip4.prefixlen + ip6 = iface2.get_ip6() + if ip6: + iface2_data.ip6 = str(ip6.ip) + iface2_data.ip6_mask = ip6.prefixlen options_data = iface1.get_link_options(unidirectional) link_data = LinkData( diff --git a/daemon/core/services/bird.py b/daemon/core/services/bird.py index a5052942..ffb177f3 100644 --- a/daemon/core/services/bird.py +++ b/daemon/core/services/bird.py @@ -3,8 +3,6 @@ bird.py: defines routing services provided by the BIRD Internet Routing Daemon. """ from typing import Optional, Tuple -import netaddr - from core.nodes.base import CoreNode from core.services.coreservices import CoreService @@ -39,10 +37,9 @@ class Bird(CoreService): Helper to return the first IPv4 address of a node as its router ID. """ for iface in node.get_ifaces(control=False): - for a in iface.addrlist: - a = a.split("/")[0] - if netaddr.valid_ipv4(a): - return a + ip4 = iface.get_ip4() + if ip4: + return str(ip4.ip) return "0.0.0.0" @classmethod diff --git a/daemon/core/services/frr.py b/daemon/core/services/frr.py index e75d8f56..6b9ada3c 100644 --- a/daemon/core/services/frr.py +++ b/daemon/core/services/frr.py @@ -67,7 +67,7 @@ class FRRZebra(CoreService): # include control interfaces in addressing but not routing daemons if hasattr(iface, "control") and iface.control is True: cfg += " " - cfg += "\n ".join(map(cls.addrstr, iface.addrlist)) + cfg += "\n ".join(map(cls.addrstr, iface.all_ips())) cfg += "\n" continue cfgv4 = "" @@ -87,19 +87,13 @@ class FRRZebra(CoreService): cfgv4 += iface_config if want_ipv4: - ipv4list = filter( - lambda x: netaddr.valid_ipv4(x.split("/")[0]), iface.addrlist - ) cfg += " " - cfg += "\n ".join(map(cls.addrstr, ipv4list)) + cfg += "\n ".join(map(cls.addrstr, iface.ip4s)) cfg += "\n" cfg += cfgv4 if want_ipv6: - ipv6list = filter( - lambda x: netaddr.valid_ipv6(x.split("/")[0]), iface.addrlist - ) cfg += " " - cfg += "\n ".join(map(cls.addrstr, ipv6list)) + cfg += "\n ".join(map(cls.addrstr, iface.ip6s)) cfg += "\n" cfg += cfgv6 cfg += "!\n" @@ -111,17 +105,17 @@ class FRRZebra(CoreService): return cfg @staticmethod - def addrstr(x: str) -> str: + def addrstr(ip: netaddr.IPNetwork) -> str: """ helper for mapping IP addresses to zebra config statements """ - addr = x.split("/")[0] - if netaddr.valid_ipv4(addr): - return "ip address %s" % x - elif netaddr.valid_ipv6(addr): - return "ipv6 address %s" % x + address = str(ip.ip) + if netaddr.valid_ipv4(address): + return "ip address %s" % ip + elif netaddr.valid_ipv6(address): + return "ipv6 address %s" % ip else: - raise ValueError("invalid address: %s", x) + raise ValueError("invalid address: %s", ip) @classmethod def generate_frr_boot(cls, node: CoreNode) -> str: @@ -333,10 +327,9 @@ class FrrService(CoreService): Helper to return the first IPv4 address of a node as its router ID. """ for iface in node.get_ifaces(control=False): - for a in iface.addrlist: - a = a.split("/")[0] - if netaddr.valid_ipv4(a): - return a + ip4 = iface.get_ip4() + if ip4: + return str(ip4.ip) return "0.0.0.0" @staticmethod @@ -413,11 +406,8 @@ class FRROspfv2(FrrService): cfg += " router-id %s\n" % rtrid # network 10.0.0.0/24 area 0 for iface in node.get_ifaces(control=False): - for a in iface.addrlist: - addr = a.split("/")[0] - if not netaddr.valid_ipv4(addr): - continue - cfg += " network %s area 0\n" % a + for ip4 in iface.ip4s: + cfg += f" network {ip4} area 0\n" cfg += "!\n" return cfg diff --git a/daemon/core/services/nrl.py b/daemon/core/services/nrl.py index 9933b130..697f4eee 100644 --- a/daemon/core/services/nrl.py +++ b/daemon/core/services/nrl.py @@ -4,8 +4,6 @@ nrl.py: defines services provided by NRL protolib tools hosted here: """ from typing import Optional, Tuple -import netaddr - from core import utils from core.nodes.base import CoreNode from core.services.coreservices import CoreService @@ -32,10 +30,9 @@ class NrlService(CoreService): interface's prefix length, so e.g. '/32' can turn into '/24'. """ for iface in node.get_ifaces(control=False): - for a in iface.addrlist: - a = a.split("/")[0] - if netaddr.valid_ipv4(a): - return f"{a}/{prefixlen}" + ip4 = iface.get_ip4() + if ip4: + return f"{ip4.ip}/{prefixlen}" return "0.0.0.0/%s" % prefixlen diff --git a/daemon/core/services/quagga.py b/daemon/core/services/quagga.py index 30d14353..7f717e59 100644 --- a/daemon/core/services/quagga.py +++ b/daemon/core/services/quagga.py @@ -64,7 +64,7 @@ class Zebra(CoreService): # include control interfaces in addressing but not routing daemons if getattr(iface, "control", False): cfg += " " - cfg += "\n ".join(map(cls.addrstr, iface.addrlist)) + cfg += "\n ".join(map(cls.addrstr, iface.all_ips())) cfg += "\n" continue cfgv4 = "" @@ -86,19 +86,13 @@ class Zebra(CoreService): cfgv4 += iface_config if want_ipv4: - ipv4list = filter( - lambda x: netaddr.valid_ipv4(x.split("/")[0]), iface.addrlist - ) cfg += " " - cfg += "\n ".join(map(cls.addrstr, ipv4list)) + cfg += "\n ".join(map(cls.addrstr, iface.ip4s)) cfg += "\n" cfg += cfgv4 if want_ipv6: - ipv6list = filter( - lambda x: netaddr.valid_ipv6(x.split("/")[0]), iface.addrlist - ) cfg += " " - cfg += "\n ".join(map(cls.addrstr, ipv6list)) + cfg += "\n ".join(map(cls.addrstr, iface.ip6s)) cfg += "\n" cfg += cfgv6 cfg += "!\n" @@ -112,17 +106,17 @@ class Zebra(CoreService): return cfg @staticmethod - def addrstr(x: str) -> str: + def addrstr(ip: netaddr.IPNetwork) -> str: """ helper for mapping IP addresses to zebra config statements """ - addr = x.split("/")[0] - if netaddr.valid_ipv4(addr): - return "ip address %s" % x - elif netaddr.valid_ipv6(addr): - return "ipv6 address %s" % x + address = str(ip.ip) + if netaddr.valid_ipv4(address): + return "ip address %s" % ip + elif netaddr.valid_ipv6(address): + return "ipv6 address %s" % ip else: - raise ValueError("invalid address: %s", x) + raise ValueError("invalid address: %s", ip) @classmethod def generate_quagga_boot(cls, node: CoreNode) -> str: @@ -255,10 +249,9 @@ class QuaggaService(CoreService): Helper to return the first IPv4 address of a node as its router ID. """ for iface in node.get_ifaces(control=False): - for a in iface.addrlist: - a = a.split("/")[0] - if netaddr.valid_ipv4(a): - return a + ip4 = iface.get_ip4() + if ip4: + return str(ip4.ip) return f"0.0.0.{node.id:d}" @staticmethod @@ -335,10 +328,8 @@ class Ospfv2(QuaggaService): cfg += " router-id %s\n" % rtrid # network 10.0.0.0/24 area 0 for iface in node.get_ifaces(control=False): - for a in iface.addrlist: - addr = a.split("/")[0] - if netaddr.valid_ipv4(addr): - cfg += " network %s area 0\n" % a + for ip4 in iface.ip4s: + cfg += f" network {ip4} area 0\n" cfg += "!\n" return cfg diff --git a/daemon/core/services/sdn.py b/daemon/core/services/sdn.py index 1f17201d..ef077662 100644 --- a/daemon/core/services/sdn.py +++ b/daemon/core/services/sdn.py @@ -5,8 +5,6 @@ sdn.py defines services to start Open vSwitch and the Ryu SDN Controller. import re from typing import Tuple -import netaddr - from core.nodes.base import CoreNode from core.services.coreservices import CoreService @@ -65,18 +63,14 @@ class OvsService(SdnService): # remove ip address of eths because quagga/zebra will assign same IPs to rtr interfaces # or assign them manually to rtr interfaces if zebra is not running - for addr in iface.addrlist: - addr = addr.split("/")[0] - if netaddr.valid_ipv4(addr): - cfg += "ip addr del %s dev %s\n" % (addr, iface.name) - if has_zebra == 0: - cfg += "ip addr add %s dev rtr%s\n" % (addr, ifnum) - elif netaddr.valid_ipv6(addr): - cfg += "ip -6 addr del %s dev %s\n" % (addr, iface.name) - if has_zebra == 0: - cfg += "ip -6 addr add %s dev rtr%s\n" % (addr, ifnum) - else: - raise ValueError("invalid address: %s" % addr) + for ip4 in iface.ip4s: + cfg += "ip addr del %s dev %s\n" % (ip4.ip, iface.name) + if has_zebra == 0: + cfg += "ip addr add %s dev rtr%s\n" % (ip4.ip, ifnum) + for ip6 in iface.ip6s: + cfg += "ip -6 addr del %s dev %s\n" % (ip6.ip, iface.name) + if has_zebra == 0: + cfg += "ip -6 addr add %s dev rtr%s\n" % (ip6.ip, ifnum) # add interfaces to bridge # Make port numbers explicit so they're easier to follow in reading the script diff --git a/daemon/core/services/utility.py b/daemon/core/services/utility.py index a44037f6..5efade1a 100644 --- a/daemon/core/services/utility.py +++ b/daemon/core/services/utility.py @@ -74,8 +74,8 @@ class DefaultRouteService(UtilService): ifaces = node.get_ifaces() if ifaces: iface = ifaces[0] - for x in iface.addrlist: - net = netaddr.IPNetwork(x).cidr + for ip in iface.all_ips(): + net = ip.cidr if net.size > 1: router = net[1] routes.append(str(router)) @@ -118,23 +118,22 @@ class StaticRouteService(UtilService): cfg += "# NOTE: this service must be customized to be of any use\n" cfg += "# Below are samples that you can uncomment and edit.\n#\n" for iface in node.get_ifaces(control=False): - cfg += "\n".join(map(cls.routestr, iface.addrlist)) + cfg += "\n".join(map(cls.routestr, iface.all_ips())) cfg += "\n" return cfg @staticmethod - def routestr(x: str) -> str: - addr = x.split("/")[0] - if netaddr.valid_ipv6(addr): + def routestr(ip: netaddr.IPNetwork) -> str: + address = str(ip.ip) + if netaddr.valid_ipv6(address): dst = "3ffe:4::/64" else: dst = "10.9.8.0/24" - net = netaddr.IPNetwork(x) - if net[-2] == net[1]: + if ip[-2] == ip[1]: return "" else: rtcmd = "#/sbin/ip route add %s via" % dst - return "%s %s" % (rtcmd, net[1]) + return "%s %s" % (rtcmd, ip[1]) class SshService(UtilService): @@ -242,25 +241,24 @@ max-lease-time 7200; ddns-update-style none; """ for iface in node.get_ifaces(control=False): - cfg += "\n".join(map(cls.subnetentry, iface.addrlist)) + cfg += "\n".join(map(cls.subnetentry, iface.all_ips())) cfg += "\n" return cfg @staticmethod - def subnetentry(x: str) -> str: + def subnetentry(ip: netaddr.IPNetwork) -> str: """ Generate a subnet declaration block given an IPv4 prefix string for inclusion in the dhcpd3 config file. """ - addr = x.split("/")[0] - if netaddr.valid_ipv6(addr): + address = str(ip.ip) + if netaddr.valid_ipv6(address): return "" else: - net = netaddr.IPNetwork(x) # divide the address space in half - index = (net.size - 2) / 2 - rangelow = net[index] - rangehigh = net[-2] + index = (ip.size - 2) / 2 + rangelow = ip[index] + rangehigh = ip[-2] return """ subnet %s netmask %s { pool { @@ -270,11 +268,11 @@ subnet %s netmask %s { } } """ % ( - net.ip, - net.netmask, + ip.ip, + ip.netmask, rangelow, rangehigh, - addr, + address, ) @@ -557,7 +555,10 @@ export LANG % node.name ) for iface in node.get_ifaces(control=False): - body += "
  • %s - %s
  • \n" % (iface.name, iface.addrlist) + body += "
  • %s - %s
  • \n" % ( + iface.name, + [str(x) for x in iface.all_ips()], + ) return "%s" % body @@ -625,7 +626,7 @@ class RadvdService(UtilService): """ cfg = "# auto-generated by RADVD service (utility.py)\n" for iface in node.get_ifaces(control=False): - prefixes = list(map(cls.subnetentry, iface.addrlist)) + prefixes = list(map(cls.subnetentry, iface.all_ips())) if len(prefixes) < 1: continue cfg += ( @@ -658,14 +659,14 @@ interface %s return cfg @staticmethod - def subnetentry(x: str) -> str: + def subnetentry(ip: netaddr.IPNetwork) -> str: """ Generate a subnet declaration block given an IPv6 prefix string for inclusion in the RADVD config file. """ - addr = x.split("/")[0] - if netaddr.valid_ipv6(addr): - return x + address = str(ip.ip) + if netaddr.valid_ipv6(address): + return str(ip) else: return "" diff --git a/daemon/core/services/xorp.py b/daemon/core/services/xorp.py index 42082377..7c24478a 100644 --- a/daemon/core/services/xorp.py +++ b/daemon/core/services/xorp.py @@ -40,7 +40,7 @@ class XorpRtrmgr(CoreService): for iface in node.get_ifaces(): cfg += " interface %s {\n" % iface.name cfg += "\tvif %s {\n" % iface.name - cfg += "".join(map(cls.addrstr, iface.addrlist)) + cfg += "".join(map(cls.addrstr, iface.all_ips())) cfg += cls.lladdrstr(iface) cfg += "\t}\n" cfg += " }\n" @@ -55,13 +55,12 @@ class XorpRtrmgr(CoreService): return cfg @staticmethod - def addrstr(x: str) -> str: + def addrstr(ip: netaddr.IPNetwork) -> str: """ helper for mapping IP addresses to XORP config statements """ - addr, plen = x.split("/") - cfg = "\t address %s {\n" % addr - cfg += "\t\tprefix-length: %s\n" % plen + cfg = "\t address %s {\n" % ip.ip + cfg += "\t\tprefix-length: %s\n" % ip.prefixlen cfg += "\t }\n" return cfg @@ -145,10 +144,9 @@ class XorpService(CoreService): Helper to return the first IPv4 address of a node as its router ID. """ for iface in node.get_ifaces(control=False): - for a in iface.addrlist: - a = a.split("/")[0] - if netaddr.valid_ipv4(a): - return a + ip4 = iface.get_ip4() + if ip4: + return str(ip4.ip) return "0.0.0.0" @classmethod @@ -180,11 +178,8 @@ class XorpOspfv2(XorpService): for iface in node.get_ifaces(control=False): cfg += "\t interface %s {\n" % iface.name cfg += "\t\tvif %s {\n" % iface.name - for a in iface.addrlist: - addr = a.split("/")[0] - if not netaddr.valid_ipv4(addr): - continue - cfg += "\t\t address %s {\n" % addr + for ip4 in iface.ip4s: + cfg += "\t\t address %s {\n" % ip4.ip cfg += "\t\t }\n" cfg += "\t\t}\n" cfg += "\t }\n" @@ -269,11 +264,8 @@ class XorpRip(XorpService): for iface in node.get_ifaces(control=False): cfg += "\tinterface %s {\n" % iface.name cfg += "\t vif %s {\n" % iface.name - for a in iface.addrlist: - addr = a.split("/")[0] - if not netaddr.valid_ipv4(addr): - continue - cfg += "\t\taddress %s {\n" % addr + for ip4 in iface.ip4s: + cfg += "\t\taddress %s {\n" % ip4.ip cfg += "\t\t disable: false\n" cfg += "\t\t}\n" cfg += "\t }\n" @@ -435,11 +427,8 @@ class XorpOlsr(XorpService): for iface in node.get_ifaces(control=False): cfg += "\tinterface %s {\n" % iface.name cfg += "\t vif %s {\n" % iface.name - for a in iface.addrlist: - addr = a.split("/")[0] - if not netaddr.valid_ipv4(addr): - continue - cfg += "\t\taddress %s {\n" % addr + for ip4 in iface.ip4s: + cfg += "\t\taddress %s {\n" % ip4.ip cfg += "\t\t}\n" cfg += "\t }\n" cfg += "\t}\n" diff --git a/daemon/core/xml/corexmldeployment.py b/daemon/core/xml/corexmldeployment.py index 7954b71a..d84f2246 100644 --- a/daemon/core/xml/corexmldeployment.py +++ b/daemon/core/xml/corexmldeployment.py @@ -164,6 +164,7 @@ class CoreXmlDeployment: if emane_element is not None: parent_element = emane_element - for address in iface.addrlist: + for ip in iface.all_ips(): + address = str(ip.ip) address_type = get_address_type(address) add_address(parent_element, address_type, address, iface.name) diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index 8af2e895..1e89f5e4 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -87,7 +87,7 @@ class TestNodes: node.addaddr(iface.node_id, addr) # then - assert iface.addrlist[0] == addr + assert str(iface.get_ip4()) == addr def test_node_addaddr_exception(self, session): # given From 20feea8f12fe3abaf598d485f9c38395c0ab6299 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 10:54:58 -0700 Subject: [PATCH 173/929] daemon: refactored usages of addr to ip and updated functions to align --- .../configservices/utilservices/services.py | 4 +- daemon/core/emulator/session.py | 2 +- daemon/core/nodes/base.py | 65 +++++++++-------- daemon/core/nodes/interface.py | 56 ++++++++++----- daemon/core/nodes/network.py | 23 +++--- daemon/core/nodes/physical.py | 71 ++++++++++--------- daemon/core/services/frr.py | 2 +- daemon/core/services/quagga.py | 2 +- daemon/core/services/utility.py | 13 ++-- daemon/core/services/xorp.py | 2 +- daemon/core/xml/corexmldeployment.py | 2 +- daemon/tests/test_nodes.py | 14 ++-- 12 files changed, 138 insertions(+), 118 deletions(-) diff --git a/daemon/core/configservices/utilservices/services.py b/daemon/core/configservices/utilservices/services.py index 8013bc41c..b6bc0eb5 100644 --- a/daemon/core/configservices/utilservices/services.py +++ b/daemon/core/configservices/utilservices/services.py @@ -29,7 +29,7 @@ class DefaultRouteService(ConfigService): ifaces = self.node.get_ifaces() if ifaces: iface = ifaces[0] - for ip in iface.all_ips(): + for ip in iface.ips(): net = ip.cidr if net.size > 1: router = net[1] @@ -76,7 +76,7 @@ class StaticRouteService(ConfigService): def data(self) -> Dict[str, Any]: routes = [] for iface in self.node.get_ifaces(control=False): - for ip in iface.all_ips(): + for ip in iface.ips(): address = str(ip.ip) if netaddr.valid_ipv6(address): dst = "3ffe:4::/64" diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index b0507269..630e1a0f 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -1548,7 +1548,7 @@ class Session: entries = [] for iface in control_net.get_ifaces(): name = iface.node.name - for ip in iface.all_ips(): + for ip in iface.ips(): entries.append(f"{ip.ip} {name}") logging.info("Adding %d /etc/hosts file entries.", len(entries)) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 90be59af..7eff9b12 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -7,7 +7,7 @@ import os import shutil import threading from threading import RLock -from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type, Union import netaddr @@ -138,6 +138,13 @@ class NodeBase(abc.ABC): return self.position.get() def get_iface(self, iface_id: int) -> CoreInterface: + """ + Retrieve interface based on id. + + :param iface_id: id of interface to retrieve + :return: interface + :raises CoreError: when interface does not exist + """ if iface_id not in self.ifaces: raise CoreError(f"node({self.name}) does not have interface({iface_id})") return self.ifaces[iface_id] @@ -436,7 +443,6 @@ class CoreNode(CoreNodeBase): """ apitype: NodeTypes = NodeTypes.DEFAULT - valid_address_types: Set[str] = {"inet", "inet6", "inet6link"} def __init__( self, @@ -750,40 +756,39 @@ class CoreNode(CoreNodeBase): if self.up: self.node_net_client.device_mac(iface.name, mac) - def addaddr(self, iface_id: int, addr: str) -> None: + def add_ip(self, iface_id: int, ip: str) -> None: """ - Add interface address. + Add an ip address to an interface in the format "10.0.0.1/24". :param iface_id: id of interface to add address to - :param addr: address to add to interface - :return: nothing - """ - addr = utils.validate_ip(addr) - iface = self.get_iface(iface_id) - iface.addaddr(addr) - if self.up: - # ipv4 check - broadcast = None - if netaddr.valid_ipv4(addr): - broadcast = "+" - self.node_net_client.create_address(iface.name, addr, broadcast) - - def deladdr(self, iface_id: int, addr: str) -> None: - """ - Delete address from an interface. - - :param iface_id: id of interface to delete address from - :param addr: address to delete from interface + :param ip: address to add to interface :return: nothing + :raises CoreError: when ip address provided is invalid :raises CoreCommandError: when a non-zero exit status occurs """ iface = self.get_iface(iface_id) - try: - iface.deladdr(addr) - except ValueError: - logging.exception("trying to delete unknown address: %s", addr) + iface.add_ip(ip) if self.up: - self.node_net_client.delete_address(iface.name, addr) + # ipv4 check + broadcast = None + if netaddr.valid_ipv4(ip): + broadcast = "+" + self.node_net_client.create_address(iface.name, ip, broadcast) + + def remove_ip(self, iface_id: int, ip: str) -> None: + """ + Remove an ip address from an interface in the format "10.0.0.1/24". + + :param iface_id: id of interface to delete address from + :param ip: ip address to remove from interface + :return: nothing + :raises CoreError: when ip address provided is invalid + :raises CoreCommandError: when a non-zero exit status occurs + """ + iface = self.get_iface(iface_id) + iface.remove_ip(ip) + if self.up: + self.node_net_client.delete_address(iface.name, ip) def ifup(self, iface_id: int) -> None: """ @@ -819,14 +824,14 @@ class CoreNode(CoreNodeBase): iface = self.get_iface(iface_id) iface.set_mac(iface_data.mac) for address in addresses: - iface.addaddr(address) + iface.add_ip(address) else: iface_id = self.newveth(iface_data.id, iface_data.name) self.attachnet(iface_id, net) if iface_data.mac: self.set_mac(iface_id, iface_data.mac) for address in addresses: - self.addaddr(iface_id, address) + self.add_ip(iface_id, address) self.ifup(iface_id) iface = self.get_iface(iface_id) return iface diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index c1603a21..c613f0cd 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -134,46 +134,64 @@ class CoreInterface: if self.net is not None: self.net.detach(self) - def addaddr(self, address: str) -> None: + def add_ip(self, ip: str) -> None: """ Add ip address in the format "10.0.0.1/24". - :param address: address to add + :param ip: ip address to add :return: nothing + :raises CoreError: when ip address provided is invalid """ try: - ip = netaddr.IPNetwork(address) - value = str(ip.ip) - if netaddr.valid_ipv4(value): + ip = netaddr.IPNetwork(ip) + address = str(ip.ip) + if netaddr.valid_ipv4(address): self.ip4s.append(ip) else: self.ip6s.append(ip) except netaddr.AddrFormatError: - raise CoreError(f"adding invalid address {address}") + raise CoreError(f"adding invalid address {ip}") - def deladdr(self, addr: str) -> None: + def remove_ip(self, ip: str) -> None: """ - Delete address. + Remove ip address in the format "10.0.0.1/24". - :param addr: address to delete + :param ip: ip address to delete :return: nothing + :raises CoreError: when ip address provided is invalid """ - if netaddr.valid_ipv4(addr): - ip4 = netaddr.IPNetwork(addr) - self.ip4s.remove(ip4) - elif netaddr.valid_ipv6(addr): - ip6 = netaddr.IPNetwork(addr) - self.ip6s.remove(ip6) - else: - raise CoreError(f"deleting invalid address {addr}") + try: + ip = netaddr.IPNetwork(ip) + address = str(ip.ip) + if netaddr.valid_ipv4(address): + self.ip4s.remove(ip) + else: + self.ip6s.remove(ip) + except (netaddr.AddrFormatError, ValueError): + raise CoreError(f"deleting invalid address {ip}") def get_ip4(self) -> Optional[netaddr.IPNetwork]: + """ + Looks for the first ip4 address. + + :return: ip4 address, None otherwise + """ return next(iter(self.ip4s), None) def get_ip6(self) -> Optional[netaddr.IPNetwork]: + """ + Looks for the first ip6 address. + + :return: ip6 address, None otherwise + """ return next(iter(self.ip6s), None) - def all_ips(self) -> List[netaddr.IPNetwork]: + def ips(self) -> List[netaddr.IPNetwork]: + """ + Retrieve a list of all ip4 and ip6 addresses combined. + + :return: ip4 and ip6 addresses + """ return self.ip4s + self.ip6s def set_mac(self, mac: str) -> None: @@ -518,7 +536,7 @@ class TunTap(CoreInterface): :return: nothing """ self.waitfordevicenode() - for ip in self.all_ips(): + for ip in self.ips(): self.node.node_net_client.create_address(self.name, str(ip)) diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 3f4ebfba..559b7ece 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -575,18 +575,17 @@ class CoreNetwork(CoreNetworkBase): return iface return None - def addrconfig(self, addrlist: List[str]) -> None: + def add_ips(self, ips: List[str]) -> None: """ - Set addresses on the bridge. + Add ip addresses on the bridge in the format "10.0.0.1/24". - :param addrlist: address list + :param ips: ip address to add :return: nothing """ if not self.up: return - - for addr in addrlist: - self.net_client.create_address(self.brname, str(addr)) + for ip in ips: + self.net_client.create_address(self.brname, ip) class GreTapBridge(CoreNetwork): @@ -663,22 +662,22 @@ class GreTapBridge(CoreNetwork): self.gretap = None super().shutdown() - def addrconfig(self, addrlist: List[str]) -> None: + def add_ips(self, ips: List[str]) -> None: """ Set the remote tunnel endpoint. This is a one-time method for creating the GreTap device, which requires the remoteip at startup. The 1st address in the provided list is remoteip, 2nd optionally specifies localip. - :param addrlist: address list + :param ips: address list :return: nothing """ if self.gretap: raise ValueError(f"gretap already exists for {self.name}") - remoteip = addrlist[0].split("/")[0] + remoteip = ips[0].split("/")[0] localip = None - if len(addrlist) > 1: - localip = addrlist[1].split("/")[0] + if len(ips) > 1: + localip = ips[1].split("/")[0] self.gretap = GreTap( session=self.session, remoteip=remoteip, @@ -700,7 +699,7 @@ class GreTapBridge(CoreNetwork): self.grekey = key addresses = iface_data.get_addresses() if addresses: - self.addrconfig(addresses) + self.add_ips(addresses) class CtrlNet(CoreNetwork): diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 0ce8946a..96440bcb 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -75,43 +75,43 @@ class PhysicalNode(CoreNodeBase): :raises CoreCommandError: when a non-zero exit status occurs """ mac = utils.validate_mac(mac) - iface = self.ifaces[iface_id] + iface = self.get_iface(iface_id) iface.set_mac(mac) if self.up: self.net_client.device_mac(iface.name, mac) - def addaddr(self, iface_id: int, addr: str) -> None: + def add_ip(self, iface_id: int, ip: str) -> None: """ - Add an address to an interface. + Add an ip address to an interface in the format "10.0.0.1/24". - :param iface_id: index of interface to add address to - :param addr: address to add + :param iface_id: id of interface to add address to + :param ip: address to add to interface :return: nothing + :raises CoreError: when ip address provided is invalid + :raises CoreCommandError: when a non-zero exit status occurs """ - addr = utils.validate_ip(addr) iface = self.get_iface(iface_id) + iface.add_ip(ip) if self.up: - self.net_client.create_address(iface.name, addr) - iface.addaddr(addr) + self.net_client.create_address(iface.name, ip) - def deladdr(self, iface_id: int, addr: str) -> None: + def remove_ip(self, iface_id: int, ip: str) -> None: """ - Delete an address from an interface. + Remove an ip address from an interface in the format "10.0.0.1/24". - :param iface_id: index of interface to delete - :param addr: address to delete + :param iface_id: id of interface to delete address from + :param ip: ip address to remove from interface :return: nothing + :raises CoreError: when ip address provided is invalid + :raises CoreCommandError: when a non-zero exit status occurs """ - iface = self.ifaces[iface_id] - try: - iface.deladdr(addr) - except ValueError: - logging.exception("trying to delete unknown address: %s", addr) + iface = self.get_iface(iface_id) + iface.remove_ip(ip) if self.up: - self.net_client.delete_address(iface.name, addr) + self.net_client.delete_address(iface.name, ip) def adopt_iface( - self, iface: CoreInterface, iface_id: int, mac: str, addrlist: List[str] + self, iface: CoreInterface, iface_id: int, mac: str, ips: List[str] ) -> None: """ When a link message is received linking this node to another part of @@ -128,8 +128,8 @@ class PhysicalNode(CoreNodeBase): iface.localname = iface.name if mac: self.set_mac(iface_id, mac) - for addr in addrlist: - self.addaddr(iface_id, addr) + for ip in ips: + self.add_ip(iface_id, ip) if self.up: self.net_client.device_up(iface.localname) @@ -317,7 +317,7 @@ class Rj45Node(CoreNodeBase): if net is not None: self.iface.attachnet(net) for addr in iface_data.get_addresses(): - self.addaddr(addr) + self.add_ip(addr) return self.iface def delete_iface(self, iface_id: int) -> None: @@ -348,30 +348,31 @@ class Rj45Node(CoreNodeBase): raise CoreError(f"node({self.name}) does not have interface({iface.name})") return self.iface_id - def addaddr(self, addr: str) -> None: + def add_ip(self, ip: str) -> None: """ - Add address to to network interface. + Add an ip address to an interface in the format "10.0.0.1/24". - :param addr: address to add + :param ip: address to add to interface :return: nothing - :raises CoreCommandError: when there is a command exception + :raises CoreError: when ip address provided is invalid + :raises CoreCommandError: when a non-zero exit status occurs """ - addr = utils.validate_ip(addr) + self.iface.add_ip(ip) if self.up: - self.net_client.create_address(self.name, addr) - self.iface.addaddr(addr) + self.net_client.create_address(self.name, ip) - def deladdr(self, addr: str) -> None: + def remove_ip(self, ip: str) -> None: """ - Delete address from network interface. + Remove an ip address from an interface in the format "10.0.0.1/24". - :param addr: address to delete + :param ip: ip address to remove from interface :return: nothing - :raises CoreCommandError: when there is a command exception + :raises CoreError: when ip address provided is invalid + :raises CoreCommandError: when a non-zero exit status occurs """ + self.iface.remove_ip(ip) if self.up: - self.net_client.delete_address(self.name, addr) - self.iface.deladdr(addr) + self.net_client.delete_address(self.name, ip) def savestate(self) -> None: """ diff --git a/daemon/core/services/frr.py b/daemon/core/services/frr.py index 6b9ada3c..632b4557 100644 --- a/daemon/core/services/frr.py +++ b/daemon/core/services/frr.py @@ -67,7 +67,7 @@ class FRRZebra(CoreService): # include control interfaces in addressing but not routing daemons if hasattr(iface, "control") and iface.control is True: cfg += " " - cfg += "\n ".join(map(cls.addrstr, iface.all_ips())) + cfg += "\n ".join(map(cls.addrstr, iface.ips())) cfg += "\n" continue cfgv4 = "" diff --git a/daemon/core/services/quagga.py b/daemon/core/services/quagga.py index 7f717e59..cb9e6b08 100644 --- a/daemon/core/services/quagga.py +++ b/daemon/core/services/quagga.py @@ -64,7 +64,7 @@ class Zebra(CoreService): # include control interfaces in addressing but not routing daemons if getattr(iface, "control", False): cfg += " " - cfg += "\n ".join(map(cls.addrstr, iface.all_ips())) + cfg += "\n ".join(map(cls.addrstr, iface.ips())) cfg += "\n" continue cfgv4 = "" diff --git a/daemon/core/services/utility.py b/daemon/core/services/utility.py index 5efade1a..414f994e 100644 --- a/daemon/core/services/utility.py +++ b/daemon/core/services/utility.py @@ -74,7 +74,7 @@ class DefaultRouteService(UtilService): ifaces = node.get_ifaces() if ifaces: iface = ifaces[0] - for ip in iface.all_ips(): + for ip in iface.ips(): net = ip.cidr if net.size > 1: router = net[1] @@ -118,7 +118,7 @@ class StaticRouteService(UtilService): cfg += "# NOTE: this service must be customized to be of any use\n" cfg += "# Below are samples that you can uncomment and edit.\n#\n" for iface in node.get_ifaces(control=False): - cfg += "\n".join(map(cls.routestr, iface.all_ips())) + cfg += "\n".join(map(cls.routestr, iface.ips())) cfg += "\n" return cfg @@ -241,7 +241,7 @@ max-lease-time 7200; ddns-update-style none; """ for iface in node.get_ifaces(control=False): - cfg += "\n".join(map(cls.subnetentry, iface.all_ips())) + cfg += "\n".join(map(cls.subnetentry, iface.ips())) cfg += "\n" return cfg @@ -555,10 +555,7 @@ export LANG % node.name ) for iface in node.get_ifaces(control=False): - body += "
  • %s - %s
  • \n" % ( - iface.name, - [str(x) for x in iface.all_ips()], - ) + body += "
  • %s - %s
  • \n" % (iface.name, [str(x) for x in iface.ips()]) return "%s" % body @@ -626,7 +623,7 @@ class RadvdService(UtilService): """ cfg = "# auto-generated by RADVD service (utility.py)\n" for iface in node.get_ifaces(control=False): - prefixes = list(map(cls.subnetentry, iface.all_ips())) + prefixes = list(map(cls.subnetentry, iface.ips())) if len(prefixes) < 1: continue cfg += ( diff --git a/daemon/core/services/xorp.py b/daemon/core/services/xorp.py index 7c24478a..a9687d45 100644 --- a/daemon/core/services/xorp.py +++ b/daemon/core/services/xorp.py @@ -40,7 +40,7 @@ class XorpRtrmgr(CoreService): for iface in node.get_ifaces(): cfg += " interface %s {\n" % iface.name cfg += "\tvif %s {\n" % iface.name - cfg += "".join(map(cls.addrstr, iface.all_ips())) + cfg += "".join(map(cls.addrstr, iface.ips())) cfg += cls.lladdrstr(iface) cfg += "\t}\n" cfg += " }\n" diff --git a/daemon/core/xml/corexmldeployment.py b/daemon/core/xml/corexmldeployment.py index d84f2246..6035bd26 100644 --- a/daemon/core/xml/corexmldeployment.py +++ b/daemon/core/xml/corexmldeployment.py @@ -164,7 +164,7 @@ class CoreXmlDeployment: if emane_element is not None: parent_element = emane_element - for ip in iface.all_ips(): + for ip in iface.ips(): address = str(ip.ip) address_type = get_address_type(address) add_address(parent_element, address_type, address, iface.name) diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index 1e89f5e4..25a62c5f 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -75,31 +75,31 @@ class TestNodes: with pytest.raises(CoreError): node.set_mac(iface.node_id, mac) - def test_node_addaddr(self, session: Session): + def test_node_add_ip(self, session: Session): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) iface_data = InterfaceData() iface = node.new_iface(switch, iface_data) - addr = "192.168.0.1/24" + ip = "192.168.0.1/24" # when - node.addaddr(iface.node_id, addr) + node.add_ip(iface.node_id, ip) # then - assert str(iface.get_ip4()) == addr + assert str(iface.get_ip4()) == ip - def test_node_addaddr_exception(self, session): + def test_node_add_ip_exception(self, session): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) iface_data = InterfaceData() iface = node.new_iface(switch, iface_data) - addr = "256.168.0.1/24" + ip = "256.168.0.1/24" # when with pytest.raises(CoreError): - node.addaddr(iface.node_id, addr) + node.add_ip(iface.node_id, ip) @pytest.mark.parametrize("net_type", NET_TYPES) def test_net(self, session, net_type): From 9e4429fbbc0212b2810a4ff4892f88a300c7739e Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 11:11:45 -0700 Subject: [PATCH 174/929] daemon: refactored InterfaceData.get_addresses to InterfaceData.get_ips --- daemon/core/emane/linkmonitor.py | 2 -- daemon/core/emulator/data.py | 12 ++++++------ daemon/core/nodes/base.py | 10 +++++----- daemon/core/nodes/network.py | 6 +++--- daemon/core/nodes/physical.py | 10 +++++----- 5 files changed, 19 insertions(+), 21 deletions(-) diff --git a/daemon/core/emane/linkmonitor.py b/daemon/core/emane/linkmonitor.py index 295aaa1e..56473f62 100644 --- a/daemon/core/emane/linkmonitor.py +++ b/daemon/core/emane/linkmonitor.py @@ -213,11 +213,9 @@ class EmaneLinkMonitor: for node in nodes: for iface in node.get_ifaces(): if isinstance(iface.net, CtrlNet): - address = None ip4 = iface.get_ip4() if ip4: address = str(ip4.ip) - if address: addresses.append(address) break return addresses diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index 5b6479ae..22d10d2d 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -142,18 +142,18 @@ class InterfaceData: ip6: str = None ip6_mask: int = None - def get_addresses(self) -> List[str]: + def get_ips(self) -> List[str]: """ Returns a list of ip4 and ip6 addresses when present. - :return: list of addresses + :return: list of ip addresses """ - addresses = [] + ips = [] if self.ip4 and self.ip4_mask: - addresses.append(f"{self.ip4}/{self.ip4_mask}") + ips.append(f"{self.ip4}/{self.ip4_mask}") if self.ip6 and self.ip6_mask: - addresses.append(f"{self.ip6}/{self.ip6_mask}") - return addresses + ips.append(f"{self.ip6}/{self.ip6_mask}") + return ips @dataclass diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 7eff9b12..50f19a82 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -811,7 +811,7 @@ class CoreNode(CoreNodeBase): :param iface_data: interface data for new interface :return: interface index """ - addresses = iface_data.get_addresses() + ips = iface_data.get_ips() with self.lock: # TODO: emane specific code if net.is_emane is True: @@ -823,15 +823,15 @@ class CoreNode(CoreNodeBase): self.attachnet(iface_id, net) iface = self.get_iface(iface_id) iface.set_mac(iface_data.mac) - for address in addresses: - iface.add_ip(address) + for ip in ips: + iface.add_ip(ip) else: iface_id = self.newveth(iface_data.id, iface_data.name) self.attachnet(iface_id, net) if iface_data.mac: self.set_mac(iface_id, iface_data.mac) - for address in addresses: - self.add_ip(iface_id, address) + for ip in ips: + self.add_ip(iface_id, ip) self.ifup(iface_id) iface = self.get_iface(iface_id) return iface diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 559b7ece..5b95c3b2 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -697,9 +697,9 @@ class GreTapBridge(CoreNetwork): :return: nothing """ self.grekey = key - addresses = iface_data.get_addresses() - if addresses: - self.add_ips(addresses) + ips = iface_data.get_ips() + if ips: + self.add_ips(ips) class CtrlNet(CoreNetwork): diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 96440bcb..8fd828d8 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -156,7 +156,7 @@ class PhysicalNode(CoreNodeBase): self, net: CoreNetworkBase, iface_data: InterfaceData ) -> CoreInterface: logging.info("creating interface") - addresses = iface_data.get_addresses() + ips = iface_data.get_ips() iface_id = iface_data.id if iface_id is None: iface_id = self.next_iface_id() @@ -167,12 +167,12 @@ class PhysicalNode(CoreNodeBase): # this is reached when this node is linked to a network node # tunnel to net not built yet, so build it now and adopt it _, remote_tap = self.session.distributed.create_gre_tunnel(net, self.server) - self.adopt_iface(remote_tap, iface_id, iface_data.mac, addresses) + self.adopt_iface(remote_tap, iface_id, iface_data.mac, ips) return remote_tap else: # this is reached when configuring services (self.up=False) iface = GreTap(node=self, name=name, session=self.session, start=False) - self.adopt_iface(iface, iface_id, iface_data.mac, addresses) + self.adopt_iface(iface, iface_id, iface_data.mac, ips) return iface def privatedir(self, path: str) -> None: @@ -316,8 +316,8 @@ class Rj45Node(CoreNodeBase): self.iface_id = iface_id if net is not None: self.iface.attachnet(net) - for addr in iface_data.get_addresses(): - self.add_ip(addr) + for ip in iface_data.get_ips(): + self.add_ip(ip) return self.iface def delete_iface(self, iface_id: int) -> None: From 19af9c3f51d7b088fcdaa2f4e7dd3ce670f99b0d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 11:18:39 -0700 Subject: [PATCH 175/929] daemon: added proper checks for FRRService calls --- daemon/core/services/frr.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/daemon/core/services/frr.py b/daemon/core/services/frr.py index 632b4557..13569772 100644 --- a/daemon/core/services/frr.py +++ b/daemon/core/services/frr.py @@ -77,6 +77,8 @@ class FRRZebra(CoreService): for s in node.services: if cls.name not in s.dependencies: continue + if not (isinstance(s, FrrService) or issubclass(s, FrrService)): + continue iface_config = s.generate_frr_iface_config(node, iface) if s.ipv4_routing: want_ipv4 = True @@ -101,6 +103,8 @@ class FRRZebra(CoreService): for s in node.services: if cls.name not in s.dependencies: continue + if not (isinstance(s, FrrService) or issubclass(s, FrrService)): + continue cfg += s.generate_frr_config(node) return cfg From 88fe860f97dd519dbfbfca3f07bdcf20b16ccb58 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 13:25:47 -0700 Subject: [PATCH 176/929] fixed examples using IpPrefixes class --- daemon/examples/python/emane80211.py | 2 +- daemon/examples/python/switch.py | 2 +- daemon/examples/python/wlan.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/examples/python/emane80211.py b/daemon/examples/python/emane80211.py index 9d6def4a..48133ce0 100644 --- a/daemon/examples/python/emane80211.py +++ b/daemon/examples/python/emane80211.py @@ -55,7 +55,7 @@ def main(): # get nodes to run example first_node = session.get_node(1, CoreNode) last_node = session.get_node(NODES, CoreNode) - address = prefixes.ip4_address(first_node) + address = prefixes.ip4_address(first_node.id) logging.info("node %s pinging %s", last_node.name, address) output = last_node.cmd(f"ping -c 3 {address}") logging.info(output) diff --git a/daemon/examples/python/switch.py b/daemon/examples/python/switch.py index f05176a3..c5e62e4a 100644 --- a/daemon/examples/python/switch.py +++ b/daemon/examples/python/switch.py @@ -40,7 +40,7 @@ def main(): # get nodes to run example first_node = session.get_node(1, CoreNode) last_node = session.get_node(NODES, CoreNode) - address = prefixes.ip4_address(first_node) + address = prefixes.ip4_address(first_node.id) logging.info("node %s pinging %s", last_node.name, address) output = last_node.cmd(f"ping -c 3 {address}") logging.info(output) diff --git a/daemon/examples/python/wlan.py b/daemon/examples/python/wlan.py index de26ab97..7c16bad8 100644 --- a/daemon/examples/python/wlan.py +++ b/daemon/examples/python/wlan.py @@ -44,7 +44,7 @@ def main(): # get nodes for example run first_node = session.get_node(1, CoreNode) last_node = session.get_node(NODES, CoreNode) - address = prefixes.ip4_address(first_node) + address = prefixes.ip4_address(first_node.id) logging.info("node %s pinging %s", last_node.name, address) output = last_node.cmd(f"ping -c 3 {address}") logging.info(output) From df9216e0f0cba624137a19e64832a05fdef0fd47 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 13:28:11 -0700 Subject: [PATCH 177/929] updated scripting docs to use new naming and fixed out bad example --- docs/scripting.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/scripting.md b/docs/scripting.md index f65d66a3..06ca483a 100644 --- a/docs/scripting.md +++ b/docs/scripting.md @@ -61,8 +61,8 @@ def main(): # create nodes for _ in range(NODES): node = session.add_node(CoreNode) - interface = prefixes.create_iface(node) - session.add_link(node.id, switch.id, iface1_data=interface) + iface_data = prefixes.create_iface(node) + session.add_link(node.id, switch.id, iface1_data=iface_data) # instantiate session session.instantiate() @@ -137,7 +137,7 @@ session = coreemu.create_session() # create node with custom services options = NodeOptions(services=["ServiceName"]) -node = session.add_node(options=options) +node = session.add_node(CoreNode, options=options) # set custom file data session.services.set_service_file(node.id, "ServiceName", "FileName", "custom file data") From cd6083aed95d2587923b039fcc426fdda506ed9d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 13:44:28 -0700 Subject: [PATCH 178/929] daemon: fixed issue not checking if an emane interface is a TunTap before using a specific function, fixed issue not looking for possible iface specific configuration for external --- daemon/core/emane/nodes.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index 9173fbfc..19d5a9e1 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -17,7 +17,7 @@ from core.emulator.enumerations import ( ) from core.errors import CoreError from core.nodes.base import CoreNetworkBase -from core.nodes.interface import CoreInterface +from core.nodes.interface import CoreInterface, TunTap if TYPE_CHECKING: from core.emane.emanemodel import EmaneModel @@ -151,18 +151,16 @@ class EmaneNet(CoreNetworkBase): warntxt = "unable to publish EMANE events because the eventservice " warntxt += "Python bindings failed to load" logging.error(warntxt) - for iface in self.get_ifaces(): - external = self.session.emane.get_config( - "external", self.id, self.model.name + config = self.session.emane.get_iface_config( + self.id, iface, self.model.name ) - if external == "0": + external = config["external"] + if isinstance(iface, TunTap) and external == "0": iface.setaddrs() - if not self.session.emane.genlocationevents(): iface.poshook = None continue - # at this point we register location handlers for generating # EMANE location events iface.poshook = self.setnemposition From f07176dd43aa06ad6724af5b82dda7b82c5f62f4 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 13:51:11 -0700 Subject: [PATCH 179/929] daemon: provide safe fallback for emane install ifaces, in case external configuration does not exist --- daemon/core/emane/nodes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index 19d5a9e1..1186f928 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -155,7 +155,7 @@ class EmaneNet(CoreNetworkBase): config = self.session.emane.get_iface_config( self.id, iface, self.model.name ) - external = config["external"] + external = config.get("external", "0") if isinstance(iface, TunTap) and external == "0": iface.setaddrs() if not self.session.emane.genlocationevents(): From cfda9509a2020b1cca86c2f7ba016a2ca0eced94 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 13:52:59 -0700 Subject: [PATCH 180/929] daemon: refactored TunTap setaddrs to set_ips to be more consistent with new naming --- daemon/core/emane/nodes.py | 2 +- daemon/core/nodes/interface.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index 1186f928..8cc9cd87 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -157,7 +157,7 @@ class EmaneNet(CoreNetworkBase): ) external = config.get("external", "0") if isinstance(iface, TunTap) and external == "0": - iface.setaddrs() + iface.set_ips() if not self.session.emane.genlocationevents(): iface.poshook = None continue diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index c613f0cd..d0e55c7e 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -529,9 +529,9 @@ class TunTap(CoreInterface): self.node.node_net_client.device_name(self.localname, self.name) self.node.node_net_client.device_up(self.name) - def setaddrs(self) -> None: + def set_ips(self) -> None: """ - Set interface addresses. + Set interface ip addresses. :return: nothing """ From 1829a8e2f8dac16d3fd891f5d44194725c5cc1e0 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 15:21:45 -0700 Subject: [PATCH 181/929] daemon: refactored CoreInterface.mac from a string to a netaddr.EUI object, providing more functionality --- daemon/core/api/grpc/grpcutils.py | 2 +- daemon/core/nodes/base.py | 3 +-- daemon/core/nodes/interface.py | 25 +++++++++++++++---------- daemon/core/nodes/network.py | 4 ++-- daemon/core/nodes/physical.py | 2 -- daemon/core/services/xorp.py | 4 ++-- daemon/core/xml/emanexml.py | 6 +++--- daemon/tests/test_nodes.py | 2 +- 8 files changed, 25 insertions(+), 23 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index adaf2549..b63cb895 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -460,7 +460,7 @@ def iface_to_proto(iface: CoreInterface) -> core_pb2.Interface: id=iface.node_id, net_id=net_id, name=iface.name, - mac=iface.mac, + mac=str(iface.mac), mtu=iface.mtu, flow_id=iface.flow_id, ip4=ip4, diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 50f19a82..aae59b70 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -750,7 +750,6 @@ class CoreNode(CoreNodeBase): :return: nothing :raises CoreCommandError: when a non-zero exit status occurs """ - mac = utils.validate_mac(mac) iface = self.get_iface(iface_id) iface.set_mac(mac) if self.up: @@ -1059,7 +1058,7 @@ class CoreNetworkBase(NodeBase): unidirectional = 1 iface2_data = InterfaceData( - id=linked_node.get_iface_id(iface), name=iface.name, mac=iface.mac + id=linked_node.get_iface_id(iface), name=iface.name, mac=str(iface.mac) ) ip4 = iface.get_ip4() if ip4: diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index d0e55c7e..22ecb620 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -56,7 +56,7 @@ class CoreInterface: self._params: Dict[str, float] = {} self.ip4s: List[netaddr.IPNetwork] = [] self.ip6s: List[netaddr.IPNetwork] = [] - self.mac: Optional[str] = None + self.mac: Optional[netaddr.EUI] = None # placeholder position hook self.poshook: Callable[[CoreInterface], None] = lambda x: None # used with EMANE @@ -149,8 +149,8 @@ class CoreInterface: self.ip4s.append(ip) else: self.ip6s.append(ip) - except netaddr.AddrFormatError: - raise CoreError(f"adding invalid address {ip}") + except netaddr.AddrFormatError as e: + raise CoreError(f"adding invalid address {ip}: {e}") def remove_ip(self, ip: str) -> None: """ @@ -167,8 +167,8 @@ class CoreInterface: self.ip4s.remove(ip) else: self.ip6s.remove(ip) - except (netaddr.AddrFormatError, ValueError): - raise CoreError(f"deleting invalid address {ip}") + except (netaddr.AddrFormatError, ValueError) as e: + raise CoreError(f"deleting invalid address {ip}: {e}") def get_ip4(self) -> Optional[netaddr.IPNetwork]: """ @@ -194,16 +194,21 @@ class CoreInterface: """ return self.ip4s + self.ip6s - def set_mac(self, mac: str) -> None: + def set_mac(self, mac: Optional[str]) -> None: """ Set mac address. - :param mac: mac address to set + :param mac: mac address to set, None for random mac :return: nothing + :raises CoreError: when there is an invalid mac address """ - if mac is not None: - mac = utils.validate_mac(mac) - self.mac = mac + if mac is None: + self.mac = mac + else: + try: + self.mac = netaddr.EUI(mac, dialect=netaddr.mac_unix_expanded) + except netaddr.AddrFormatError as e: + raise CoreError(f"invalid mac address({mac}): {e}") def getparam(self, key: str) -> float: """ diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 5b95c3b2..7d8f805e 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -878,7 +878,7 @@ class PtpNet(CoreNetwork): unidirectional = 1 iface1_data = InterfaceData( - id=iface1.node.get_iface_id(iface1), name=iface1.name, mac=iface1.mac + id=iface1.node.get_iface_id(iface1), name=iface1.name, mac=str(iface1.mac) ) ip4 = iface1.get_ip4() if ip4: @@ -890,7 +890,7 @@ class PtpNet(CoreNetwork): iface1_data.ip6_mask = ip6.prefixlen iface2_data = InterfaceData( - id=iface2.node.get_iface_id(iface2), name=iface2.name, mac=iface2.mac + id=iface2.node.get_iface_id(iface2), name=iface2.name, mac=str(iface2.mac) ) ip4 = iface2.get_ip4() if ip4: diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 8fd828d8..3751d9ee 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -7,7 +7,6 @@ import os import threading from typing import IO, TYPE_CHECKING, List, Optional, Tuple -from core import utils from core.constants import MOUNT_BIN, UMOUNT_BIN from core.emulator.data import InterfaceData, LinkOptions from core.emulator.distributed import DistributedServer @@ -74,7 +73,6 @@ class PhysicalNode(CoreNodeBase): :return: nothing :raises CoreCommandError: when a non-zero exit status occurs """ - mac = utils.validate_mac(mac) iface = self.get_iface(iface_id) iface.set_mac(mac) if self.up: diff --git a/daemon/core/services/xorp.py b/daemon/core/services/xorp.py index a9687d45..485fe159 100644 --- a/daemon/core/services/xorp.py +++ b/daemon/core/services/xorp.py @@ -69,7 +69,7 @@ class XorpRtrmgr(CoreService): """ helper for adding link-local address entries (required by OSPFv3) """ - cfg = "\t address %s {\n" % netaddr.EUI(iface.mac).eui64() + cfg = "\t address %s {\n" % iface.mac.eui64() cfg += "\t\tprefix-length: 64\n" cfg += "\t }\n" return cfg @@ -292,7 +292,7 @@ class XorpRipng(XorpService): for iface in node.get_ifaces(control=False): cfg += "\tinterface %s {\n" % iface.name cfg += "\t vif %s {\n" % iface.name - cfg += "\t\taddress %s {\n" % netaddr.EUI(iface.mac).eui64() + cfg += "\t\taddress %s {\n" % iface.mac.eui64() cfg += "\t\t disable: false\n" cfg += "\t\t}\n" cfg += "\t }\n" diff --git a/daemon/core/xml/emanexml.py b/daemon/core/xml/emanexml.py index d716777b..eece57c9 100644 --- a/daemon/core/xml/emanexml.py +++ b/daemon/core/xml/emanexml.py @@ -230,9 +230,9 @@ def build_node_platform_xml( platform_element.append(nem_element) node.setnemid(iface, nem_id) - macstr = _MAC_PREFIX + ":00:00:" - macstr += f"{(nem_id >> 8) & 0xFF:02X}:{nem_id & 0xFF:02X}" - iface.set_mac(macstr) + mac = _MAC_PREFIX + ":00:00:" + mac += f"{(nem_id >> 8) & 0xFF:02X}:{nem_id & 0xFF:02X}" + iface.set_mac(mac) # increment nem id nem_id += 1 diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index 25a62c5f..a827fe25 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -61,7 +61,7 @@ class TestNodes: node.set_mac(iface.node_id, mac) # then - assert iface.mac == mac + assert str(iface.mac) == mac def test_node_set_mac_exception(self, session: Session): # given From 0d4a360e89319f568b9d10515da0f04781db1d0c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 15:32:17 -0700 Subject: [PATCH 182/929] daemon: removed utils.validate_ip and shifted tests to test_nodes --- daemon/core/utils.py | 14 -------------- daemon/tests/test_nodes.py | 17 ++++++++++++++--- daemon/tests/test_utils.py | 18 ------------------ 3 files changed, 14 insertions(+), 35 deletions(-) diff --git a/daemon/core/utils.py b/daemon/core/utils.py index 3b1ea46a..4b932485 100644 --- a/daemon/core/utils.py +++ b/daemon/core/utils.py @@ -444,17 +444,3 @@ def validate_mac(value: str) -> str: return str(mac) except netaddr.AddrFormatError as e: raise CoreError(f"invalid mac address {value}: {e}") - - -def validate_ip(value: str) -> str: - """ - Validate ip address with prefix and return formatted version. - - :param value: address to validate - :return: formatted ip address - """ - try: - ip = netaddr.IPNetwork(value) - return str(ip) - except (ValueError, netaddr.AddrFormatError) as e: - raise CoreError(f"invalid ip address {value}: {e}") diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index a827fe25..1741622e 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -75,19 +75,30 @@ class TestNodes: with pytest.raises(CoreError): node.set_mac(iface.node_id, mac) - def test_node_add_ip(self, session: Session): + @pytest.mark.parametrize( + "ip,expected,is_ip6", + [ + ("127", "127.0.0.0/32", False), + ("10.0.0.1/24", "10.0.0.1/24", False), + ("2001::", "2001::/128", True), + ("2001::/64", "2001::/64", True), + ], + ) + def test_node_add_ip(self, session: Session, ip: str, expected: str, is_ip6: bool): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) iface_data = InterfaceData() iface = node.new_iface(switch, iface_data) - ip = "192.168.0.1/24" # when node.add_ip(iface.node_id, ip) # then - assert str(iface.get_ip4()) == ip + if is_ip6: + assert str(iface.get_ip6()) == expected + else: + assert str(iface.get_ip4()) == expected def test_node_add_ip_exception(self, session): # given diff --git a/daemon/tests/test_utils.py b/daemon/tests/test_utils.py index 3e43b789..22bf0ee5 100644 --- a/daemon/tests/test_utils.py +++ b/daemon/tests/test_utils.py @@ -25,24 +25,6 @@ class TestUtils: assert len(two_args) == 2 assert len(unicode_args) == 3 - @pytest.mark.parametrize( - "data,expected", - [ - ("127", "127.0.0.0/32"), - ("10.0.0.1/24", "10.0.0.1/24"), - ("2001::", "2001::/128"), - ("2001::/64", "2001::/64"), - ], - ) - def test_validate_ip(self, data: str, expected: str): - value = utils.validate_ip(data) - assert value == expected - - @pytest.mark.parametrize("data", ["256", "1270.0.0.1", "127.0.0.0.1"]) - def test_validate_ip_exception(self, data: str): - with pytest.raises(CoreError): - utils.validate_ip("") - @pytest.mark.parametrize( "data,expected", [ From adfce5263232e89971173b864d6609ef99a1af64 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 15:41:41 -0700 Subject: [PATCH 183/929] daemon: removed utils.validate_mac and shifted tests to test_nodes --- daemon/core/utils.py | 16 +--------------- daemon/tests/test_nodes.py | 18 +++++++++++++----- daemon/tests/test_utils.py | 20 -------------------- 3 files changed, 14 insertions(+), 40 deletions(-) diff --git a/daemon/core/utils.py b/daemon/core/utils.py index 4b932485..0e082187 100644 --- a/daemon/core/utils.py +++ b/daemon/core/utils.py @@ -33,7 +33,7 @@ from typing import ( import netaddr -from core.errors import CoreCommandError, CoreError +from core.errors import CoreCommandError if TYPE_CHECKING: from core.emulator.session import Session @@ -430,17 +430,3 @@ def random_mac() -> str: value |= 0x00163E << 24 mac = netaddr.EUI(value, dialect=netaddr.mac_unix_expanded) return str(mac) - - -def validate_mac(value: str) -> str: - """ - Validate mac and return unix formatted version. - - :param value: address to validate - :return: unix formatted mac - """ - try: - mac = netaddr.EUI(value, dialect=netaddr.mac_unix_expanded) - return str(mac) - except netaddr.AddrFormatError as e: - raise CoreError(f"invalid mac address {value}: {e}") diff --git a/daemon/tests/test_nodes.py b/daemon/tests/test_nodes.py index 1741622e..8ed21f27 100644 --- a/daemon/tests/test_nodes.py +++ b/daemon/tests/test_nodes.py @@ -49,27 +49,35 @@ class TestNodes: with pytest.raises(CoreError): session.get_node(node.id, CoreNode) - def test_node_set_mac(self, session: Session): + @pytest.mark.parametrize( + "mac,expected", + [ + ("AA-AA-AA-FF-FF-FF", "aa:aa:aa:ff:ff:ff"), + ("00:00:00:FF:FF:FF", "00:00:00:ff:ff:ff"), + ], + ) + def test_node_set_mac(self, session: Session, mac: str, expected: str): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) iface_data = InterfaceData() iface = node.new_iface(switch, iface_data) - mac = "aa:aa:aa:ff:ff:ff" # when node.set_mac(iface.node_id, mac) # then - assert str(iface.mac) == mac + assert str(iface.mac) == expected - def test_node_set_mac_exception(self, session: Session): + @pytest.mark.parametrize( + "mac", ["AAA:AA:AA:FF:FF:FF", "AA:AA:AA:FF:FF", "AA/AA/AA/FF/FF/FF"] + ) + def test_node_set_mac_exception(self, session: Session, mac: str): # given node = session.add_node(CoreNode) switch = session.add_node(SwitchNode) iface_data = InterfaceData() iface = node.new_iface(switch, iface_data) - mac = "aa:aa:aa:ff:ff:fff" # when with pytest.raises(CoreError): diff --git a/daemon/tests/test_utils.py b/daemon/tests/test_utils.py index 22bf0ee5..5a4f25a4 100644 --- a/daemon/tests/test_utils.py +++ b/daemon/tests/test_utils.py @@ -1,8 +1,6 @@ import netaddr -import pytest from core import utils -from core.errors import CoreError class TestUtils: @@ -25,24 +23,6 @@ class TestUtils: assert len(two_args) == 2 assert len(unicode_args) == 3 - @pytest.mark.parametrize( - "data,expected", - [ - ("AA-AA-AA-FF-FF-FF", "aa:aa:aa:ff:ff:ff"), - ("00:00:00:FF:FF:FF", "00:00:00:ff:ff:ff"), - ], - ) - def test_validate_mac(self, data: str, expected: str): - value = utils.validate_mac(data) - assert value == expected - - @pytest.mark.parametrize( - "data", ["AAA:AA:AA:FF:FF:FF", "AA:AA:AA:FF:FF", "AA/AA/AA/FF/FF/FF"] - ) - def test_validate_mac_exception(self, data: str): - with pytest.raises(CoreError): - utils.validate_mac(data) - def test_random_mac(self): value = utils.random_mac() assert netaddr.EUI(value) is not None From 0356f3b19c637a3a8f43fccc56e78418d34c63c0 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 22:08:24 -0700 Subject: [PATCH 184/929] pygui: added type hinting to everything under base core.gui --- daemon/core/api/grpc/client.py | 4 +- daemon/core/gui/app.py | 32 ++--- daemon/core/gui/appconfig.py | 136 ++++++++++---------- daemon/core/gui/coreclient.py | 225 ++++++++++++++++++--------------- daemon/core/gui/graph/graph.py | 4 +- daemon/core/gui/images.py | 56 ++++---- daemon/core/gui/interface.py | 40 +++--- daemon/core/gui/menubar.py | 18 +-- daemon/core/gui/nodeutils.py | 60 +++++---- daemon/core/gui/observers.py | 10 +- daemon/core/gui/statusbar.py | 28 ++-- daemon/core/gui/task.py | 20 +-- daemon/core/gui/themes.py | 61 ++++----- daemon/core/gui/toolbar.py | 68 +++++----- daemon/core/gui/tooltip.py | 18 +-- daemon/core/gui/validation.py | 21 ++- daemon/core/gui/widgets.py | 102 ++++++++------- 17 files changed, 473 insertions(+), 430 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index db908e05..5aa6713d 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -436,7 +436,7 @@ class CoreGrpcClient: session_id: int, handler: Callable[[core_pb2.Event], None], events: List[core_pb2.Event] = None, - ) -> Any: + ) -> grpc.Channel: """ Listen for session events. @@ -453,7 +453,7 @@ class CoreGrpcClient: def throughputs( self, session_id: int, handler: Callable[[core_pb2.ThroughputsEvent], None] - ) -> Any: + ) -> grpc.Channel: """ Listen for throughput events with information for interfaces and bridges. diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index c795a46a..cb385e9e 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -3,10 +3,12 @@ import math import tkinter as tk from tkinter import PhotoImage, font, ttk from tkinter.ttk import Progressbar +from typing import Dict, Optional import grpc from core.gui import appconfig, themes +from core.gui.appconfig import GuiConfig from core.gui.coreclient import CoreClient from core.gui.dialogs.error import ErrorDialog from core.gui.graph.graph import CanvasGraph @@ -16,8 +18,8 @@ from core.gui.nodeutils import NodeUtils from core.gui.statusbar import StatusBar from core.gui.toolbar import Toolbar -WIDTH = 1000 -HEIGHT = 800 +WIDTH: int = 1000 +HEIGHT: int = 800 class Application(ttk.Frame): @@ -27,25 +29,25 @@ class Application(ttk.Frame): NodeUtils.setup() # widgets - self.menubar = None - self.toolbar = None - self.right_frame = None - self.canvas = None - self.statusbar = None - self.progress = None + self.menubar: Optional[Menubar] = None + self.toolbar: Optional[Toolbar] = None + self.right_frame: Optional[ttk.Frame] = None + self.canvas: Optional[CanvasGraph] = None + self.statusbar: Optional[StatusBar] = None + self.progress: Optional[Progressbar] = None # fonts - self.fonts_size = None - self.icon_text_font = None - self.edge_font = None + self.fonts_size: Dict[str, int] = {} + self.icon_text_font: Optional[font.Font] = None + self.edge_font: Optional[font.Font] = None # setup - self.guiconfig = appconfig.read() - self.app_scale = self.guiconfig.scale + self.guiconfig: GuiConfig = appconfig.read() + self.app_scale: float = self.guiconfig.scale self.setup_scaling() - self.style = ttk.Style() + self.style: ttk.Style = ttk.Style() self.setup_theme() - self.core = CoreClient(self, proxy) + self.core: CoreClient = CoreClient(self, proxy) self.setup_app() self.draw() self.core.setup() diff --git a/daemon/core/gui/appconfig.py b/daemon/core/gui/appconfig.py index 077f938d..6bc213eb 100644 --- a/daemon/core/gui/appconfig.py +++ b/daemon/core/gui/appconfig.py @@ -1,32 +1,32 @@ import os import shutil from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional, Type import yaml from core.gui import themes -HOME_PATH = Path.home().joinpath(".coregui") -BACKGROUNDS_PATH = HOME_PATH.joinpath("backgrounds") -CUSTOM_EMANE_PATH = HOME_PATH.joinpath("custom_emane") -CUSTOM_SERVICE_PATH = HOME_PATH.joinpath("custom_services") -ICONS_PATH = HOME_PATH.joinpath("icons") -MOBILITY_PATH = HOME_PATH.joinpath("mobility") -XMLS_PATH = HOME_PATH.joinpath("xmls") -CONFIG_PATH = HOME_PATH.joinpath("config.yaml") -LOG_PATH = HOME_PATH.joinpath("gui.log") -SCRIPT_PATH = HOME_PATH.joinpath("scripts") +HOME_PATH: Path = Path.home().joinpath(".coregui") +BACKGROUNDS_PATH: Path = HOME_PATH.joinpath("backgrounds") +CUSTOM_EMANE_PATH: Path = HOME_PATH.joinpath("custom_emane") +CUSTOM_SERVICE_PATH: Path = HOME_PATH.joinpath("custom_services") +ICONS_PATH: Path = HOME_PATH.joinpath("icons") +MOBILITY_PATH: Path = HOME_PATH.joinpath("mobility") +XMLS_PATH: Path = HOME_PATH.joinpath("xmls") +CONFIG_PATH: Path = HOME_PATH.joinpath("config.yaml") +LOG_PATH: Path = HOME_PATH.joinpath("gui.log") +SCRIPT_PATH: Path = HOME_PATH.joinpath("scripts") # local paths -DATA_PATH = Path(__file__).parent.joinpath("data") -LOCAL_ICONS_PATH = DATA_PATH.joinpath("icons").absolute() -LOCAL_BACKGROUND_PATH = DATA_PATH.joinpath("backgrounds").absolute() -LOCAL_XMLS_PATH = DATA_PATH.joinpath("xmls").absolute() -LOCAL_MOBILITY_PATH = DATA_PATH.joinpath("mobility").absolute() +DATA_PATH: Path = Path(__file__).parent.joinpath("data") +LOCAL_ICONS_PATH: Path = DATA_PATH.joinpath("icons").absolute() +LOCAL_BACKGROUND_PATH: Path = DATA_PATH.joinpath("backgrounds").absolute() +LOCAL_XMLS_PATH: Path = DATA_PATH.joinpath("xmls").absolute() +LOCAL_MOBILITY_PATH: Path = DATA_PATH.joinpath("mobility").absolute() # configuration data -TERMINALS = { +TERMINALS: Dict[str, str] = { "xterm": "xterm -e", "aterm": "aterm -e", "eterm": "eterm -e", @@ -36,45 +36,45 @@ TERMINALS = { "xfce4-terminal": "xfce4-terminal -x", "gnome-terminal": "gnome-terminal --window --", } -EDITORS = ["$EDITOR", "vim", "emacs", "gedit", "nano", "vi"] +EDITORS: List[str] = ["$EDITOR", "vim", "emacs", "gedit", "nano", "vi"] class IndentDumper(yaml.Dumper): - def increase_indent(self, flow=False, indentless=False): - return super().increase_indent(flow, False) + def increase_indent(self, flow: bool = False, indentless: bool = False) -> None: + super().increase_indent(flow, False) class CustomNode(yaml.YAMLObject): - yaml_tag = "!CustomNode" - yaml_loader = yaml.SafeLoader + yaml_tag: str = "!CustomNode" + yaml_loader: Type[yaml.SafeLoader] = yaml.SafeLoader def __init__(self, name: str, image: str, services: List[str]) -> None: - self.name = name - self.image = image - self.services = services + self.name: str = name + self.image: str = image + self.services: List[str] = services class CoreServer(yaml.YAMLObject): - yaml_tag = "!CoreServer" - yaml_loader = yaml.SafeLoader + yaml_tag: str = "!CoreServer" + yaml_loader: Type[yaml.SafeLoader] = yaml.SafeLoader def __init__(self, name: str, address: str) -> None: - self.name = name - self.address = address + self.name: str = name + self.address: str = address class Observer(yaml.YAMLObject): - yaml_tag = "!Observer" - yaml_loader = yaml.SafeLoader + yaml_tag: str = "!Observer" + yaml_loader: Type[yaml.SafeLoader] = yaml.SafeLoader def __init__(self, name: str, cmd: str) -> None: - self.name = name - self.cmd = cmd + self.name: str = name + self.cmd: str = cmd class PreferencesConfig(yaml.YAMLObject): - yaml_tag = "!PreferencesConfig" - yaml_loader = yaml.SafeLoader + yaml_tag: str = "!PreferencesConfig" + yaml_loader: Type[yaml.SafeLoader] = yaml.SafeLoader def __init__( self, @@ -85,17 +85,17 @@ class PreferencesConfig(yaml.YAMLObject): width: int = 1000, height: int = 750, ) -> None: - self.theme = theme - self.editor = editor - self.terminal = terminal - self.gui3d = gui3d - self.width = width - self.height = height + self.theme: str = theme + self.editor: str = editor + self.terminal: str = terminal + self.gui3d: str = gui3d + self.width: int = width + self.height: int = height class LocationConfig(yaml.YAMLObject): - yaml_tag = "!LocationConfig" - yaml_loader = yaml.SafeLoader + yaml_tag: str = "!LocationConfig" + yaml_loader: Type[yaml.SafeLoader] = yaml.SafeLoader def __init__( self, @@ -107,18 +107,18 @@ class LocationConfig(yaml.YAMLObject): alt: float = 2.0, scale: float = 150.0, ) -> None: - self.x = x - self.y = y - self.z = z - self.lat = lat - self.lon = lon - self.alt = alt - self.scale = scale + self.x: float = x + self.y: float = y + self.z: float = z + self.lat: float = lat + self.lon: float = lon + self.alt: float = alt + self.scale: float = scale class IpConfigs(yaml.YAMLObject): - yaml_tag = "!IpConfigs" - yaml_loader = yaml.SafeLoader + yaml_tag: str = "!IpConfigs" + yaml_loader: Type[yaml.SafeLoader] = yaml.SafeLoader def __init__( self, @@ -129,21 +129,21 @@ class IpConfigs(yaml.YAMLObject): ) -> None: if ip4s is None: ip4s = ["10.0.0.0", "192.168.0.0", "172.16.0.0"] - self.ip4s = ip4s + self.ip4s: List[str] = ip4s if ip6s is None: ip6s = ["2001::", "2002::", "a::"] - self.ip6s = ip6s + self.ip6s: List[str] = ip6s if ip4 is None: ip4 = self.ip4s[0] - self.ip4 = ip4 + self.ip4: str = ip4 if ip6 is None: ip6 = self.ip6s[0] - self.ip6 = ip6 + self.ip6: str = ip6 class GuiConfig(yaml.YAMLObject): - yaml_tag = "!GuiConfig" - yaml_loader = yaml.SafeLoader + yaml_tag: str = "!GuiConfig" + yaml_loader: Type[yaml.SafeLoader] = yaml.SafeLoader def __init__( self, @@ -159,30 +159,30 @@ class GuiConfig(yaml.YAMLObject): ) -> None: if preferences is None: preferences = PreferencesConfig() - self.preferences = preferences + self.preferences: PreferencesConfig = preferences if location is None: location = LocationConfig() - self.location = location + self.location: LocationConfig = location if servers is None: servers = [] - self.servers = servers + self.servers: List[CoreServer] = servers if nodes is None: nodes = [] - self.nodes = nodes + self.nodes: List[CustomNode] = nodes if recentfiles is None: recentfiles = [] - self.recentfiles = recentfiles + self.recentfiles: List[str] = recentfiles if observers is None: observers = [] - self.observers = observers - self.scale = scale + self.observers: List[Observer] = observers + self.scale: float = scale if ips is None: ips = IpConfigs() - self.ips = ips - self.mac = mac + self.ips: IpConfigs = ips + self.mac: str = mac -def copy_files(current_path, new_path) -> None: +def copy_files(current_path: Path, new_path: Path) -> None: for current_file in current_path.glob("*"): new_file = new_path.joinpath(current_file.name) shutil.copy(current_file, new_file) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 8b0c423c..24708769 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -4,18 +4,41 @@ Incorporate grpc into python tkinter GUI import json import logging import os +import tkinter as tk from pathlib import Path from tkinter import messagebox -from typing import TYPE_CHECKING, Dict, Iterable, List, Optional +from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Tuple import grpc -from core.api.grpc import client, common_pb2, configservices_pb2, core_pb2 +from core.api.grpc import client +from core.api.grpc.common_pb2 import ConfigOption +from core.api.grpc.configservices_pb2 import ConfigService, ConfigServiceConfig +from core.api.grpc.core_pb2 import ( + Event, + ExceptionEvent, + Hook, + Interface, + Link, + LinkEvent, + LinkType, + MessageType, + Node, + NodeEvent, + NodeType, + Position, + SessionLocation, + SessionState, + StartSessionResponse, + StopSessionResponse, + ThroughputsEvent, +) from core.api.grpc.emane_pb2 import EmaneModelConfig from core.api.grpc.mobility_pb2 import MobilityConfig from core.api.grpc.services_pb2 import NodeServiceData, ServiceConfig, ServiceFileConfig from core.api.grpc.wlan_pb2 import WlanConfig from core.gui import appconfig +from core.gui.appconfig import CoreServer from core.gui.dialogs.emaneinstall import EmaneInstallDialog from core.gui.dialogs.error import ErrorDialog from core.gui.dialogs.mobilityplayer import MobilityPlayer @@ -34,47 +57,46 @@ GUI_SOURCE = "gui" class CoreClient: - def __init__(self, app: "Application", proxy: bool): + def __init__(self, app: "Application", proxy: bool) -> None: """ Create a CoreGrpc instance """ - self._client = client.CoreGrpcClient(proxy=proxy) - self.session_id = None - self.node_ids = [] - self.app = app - self.master = app.master - self.services = {} - self.config_services_groups = {} - self.config_services = {} - self.default_services = {} - self.emane_models = [] - self.observer = None + self.app: "Application" = app + self.master: tk.Tk = app.master + self._client: client.CoreGrpcClient = client.CoreGrpcClient(proxy=proxy) + self.session_id: Optional[int] = None + self.services: Dict[str, Set[str]] = {} + self.config_services_groups: Dict[str, Set[str]] = {} + self.config_services: Dict[str, ConfigService] = {} + self.default_services: Dict[NodeType, Set[str]] = {} + self.emane_models: List[str] = [] + self.observer: Optional[str] = None # loaded configuration data - self.servers = {} - self.custom_nodes = {} - self.custom_observers = {} + self.servers: Dict[str, CoreServer] = {} + self.custom_nodes: Dict[str, NodeDraw] = {} + self.custom_observers: Dict[str, str] = {} self.read_config() # helpers - self.iface_to_edge = {} - self.ifaces_manager = InterfaceManager(self.app) + self.iface_to_edge: Dict[Tuple[int, int], Tuple[int, int]] = {} + self.ifaces_manager: InterfaceManager = InterfaceManager(self.app) # session data - self.state = None - self.canvas_nodes = {} - self.location = None - self.links = {} - self.hooks = {} - self.emane_config = None - self.mobility_players = {} - self.handling_throughputs = None - self.handling_events = None - self.xml_dir = None - self.xml_file = None + self.state: Optional[SessionState] = None + self.canvas_nodes: Dict[int, CanvasNode] = {} + self.location: Optional[SessionLocation] = None + self.links: Dict[Tuple[int, int], CanvasEdge] = {} + self.hooks: Dict[str, Hook] = {} + self.emane_config: Dict[str, ConfigOption] = {} + self.mobility_players: Dict[int, MobilityPlayer] = {} + self.handling_throughputs: Optional[grpc.Channel] = None + self.handling_events: Optional[grpc.Channel] = None + self.xml_dir: Optional[str] = None + self.xml_file: Optional[str] = None @property - def client(self): + def client(self) -> client.CoreGrpcClient: if self.session_id: response = self._client.check_session(self.session_id) if not response.result: @@ -89,7 +111,7 @@ class CoreClient: self.enable_throughputs() return self._client - def reset(self): + def reset(self) -> None: # helpers self.ifaces_manager.reset() self.iface_to_edge.clear() @@ -104,14 +126,14 @@ class CoreClient: self.cancel_throughputs() self.cancel_events() - def close_mobility_players(self): + def close_mobility_players(self) -> None: for mobility_player in self.mobility_players.values(): mobility_player.close() - def set_observer(self, value: str): + def set_observer(self, value: Optional[str]) -> None: self.observer = value - def read_config(self): + def read_config(self) -> None: # read distributed servers for server in self.app.guiconfig.servers: self.servers[server.name] = server @@ -125,7 +147,7 @@ class CoreClient: for observer in self.app.guiconfig.observers: self.custom_observers[observer.name] = observer - def handle_events(self, event: core_pb2.Event): + def handle_events(self, event: Event) -> None: if event.session_id != self.session_id: logging.warning( "ignoring event session(%s) current(%s)", @@ -139,7 +161,7 @@ class CoreClient: elif event.HasField("session_event"): logging.info("session event: %s", event) session_event = event.session_event - if session_event.event <= core_pb2.SessionState.SHUTDOWN: + if session_event.event <= SessionState.SHUTDOWN: self.state = event.session_event.event elif session_event.event in {7, 8, 9}: node_id = session_event.node_id @@ -162,7 +184,7 @@ class CoreClient: else: logging.info("unhandled event: %s", event) - def handle_link_event(self, event: core_pb2.LinkEvent): + def handle_link_event(self, event: LinkEvent) -> None: logging.debug("Link event: %s", event) node1_id = event.link.node1_id node2_id = event.link.node2_id @@ -171,16 +193,16 @@ class CoreClient: return canvas_node1 = self.canvas_nodes[node1_id] canvas_node2 = self.canvas_nodes[node2_id] - if event.message_type == core_pb2.MessageType.ADD: + if event.message_type == MessageType.ADD: self.app.canvas.add_wireless_edge(canvas_node1, canvas_node2, event.link) - elif event.message_type == core_pb2.MessageType.DELETE: + elif event.message_type == MessageType.DELETE: self.app.canvas.delete_wireless_edge(canvas_node1, canvas_node2, event.link) - elif event.message_type == core_pb2.MessageType.NONE: + elif event.message_type == MessageType.NONE: self.app.canvas.update_wireless_edge(canvas_node1, canvas_node2, event.link) else: logging.warning("unknown link event: %s", event) - def handle_node_event(self, event: core_pb2.NodeEvent): + def handle_node_event(self, event: NodeEvent) -> None: logging.debug("node event: %s", event) if event.source == GUI_SOURCE: return @@ -190,22 +212,22 @@ class CoreClient: canvas_node = self.canvas_nodes[node_id] canvas_node.move(x, y) - def enable_throughputs(self): + def enable_throughputs(self) -> None: self.handling_throughputs = self.client.throughputs( self.session_id, self.handle_throughputs ) - def cancel_throughputs(self): + def cancel_throughputs(self) -> None: if self.handling_throughputs: self.handling_throughputs.cancel() self.handling_throughputs = None - def cancel_events(self): + def cancel_events(self) -> None: if self.handling_events: self.handling_events.cancel() self.handling_events = None - def handle_throughputs(self, event: core_pb2.ThroughputsEvent): + def handle_throughputs(self, event: ThroughputsEvent) -> None: if event.session_id != self.session_id: logging.warning( "ignoring throughput event session(%s) current(%s)", @@ -216,11 +238,11 @@ class CoreClient: logging.debug("handling throughputs event: %s", event) self.app.after(0, self.app.canvas.set_throughputs, event) - def handle_exception_event(self, event: core_pb2.ExceptionEvent): + def handle_exception_event(self, event: ExceptionEvent) -> None: logging.info("exception event: %s", event) self.app.statusbar.core_alarms.append(event) - def join_session(self, session_id: int, query_location: bool = True): + def join_session(self, session_id: int, query_location: bool = True) -> None: logging.info("join session(%s)", session_id) # update session and title self.session_id = session_id @@ -331,9 +353,9 @@ class CoreClient: self.app.after(0, self.app.joined_session_update) def is_runtime(self) -> bool: - return self.state == core_pb2.SessionState.RUNTIME + return self.state == SessionState.RUNTIME - def parse_metadata(self, config: Dict[str, str]): + def parse_metadata(self, config: Dict[str, str]) -> None: # canvas setting canvas_config = config.get("canvas") logging.debug("canvas metadata: %s", canvas_config) @@ -386,7 +408,7 @@ class CoreClient: except ValueError: logging.exception("unknown shape: %s", shape_type) - def create_new_session(self): + def create_new_session(self) -> None: """ Create a new session """ @@ -394,7 +416,7 @@ class CoreClient: response = self.client.create_session() logging.info("created session: %s", response) location_config = self.app.guiconfig.location - self.location = core_pb2.SessionLocation( + self.location = SessionLocation( x=location_config.x, y=location_config.y, z=location_config.z, @@ -407,7 +429,7 @@ class CoreClient: except grpc.RpcError as e: self.app.show_grpc_exception("New Session Error", e) - def delete_session(self, session_id: int = None): + def delete_session(self, session_id: int = None) -> None: if session_id is None: session_id = self.session_id try: @@ -416,7 +438,7 @@ class CoreClient: except grpc.RpcError as e: self.app.show_grpc_exception("Delete Session Error", e) - def setup(self): + def setup(self) -> None: """ Query sessions, if there exist any, prompt whether to join one """ @@ -451,7 +473,7 @@ class CoreClient: dialog.show() self.app.close() - def edit_node(self, core_node: core_pb2.Node): + def edit_node(self, core_node: Node) -> None: try: self.client.edit_node( self.session_id, core_node.id, core_node.position, source=GUI_SOURCE @@ -459,12 +481,12 @@ class CoreClient: except grpc.RpcError as e: self.app.show_grpc_exception("Edit Node Error", e) - def start_session(self) -> core_pb2.StartSessionResponse: + def start_session(self) -> StartSessionResponse: self.ifaces_manager.reset_mac() nodes = [x.core_node for x in self.canvas_nodes.values()] links = [] for edge in self.links.values(): - link = core_pb2.Link() + link = Link() link.CopyFrom(edge.link) if link.HasField("iface1") and not link.iface1.mac: link.iface1.mac = self.ifaces_manager.next_mac() @@ -485,7 +507,7 @@ class CoreClient: emane_config = {x: self.emane_config[x].value for x in self.emane_config} else: emane_config = None - response = core_pb2.StartSessionResponse(result=False) + response = StartSessionResponse(result=False) try: response = self.client.start_session( self.session_id, @@ -511,10 +533,10 @@ class CoreClient: self.app.show_grpc_exception("Start Session Error", e) return response - def stop_session(self, session_id: int = None) -> core_pb2.StartSessionResponse: + def stop_session(self, session_id: int = None) -> StopSessionResponse: if not session_id: session_id = self.session_id - response = core_pb2.StopSessionResponse(result=False) + response = StopSessionResponse(result=False) try: response = self.client.stop_session(session_id) logging.info("stopped session(%s), result: %s", session_id, response) @@ -522,9 +544,9 @@ class CoreClient: self.app.show_grpc_exception("Stop Session Error", e) return response - def show_mobility_players(self): + def show_mobility_players(self) -> None: for canvas_node in self.canvas_nodes.values(): - if canvas_node.core_node.type != core_pb2.NodeType.WIRELESS_LAN: + if canvas_node.core_node.type != NodeType.WIRELESS_LAN: continue if canvas_node.mobility_config: mobility_player = MobilityPlayer( @@ -534,7 +556,7 @@ class CoreClient: self.mobility_players[node_id] = mobility_player mobility_player.show() - def set_metadata(self): + def set_metadata(self) -> None: # create canvas data wallpaper = None if self.app.canvas.wallpaper_file: @@ -558,7 +580,7 @@ class CoreClient: response = self.client.set_session_metadata(self.session_id, metadata) logging.info("set session metadata %s, result: %s", metadata, response) - def launch_terminal(self, node_id: int): + def launch_terminal(self, node_id: int) -> None: try: terminal = self.app.guiconfig.preferences.terminal if not terminal: @@ -575,12 +597,12 @@ class CoreClient: except grpc.RpcError as e: self.app.show_grpc_exception("Node Terminal Error", e) - def save_xml(self, file_path: str): + def save_xml(self, file_path: str) -> None: """ Save core session as to an xml file """ try: - if self.state != core_pb2.SessionState.RUNTIME: + if self.state != SessionState.RUNTIME: logging.debug("Send session data to the daemon") self.send_data() response = self.client.save_xml(self.session_id, file_path) @@ -588,7 +610,7 @@ class CoreClient: except grpc.RpcError as e: self.app.show_grpc_exception("Save XML Error", e) - def open_xml(self, file_path: str): + def open_xml(self, file_path: str) -> None: """ Open core xml """ @@ -627,7 +649,8 @@ class CoreClient: shutdown=shutdowns, ) logging.info( - "Set %s service for node(%s), files: %s, Startup: %s, Validation: %s, Shutdown: %s, Result: %s", + "Set %s service for node(%s), files: %s, Startup: %s, " + "Validation: %s, Shutdown: %s, Result: %s", service_name, node_id, files, @@ -656,7 +679,7 @@ class CoreClient: def set_node_service_file( self, node_id: int, service_name: str, file_name: str, data: str - ): + ) -> None: response = self.client.set_node_service_file( self.session_id, node_id, service_name, file_name, data ) @@ -669,18 +692,16 @@ class CoreClient: response, ) - def create_nodes_and_links(self): + def create_nodes_and_links(self) -> None: """ create nodes and links that have not been created yet """ node_protos = [x.core_node for x in self.canvas_nodes.values()] link_protos = [x.link for x in self.links.values()] - if self.state != core_pb2.SessionState.DEFINITION: - self.client.set_session_state( - self.session_id, core_pb2.SessionState.DEFINITION - ) + if self.state != SessionState.DEFINITION: + self.client.set_session_state(self.session_id, SessionState.DEFINITION) - self.client.set_session_state(self.session_id, core_pb2.SessionState.DEFINITION) + self.client.set_session_state(self.session_id, SessionState.DEFINITION) for node_proto in node_protos: response = self.client.add_node(self.session_id, node_proto) logging.debug("create node: %s", response) @@ -695,7 +716,7 @@ class CoreClient: ) logging.debug("create link: %s", response) - def send_data(self): + def send_data(self) -> None: """ send to daemon all session info, but don't start the session """ @@ -738,10 +759,9 @@ class CoreClient: if self.emane_config: config = {x: self.emane_config[x].value for x in self.emane_config} self.client.set_emane_config(self.session_id, config) - self.set_metadata() - def close(self): + def close(self) -> None: """ Clean ups when done using grpc """ @@ -760,31 +780,31 @@ class CoreClient: return i def create_node( - self, x: float, y: float, node_type: core_pb2.NodeType, model: str - ) -> Optional[core_pb2.Node]: + self, x: float, y: float, node_type: NodeType, model: str + ) -> Optional[Node]: """ Add node, with information filled in, to grpc manager """ node_id = self.next_node_id() - position = core_pb2.Position(x=x, y=y) + position = Position(x=x, y=y) image = None if NodeUtils.is_image_node(node_type): image = "ubuntu:latest" emane = None - if node_type == core_pb2.NodeType.EMANE: + if node_type == NodeType.EMANE: if not self.emane_models: dialog = EmaneInstallDialog(self.app) dialog.show() return emane = self.emane_models[0] name = f"EMANE{node_id}" - elif node_type == core_pb2.NodeType.WIRELESS_LAN: + elif node_type == NodeType.WIRELESS_LAN: name = f"WLAN{node_id}" - elif node_type in [core_pb2.NodeType.RJ45, core_pb2.NodeType.TUNNEL]: + elif node_type in [NodeType.RJ45, NodeType.TUNNEL]: name = "UNASSIGNED" else: name = f"n{node_id}" - node = core_pb2.Node( + node = Node( id=node_id, type=node_type, name=name, @@ -810,7 +830,7 @@ class CoreClient: ) return node - def deleted_graph_nodes(self, canvas_nodes: List[core_pb2.Node]): + def deleted_graph_nodes(self, canvas_nodes: List[Node]) -> None: """ remove the nodes selected by the user and anything related to that node such as link, configurations, interfaces @@ -826,14 +846,14 @@ class CoreClient: links.append(edge.link) self.ifaces_manager.removed(links) - def create_iface(self, canvas_node: CanvasNode) -> core_pb2.Interface: + def create_iface(self, canvas_node: CanvasNode) -> Interface: node = canvas_node.core_node ip4, ip6 = self.ifaces_manager.get_ips(node) ip4_mask = self.ifaces_manager.ip4_mask ip6_mask = self.ifaces_manager.ip6_mask iface_id = canvas_node.next_iface_id() name = f"eth{iface_id}" - iface = core_pb2.Interface( + iface = Interface( id=iface_id, name=name, ip4=ip4, @@ -852,7 +872,7 @@ class CoreClient: def create_link( self, edge: CanvasEdge, canvas_src_node: CanvasNode, canvas_dst_node: CanvasNode - ): + ) -> None: """ Create core link for a pair of canvas nodes, with token referencing the canvas edge. @@ -873,8 +893,8 @@ class CoreClient: dst_iface = self.create_iface(canvas_dst_node) self.iface_to_edge[(dst_node.id, dst_iface.id)] = edge.token - link = core_pb2.Link( - type=core_pb2.LinkType.WIRED, + link = Link( + type=LinkType.WIRED, node1_id=src_node.id, node2_id=dst_node.id, iface1=src_iface, @@ -896,7 +916,7 @@ class CoreClient: def get_wlan_configs_proto(self) -> List[WlanConfig]: configs = [] for canvas_node in self.canvas_nodes.values(): - if canvas_node.core_node.type != core_pb2.NodeType.WIRELESS_LAN: + if canvas_node.core_node.type != NodeType.WIRELESS_LAN: continue if not canvas_node.wlan_config: continue @@ -910,7 +930,7 @@ class CoreClient: def get_mobility_configs_proto(self) -> List[MobilityConfig]: configs = [] for canvas_node in self.canvas_nodes.values(): - if canvas_node.core_node.type != core_pb2.NodeType.WIRELESS_LAN: + if canvas_node.core_node.type != NodeType.WIRELESS_LAN: continue if not canvas_node.mobility_config: continue @@ -924,7 +944,7 @@ class CoreClient: def get_emane_model_configs_proto(self) -> List[EmaneModelConfig]: configs = [] for canvas_node in self.canvas_nodes.values(): - if canvas_node.core_node.type != core_pb2.NodeType.EMANE: + if canvas_node.core_node.type != NodeType.EMANE: continue node_id = canvas_node.core_node.id for key, config in canvas_node.emane_model_configs.items(): @@ -975,9 +995,7 @@ class CoreClient: configs.append(config_proto) return configs - def get_config_service_configs_proto( - self - ) -> List[configservices_pb2.ConfigServiceConfig]: + def get_config_service_configs_proto(self) -> List[ConfigServiceConfig]: config_service_protos = [] for canvas_node in self.canvas_nodes.values(): if not NodeUtils.is_container_node(canvas_node.core_node.type): @@ -987,7 +1005,7 @@ class CoreClient: node_id = canvas_node.core_node.id for name, service_config in canvas_node.config_service_configs.items(): config = service_config.get("config", {}) - config_proto = configservices_pb2.ConfigServiceConfig( + config_proto = ConfigServiceConfig( node_id=node_id, name=name, templates=service_config["templates"], @@ -1000,7 +1018,7 @@ class CoreClient: logging.info("running node(%s) cmd: %s", node_id, self.observer) return self.client.node_command(self.session_id, node_id, self.observer).output - def get_wlan_config(self, node_id: int) -> Dict[str, common_pb2.ConfigOption]: + def get_wlan_config(self, node_id: int) -> Dict[str, ConfigOption]: response = self.client.get_wlan_config(self.session_id, node_id) config = response.config logging.debug( @@ -1010,7 +1028,7 @@ class CoreClient: ) return dict(config) - def get_mobility_config(self, node_id: int) -> Dict[str, common_pb2.ConfigOption]: + def get_mobility_config(self, node_id: int) -> Dict[str, ConfigOption]: response = self.client.get_mobility_config(self.session_id, node_id) config = response.config logging.debug( @@ -1022,7 +1040,7 @@ class CoreClient: def get_emane_model_config( self, node_id: int, model: str, iface_id: int = None - ) -> Dict[str, common_pb2.ConfigOption]: + ) -> Dict[str, ConfigOption]: if iface_id is None: iface_id = -1 response = self.client.get_emane_model_config( @@ -1030,7 +1048,8 @@ class CoreClient: ) config = response.config logging.debug( - "get emane model config: node id: %s, EMANE model: %s, interface: %s, config: %s", + "get emane model config: node id: %s, EMANE model: %s, " + "interface: %s, config: %s", node_id, model, iface_id, @@ -1038,7 +1057,7 @@ class CoreClient: ) return dict(config) - def execute_script(self, script): + def execute_script(self, script) -> None: response = self.client.execute_script(script) logging.info("execute python script %s", response) if response.session_id != -1: diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 269e3973..834220ea 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -2,7 +2,7 @@ import logging import tkinter as tk from copy import deepcopy from tkinter import BooleanVar -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING, Optional, Tuple from PIL import Image, ImageTk @@ -864,7 +864,7 @@ class CanvasGraph(tk.Canvas): for tag in tags.ORGANIZE_TAGS: self.tag_raise(tag) - def set_wallpaper(self, filename: str): + def set_wallpaper(self, filename: Optional[str]): logging.debug("setting wallpaper: %s", filename) if filename: img = Image.open(filename) diff --git a/daemon/core/gui/images.py b/daemon/core/gui/images.py index 3a953054..22719457 100644 --- a/daemon/core/gui/images.py +++ b/daemon/core/gui/images.py @@ -1,46 +1,44 @@ from enum import Enum from tkinter import messagebox +from typing import Dict, Optional, Tuple -from PIL import Image, ImageTk +from PIL import Image +from PIL.ImageTk import PhotoImage -from core.api.grpc import core_pb2 +from core.api.grpc.core_pb2 import NodeType from core.gui.appconfig import LOCAL_ICONS_PATH class Images: - images = {} + images: Dict[str, str] = {} @classmethod - def create(cls, file_path: str, width: int, height: int = None): + def create(cls, file_path: str, width: int, height: int = None) -> PhotoImage: if height is None: height = width image = Image.open(file_path) image = image.resize((width, height), Image.ANTIALIAS) - return ImageTk.PhotoImage(image) + return PhotoImage(image) @classmethod - def load_all(cls): + def load_all(cls) -> None: for image in LOCAL_ICONS_PATH.glob("*"): cls.images[image.stem] = str(image) @classmethod - def get( - cls, image_enum: Enum, width: int, height: int = None - ) -> ImageTk.PhotoImage: + def get(cls, image_enum: Enum, width: int, height: int = None) -> PhotoImage: file_path = cls.images[image_enum.value] return cls.create(file_path, width, height) @classmethod def get_with_image_file( cls, stem: str, width: int, height: int = None - ) -> ImageTk.PhotoImage: + ) -> PhotoImage: file_path = cls.images[stem] return cls.create(file_path, width, height) @classmethod - def get_custom( - cls, name: str, width: int, height: int = None - ) -> ImageTk.PhotoImage: + def get_custom(cls, name: str, width: int, height: int = None) -> PhotoImage: try: file_path = cls.images[name] return cls.create(file_path, width, height) @@ -95,22 +93,22 @@ class ImageEnum(Enum): class TypeToImage: - type_to_image = { - (core_pb2.NodeType.DEFAULT, "router"): ImageEnum.ROUTER, - (core_pb2.NodeType.DEFAULT, "PC"): ImageEnum.PC, - (core_pb2.NodeType.DEFAULT, "host"): ImageEnum.HOST, - (core_pb2.NodeType.DEFAULT, "mdr"): ImageEnum.MDR, - (core_pb2.NodeType.DEFAULT, "prouter"): ImageEnum.PROUTER, - (core_pb2.NodeType.HUB, ""): ImageEnum.HUB, - (core_pb2.NodeType.SWITCH, ""): ImageEnum.SWITCH, - (core_pb2.NodeType.WIRELESS_LAN, ""): ImageEnum.WLAN, - (core_pb2.NodeType.EMANE, ""): ImageEnum.EMANE, - (core_pb2.NodeType.RJ45, ""): ImageEnum.RJ45, - (core_pb2.NodeType.TUNNEL, ""): ImageEnum.TUNNEL, - (core_pb2.NodeType.DOCKER, ""): ImageEnum.DOCKER, - (core_pb2.NodeType.LXC, ""): ImageEnum.LXC, + type_to_image: Dict[Tuple[NodeType, str], ImageEnum] = { + (NodeType.DEFAULT, "router"): ImageEnum.ROUTER, + (NodeType.DEFAULT, "PC"): ImageEnum.PC, + (NodeType.DEFAULT, "host"): ImageEnum.HOST, + (NodeType.DEFAULT, "mdr"): ImageEnum.MDR, + (NodeType.DEFAULT, "prouter"): ImageEnum.PROUTER, + (NodeType.HUB, ""): ImageEnum.HUB, + (NodeType.SWITCH, ""): ImageEnum.SWITCH, + (NodeType.WIRELESS_LAN, ""): ImageEnum.WLAN, + (NodeType.EMANE, ""): ImageEnum.EMANE, + (NodeType.RJ45, ""): ImageEnum.RJ45, + (NodeType.TUNNEL, ""): ImageEnum.TUNNEL, + (NodeType.DOCKER, ""): ImageEnum.DOCKER, + (NodeType.LXC, ""): ImageEnum.LXC, } @classmethod - def get(cls, node_type, model): - return cls.type_to_image.get((node_type, model), None) + def get(cls, node_type, model) -> Optional[ImageEnum]: + return cls.type_to_image.get((node_type, model)) diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index 6c82ca51..f4f2e3cc 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -1,18 +1,18 @@ import logging -from typing import TYPE_CHECKING, Any, List, Optional, Set, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple import netaddr from netaddr import EUI, IPNetwork +from core.api.grpc.core_pb2 import Interface, Link, Node +from core.gui.graph.node import CanvasNode from core.gui.nodeutils import NodeUtils if TYPE_CHECKING: from core.gui.app import Application - from core.api.grpc import core_pb2 - from core.gui.graph.node import CanvasNode -def get_index(iface: "core_pb2.Interface") -> Optional[int]: +def get_index(iface: Interface) -> Optional[int]: if not iface.ip4: return None net = netaddr.IPNetwork(f"{iface.ip4}/{iface.ip4_mask}") @@ -44,18 +44,18 @@ class Subnets: class InterfaceManager: def __init__(self, app: "Application") -> None: - self.app = app + self.app: "Application" = app ip4 = self.app.guiconfig.ips.ip4 ip6 = self.app.guiconfig.ips.ip6 - self.ip4_mask = 24 - self.ip6_mask = 64 - self.ip4_subnets = IPNetwork(f"{ip4}/{self.ip4_mask}") - self.ip6_subnets = IPNetwork(f"{ip6}/{self.ip6_mask}") + self.ip4_mask: int = 24 + self.ip6_mask: int = 64 + self.ip4_subnets: IPNetwork = IPNetwork(f"{ip4}/{self.ip4_mask}") + self.ip6_subnets: IPNetwork = IPNetwork(f"{ip6}/{self.ip6_mask}") mac = self.app.guiconfig.mac - self.mac = EUI(mac, dialect=netaddr.mac_unix_expanded) - self.current_mac = None - self.current_subnets = None - self.used_subnets = {} + self.mac: EUI = EUI(mac, dialect=netaddr.mac_unix_expanded) + self.current_mac: Optional[EUI] = None + self.current_subnets: Optional[Subnets] = None + self.used_subnets: Dict[Tuple[IPNetwork, IPNetwork], Subnets] = {} def update_ips(self, ip4: str, ip6: str) -> None: self.reset() @@ -84,7 +84,7 @@ class InterfaceManager: self.current_subnets = None self.used_subnets.clear() - def removed(self, links: List["core_pb2.Link"]) -> None: + def removed(self, links: List[Link]) -> None: # get remaining subnets remaining_subnets = set() for edge in self.app.core.links.values(): @@ -114,7 +114,7 @@ class InterfaceManager: subnets.used_indexes.discard(index) self.current_subnets = None - def joined(self, links: List["core_pb2.Link"]) -> None: + def joined(self, links: List[Link]) -> None: ifaces = [] for link in links: if link.HasField("iface1"): @@ -132,7 +132,7 @@ class InterfaceManager: if subnets.key() not in self.used_subnets: self.used_subnets[subnets.key()] = subnets - def next_index(self, node: "core_pb2.Node") -> int: + def next_index(self, node: Node) -> int: if NodeUtils.is_router_node(node): index = 1 else: @@ -144,13 +144,13 @@ class InterfaceManager: index += 1 return index - def get_ips(self, node: "core_pb2.Node") -> [str, str]: + def get_ips(self, node: Node) -> [str, str]: index = self.next_index(node) ip4 = self.current_subnets.ip4[index] ip6 = self.current_subnets.ip6[index] return str(ip4), str(ip6) - def get_subnets(self, iface: "core_pb2.Interface") -> Subnets: + def get_subnets(self, iface: Interface) -> Subnets: ip4_subnet = self.ip4_subnets if iface.ip4: ip4_subnet = IPNetwork(f"{iface.ip4}/{iface.ip4_mask}").cidr @@ -161,7 +161,7 @@ class InterfaceManager: return self.used_subnets.get(subnets.key(), subnets) def determine_subnets( - self, canvas_src_node: "CanvasNode", canvas_dst_node: "CanvasNode" + self, canvas_src_node: CanvasNode, canvas_dst_node: CanvasNode ) -> None: src_node = canvas_src_node.core_node dst_node = canvas_dst_node.core_node @@ -185,7 +185,7 @@ class InterfaceManager: logging.info("ignoring subnet change for link between network nodes") def find_subnets( - self, canvas_node: "CanvasNode", visited: Set[int] = None + self, canvas_node: CanvasNode, visited: Set[int] = None ) -> Optional[IPNetwork]: logging.info("finding subnet for node: %s", canvas_node.core_node.name) canvas = self.app.canvas diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index cf4216d8..523f8f11 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -4,9 +4,10 @@ import tkinter as tk import webbrowser from functools import partial from tkinter import filedialog, messagebox -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from core.gui.appconfig import XMLS_PATH +from core.gui.coreclient import CoreClient from core.gui.dialogs.about import AboutDialog from core.gui.dialogs.canvassizeandscale import SizeAndScaleDialog from core.gui.dialogs.canvaswallpaper import CanvasWallpaperDialog @@ -22,6 +23,7 @@ from core.gui.dialogs.servers import ServersDialog from core.gui.dialogs.sessionoptions import SessionOptionsDialog from core.gui.dialogs.sessions import SessionsDialog from core.gui.dialogs.throughput import ThroughputDialog +from core.gui.graph.graph import CanvasGraph from core.gui.nodeutils import ICON_SIZE from core.gui.observers import ObserversMenu from core.gui.task import ProgressTask @@ -29,7 +31,7 @@ from core.gui.task import ProgressTask if TYPE_CHECKING: from core.gui.app import Application -MAX_FILES = 3 +MAX_FILES: int = 3 class Menubar(tk.Menu): @@ -42,12 +44,12 @@ class Menubar(tk.Menu): Create a CoreMenubar instance """ super().__init__(app) - self.app = app - self.core = app.core - self.canvas = app.canvas - self.recent_menu = None - self.edit_menu = None - self.observers_menu = None + self.app: "Application" = app + self.core: CoreClient = app.core + self.canvas: CanvasGraph = app.canvas + self.recent_menu: Optional[tk.Menu] = None + self.edit_menu: Optional[tk.Menu] = None + self.observers_menu: Optional[tk.Menu] = None self.draw() def draw(self) -> None: diff --git a/daemon/core/gui/nodeutils.py b/daemon/core/gui/nodeutils.py index 40204662..402eca4d 100644 --- a/daemon/core/gui/nodeutils.py +++ b/daemon/core/gui/nodeutils.py @@ -1,38 +1,36 @@ import logging -from typing import TYPE_CHECKING, List, Optional, Set +from typing import List, Optional, Set + +from PIL.ImageTk import PhotoImage from core.api.grpc.core_pb2 import Node, NodeType from core.gui.appconfig import CustomNode, GuiConfig from core.gui.images import ImageEnum, Images, TypeToImage -if TYPE_CHECKING: - from core.api.grpc import core_pb2 - from PIL import ImageTk - -ICON_SIZE = 48 -ANTENNA_SIZE = 32 +ICON_SIZE: int = 48 +ANTENNA_SIZE: int = 32 class NodeDraw: - def __init__(self): + def __init__(self) -> None: self.custom: bool = False - self.image = None + self.image: Optional[str] = None self.image_enum: Optional[ImageEnum] = None - self.image_file = None - self.node_type: core_pb2.NodeType = None + self.image_file: Optional[str] = None + self.node_type: NodeType = None self.model: Optional[str] = None self.services: Set[str] = set() - self.label = None + self.label: Optional[str] = None @classmethod def from_setup( cls, image_enum: ImageEnum, - node_type: "core_pb2.NodeType", + node_type: NodeType, label: str, model: str = None, - tooltip=None, - ): + tooltip: str = None, + ) -> "NodeDraw": node_draw = NodeDraw() node_draw.image_enum = image_enum node_draw.image = Images.get(image_enum, ICON_SIZE) @@ -43,7 +41,7 @@ class NodeDraw: return node_draw @classmethod - def from_custom(cls, custom_node: CustomNode): + def from_custom(cls, custom_node: CustomNode) -> "NodeDraw": node_draw = NodeDraw() node_draw.custom = True node_draw.image_file = custom_node.image @@ -57,17 +55,17 @@ class NodeDraw: class NodeUtils: - NODES = [] - NETWORK_NODES = [] + NODES: List[NodeDraw] = [] + NETWORK_NODES: List[NodeDraw] = [] NODE_ICONS = {} - CONTAINER_NODES = {NodeType.DEFAULT, NodeType.DOCKER, NodeType.LXC} - IMAGE_NODES = {NodeType.DOCKER, NodeType.LXC} - WIRELESS_NODES = {NodeType.WIRELESS_LAN, NodeType.EMANE} - RJ45_NODES = {NodeType.RJ45} - IGNORE_NODES = {NodeType.CONTROL_NET, NodeType.PEER_TO_PEER} - NODE_MODELS = {"router", "host", "PC", "mdr", "prouter"} - ROUTER_NODES = {"router", "mdr"} - ANTENNA_ICON = None + CONTAINER_NODES: Set[NodeType] = {NodeType.DEFAULT, NodeType.DOCKER, NodeType.LXC} + IMAGE_NODES: Set[NodeType] = {NodeType.DOCKER, NodeType.LXC} + WIRELESS_NODES: Set[NodeType] = {NodeType.WIRELESS_LAN, NodeType.EMANE} + RJ45_NODES: Set[NodeType] = {NodeType.RJ45} + IGNORE_NODES: Set[NodeType] = {NodeType.CONTROL_NET, NodeType.PEER_TO_PEER} + NODE_MODELS: Set[str] = {"router", "host", "PC", "mdr", "prouter"} + ROUTER_NODES: Set[str] = {"router", "mdr"} + ANTENNA_ICON: PhotoImage = None @classmethod def is_router_node(cls, node: Node) -> bool: @@ -99,8 +97,8 @@ class NodeUtils: @classmethod def node_icon( - cls, node_type: NodeType, model: str, gui_config: GuiConfig, scale=1.0 - ) -> "ImageTk.PhotoImage": + cls, node_type: NodeType, model: str, gui_config: GuiConfig, scale: float = 1.0 + ) -> PhotoImage: image_enum = TypeToImage.get(node_type, model) if image_enum: @@ -112,8 +110,8 @@ class NodeUtils: @classmethod def node_image( - cls, core_node: "core_pb2.Node", gui_config: GuiConfig, scale=1.0 - ) -> "ImageTk.PhotoImage": + cls, core_node: Node, gui_config: GuiConfig, scale: float = 1.0 + ) -> PhotoImage: image = cls.node_icon(core_node.type, core_node.model, gui_config, scale) if core_node.icon: try: @@ -141,7 +139,7 @@ class NodeUtils: return None @classmethod - def setup(cls): + def setup(cls) -> None: nodes = [ (ImageEnum.ROUTER, NodeType.DEFAULT, "Router", "router"), (ImageEnum.HOST, NodeType.DEFAULT, "Host", "host"), diff --git a/daemon/core/gui/observers.py b/daemon/core/gui/observers.py index 27d0a26e..7879494b 100644 --- a/daemon/core/gui/observers.py +++ b/daemon/core/gui/observers.py @@ -1,13 +1,13 @@ import tkinter as tk from functools import partial -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict from core.gui.dialogs.observers import ObserverDialog if TYPE_CHECKING: from core.gui.app import Application -OBSERVERS = { +OBSERVERS: Dict[str, str] = { "List Processes": "ps", "Show Interfaces": "ip address", "IPV4 Routes": "ip -4 route", @@ -23,9 +23,9 @@ OBSERVERS = { class ObserversMenu(tk.Menu): def __init__(self, master: tk.BaseWidget, app: "Application") -> None: super().__init__(master) - self.app = app - self.observer = tk.StringVar(value=tk.NONE) - self.custom_index = 0 + self.app: "Application" = app + self.observer: tk.StringVar = tk.StringVar(value=tk.NONE) + self.custom_index: int = 0 self.draw() def draw(self) -> None: diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py index 3f58e7a0..2b597b63 100644 --- a/daemon/core/gui/statusbar.py +++ b/daemon/core/gui/statusbar.py @@ -3,8 +3,9 @@ status bar """ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional +from core.api.grpc.core_pb2 import ExceptionEvent from core.gui.dialogs.alerts import AlertsDialog from core.gui.themes import Styles @@ -13,20 +14,19 @@ if TYPE_CHECKING: class StatusBar(ttk.Frame): - def __init__(self, master: tk.Widget, app: "Application"): + def __init__(self, master: tk.Widget, app: "Application") -> None: super().__init__(master) - self.app = app - self.status = None - self.statusvar = tk.StringVar() - self.zoom = None - self.cpu_usage = None - self.memory = None - self.alerts_button = None - self.running = False - self.core_alarms = [] + self.app: "Application" = app + self.status: Optional[ttk.Label] = None + self.statusvar: tk.StringVar = tk.StringVar() + self.zoom: Optional[ttk.Label] = None + self.cpu_usage: Optional[ttk.Label] = None + self.alerts_button: Optional[ttk.Button] = None + self.running: bool = False + self.core_alarms: List[ExceptionEvent] = [] self.draw() - def draw(self): + def draw(self) -> None: self.columnconfigure(0, weight=7) self.columnconfigure(1, weight=1) self.columnconfigure(2, weight=1) @@ -64,9 +64,9 @@ class StatusBar(ttk.Frame): ) self.alerts_button.grid(row=0, column=3, sticky="ew") - def click_alerts(self): + def click_alerts(self) -> None: dialog = AlertsDialog(self.app) dialog.show() - def set_status(self, message: str): + def set_status(self, message: str) -> None: self.statusvar.set(message) diff --git a/daemon/core/gui/task.py b/daemon/core/gui/task.py index 2f055a90..b4a5f68f 100644 --- a/daemon/core/gui/task.py +++ b/daemon/core/gui/task.py @@ -1,7 +1,7 @@ import logging import threading import time -from typing import TYPE_CHECKING, Any, Callable, Tuple +from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple if TYPE_CHECKING: from core.gui.app import Application @@ -16,14 +16,14 @@ class ProgressTask: callback: Callable = None, args: Tuple[Any] = None, ): - self.app = app - self.title = title - self.task = task - self.callback = callback - self.args = args - if self.args is None: - self.args = () - self.time = None + self.app: "Application" = app + self.title: str = title + self.task: Callable = task + self.callback: Callable = callback + if args is None: + args = () + self.args: Tuple[Any] = args + self.time: Optional[float] = None def start(self) -> None: self.app.progress.grid(sticky="ew") @@ -49,7 +49,7 @@ class ProgressTask: finally: self.app.after(0, self.complete) - def complete(self): + def complete(self) -> None: self.app.progress.stop() self.app.progress.grid_forget() total = time.perf_counter() - self.time diff --git a/daemon/core/gui/themes.py b/daemon/core/gui/themes.py index 141a7a5c..93a0a599 100644 --- a/daemon/core/gui/themes.py +++ b/daemon/core/gui/themes.py @@ -1,39 +1,40 @@ import tkinter as tk from tkinter import font, ttk +from typing import Dict, Tuple -THEME_DARK = "black" -PADX = (0, 5) -PADY = (0, 5) -FRAME_PAD = 5 -DIALOG_PAD = 5 +THEME_DARK: str = "black" +PADX: Tuple[int, int] = (0, 5) +PADY: Tuple[int, int] = (0, 5) +FRAME_PAD: int = 5 +DIALOG_PAD: int = 5 class Styles: - tooltip = "Tooltip.TLabel" - tooltip_frame = "Tooltip.TFrame" - service_checkbutton = "Service.TCheckbutton" - picker_button = "Picker.TButton" - green_alert = "GAlert.TButton" - red_alert = "RAlert.TButton" - yellow_alert = "YAlert.TButton" + tooltip: str = "Tooltip.TLabel" + tooltip_frame: str = "Tooltip.TFrame" + service_checkbutton: str = "Service.TCheckbutton" + picker_button: str = "Picker.TButton" + green_alert: str = "GAlert.TButton" + red_alert: str = "RAlert.TButton" + yellow_alert: str = "YAlert.TButton" class Colors: - disabledfg = "DarkGrey" - frame = "#424242" - dark = "#222222" - darker = "#121212" - darkest = "black" - lighter = "#626262" - lightest = "#ffffff" - selectbg = "#4a6984" - selectfg = "#ffffff" - white = "white" - black = "black" - listboxbg = "#f2f1f0" + disabledfg: str = "DarkGrey" + frame: str = "#424242" + dark: str = "#222222" + darker: str = "#121212" + darkest: str = "black" + lighter: str = "#626262" + lightest: str = "#ffffff" + selectbg: str = "#4a6984" + selectfg: str = "#ffffff" + white: str = "white" + black: str = "black" + listboxbg: str = "#f2f1f0" -def load(style: ttk.Style): +def load(style: ttk.Style) -> None: style.theme_create( THEME_DARK, "clam", @@ -139,13 +140,13 @@ def load(style: ttk.Style): ) -def theme_change_menu(event: tk.Event): +def theme_change_menu(event: tk.Event) -> None: if not isinstance(event.widget, tk.Menu): return style_menu(event.widget) -def style_menu(widget: tk.Widget): +def style_menu(widget: tk.Widget) -> None: style = ttk.Style() bg = style.lookup(".", "background") fg = style.lookup(".", "foreground") @@ -157,7 +158,7 @@ def style_menu(widget: tk.Widget): ) -def style_listbox(widget: tk.Widget): +def style_listbox(widget: tk.Widget) -> None: style = ttk.Style() bg = style.lookup(".", "background") fg = style.lookup(".", "foreground") @@ -174,7 +175,7 @@ def style_listbox(widget: tk.Widget): ) -def theme_change(event: tk.Event): +def theme_change(event: tk.Event) -> None: style = ttk.Style() style.configure(Styles.picker_button, font="TkSmallCaptionFont") style.configure( @@ -203,7 +204,7 @@ def theme_change(event: tk.Event): ) -def scale_fonts(fonts_size, scale): +def scale_fonts(fonts_size: Dict[str, int], scale: float) -> None: for name in font.names(): f = font.nametofont(name) if name in fonts_size: diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 54fac126..c3e9067f 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -3,7 +3,7 @@ import tkinter as tk from enum import Enum from functools import partial from tkinter import ttk -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Callable, List, Optional from PIL.ImageTk import PhotoImage @@ -23,8 +23,8 @@ from core.gui.tooltip import Tooltip if TYPE_CHECKING: from core.gui.app import Application -TOOLBAR_SIZE = 32 -PICKER_SIZE = 24 +TOOLBAR_SIZE: int = 32 +PICKER_SIZE: int = 24 class NodeTypeEnum(Enum): @@ -42,8 +42,8 @@ def enable_buttons(frame: ttk.Frame, enabled: bool) -> None: class PickerFrame(ttk.Frame): def __init__(self, app: "Application", button: ttk.Button) -> None: super().__init__(app) - self.app = app - self.button = button + self.app: "Application" = app + self.button: ttk.Button = button def create_node_button(self, node_draw: NodeDraw, func: Callable) -> None: self.create_button( @@ -85,10 +85,10 @@ class PickerFrame(ttk.Frame): class ButtonBar(ttk.Frame): - def __init__(self, master: tk.Widget, app: "Application"): + def __init__(self, master: tk.Widget, app: "Application") -> None: super().__init__(master) - self.app = app - self.radio_buttons = [] + self.app: "Application" = app + self.radio_buttons: List[ttk.Button] = [] def create_button( self, image_enum: ImageEnum, func: Callable, tooltip: str, radio: bool = False @@ -109,14 +109,14 @@ class ButtonBar(ttk.Frame): class MarkerFrame(ttk.Frame): - PAD = 3 + PAD: int = 3 def __init__(self, master: tk.BaseWidget, app: "Application") -> None: super().__init__(master, padding=self.PAD) - self.app = app - self.color = "#000000" - self.size = tk.DoubleVar() - self.color_frame = None + self.app: "Application" = app + self.color: str = "#000000" + self.size: tk.DoubleVar = tk.DoubleVar() + self.color_frame: Optional[tk.Frame] = None self.draw() def draw(self) -> None: @@ -144,7 +144,7 @@ class MarkerFrame(ttk.Frame): self.color_frame.bind("", self.click_color) Tooltip(self.color_frame, "Marker Color") - def click_clear(self): + def click_clear(self) -> None: self.app.canvas.delete(tags.MARKER) def click_color(self, _event: tk.Event) -> None: @@ -163,37 +163,37 @@ class Toolbar(ttk.Frame): Create a CoreToolbar instance """ super().__init__(app) - self.app = app + self.app: "Application" = app # design buttons - self.play_button = None - self.select_button = None - self.link_button = None - self.node_button = None - self.network_button = None - self.annotation_button = None + self.play_button: Optional[ttk.Button] = None + self.select_button: Optional[ttk.Button] = None + self.link_button: Optional[ttk.Button] = None + self.node_button: Optional[ttk.Button] = None + self.network_button: Optional[ttk.Button] = None + self.annotation_button: Optional[ttk.Button] = None # runtime buttons - self.runtime_select_button = None - self.stop_button = None - self.runtime_marker_button = None - self.run_command_button = None + self.runtime_select_button: Optional[ttk.Button] = None + self.stop_button: Optional[ttk.Button] = None + self.runtime_marker_button: Optional[ttk.Button] = None + self.run_command_button: Optional[ttk.Button] = None # frames - self.design_frame = None - self.runtime_frame = None - self.marker_frame = None - self.picker = None + self.design_frame: Optional[ButtonBar] = None + self.runtime_frame: Optional[ButtonBar] = None + self.marker_frame: Optional[MarkerFrame] = None + self.picker: Optional[PickerFrame] = None # observers - self.observers_menu = None + self.observers_menu: Optional[ObserversMenu] = None # these variables help keep track of what images being drawn so that scaling # is possible since PhotoImage does not have resize method - self.current_node = NodeUtils.NODES[0] - self.current_network = NodeUtils.NETWORK_NODES[0] - self.current_annotation = ShapeType.MARKER - self.annotation_enum = ImageEnum.MARKER + self.current_node: NodeDraw = NodeUtils.NODES[0] + self.current_network: NodeDraw = NodeUtils.NETWORK_NODES[0] + self.current_annotation: ShapeType = ShapeType.MARKER + self.annotation_enum: ImageEnum = ImageEnum.MARKER # draw components self.draw() diff --git a/daemon/core/gui/tooltip.py b/daemon/core/gui/tooltip.py index bc1ed9b5..c2978510 100644 --- a/daemon/core/gui/tooltip.py +++ b/daemon/core/gui/tooltip.py @@ -1,5 +1,6 @@ import tkinter as tk from tkinter import ttk +from typing import Optional from core.gui.themes import Styles @@ -9,19 +10,19 @@ class Tooltip(object): Create tool tip for a given widget """ - def __init__(self, widget: tk.Widget, text: str = "widget info"): - self.widget = widget - self.text = text + def __init__(self, widget: tk.BaseWidget, text: str = "widget info") -> None: + self.widget: tk.BaseWidget = widget + self.text: str = text self.widget.bind("", self.on_enter) self.widget.bind("", self.on_leave) - self.waittime = 400 - self.id = None - self.tw = None + self.waittime: int = 400 + self.id: Optional[str] = None + self.tw: Optional[tk.Toplevel] = None - def on_enter(self, event: tk.Event = None): + def on_enter(self, event: tk.Event = None) -> None: self.schedule() - def on_leave(self, event: tk.Event = None): + def on_leave(self, event: tk.Event = None) -> None: self.unschedule() self.close(event) @@ -39,7 +40,6 @@ class Tooltip(object): x, y, cx, cy = self.widget.bbox("insert") x += self.widget.winfo_rootx() y += self.widget.winfo_rooty() + 32 - self.tw = tk.Toplevel(self.widget) self.tw.wm_overrideredirect(True) self.tw.wm_geometry("+%d+%d" % (x, y)) diff --git a/daemon/core/gui/validation.py b/daemon/core/gui/validation.py index 873db189..22f12bb8 100644 --- a/daemon/core/gui/validation.py +++ b/daemon/core/gui/validation.py @@ -4,16 +4,23 @@ input validation import re import tkinter as tk from tkinter import ttk +from typing import Any, Optional, Pattern -SMALLEST_SCALE = 0.5 -LARGEST_SCALE = 5.0 -HEX_REGEX = re.compile("^([#]([0-9]|[a-f])+)$|^[#]$") +SMALLEST_SCALE: float = 0.5 +LARGEST_SCALE: float = 5.0 +HEX_REGEX: Pattern = re.compile("^([#]([0-9]|[a-f])+)$|^[#]$") class ValidationEntry(ttk.Entry): - empty = None + empty: Optional[str] = None - def __init__(self, master=None, widget=None, empty_enabled=True, **kwargs) -> None: + def __init__( + self, + master: tk.BaseWidget = None, + widget: tk.BaseWidget = None, + empty_enabled: bool = True, + **kwargs: Any + ) -> None: super().__init__(master, widget, **kwargs) cmd = self.register(self.is_valid) self.configure(validate="key", validatecommand=(cmd, "%P")) @@ -30,7 +37,7 @@ class ValidationEntry(ttk.Entry): class PositiveIntEntry(ValidationEntry): - empty = "0" + empty: str = "0" def is_valid(self, s: str) -> bool: if not s: @@ -92,7 +99,7 @@ class HexEntry(ValidationEntry): class NodeNameEntry(ValidationEntry): - empty = "noname" + empty: str = "noname" def is_valid(self, s: str) -> bool: if len(s) < 0: diff --git a/daemon/core/gui/widgets.py b/daemon/core/gui/widgets.py index 6f51bd8c..2eded212 100644 --- a/daemon/core/gui/widgets.py +++ b/daemon/core/gui/widgets.py @@ -1,53 +1,63 @@ import logging import tkinter as tk from functools import partial -from pathlib import PosixPath +from pathlib import Path from tkinter import filedialog, font, ttk -from typing import TYPE_CHECKING, Dict +from typing import TYPE_CHECKING, Any, Callable, Dict, Set, Type -from core.api.grpc import common_pb2, core_pb2 +from core.api.grpc import core_pb2 +from core.api.grpc.common_pb2 import ConfigOption +from core.api.grpc.core_pb2 import ConfigOptionType from core.gui import themes, validation +from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY if TYPE_CHECKING: from core.gui.app import Application - from core.gui.dialogs.dialog import Dialog -INT_TYPES = { - core_pb2.ConfigOptionType.UINT8, - core_pb2.ConfigOptionType.UINT16, - core_pb2.ConfigOptionType.UINT32, - core_pb2.ConfigOptionType.UINT64, - core_pb2.ConfigOptionType.INT8, - core_pb2.ConfigOptionType.INT16, - core_pb2.ConfigOptionType.INT32, - core_pb2.ConfigOptionType.INT64, +INT_TYPES: Set[ConfigOptionType] = { + ConfigOptionType.UINT8, + ConfigOptionType.UINT16, + ConfigOptionType.UINT32, + ConfigOptionType.UINT64, + ConfigOptionType.INT8, + ConfigOptionType.INT16, + ConfigOptionType.INT32, + ConfigOptionType.INT64, } -def file_button_click(value: tk.StringVar, parent: tk.Widget): +def file_button_click(value: tk.StringVar, parent: tk.Widget) -> None: file_path = filedialog.askopenfilename(title="Select File", parent=parent) if file_path: value.set(file_path) class FrameScroll(ttk.Frame): - def __init__(self, master: tk.Widget, app: "Application", _cls=ttk.Frame, **kw): + def __init__( + self, + master: tk.Widget, + app: "Application", + _cls: Type[ttk.Frame] = ttk.Frame, + **kw: Any + ) -> None: super().__init__(master, **kw) - self.app = app + self.app: "Application" = app self.rowconfigure(0, weight=1) self.columnconfigure(0, weight=1) bg = self.app.style.lookup(".", "background") - self.canvas = tk.Canvas(self, highlightthickness=0, background=bg) + self.canvas: tk.Canvas = tk.Canvas(self, highlightthickness=0, background=bg) self.canvas.grid(row=0, sticky="nsew", padx=2, pady=2) self.canvas.columnconfigure(0, weight=1) self.canvas.rowconfigure(0, weight=1) - self.scrollbar = ttk.Scrollbar( + self.scrollbar: ttk.Scrollbar = ttk.Scrollbar( self, orient="vertical", command=self.canvas.yview ) self.scrollbar.grid(row=0, column=1, sticky="ns") - self.frame = _cls(self.canvas) - self.frame_id = self.canvas.create_window(0, 0, anchor="nw", window=self.frame) + self.frame: ttk.Frame = _cls(self.canvas) + self.frame_id: int = self.canvas.create_window( + 0, 0, anchor="nw", window=self.frame + ) self.canvas.update_idletasks() self.canvas.configure( scrollregion=self.canvas.bbox("all"), yscrollcommand=self.scrollbar.set @@ -55,16 +65,16 @@ class FrameScroll(ttk.Frame): self.frame.bind("", self._configure_frame) self.canvas.bind("", self._configure_canvas) - def _configure_frame(self, event: tk.Event): + def _configure_frame(self, event: tk.Event) -> None: req_width = self.frame.winfo_reqwidth() if req_width != self.canvas.winfo_reqwidth(): self.canvas.configure(width=req_width) self.canvas.configure(scrollregion=self.canvas.bbox("all")) - def _configure_canvas(self, event: tk.Event): + def _configure_canvas(self, event: tk.Event) -> None: self.canvas.itemconfig(self.frame_id, width=event.width) - def clear(self): + def clear(self) -> None: for widget in self.frame.winfo_children(): widget.destroy() @@ -74,15 +84,15 @@ class ConfigFrame(ttk.Notebook): self, master: tk.Widget, app: "Application", - config: Dict[str, common_pb2.ConfigOption], - **kw - ): + config: Dict[str, ConfigOption], + **kw: Any + ) -> None: super().__init__(master, **kw) - self.app = app - self.config = config - self.values = {} + self.app: "Application" = app + self.config: Dict[str, ConfigOption] = config + self.values: Dict[str, tk.StringVar] = {} - def draw_config(self): + def draw_config(self) -> None: group_mapping = {} for key in self.config: option = self.config[key] @@ -142,7 +152,7 @@ class ConfigFrame(ttk.Notebook): logging.error("unhandled config option type: %s", option.type) self.values[option.name] = value - def parse_config(self): + def parse_config(self) -> Dict[str, str]: for key in self.config: option = self.config[key] value = self.values[key] @@ -169,13 +179,13 @@ class ConfigFrame(ttk.Notebook): class ListboxScroll(ttk.Frame): - def __init__(self, master: tk.Widget = None, **kw): + def __init__(self, master: tk.BaseWidget = None, **kw: Any) -> None: super().__init__(master, **kw) self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) - self.scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL) + self.scrollbar: ttk.Scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL) self.scrollbar.grid(row=0, column=1, sticky="ns") - self.listbox = tk.Listbox( + self.listbox: tk.Listbox = tk.Listbox( self, selectmode=tk.BROWSE, yscrollcommand=self.scrollbar.set, @@ -187,12 +197,18 @@ class ListboxScroll(ttk.Frame): class CheckboxList(FrameScroll): - def __init__(self, master: ttk.Widget, app: "Application", clicked=None, **kw): + def __init__( + self, + master: ttk.Widget, + app: "Application", + clicked: Callable = None, + **kw: Any + ) -> None: super().__init__(master, app, **kw) - self.clicked = clicked + self.clicked: Callable = clicked self.frame.columnconfigure(0, weight=1) - def add(self, name: str, checked: bool): + def add(self, name: str, checked: bool) -> None: var = tk.BooleanVar(value=checked) func = partial(self.clicked, name, var) checkbox = ttk.Checkbutton(self.frame, text=name, variable=var, command=func) @@ -200,16 +216,16 @@ class CheckboxList(FrameScroll): class CodeFont(font.Font): - def __init__(self): + def __init__(self) -> None: super().__init__(font="TkFixedFont", color="green") class CodeText(ttk.Frame): - def __init__(self, master: tk.Widget, **kwargs): + def __init__(self, master: tk.BaseWidget, **kwargs: Any) -> None: super().__init__(master, **kwargs) self.rowconfigure(0, weight=1) self.columnconfigure(0, weight=1) - self.text = tk.Text( + self.text: tk.Text = tk.Text( self, bd=0, bg="black", @@ -229,14 +245,14 @@ class CodeText(ttk.Frame): class Spinbox(ttk.Entry): - def __init__(self, master: tk.Widget = None, **kwargs): + def __init__(self, master: tk.BaseWidget = None, **kwargs: Any) -> None: super().__init__(master, "ttk::spinbox", **kwargs) - def set(self, value): + def set(self, value: str) -> None: self.tk.call(self._w, "set", value) -def image_chooser(parent: "Dialog", path: PosixPath): +def image_chooser(parent: Dialog, path: Path) -> str: return filedialog.askopenfilename( parent=parent, initialdir=str(path), From 11be40bc90135040897da6a2b0e2372f1fb7f097 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 19 Jun 2020 23:24:07 -0700 Subject: [PATCH 185/929] pygui: added class variable type hinting to core.gui.graph --- daemon/core/gui/coreclient.py | 2 +- daemon/core/gui/graph/edges.py | 69 +++++----- daemon/core/gui/graph/graph.py | 190 ++++++++++++++-------------- daemon/core/gui/graph/node.py | 105 ++++++++------- daemon/core/gui/graph/shape.py | 64 +++++----- daemon/core/gui/graph/shapeutils.py | 3 +- daemon/core/gui/graph/tags.py | 32 ++--- daemon/core/gui/graph/tooltip.py | 40 +++--- 8 files changed, 256 insertions(+), 249 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 24708769..5e1bf4c2 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -79,7 +79,7 @@ class CoreClient: self.read_config() # helpers - self.iface_to_edge: Dict[Tuple[int, int], Tuple[int, int]] = {} + self.iface_to_edge: Dict[Tuple[int, ...], Tuple[int, ...]] = {} self.ifaces_manager: InterfaceManager = InterfaceManager(self.app) # session data diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index ac637b28..e9ac2587 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -1,9 +1,10 @@ import logging import math import tkinter as tk -from typing import TYPE_CHECKING, Any, Tuple +from typing import TYPE_CHECKING, Optional, Tuple from core.api.grpc import core_pb2 +from core.api.grpc.core_pb2 import Interface, Link from core.gui import themes from core.gui.dialogs.linkconfig import LinkConfigurationDialog from core.gui.graph import tags @@ -12,12 +13,12 @@ from core.gui.nodeutils import NodeUtils if TYPE_CHECKING: from core.gui.graph.graph import CanvasGraph -TEXT_DISTANCE = 0.30 -EDGE_WIDTH = 3 -EDGE_COLOR = "#ff0000" -WIRELESS_WIDTH = 1.5 -WIRELESS_COLOR = "#009933" -ARC_DISTANCE = 50 +TEXT_DISTANCE: float = 0.30 +EDGE_WIDTH: int = 3 +EDGE_COLOR: str = "#ff0000" +WIRELESS_WIDTH: float = 1.5 +WIRELESS_COLOR: str = "#009933" +ARC_DISTANCE: int = 50 def create_edge_token(src: int, dst: int, network: int = None) -> Tuple[int, ...]: @@ -57,20 +58,20 @@ def arc_edges(edges) -> None: class Edge: - tag = tags.EDGE + tag: str = tags.EDGE def __init__(self, canvas: "CanvasGraph", src: int, dst: int = None) -> None: self.canvas = canvas - self.id = None - self.src = src - self.dst = dst - self.arc = 0 - self.token = None - self.src_label = None - self.middle_label = None - self.dst_label = None - self.color = EDGE_COLOR - self.width = EDGE_WIDTH + self.id: Optional[int] = None + self.src: int = src + self.dst: int = dst + self.arc: int = 0 + self.token: Optional[Tuple[int, ...]] = None + self.src_label: Optional[int] = None + self.middle_label: Optional[int] = None + self.dst_label: Optional[int] = None + self.color: str = EDGE_COLOR + self.width: int = EDGE_WIDTH @classmethod def create_token(cls, src: int, dst: int) -> Tuple[int, ...]: @@ -120,7 +121,7 @@ class Edge: fill=self.color, ) - def redraw(self): + def redraw(self) -> None: self.canvas.itemconfig(self.id, width=self.scaled_width(), fill=self.color) src_x, src_y, _, _, _, _ = self.canvas.coords(self.id) src_pos = src_x, src_y @@ -233,13 +234,13 @@ class CanvasWirelessEdge(Edge): dst: int, src_pos: Tuple[float, float], dst_pos: Tuple[float, float], - token: Tuple[Any, ...], + token: Tuple[int, ...], ) -> None: logging.debug("drawing wireless link from node %s to node %s", src, dst) super().__init__(canvas, src, dst) - self.token = token - self.width = WIRELESS_WIDTH - self.color = WIRELESS_COLOR + self.token: Tuple[int, ...] = token + self.width: float = WIRELESS_WIDTH + self.color: str = WIRELESS_COLOR self.draw(src_pos, dst_pos) @@ -259,19 +260,19 @@ class CanvasEdge(Edge): Create an instance of canvas edge object """ super().__init__(canvas, src) - self.src_iface = None - self.dst_iface = None - self.text_src = None - self.text_dst = None - self.link = None - self.asymmetric_link = None - self.throughput = None + self.src_iface: Optional[Interface] = None + self.dst_iface: Optional[Interface] = None + self.text_src: Optional[int] = None + self.text_dst: Optional[int] = None + self.link: Optional[Link] = None + self.asymmetric_link: Optional[Link] = None + self.throughput: Optional[float] = None self.draw(src_pos, dst_pos) self.set_binding() - self.context = tk.Menu(self.canvas) + self.context: tk.Menu = tk.Menu(self.canvas) self.create_context() - def create_context(self): + def create_context(self) -> None: themes.style_menu(self.context) self.context.add_command(label="Configure", command=self.click_configure) self.context.add_command(label="Delete", command=self.click_delete) @@ -279,7 +280,7 @@ class CanvasEdge(Edge): def set_binding(self) -> None: self.canvas.tag_bind(self.id, "", self.show_context) - def set_link(self, link) -> None: + def set_link(self, link: Link) -> None: self.link = link self.draw_labels() @@ -383,7 +384,7 @@ class CanvasEdge(Edge): self.context.entryconfigure(1, state=state) self.context.tk_popup(event.x_root, event.y_root) - def click_delete(self): + def click_delete(self) -> None: self.canvas.delete_edge(self) def click_configure(self) -> None: diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 834220ea..53115750 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -2,11 +2,12 @@ import logging import tkinter as tk from copy import deepcopy from tkinter import BooleanVar -from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple -from PIL import Image, ImageTk +from PIL import Image +from PIL.ImageTk import PhotoImage -from core.api.grpc import core_pb2 +from core.api.grpc.core_pb2 import Interface, Link, LinkType, Session, ThroughputsEvent from core.gui.dialogs.shapemod import ShapeDialog from core.gui.graph import tags from core.gui.graph.edges import ( @@ -21,7 +22,7 @@ from core.gui.graph.node import CanvasNode from core.gui.graph.shape import Shape from core.gui.graph.shapeutils import ShapeType, is_draw_shape, is_marker from core.gui.images import ImageEnum, TypeToImage -from core.gui.nodeutils import NodeUtils +from core.gui.nodeutils import NodeDraw, NodeUtils if TYPE_CHECKING: from core.gui.app import Application @@ -48,58 +49,59 @@ class ShowVar(BooleanVar): class CanvasGraph(tk.Canvas): - def __init__(self, master: tk.Widget, app: "Application", core: "CoreClient"): + def __init__( + self, master: tk.BaseWidget, app: "Application", core: "CoreClient" + ) -> None: super().__init__(master, highlightthickness=0, background="#cccccc") - self.app = app - self.core = core - self.mode = GraphMode.SELECT - self.annotation_type = None - self.selection = {} - self.select_box = None - self.selected = None - self.node_draw = None - self.nodes = {} - self.edges = {} - self.shapes = {} - self.wireless_edges = {} + self.app: "Application" = app + self.core: "CoreClient" = core + self.mode: GraphMode = GraphMode.SELECT + self.annotation_type: Optional[ShapeType] = None + self.selection: Dict[int, int] = {} + self.select_box: Optional[Shape] = None + self.selected: Optional[int] = None + self.node_draw: Optional[NodeDraw] = None + self.nodes: Dict[int, CanvasNode] = {} + self.edges: Dict[int, CanvasEdge] = {} + self.shapes: Dict[int, Shape] = {} + self.wireless_edges: Dict[Tuple[int, ...], CanvasWirelessEdge] = {} # map wireless/EMANE node to the set of MDRs connected to that node - self.wireless_network = {} + self.wireless_network: Dict[int, Set[int]] = {} - self.drawing_edge = None - self.rect = None - self.shape_drawing = False + self.drawing_edge: Optional[CanvasEdge] = None + self.rect: Optional[int] = None + self.shape_drawing: bool = False width = self.app.guiconfig.preferences.width height = self.app.guiconfig.preferences.height - self.default_dimensions = (width, height) - self.current_dimensions = self.default_dimensions - self.ratio = 1.0 - self.offset = (0, 0) - self.cursor = (0, 0) - self.marker_tool = None - self.to_copy = [] + self.default_dimensions: Tuple[int, int] = (width, height) + self.current_dimensions: Tuple[int, int] = self.default_dimensions + self.ratio: float = 1.0 + self.offset: Tuple[int, int] = (0, 0) + self.cursor: Tuple[int, int] = (0, 0) + self.to_copy: List[CanvasNode] = [] # background related - self.wallpaper_id = None - self.wallpaper = None - self.wallpaper_drawn = None - self.wallpaper_file = "" - self.scale_option = tk.IntVar(value=1) - self.adjust_to_dim = tk.BooleanVar(value=False) + self.wallpaper_id: Optional[int] = None + self.wallpaper: Optional[Image.Image] = None + self.wallpaper_drawn: Optional[PhotoImage] = None + self.wallpaper_file: str = "" + self.scale_option: tk.IntVar = tk.IntVar(value=1) + self.adjust_to_dim: tk.BooleanVar = tk.BooleanVar(value=False) # throughput related - self.throughput_threshold = 250.0 - self.throughput_width = 10 - self.throughput_color = "#FF0000" + self.throughput_threshold: float = 250.0 + self.throughput_width: int = 10 + self.throughput_color: str = "#FF0000" # drawing related - self.show_node_labels = ShowVar(self, tags.NODE_LABEL, value=True) - self.show_link_labels = ShowVar(self, tags.LINK_LABEL, value=True) - self.show_grid = ShowVar(self, tags.GRIDLINE, value=True) - self.show_annotations = ShowVar(self, tags.ANNOTATION, value=True) - self.show_iface_names = BooleanVar(value=False) - self.show_ip4s = BooleanVar(value=True) - self.show_ip6s = BooleanVar(value=True) + self.show_node_labels: ShowVar = ShowVar(self, tags.NODE_LABEL, value=True) + self.show_link_labels: ShowVar = ShowVar(self, tags.LINK_LABEL, value=True) + self.show_grid: ShowVar = ShowVar(self, tags.GRIDLINE, value=True) + self.show_annotations: ShowVar = ShowVar(self, tags.ANNOTATION, value=True) + self.show_iface_names: BooleanVar = BooleanVar(value=False) + self.show_ip4s: BooleanVar = BooleanVar(value=True) + self.show_ip6s: BooleanVar = BooleanVar(value=True) # bindings self.setup_bindings() @@ -108,7 +110,7 @@ class CanvasGraph(tk.Canvas): self.draw_canvas() self.draw_grid() - def draw_canvas(self, dimensions: Tuple[int, int] = None): + def draw_canvas(self, dimensions: Tuple[int, int] = None) -> None: if self.rect is not None: self.delete(self.rect) if not dimensions: @@ -125,7 +127,7 @@ class CanvasGraph(tk.Canvas): ) self.configure(scrollregion=self.bbox(tk.ALL)) - def reset_and_redraw(self, session: core_pb2.Session): + def reset_and_redraw(self, session: Session) -> None: """ Reset the private variables CanvasGraph object, redraw nodes given the new grpc client. @@ -157,7 +159,7 @@ class CanvasGraph(tk.Canvas): self.drawing_edge = None self.draw_session(session) - def setup_bindings(self): + def setup_bindings(self) -> None: """ Bind any mouse events or hot keys to the matching action """ @@ -173,28 +175,28 @@ class CanvasGraph(tk.Canvas): self.bind("", lambda e: self.scan_mark(e.x, e.y)) self.bind("", lambda e: self.scan_dragto(e.x, e.y, gain=1)) - def get_actual_coords(self, x: float, y: float) -> [float, float]: + def get_actual_coords(self, x: float, y: float) -> Tuple[float, float]: actual_x = (x - self.offset[0]) / self.ratio actual_y = (y - self.offset[1]) / self.ratio return actual_x, actual_y - def get_scaled_coords(self, x: float, y: float) -> [float, float]: + def get_scaled_coords(self, x: float, y: float) -> Tuple[float, float]: scaled_x = (x * self.ratio) + self.offset[0] scaled_y = (y * self.ratio) + self.offset[1] return scaled_x, scaled_y - def inside_canvas(self, x: float, y: float) -> [bool, bool]: + def inside_canvas(self, x: float, y: float) -> Tuple[bool, bool]: x1, y1, x2, y2 = self.bbox(self.rect) valid_x = x1 <= x <= x2 valid_y = y1 <= y <= y2 return valid_x and valid_y - def valid_position(self, x1: int, y1: int, x2: int, y2: int) -> [bool, bool]: + def valid_position(self, x1: int, y1: int, x2: int, y2: int) -> Tuple[bool, bool]: valid_topleft = self.inside_canvas(x1, y1) valid_bottomright = self.inside_canvas(x2, y2) return valid_topleft and valid_bottomright - def set_throughputs(self, throughputs_event: core_pb2.ThroughputsEvent): + def set_throughputs(self, throughputs_event: ThroughputsEvent) -> None: for iface_throughput in throughputs_event.iface_throughputs: node_id = iface_throughput.node_id iface_id = iface_throughput.iface_id @@ -209,7 +211,7 @@ class CanvasGraph(tk.Canvas): else: del self.core.iface_to_edge[iface_to_edge_id] - def draw_grid(self): + def draw_grid(self) -> None: """ Create grid. """ @@ -223,9 +225,7 @@ class CanvasGraph(tk.Canvas): self.tag_lower(tags.GRIDLINE) self.tag_lower(self.rect) - def add_wireless_edge( - self, src: CanvasNode, dst: CanvasNode, link: core_pb2.Link - ) -> None: + def add_wireless_edge(self, src: CanvasNode, dst: CanvasNode, link: Link) -> None: network_id = link.network_id if link.network_id else None token = create_edge_token(src.id, dst.id, network_id) if token in self.wireless_edges: @@ -248,7 +248,7 @@ class CanvasGraph(tk.Canvas): arc_edges(common_edges) def delete_wireless_edge( - self, src: CanvasNode, dst: CanvasNode, link: core_pb2.Link + self, src: CanvasNode, dst: CanvasNode, link: Link ) -> None: network_id = link.network_id if link.network_id else None token = create_edge_token(src.id, dst.id, network_id) @@ -263,7 +263,7 @@ class CanvasGraph(tk.Canvas): arc_edges(common_edges) def update_wireless_edge( - self, src: CanvasNode, dst: CanvasNode, link: core_pb2.Link + self, src: CanvasNode, dst: CanvasNode, link: Link ) -> None: if not link.label: return @@ -275,7 +275,7 @@ class CanvasGraph(tk.Canvas): edge = self.wireless_edges[token] edge.middle_label_text(link.label) - def draw_session(self, session: core_pb2.Session): + def draw_session(self, session: Session) -> None: """ Draw existing session. """ @@ -306,7 +306,7 @@ class CanvasGraph(tk.Canvas): node2 = canvas_node2.core_node token = create_edge_token(canvas_node1.id, canvas_node2.id) - if link.type == core_pb2.LinkType.WIRELESS: + if link.type == LinkType.WIRELESS: self.add_wireless_edge(canvas_node1, canvas_node2, link) else: if token not in self.edges: @@ -337,7 +337,7 @@ class CanvasGraph(tk.Canvas): else: logging.error("duplicate link received: %s", link) - def stopped_session(self): + def stopped_session(self) -> None: # clear wireless edges for edge in self.wireless_edges.values(): edge.delete() @@ -351,7 +351,7 @@ class CanvasGraph(tk.Canvas): for edge in self.edges.values(): edge.reset() - def canvas_xy(self, event: tk.Event) -> [float, float]: + def canvas_xy(self, event: tk.Event) -> Tuple[float, float]: """ Convert window coordinate to canvas coordinate """ @@ -379,7 +379,7 @@ class CanvasGraph(tk.Canvas): return selected - def click_release(self, event: tk.Event): + def click_release(self, event: tk.Event) -> None: """ Draw a node or finish drawing an edge according to the current graph mode """ @@ -418,7 +418,7 @@ class CanvasGraph(tk.Canvas): self.mode = GraphMode.NODE self.selected = None - def handle_edge_release(self, _event: tk.Event): + def handle_edge_release(self, _event: tk.Event) -> None: edge = self.drawing_edge self.drawing_edge = None @@ -454,7 +454,7 @@ class CanvasGraph(tk.Canvas): node_dst.edges.add(edge) self.core.create_link(edge, node_src, node_dst) - def select_object(self, object_id: int, choose_multiple: bool = False): + def select_object(self, object_id: int, choose_multiple: bool = False) -> None: """ create a bounding box when a node is selected """ @@ -475,7 +475,7 @@ class CanvasGraph(tk.Canvas): selection_id = self.selection.pop(object_id) self.delete(selection_id) - def clear_selection(self): + def clear_selection(self) -> None: """ Clear current selection boxes. """ @@ -483,7 +483,7 @@ class CanvasGraph(tk.Canvas): self.delete(_id) self.selection.clear() - def move_selection(self, object_id: int, x_offset: float, y_offset: float): + def move_selection(self, object_id: int, x_offset: float, y_offset: float) -> None: select_id = self.selection.get(object_id) if select_id is not None: self.move(select_id, x_offset, y_offset) @@ -531,7 +531,7 @@ class CanvasGraph(tk.Canvas): self.core.deleted_graph_nodes(nodes) self.core.deleted_graph_edges(edges) - def delete_edge(self, edge: CanvasEdge): + def delete_edge(self, edge: CanvasEdge) -> None: edge.delete() del self.edges[edge.token] src_node = self.nodes[edge.src] @@ -550,7 +550,7 @@ class CanvasGraph(tk.Canvas): src_node.delete_antenna() self.core.deleted_graph_edges([edge]) - def zoom(self, event: tk.Event, factor: float = None): + def zoom(self, event: tk.Event, factor: float = None) -> None: if not factor: factor = ZOOM_IN if event.delta > 0 else ZOOM_OUT event.x, event.y = self.canvasx(event.x), self.canvasy(event.y) @@ -568,7 +568,7 @@ class CanvasGraph(tk.Canvas): if self.wallpaper: self.redraw_wallpaper() - def click_press(self, event: tk.Event): + def click_press(self, event: tk.Event) -> None: """ Start drawing an edge if mouse click is on a node """ @@ -630,7 +630,7 @@ class CanvasGraph(tk.Canvas): self.select_box = shape self.clear_selection() - def ctrl_click(self, event: tk.Event): + def ctrl_click(self, event: tk.Event) -> None: # update cursor location x, y = self.canvas_xy(event) if not self.inside_canvas(x, y): @@ -648,7 +648,7 @@ class CanvasGraph(tk.Canvas): ): self.select_object(selected, choose_multiple=True) - def click_motion(self, event: tk.Event): + def click_motion(self, event: tk.Event) -> None: x, y = self.canvas_xy(event) if not self.inside_canvas(x, y): if self.select_box: @@ -701,7 +701,7 @@ class CanvasGraph(tk.Canvas): if self.select_box and self.mode == GraphMode.SELECT: self.select_box.shape_motion(x, y) - def press_delete(self, _event: tk.Event): + def press_delete(self, _event: tk.Event) -> None: """ delete selected nodes and any data that relates to it """ @@ -711,7 +711,7 @@ class CanvasGraph(tk.Canvas): else: logging.debug("node deletion is disabled during runtime state") - def double_click(self, event: tk.Event): + def double_click(self, event: tk.Event) -> None: selected = self.get_selected(event) if selected is not None and selected in self.shapes: shape = self.shapes[selected] @@ -737,7 +737,7 @@ class CanvasGraph(tk.Canvas): self.core.canvas_nodes[core_node.id] = node self.nodes[node.id] = node - def width_and_height(self): + def width_and_height(self) -> Tuple[int, int]: """ retrieve canvas width and height in pixels """ @@ -753,8 +753,8 @@ class CanvasGraph(tk.Canvas): return image def draw_wallpaper( - self, image: ImageTk.PhotoImage, x: float = None, y: float = None - ): + self, image: PhotoImage, x: float = None, y: float = None + ) -> None: if x is None and y is None: x1, y1, x2, y2 = self.bbox(self.rect) x = (x1 + x2) / 2 @@ -762,7 +762,7 @@ class CanvasGraph(tk.Canvas): self.wallpaper_id = self.create_image((x, y), image=image, tags=tags.WALLPAPER) self.wallpaper_drawn = image - def wallpaper_upper_left(self): + def wallpaper_upper_left(self) -> None: self.delete(self.wallpaper_id) # create new scaled image, cropped if needed @@ -775,7 +775,7 @@ class CanvasGraph(tk.Canvas): if image.height > height: cropy = image.height cropped = image.crop((0, 0, cropx, cropy)) - image = ImageTk.PhotoImage(cropped) + image = PhotoImage(cropped) # draw on canvas x1, y1, _, _ = self.bbox(self.rect) @@ -783,7 +783,7 @@ class CanvasGraph(tk.Canvas): y = (cropy / 2) + y1 self.draw_wallpaper(image, x, y) - def wallpaper_center(self): + def wallpaper_center(self) -> None: """ place the image at the center of canvas """ @@ -803,26 +803,26 @@ class CanvasGraph(tk.Canvas): x2 = image.width - cropx y2 = image.height - cropy cropped = image.crop((x1, y1, x2, y2)) - image = ImageTk.PhotoImage(cropped) + image = PhotoImage(cropped) self.draw_wallpaper(image) - def wallpaper_scaled(self): + def wallpaper_scaled(self) -> None: """ scale image based on canvas dimension """ self.delete(self.wallpaper_id) canvas_w, canvas_h = self.width_and_height() image = self.wallpaper.resize((int(canvas_w), int(canvas_h)), Image.ANTIALIAS) - image = ImageTk.PhotoImage(image) + image = PhotoImage(image) self.draw_wallpaper(image) - def resize_to_wallpaper(self): + def resize_to_wallpaper(self) -> None: self.delete(self.wallpaper_id) - image = ImageTk.PhotoImage(self.wallpaper) + image = PhotoImage(self.wallpaper) self.redraw_canvas((image.width(), image.height())) self.draw_wallpaper(image) - def redraw_canvas(self, dimensions: Tuple[int, int] = None): + def redraw_canvas(self, dimensions: Tuple[int, int] = None) -> None: logging.debug("redrawing canvas to dimensions: %s", dimensions) # reset scale and move back to original position @@ -843,7 +843,7 @@ class CanvasGraph(tk.Canvas): self.draw_grid() self.app.canvas.show_grid.click_handler() - def redraw_wallpaper(self): + def redraw_wallpaper(self) -> None: if self.adjust_to_dim.get(): logging.debug("drawing wallpaper to canvas dimensions") self.resize_to_wallpaper() @@ -864,7 +864,7 @@ class CanvasGraph(tk.Canvas): for tag in tags.ORGANIZE_TAGS: self.tag_raise(tag) - def set_wallpaper(self, filename: Optional[str]): + def set_wallpaper(self, filename: Optional[str]) -> None: logging.debug("setting wallpaper: %s", filename) if filename: img = Image.open(filename) @@ -880,7 +880,7 @@ class CanvasGraph(tk.Canvas): def is_selection_mode(self) -> bool: return self.mode == GraphMode.SELECT - def create_edge(self, source: CanvasNode, dest: CanvasNode): + def create_edge(self, source: CanvasNode, dest: CanvasNode) -> None: """ create an edge between source node and destination node """ @@ -894,7 +894,7 @@ class CanvasGraph(tk.Canvas): self.nodes[dest.id].edges.add(edge) self.core.create_link(edge, source, dest) - def copy(self): + def copy(self) -> None: if self.core.is_runtime(): logging.debug("copy is disabled during runtime state") return @@ -905,7 +905,7 @@ class CanvasGraph(tk.Canvas): canvas_node = self.nodes[node_id] self.to_copy.append(canvas_node) - def paste(self): + def paste(self) -> None: if self.core.is_runtime(): logging.debug("paste is disabled during runtime state") return @@ -972,11 +972,11 @@ class CanvasGraph(tk.Canvas): else: asym_iface1 = None if iface1_id: - asym_iface1 = core_pb2.Interface(id=iface1_id) + asym_iface1 = Interface(id=iface1_id) asym_iface2 = None if iface2_id: - asym_iface2 = core_pb2.Interface(id=iface2_id) - copy_edge.asymmetric_link = core_pb2.Link( + asym_iface2 = Interface(id=iface2_id) + copy_edge.asymmetric_link = Link( node1_id=copy_link.node2_id, node2_id=copy_link.node1_id, iface1=asym_iface1, @@ -990,7 +990,7 @@ class CanvasGraph(tk.Canvas): ) self.tag_raise(tags.NODE) - def scale_graph(self): + def scale_graph(self) -> None: for nid, canvas_node in self.nodes.items(): img = None if NodeUtils.is_custom( diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 3ba4b3f7..f936bc79 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -1,12 +1,14 @@ import functools import logging import tkinter as tk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple import grpc +from PIL.ImageTk import PhotoImage -from core.api.grpc import core_pb2 -from core.api.grpc.core_pb2 import NodeType +from core.api.grpc.common_pb2 import ConfigOption +from core.api.grpc.core_pb2 import Interface, Node, NodeType +from core.api.grpc.services_pb2 import NodeServiceData from core.gui import themes from core.gui.dialogs.emaneconfig import EmaneConfigDialog from core.gui.dialogs.mobilityconfig import MobilityConfigDialog @@ -15,36 +17,31 @@ from core.gui.dialogs.nodeconfigservice import NodeConfigServiceDialog from core.gui.dialogs.nodeservice import NodeServiceDialog from core.gui.dialogs.wlanconfig import WlanConfigDialog from core.gui.graph import tags -from core.gui.graph.edges import CanvasEdge +from core.gui.graph.edges import CanvasEdge, CanvasWirelessEdge from core.gui.graph.tooltip import CanvasTooltip from core.gui.images import ImageEnum from core.gui.nodeutils import ANTENNA_SIZE, NodeUtils if TYPE_CHECKING: from core.gui.app import Application - from PIL.ImageTk import PhotoImage + from core.gui.graph.graph import CanvasGraph -NODE_TEXT_OFFSET = 5 +NODE_TEXT_OFFSET: int = 5 class CanvasNode: def __init__( - self, - app: "Application", - x: float, - y: float, - core_node: core_pb2.Node, - image: "PhotoImage", + self, app: "Application", x: float, y: float, core_node: Node, image: PhotoImage ): - self.app = app - self.canvas = app.canvas - self.image = image - self.core_node = core_node - self.id = self.canvas.create_image( + self.app: "Application" = app + self.canvas: "CanvasGraph" = app.canvas + self.image: PhotoImage = image + self.core_node: Node = core_node + self.id: int = self.canvas.create_image( x, y, anchor=tk.CENTER, image=self.image, tags=tags.NODE ) label_y = self._get_label_y() - self.text_id = self.canvas.create_text( + self.text_id: int = self.canvas.create_text( x, label_y, text=self.core_node.name, @@ -53,21 +50,21 @@ class CanvasNode: fill="#0000CD", state=self.canvas.show_node_labels.state(), ) - self.tooltip = CanvasTooltip(self.canvas) - self.edges = set() - self.ifaces = {} - self.wireless_edges = set() - self.antennas = [] - self.antenna_images = {} + self.tooltip: CanvasTooltip = CanvasTooltip(self.canvas) + self.edges: Set[CanvasEdge] = set() + self.ifaces: Dict[int, Interface] = {} + self.wireless_edges: Set[CanvasWirelessEdge] = set() + self.antennas: List[int] = [] + self.antenna_images: Dict[int, PhotoImage] = {} # possible configurations - self.emane_model_configs = {} - self.wlan_config = {} - self.mobility_config = {} - self.service_configs = {} - self.service_file_configs = {} - self.config_service_configs = {} + self.emane_model_configs: Dict[Tuple[str, Optional[int]], ConfigOption] = {} + self.wlan_config: Dict[str, ConfigOption] = {} + self.mobility_config: Dict[str, ConfigOption] = {} + self.service_configs: Dict[str, NodeServiceData] = {} + self.service_file_configs: Dict[str, Dict[str, str]] = {} + self.config_service_configs: Dict[str, Any] = {} self.setup_bindings() - self.context = tk.Menu(self.canvas) + self.context: tk.Menu = tk.Menu(self.canvas) themes.style_menu(self.context) def next_iface_id(self) -> int: @@ -76,19 +73,19 @@ class CanvasNode: i += 1 return i - def setup_bindings(self): + def setup_bindings(self) -> None: self.canvas.tag_bind(self.id, "", self.double_click) self.canvas.tag_bind(self.id, "", self.on_enter) self.canvas.tag_bind(self.id, "", self.on_leave) self.canvas.tag_bind(self.id, "", self.show_context) - def delete(self): + def delete(self) -> None: logging.debug("Delete canvas node for %s", self.core_node) self.canvas.delete(self.id) self.canvas.delete(self.text_id) self.delete_antennas() - def add_antenna(self): + def add_antenna(self) -> None: x, y = self.canvas.coords(self.id) offset = len(self.antennas) * 8 * self.app.app_scale img = self.app.get_icon(ImageEnum.ANTENNA, ANTENNA_SIZE) @@ -102,7 +99,7 @@ class CanvasNode: self.antennas.append(antenna_id) self.antenna_images[antenna_id] = img - def delete_antenna(self): + def delete_antenna(self) -> None: """ delete one antenna """ @@ -112,7 +109,7 @@ class CanvasNode: self.canvas.delete(antenna_id) self.antenna_images.pop(antenna_id, None) - def delete_antennas(self): + def delete_antennas(self) -> None: """ delete all antennas """ @@ -122,30 +119,30 @@ class CanvasNode: self.antennas.clear() self.antenna_images.clear() - def redraw(self): + def redraw(self) -> None: self.canvas.itemconfig(self.id, image=self.image) self.canvas.itemconfig(self.text_id, text=self.core_node.name) for edge in self.edges: edge.redraw() - def _get_label_y(self): + def _get_label_y(self) -> int: image_box = self.canvas.bbox(self.id) return image_box[3] + NODE_TEXT_OFFSET - def scale_text(self): + def scale_text(self) -> None: text_bound = self.canvas.bbox(self.text_id) prev_y = (text_bound[3] + text_bound[1]) / 2 new_y = self._get_label_y() self.canvas.move(self.text_id, 0, new_y - prev_y) - def move(self, x: int, y: int): + def move(self, x: int, y: int) -> None: x, y = self.canvas.get_scaled_coords(x, y) current_x, current_y = self.canvas.coords(self.id) x_offset = x - current_x y_offset = y - current_y self.motion(x_offset, y_offset, update=False) - def motion(self, x_offset: int, y_offset: int, update: bool = True): + def motion(self, x_offset: float, y_offset: float, update: bool = True) -> None: original_position = self.canvas.coords(self.id) self.canvas.move(self.id, x_offset, y_offset) pos = self.canvas.coords(self.id) @@ -177,7 +174,7 @@ class CanvasNode: if self.app.core.is_runtime() and update: self.app.core.edit_node(self.core_node) - def on_enter(self, event: tk.Event): + def on_enter(self, event: tk.Event) -> None: if self.app.core.is_runtime() and self.app.core.observer: self.tooltip.text.set("waiting...") self.tooltip.on_enter(event) @@ -187,10 +184,10 @@ class CanvasNode: except grpc.RpcError as e: self.app.show_grpc_exception("Observer Error", e) - def on_leave(self, event: tk.Event): + def on_leave(self, event: tk.Event) -> None: self.tooltip.on_leave(event) - def double_click(self, event: tk.Event): + def double_click(self, event: tk.Event) -> None: if self.app.core.is_runtime(): self.canvas.core.launch_terminal(self.core_node.id) else: @@ -270,37 +267,37 @@ class CanvasNode: self.canvas.selection[self.id] = self self.canvas.copy() - def show_config(self): + def show_config(self) -> None: dialog = NodeConfigDialog(self.app, self) dialog.show() - def show_wlan_config(self): + def show_wlan_config(self) -> None: dialog = WlanConfigDialog(self.app, self) if not dialog.has_error: dialog.show() - def show_mobility_config(self): + def show_mobility_config(self) -> None: dialog = MobilityConfigDialog(self.app, self) if not dialog.has_error: dialog.show() - def show_mobility_player(self): + def show_mobility_player(self) -> None: mobility_player = self.app.core.mobility_players[self.core_node.id] mobility_player.show() - def show_emane_config(self): + def show_emane_config(self) -> None: dialog = EmaneConfigDialog(self.app, self) dialog.show() - def show_services(self): + def show_services(self) -> None: dialog = NodeServiceDialog(self.app, self) dialog.show() - def show_config_services(self): + def show_config_services(self) -> None: dialog = NodeConfigServiceDialog(self.app, self) dialog.show() - def has_emane_link(self, iface_id: int) -> core_pb2.Node: + def has_emane_link(self, iface_id: int) -> Node: result = None for edge in self.edges: if self.id == edge.src: @@ -317,14 +314,14 @@ class CanvasNode: break return result - def wireless_link_selected(self): + def wireless_link_selected(self) -> None: nodes = [x for x in self.canvas.selection if x in self.canvas.nodes] for node_id in nodes: canvas_node = self.canvas.nodes[node_id] self.canvas.create_edge(self, canvas_node) self.canvas.clear_selection() - def scale_antennas(self): + def scale_antennas(self) -> None: for i in range(len(self.antennas)): antenna_id = self.antennas[i] image = self.app.get_icon(ImageEnum.ANTENNA, ANTENNA_SIZE) diff --git a/daemon/core/gui/graph/shape.py b/daemon/core/gui/graph/shape.py index 70f67d14..36298655 100644 --- a/daemon/core/gui/graph/shape.py +++ b/daemon/core/gui/graph/shape.py @@ -1,5 +1,5 @@ import logging -from typing import TYPE_CHECKING, Dict, List, Union +from typing import TYPE_CHECKING, Dict, List, Optional, Union from core.gui.dialogs.shapemod import ShapeDialog from core.gui.graph import tags @@ -23,17 +23,17 @@ class AnnotationData: bold: bool = False, italic: bool = False, underline: bool = False, - ): - self.text = text - self.font = font - self.font_size = font_size - self.text_color = text_color - self.fill_color = fill_color - self.border_color = border_color - self.border_width = border_width - self.bold = bold - self.italic = italic - self.underline = underline + ) -> None: + self.text: str = text + self.font: str = font + self.font_size: int = font_size + self.text_color: str = text_color + self.fill_color: str = fill_color + self.border_color: str = border_color + self.border_width: int = border_width + self.bold: bool = bold + self.italic: bool = italic + self.underline: bool = underline class Shape: @@ -47,29 +47,29 @@ class Shape: x2: float = None, y2: float = None, data: AnnotationData = None, - ): - self.app = app - self.canvas = canvas - self.shape_type = shape_type - self.id = None - self.text_id = None - self.x1 = x1 - self.y1 = y1 + ) -> None: + self.app: "Application" = app + self.canvas: "CanvasGraph" = canvas + self.shape_type: ShapeType = shape_type + self.id: Optional[int] = None + self.text_id: Optional[int] = None + self.x1: float = x1 + self.y1: float = y1 if x2 is None: x2 = x1 - self.x2 = x2 + self.x2: float = x2 if y2 is None: y2 = y1 - self.y2 = y2 + self.y2: float = y2 if data is None: - self.created = False - self.shape_data = AnnotationData() + self.created: bool = False + self.shape_data: AnnotationData = AnnotationData() else: - self.created = True + self.created: bool = True self.shape_data = data self.draw() - def draw(self): + def draw(self) -> None: if self.created: dash = None else: @@ -127,7 +127,7 @@ class Shape: font.append("underline") return font - def draw_shape_text(self): + def draw_shape_text(self) -> None: if self.shape_data.text: x = (self.x1 + self.x2) / 2 y = self.y1 + 1.5 * self.shape_data.font_size @@ -142,18 +142,18 @@ class Shape: state=self.canvas.show_annotations.state(), ) - def shape_motion(self, x1: float, y1: float): + def shape_motion(self, x1: float, y1: float) -> None: self.canvas.coords(self.id, self.x1, self.y1, x1, y1) - def shape_complete(self, x: float, y: float): + def shape_complete(self, x: float, y: float) -> None: self.canvas.organize() s = ShapeDialog(self.app, self) s.show() - def disappear(self): + def disappear(self) -> None: self.canvas.delete(self.id) - def motion(self, x_offset: float, y_offset: float): + def motion(self, x_offset: float, y_offset: float) -> None: original_position = self.canvas.coords(self.id) self.canvas.move(self.id, x_offset, y_offset) coords = self.canvas.coords(self.id) @@ -166,7 +166,7 @@ class Shape: if self.text_id is not None: self.canvas.move(self.text_id, x_offset, y_offset) - def delete(self): + def delete(self) -> None: logging.debug("Delete shape, id(%s)", self.id) self.canvas.delete(self.id) self.canvas.delete(self.text_id) diff --git a/daemon/core/gui/graph/shapeutils.py b/daemon/core/gui/graph/shapeutils.py index ce2b7f96..2b62a46c 100644 --- a/daemon/core/gui/graph/shapeutils.py +++ b/daemon/core/gui/graph/shapeutils.py @@ -1,4 +1,5 @@ import enum +from typing import Set class ShapeType(enum.Enum): @@ -8,7 +9,7 @@ class ShapeType(enum.Enum): TEXT = "text" -SHAPES = {ShapeType.OVAL, ShapeType.RECTANGLE} +SHAPES: Set[ShapeType] = {ShapeType.OVAL, ShapeType.RECTANGLE} def is_draw_shape(shape_type: ShapeType) -> bool: diff --git a/daemon/core/gui/graph/tags.py b/daemon/core/gui/graph/tags.py index c0721193..b7b35517 100644 --- a/daemon/core/gui/graph/tags.py +++ b/daemon/core/gui/graph/tags.py @@ -1,17 +1,19 @@ -ANNOTATION = "annotation" -GRIDLINE = "gridline" -SHAPE = "shape" -SHAPE_TEXT = "shapetext" -EDGE = "edge" -LINK_LABEL = "linklabel" -WIRELESS_EDGE = "wireless" -ANTENNA = "antenna" -NODE_LABEL = "nodename" -NODE = "node" -WALLPAPER = "wallpaper" -SELECTION = "selectednodes" -MARKER = "marker" -ORGANIZE_TAGS = [ +from typing import List + +ANNOTATION: str = "annotation" +GRIDLINE: str = "gridline" +SHAPE: str = "shape" +SHAPE_TEXT: str = "shapetext" +EDGE: str = "edge" +LINK_LABEL: str = "linklabel" +WIRELESS_EDGE: str = "wireless" +ANTENNA: str = "antenna" +NODE_LABEL: str = "nodename" +NODE: str = "node" +WALLPAPER: str = "wallpaper" +SELECTION: str = "selectednodes" +MARKER: str = "marker" +ORGANIZE_TAGS: List[str] = [ WALLPAPER, GRIDLINE, SHAPE, @@ -25,7 +27,7 @@ ORGANIZE_TAGS = [ SELECTION, MARKER, ] -RESET_TAGS = [ +RESET_TAGS: List[str] = [ EDGE, NODE, NODE_LABEL, diff --git a/daemon/core/gui/graph/tooltip.py b/daemon/core/gui/graph/tooltip.py index a2193901..6e4aa62f 100644 --- a/daemon/core/gui/graph/tooltip.py +++ b/daemon/core/gui/graph/tooltip.py @@ -1,6 +1,6 @@ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, Tuple from core.gui.themes import Styles @@ -27,39 +27,45 @@ class CanvasTooltip: self, canvas: "CanvasGraph", *, - pad=(5, 3, 5, 3), + pad: Tuple[int, int, int, int] = (5, 3, 5, 3), waittime: int = 400, wraplength: int = 600 - ): + ) -> None: # in miliseconds, originally 500 - self.waittime = waittime + self.waittime: int = waittime # in pixels, originally 180 - self.wraplength = wraplength - self.canvas = canvas - self.text = tk.StringVar() - self.pad = pad - self.id = None - self.tw = None + self.wraplength: int = wraplength + self.canvas: "CanvasGraph" = canvas + self.text: tk.StringVar = tk.StringVar() + self.pad: Tuple[int, int, int, int] = pad + self.id: Optional[str] = None + self.tw: Optional[tk.Toplevel] = None - def on_enter(self, event: tk.Event = None): + def on_enter(self, event: tk.Event = None) -> None: self.schedule() - def on_leave(self, event: tk.Event = None): + def on_leave(self, event: tk.Event = None) -> None: self.unschedule() self.hide() - def schedule(self): + def schedule(self) -> None: self.unschedule() self.id = self.canvas.after(self.waittime, self.show) - def unschedule(self): + def unschedule(self) -> None: id_ = self.id self.id = None if id_: self.canvas.after_cancel(id_) - def show(self, event: tk.Event = None): - def tip_pos_calculator(canvas, label, *, tip_delta=(10, 5), pad=(5, 3, 5, 3)): + def show(self, event: tk.Event = None) -> None: + def tip_pos_calculator( + canvas: "CanvasGraph", + label: ttk.Label, + *, + tip_delta: Tuple[int, int] = (10, 5), + pad: Tuple[int, int, int, int] = (5, 3, 5, 3) + ): c = canvas s_width, s_height = c.winfo_screenwidth(), c.winfo_screenheight() width, height = ( @@ -108,7 +114,7 @@ class CanvasTooltip: x, y = tip_pos_calculator(canvas, label, pad=pad) self.tw.wm_geometry("+%d+%d" % (x, y)) - def hide(self): + def hide(self) -> None: if self.tw: self.tw.destroy() self.tw = None From 527d34e3746d781908abe6a256b90db89dd77d9a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 22 Jun 2020 11:04:33 -0700 Subject: [PATCH 186/929] pygui: added type hinting to class variables for core.gui.dialogs --- daemon/core/gui/coreclient.py | 4 +- daemon/core/gui/dialogs/about.py | 4 +- daemon/core/gui/dialogs/alerts.py | 18 +-- daemon/core/gui/dialogs/canvassizeandscale.py | 49 +++--- daemon/core/gui/dialogs/canvaswallpaper.py | 43 ++--- daemon/core/gui/dialogs/colorpicker.py | 52 +++--- .../core/gui/dialogs/configserviceconfig.py | 113 +++++++------ daemon/core/gui/dialogs/copyserviceconfig.py | 15 +- daemon/core/gui/dialogs/customnodes.py | 72 +++++---- daemon/core/gui/dialogs/dialog.py | 12 +- daemon/core/gui/dialogs/emaneconfig.py | 78 ++++----- daemon/core/gui/dialogs/emaneinstall.py | 4 +- daemon/core/gui/dialogs/error.py | 8 +- daemon/core/gui/dialogs/executepython.py | 20 +-- daemon/core/gui/dialogs/find.py | 6 +- daemon/core/gui/dialogs/hooks.py | 40 ++--- daemon/core/gui/dialogs/ipdialog.py | 18 +-- daemon/core/gui/dialogs/linkconfig.py | 57 +++---- daemon/core/gui/dialogs/macdialog.py | 2 +- daemon/core/gui/dialogs/mobilityconfig.py | 29 ++-- daemon/core/gui/dialogs/mobilityplayer.py | 70 ++++---- daemon/core/gui/dialogs/nodeconfig.py | 50 +++--- daemon/core/gui/dialogs/nodeconfigservice.py | 32 ++-- daemon/core/gui/dialogs/nodeservice.py | 28 ++-- daemon/core/gui/dialogs/observers.py | 38 ++--- daemon/core/gui/dialogs/preferences.py | 28 ++-- daemon/core/gui/dialogs/runtool.py | 10 +- daemon/core/gui/dialogs/servers.py | 42 ++--- daemon/core/gui/dialogs/serviceconfig.py | 149 +++++++++--------- daemon/core/gui/dialogs/sessionoptions.py | 17 +- daemon/core/gui/dialogs/sessions.py | 19 +-- daemon/core/gui/dialogs/shapemod.py | 63 ++++---- daemon/core/gui/dialogs/throughput.py | 31 ++-- daemon/core/gui/dialogs/wlanconfig.py | 46 +++--- daemon/core/gui/graph/node.py | 6 +- daemon/core/gui/menubar.py | 2 +- daemon/core/gui/nodeutils.py | 2 +- 37 files changed, 664 insertions(+), 613 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 5e1bf4c2..39ee486a 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -38,7 +38,7 @@ from core.api.grpc.mobility_pb2 import MobilityConfig from core.api.grpc.services_pb2 import NodeServiceData, ServiceConfig, ServiceFileConfig from core.api.grpc.wlan_pb2 import WlanConfig from core.gui import appconfig -from core.gui.appconfig import CoreServer +from core.gui.appconfig import CoreServer, Observer from core.gui.dialogs.emaneinstall import EmaneInstallDialog from core.gui.dialogs.error import ErrorDialog from core.gui.dialogs.mobilityplayer import MobilityPlayer @@ -75,7 +75,7 @@ class CoreClient: # loaded configuration data self.servers: Dict[str, CoreServer] = {} self.custom_nodes: Dict[str, NodeDraw] = {} - self.custom_observers: Dict[str, str] = {} + self.custom_observers: Dict[str, Observer] = {} self.read_config() # helpers diff --git a/daemon/core/gui/dialogs/about.py b/daemon/core/gui/dialogs/about.py index 2e649169..fa96e218 100644 --- a/daemon/core/gui/dialogs/about.py +++ b/daemon/core/gui/dialogs/about.py @@ -35,11 +35,11 @@ THE POSSIBILITY OF SUCH DAMAGE.\ class AboutDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: super().__init__(app, "About CORE") self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) diff --git a/daemon/core/gui/dialogs/alerts.py b/daemon/core/gui/dialogs/alerts.py index a0c3e68b..00ef1e8c 100644 --- a/daemon/core/gui/dialogs/alerts.py +++ b/daemon/core/gui/dialogs/alerts.py @@ -3,9 +3,9 @@ check engine light """ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, Optional -from core.api.grpc.core_pb2 import ExceptionLevel +from core.api.grpc.core_pb2 import ExceptionEvent, ExceptionLevel from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY from core.gui.widgets import CodeText @@ -15,14 +15,14 @@ if TYPE_CHECKING: class AlertsDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: super().__init__(app, "Alerts") - self.tree = None - self.codetext = None - self.alarm_map = {} + self.tree: Optional[ttk.Treeview] = None + self.codetext: Optional[CodeText] = None + self.alarm_map: Dict[int, ExceptionEvent] = {} self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) self.top.rowconfigure(1, weight=1) @@ -97,13 +97,13 @@ class AlertsDialog(Dialog): button = ttk.Button(frame, text="Close", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def reset_alerts(self): + def reset_alerts(self) -> None: self.codetext.text.delete("1.0", tk.END) for item in self.tree.get_children(): self.tree.delete(item) self.app.statusbar.core_alarms.clear() - def click_select(self, event: tk.Event): + def click_select(self, event: tk.Event) -> None: current = self.tree.selection()[0] alarm = self.alarm_map[current] self.codetext.text.config(state=tk.NORMAL) diff --git a/daemon/core/gui/dialogs/canvassizeandscale.py b/daemon/core/gui/dialogs/canvassizeandscale.py index 6a63a1ae..b93bd920 100644 --- a/daemon/core/gui/dialogs/canvassizeandscale.py +++ b/daemon/core/gui/dialogs/canvassizeandscale.py @@ -7,38 +7,43 @@ from typing import TYPE_CHECKING from core.gui import validation from core.gui.dialogs.dialog import Dialog +from core.gui.graph.graph import CanvasGraph from core.gui.themes import FRAME_PAD, PADX, PADY if TYPE_CHECKING: from core.gui.app import Application -PIXEL_SCALE = 100 +PIXEL_SCALE: int = 100 class SizeAndScaleDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: """ create an instance for size and scale object """ super().__init__(app, "Canvas Size and Scale") - self.canvas = self.app.canvas - self.section_font = font.Font(weight="bold") + self.canvas: CanvasGraph = self.app.canvas + self.section_font: font.Font = font.Font(weight="bold") width, height = self.canvas.current_dimensions - self.pixel_width = tk.IntVar(value=width) - self.pixel_height = tk.IntVar(value=height) + self.pixel_width: tk.IntVar = tk.IntVar(value=width) + self.pixel_height: tk.IntVar = tk.IntVar(value=height) location = self.app.core.location - self.x = tk.DoubleVar(value=location.x) - self.y = tk.DoubleVar(value=location.y) - self.lat = tk.DoubleVar(value=location.lat) - self.lon = tk.DoubleVar(value=location.lon) - self.alt = tk.DoubleVar(value=location.alt) - self.scale = tk.DoubleVar(value=location.scale) - self.meters_width = tk.IntVar(value=width / PIXEL_SCALE * location.scale) - self.meters_height = tk.IntVar(value=height / PIXEL_SCALE * location.scale) - self.save_default = tk.BooleanVar(value=False) + self.x: tk.DoubleVar = tk.DoubleVar(value=location.x) + self.y: tk.DoubleVar = tk.DoubleVar(value=location.y) + self.lat: tk.DoubleVar = tk.DoubleVar(value=location.lat) + self.lon: tk.DoubleVar = tk.DoubleVar(value=location.lon) + self.alt: tk.DoubleVar = tk.DoubleVar(value=location.alt) + self.scale: tk.DoubleVar = tk.DoubleVar(value=location.scale) + self.meters_width: tk.IntVar = tk.IntVar( + value=width / PIXEL_SCALE * location.scale + ) + self.meters_height: tk.IntVar = tk.IntVar( + value=height / PIXEL_SCALE * location.scale + ) + self.save_default: tk.BooleanVar = tk.BooleanVar(value=False) self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.draw_size() self.draw_scale() @@ -47,7 +52,7 @@ class SizeAndScaleDialog(Dialog): self.draw_spacer() self.draw_buttons() - def draw_size(self): + def draw_size(self) -> None: label_frame = ttk.Labelframe(self.top, text="Size", padding=FRAME_PAD) label_frame.grid(sticky="ew") label_frame.columnconfigure(0, weight=1) @@ -84,7 +89,7 @@ class SizeAndScaleDialog(Dialog): label = ttk.Label(frame, text="Meters") label.grid(row=0, column=4, sticky="w") - def draw_scale(self): + def draw_scale(self) -> None: label_frame = ttk.Labelframe(self.top, text="Scale", padding=FRAME_PAD) label_frame.grid(sticky="ew") label_frame.columnconfigure(0, weight=1) @@ -99,7 +104,7 @@ class SizeAndScaleDialog(Dialog): label = ttk.Label(frame, text="Meters") label.grid(row=0, column=2, sticky="w") - def draw_reference_point(self): + def draw_reference_point(self) -> None: label_frame = ttk.Labelframe( self.top, text="Reference Point", padding=FRAME_PAD ) @@ -150,13 +155,13 @@ class SizeAndScaleDialog(Dialog): entry = validation.FloatEntry(frame, textvariable=self.alt) entry.grid(row=0, column=5, sticky="ew") - def draw_save_as_default(self): + def draw_save_as_default(self) -> None: button = ttk.Checkbutton( self.top, text="Save as default?", variable=self.save_default ) button.grid(sticky="w", pady=PADY) - def draw_buttons(self): + def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) @@ -168,7 +173,7 @@ class SizeAndScaleDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_apply(self): + def click_apply(self) -> None: width, height = self.pixel_width.get(), self.pixel_height.get() self.canvas.redraw_canvas((width, height)) if self.canvas.wallpaper: diff --git a/daemon/core/gui/dialogs/canvaswallpaper.py b/daemon/core/gui/dialogs/canvaswallpaper.py index 5e8460be..8a1e71d8 100644 --- a/daemon/core/gui/dialogs/canvaswallpaper.py +++ b/daemon/core/gui/dialogs/canvaswallpaper.py @@ -4,10 +4,11 @@ set wallpaper import logging import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional from core.gui.appconfig import BACKGROUNDS_PATH from core.gui.dialogs.dialog import Dialog +from core.gui.graph.graph import CanvasGraph from core.gui.images import Images from core.gui.themes import PADX, PADY from core.gui.widgets import image_chooser @@ -17,20 +18,22 @@ if TYPE_CHECKING: class CanvasWallpaperDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: """ create an instance of CanvasWallpaper object """ super().__init__(app, "Canvas Background") - self.canvas = self.app.canvas - self.scale_option = tk.IntVar(value=self.canvas.scale_option.get()) - self.adjust_to_dim = tk.BooleanVar(value=self.canvas.adjust_to_dim.get()) - self.filename = tk.StringVar(value=self.canvas.wallpaper_file) - self.image_label = None - self.options = [] + self.canvas: CanvasGraph = self.app.canvas + self.scale_option: tk.IntVar = tk.IntVar(value=self.canvas.scale_option.get()) + self.adjust_to_dim: tk.BooleanVar = tk.BooleanVar( + value=self.canvas.adjust_to_dim.get() + ) + self.filename: tk.StringVar = tk.StringVar(value=self.canvas.wallpaper_file) + self.image_label: Optional[ttk.Label] = None + self.options: List[ttk.Radiobutton] = [] self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.draw_image() self.draw_image_label() @@ -40,19 +43,19 @@ class CanvasWallpaperDialog(Dialog): self.draw_spacer() self.draw_buttons() - def draw_image(self): + def draw_image(self) -> None: self.image_label = ttk.Label( self.top, text="(image preview)", width=32, anchor=tk.CENTER ) self.image_label.grid(pady=PADY) - def draw_image_label(self): + def draw_image_label(self) -> None: label = ttk.Label(self.top, text="Image filename: ") label.grid(sticky="ew") if self.filename.get(): self.draw_preview() - def draw_image_selection(self): + def draw_image_selection(self) -> None: frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=2) frame.columnconfigure(1, weight=1) @@ -69,7 +72,7 @@ class CanvasWallpaperDialog(Dialog): button = ttk.Button(frame, text="Clear", command=self.click_clear) button.grid(row=0, column=2, sticky="ew") - def draw_options(self): + def draw_options(self) -> None: frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) @@ -101,7 +104,7 @@ class CanvasWallpaperDialog(Dialog): button.grid(row=0, column=3, sticky="ew") self.options.append(button) - def draw_additional_options(self): + def draw_additional_options(self) -> None: checkbutton = ttk.Checkbutton( self.top, text="Adjust canvas size to image dimensions", @@ -110,7 +113,7 @@ class CanvasWallpaperDialog(Dialog): ) checkbutton.grid(sticky="ew", padx=PADX) - def draw_buttons(self): + def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(pady=PADY, sticky="ew") frame.columnconfigure(0, weight=1) @@ -122,18 +125,18 @@ class CanvasWallpaperDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_open_image(self): + def click_open_image(self) -> None: filename = image_chooser(self, BACKGROUNDS_PATH) if filename: self.filename.set(filename) self.draw_preview() - def draw_preview(self): + def draw_preview(self) -> None: image = Images.create(self.filename.get(), 250, 135) self.image_label.config(image=image) self.image_label.image = image - def click_clear(self): + def click_clear(self) -> None: """ delete like shown in image link entry if there is any """ @@ -143,7 +146,7 @@ class CanvasWallpaperDialog(Dialog): self.image_label.config(image="", width=32) self.image_label.image = None - def click_adjust_canvas(self): + def click_adjust_canvas(self) -> None: # deselect all radio buttons and grey them out if self.adjust_to_dim.get(): self.scale_option.set(0) @@ -155,7 +158,7 @@ class CanvasWallpaperDialog(Dialog): for option in self.options: option.config(state=tk.NORMAL) - def click_apply(self): + def click_apply(self) -> None: self.canvas.scale_option.set(self.scale_option.get()) self.canvas.adjust_to_dim.set(self.adjust_to_dim.get()) self.canvas.show_grid.click_handler() diff --git a/daemon/core/gui/dialogs/colorpicker.py b/daemon/core/gui/dialogs/colorpicker.py index b1968cd4..908b8acb 100644 --- a/daemon/core/gui/dialogs/colorpicker.py +++ b/daemon/core/gui/dialogs/colorpicker.py @@ -3,7 +3,7 @@ custom color picker """ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, Tuple from core.gui import validation from core.gui.dialogs.dialog import Dialog @@ -18,23 +18,23 @@ class ColorPickerDialog(Dialog): self, master: tk.BaseWidget, app: "Application", initcolor: str = "#000000" ): super().__init__(app, "Color Picker", master=master) - self.red_entry = None - self.blue_entry = None - self.green_entry = None - self.hex_entry = None - self.red_label = None - self.green_label = None - self.blue_label = None - self.display = None - self.color = initcolor + self.red_entry: Optional[validation.RgbEntry] = None + self.blue_entry: Optional[validation.RgbEntry] = None + self.green_entry: Optional[validation.RgbEntry] = None + self.hex_entry: Optional[validation.HexEntry] = None + self.red_label: Optional[ttk.Label] = None + self.green_label: Optional[ttk.Label] = None + self.blue_label: Optional[ttk.Label] = None + self.display: Optional[tk.Frame] = None + self.color: str = initcolor red, green, blue = self.get_rgb(initcolor) - self.red = tk.IntVar(value=red) - self.blue = tk.IntVar(value=blue) - self.green = tk.IntVar(value=green) - self.hex = tk.StringVar(value=initcolor) - self.red_scale = tk.IntVar(value=red) - self.green_scale = tk.IntVar(value=green) - self.blue_scale = tk.IntVar(value=blue) + self.red: tk.IntVar = tk.IntVar(value=red) + self.blue: tk.IntVar = tk.IntVar(value=blue) + self.green: tk.IntVar = tk.IntVar(value=green) + self.hex: tk.StringVar = tk.StringVar(value=initcolor) + self.red_scale: tk.IntVar = tk.IntVar(value=red) + self.green_scale: tk.IntVar = tk.IntVar(value=green) + self.blue_scale: tk.IntVar = tk.IntVar(value=blue) self.draw() self.set_bindings() @@ -42,7 +42,7 @@ class ColorPickerDialog(Dialog): self.show() return self.color - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(3, weight=1) @@ -136,7 +136,7 @@ class ColorPickerDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def set_bindings(self): + def set_bindings(self) -> None: self.red_entry.bind("", lambda x: self.current_focus("rgb")) self.green_entry.bind("", lambda x: self.current_focus("rgb")) self.blue_entry.bind("", lambda x: self.current_focus("rgb")) @@ -146,7 +146,7 @@ class ColorPickerDialog(Dialog): self.blue.trace_add("write", self.update_color) self.hex.trace_add("write", self.update_color) - def button_ok(self): + def button_ok(self) -> None: self.color = self.hex.get() self.destroy() @@ -159,10 +159,10 @@ class ColorPickerDialog(Dialog): green = self.green_entry.get() return "#%02x%02x%02x" % (int(red), int(green), int(blue)) - def current_focus(self, focus: str): + def current_focus(self, focus: str) -> None: self.focus = focus - def update_color(self, arg1=None, arg2=None, arg3=None): + def update_color(self, arg1=None, arg2=None, arg3=None) -> None: if self.focus == "rgb": red = self.red_entry.get() blue = self.blue_entry.get() @@ -184,7 +184,7 @@ class ColorPickerDialog(Dialog): self.display.config(background=hex_code) self.set_label(str(red), str(green), str(blue)) - def scale_callback(self, var: tk.IntVar, color_var: tk.IntVar): + def scale_callback(self, var: tk.IntVar, color_var: tk.IntVar) -> None: color_var.set(var.get()) self.focus = "rgb" self.update_color() @@ -194,17 +194,17 @@ class ColorPickerDialog(Dialog): self.green_scale.set(green) self.blue_scale.set(blue) - def set_entry(self, red: int, green: int, blue: int): + def set_entry(self, red: int, green: int, blue: int) -> None: self.red.set(red) self.green.set(green) self.blue.set(blue) - def set_label(self, red: str, green: str, blue: str): + def set_label(self, red: str, green: str, blue: str) -> None: self.red_label.configure(background="#%02x%02x%02x" % (int(red), 0, 0)) self.green_label.configure(background="#%02x%02x%02x" % (0, int(green), 0)) self.blue_label.configure(background="#%02x%02x%02x" % (0, 0, int(blue))) - def get_rgb(self, hex_code: str) -> [int, int, int]: + def get_rgb(self, hex_code: str) -> Tuple[int, int, int]: """ convert a valid hex code to RGB values """ diff --git a/daemon/core/gui/dialogs/configserviceconfig.py b/daemon/core/gui/dialogs/configserviceconfig.py index 42041a8e..c2d42ee4 100644 --- a/daemon/core/gui/dialogs/configserviceconfig.py +++ b/daemon/core/gui/dialogs/configserviceconfig.py @@ -4,10 +4,11 @@ Service configuration dialog import logging import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Dict, List, Optional, Set import grpc +from core.api.grpc.common_pb2 import ConfigOption from core.api.grpc.services_pb2 import ServiceValidationMode from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY @@ -16,6 +17,7 @@ from core.gui.widgets import CodeText, ConfigFrame, ListboxScroll if TYPE_CHECKING: from core.gui.app import Application from core.gui.graph.node import CanvasNode + from core.gui.coreclient import CoreClient class ConfigServiceConfigDialog(Dialog): @@ -26,56 +28,53 @@ class ConfigServiceConfigDialog(Dialog): service_name: str, canvas_node: "CanvasNode", node_id: int, - ): + ) -> None: title = f"{service_name} Config Service" super().__init__(app, title, master=master) - self.core = app.core - self.canvas_node = canvas_node - self.node_id = node_id - self.service_name = service_name - self.radiovar = tk.IntVar() + self.core: "CoreClient" = app.core + self.canvas_node: "CanvasNode" = canvas_node + self.node_id: int = node_id + self.service_name: str = service_name + self.radiovar: tk.IntVar = tk.IntVar() self.radiovar.set(2) - self.directories = [] - self.templates = [] - self.dependencies = [] - self.executables = [] - self.startup_commands = [] - self.validation_commands = [] - self.shutdown_commands = [] - self.default_startup = [] - self.default_validate = [] - self.default_shutdown = [] - self.validation_mode = None - self.validation_time = None - self.validation_period = tk.StringVar() - self.modes = [] - self.mode_configs = {} - - self.notebook = None - self.templates_combobox = None - self.modes_combobox = None - self.startup_commands_listbox = None - self.shutdown_commands_listbox = None - self.validate_commands_listbox = None - self.validation_time_entry = None - self.validation_mode_entry = None - self.template_text = None - self.validation_period_entry = None - self.original_service_files = {} - self.temp_service_files = {} - self.modified_files = set() - self.config_frame = None - self.default_config = None - self.config = None - - self.has_error = False + self.directories: List[str] = [] + self.templates: List[str] = [] + self.dependencies: List[str] = [] + self.executables: List[str] = [] + self.startup_commands: List[str] = [] + self.validation_commands: List[str] = [] + self.shutdown_commands: List[str] = [] + self.default_startup: List[str] = [] + self.default_validate: List[str] = [] + self.default_shutdown: List[str] = [] + self.validation_mode: Optional[ServiceValidationMode] = None + self.validation_time: Optional[int] = None + self.validation_period: tk.StringVar = tk.StringVar() + self.modes: List[str] = [] + self.mode_configs: Dict[str, str] = {} + self.notebook: Optional[ttk.Notebook] = None + self.templates_combobox: Optional[ttk.Combobox] = None + self.modes_combobox: Optional[ttk.Combobox] = None + self.startup_commands_listbox: Optional[tk.Listbox] = None + self.shutdown_commands_listbox: Optional[tk.Listbox] = None + self.validate_commands_listbox: Optional[tk.Listbox] = None + self.validation_time_entry: Optional[ttk.Entry] = None + self.validation_mode_entry: Optional[ttk.Entry] = None + self.template_text: Optional[CodeText] = None + self.validation_period_entry: Optional[ttk.Entry] = None + self.original_service_files: Dict[str, str] = {} + self.temp_service_files: Dict[str, str] = {} + self.modified_files: Set[str] = set() + self.config_frame: Optional[ConfigFrame] = None + self.default_config: Dict[str, str] = {} + self.config: Dict[str, ConfigOption] = {} + self.has_error: bool = False self.load() - if not self.has_error: self.draw() - def load(self): + def load(self) -> None: try: self.core.create_nodes_and_links() service = self.core.config_services[self.service_name] @@ -116,7 +115,7 @@ class ConfigServiceConfigDialog(Dialog): self.app.show_grpc_exception("Get Config Service Error", e) self.has_error = True - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) @@ -130,7 +129,7 @@ class ConfigServiceConfigDialog(Dialog): self.draw_tab_validation() self.draw_buttons() - def draw_tab_files(self): + def draw_tab_files(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) tab.grid(sticky="nsew") tab.columnconfigure(0, weight=1) @@ -174,7 +173,7 @@ class ConfigServiceConfigDialog(Dialog): ) self.template_text.text.bind("", self.update_template_file_data) - def draw_tab_config(self): + def draw_tab_config(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) tab.grid(sticky="nsew") tab.columnconfigure(0, weight=1) @@ -198,7 +197,7 @@ class ConfigServiceConfigDialog(Dialog): self.config_frame.grid(sticky="nsew", pady=PADY) tab.rowconfigure(self.config_frame.grid_info()["row"], weight=1) - def draw_tab_startstop(self): + def draw_tab_startstop(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) tab.grid(sticky="nsew") tab.columnconfigure(0, weight=1) @@ -239,7 +238,7 @@ class ConfigServiceConfigDialog(Dialog): elif i == 2: self.validate_commands_listbox = listbox_scroll.listbox - def draw_tab_validation(self): + def draw_tab_validation(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) tab.grid(sticky="ew") tab.columnconfigure(0, weight=1) @@ -298,7 +297,7 @@ class ConfigServiceConfigDialog(Dialog): for dependency in self.dependencies: listbox_scroll.listbox.insert("end", dependency) - def draw_buttons(self): + def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") for i in range(4): @@ -312,7 +311,7 @@ class ConfigServiceConfigDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=3, sticky="ew") - def click_apply(self): + def click_apply(self) -> None: current_listbox = self.master.current.listbox if not self.is_custom(): self.canvas_node.config_service_configs.pop(self.service_name, None) @@ -333,18 +332,18 @@ class ConfigServiceConfigDialog(Dialog): current_listbox.itemconfig(all_current.index(self.service_name), bg="green") self.destroy() - def handle_template_changed(self, event: tk.Event): + def handle_template_changed(self, event: tk.Event) -> None: template = self.templates_combobox.get() self.template_text.text.delete(1.0, "end") self.template_text.text.insert("end", self.temp_service_files[template]) - def handle_mode_changed(self, event: tk.Event): + def handle_mode_changed(self, event: tk.Event) -> None: mode = self.modes_combobox.get() config = self.mode_configs[mode] logging.info("mode config: %s", config) self.config_frame.set_values(config) - def update_template_file_data(self, event: tk.Event): + def update_template_file_data(self, event: tk.Event) -> None: scrolledtext = event.widget template = self.templates_combobox.get() self.temp_service_files[template] = scrolledtext.get(1.0, "end") @@ -353,7 +352,7 @@ class ConfigServiceConfigDialog(Dialog): else: self.modified_files.discard(template) - def is_custom(self): + def is_custom(self) -> bool: has_custom_templates = len(self.modified_files) > 0 has_custom_config = False if self.config_frame: @@ -361,7 +360,7 @@ class ConfigServiceConfigDialog(Dialog): has_custom_config = self.default_config != current return has_custom_templates or has_custom_config - def click_defaults(self): + def click_defaults(self) -> None: self.canvas_node.config_service_configs.pop(self.service_name, None) logging.info( "cleared config service config: %s", self.canvas_node.config_service_configs @@ -374,12 +373,12 @@ class ConfigServiceConfigDialog(Dialog): logging.info("resetting defaults: %s", self.default_config) self.config_frame.set_values(self.default_config) - def click_copy(self): + def click_copy(self) -> None: pass def append_commands( self, commands: List[str], listbox: tk.Listbox, to_add: List[str] - ): + ) -> None: for cmd in to_add: commands.append(cmd) listbox.insert(tk.END, cmd) diff --git a/daemon/core/gui/dialogs/copyserviceconfig.py b/daemon/core/gui/dialogs/copyserviceconfig.py index ff75a59a..35559cb9 100644 --- a/daemon/core/gui/dialogs/copyserviceconfig.py +++ b/daemon/core/gui/dialogs/copyserviceconfig.py @@ -15,17 +15,16 @@ if TYPE_CHECKING: class CopyServiceConfigDialog(Dialog): - def __init__(self, master: tk.BaseWidget, app: "Application", node_id: int): + def __init__(self, master: tk.BaseWidget, app: "Application", node_id: int) -> None: super().__init__(app, f"Copy services to node {node_id}", master=master) self.parent = master self.node_id = node_id self.service_configs = app.core.service_configs self.file_configs = app.core.file_configs - self.tree = None self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.tree = ttk.Treeview(self.top) self.tree.grid(row=0, column=0, sticky="ew", padx=PADX) @@ -88,7 +87,7 @@ class CopyServiceConfigDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=2, sticky="ew", padx=PADX) - def click_copy(self): + def click_copy(self) -> None: selected = self.tree.selection() if selected: item = self.tree.item(selected[0]) @@ -127,7 +126,7 @@ class CopyServiceConfigDialog(Dialog): ) self.destroy() - def click_view(self): + def click_view(self) -> None: selected = self.tree.selection() data = "" if selected: @@ -159,7 +158,7 @@ class CopyServiceConfigDialog(Dialog): ) dialog.show() - def get_node_service(self, selected: Tuple[str]) -> [int, str]: + def get_node_service(self, selected: Tuple[str]) -> Tuple[int, str]: service_tree_id = self.tree.parent(selected[0]) service_name = self.tree.item(service_tree_id)["text"] node_tree_id = self.tree.parent(service_tree_id) @@ -175,14 +174,14 @@ class ViewConfigDialog(Dialog): node_id: int, data: str, filename: str = None, - ): + ) -> None: super().__init__(app, f"n{node_id} config data", master=master) self.data = data self.service_data = None self.filepath = tk.StringVar(value=f"/tmp/services.tmp-n{node_id}-{filename}") self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) frame = ttk.Frame(self.top, padding=FRAME_PAD) frame.columnconfigure(0, weight=1) diff --git a/daemon/core/gui/dialogs/customnodes.py b/daemon/core/gui/dialogs/customnodes.py index 56012780..df3bafa7 100644 --- a/daemon/core/gui/dialogs/customnodes.py +++ b/daemon/core/gui/dialogs/customnodes.py @@ -2,7 +2,9 @@ import logging import tkinter as tk from pathlib import Path from tkinter import ttk -from typing import TYPE_CHECKING, Set +from typing import TYPE_CHECKING, Optional, Set + +from PIL.ImageTk import PhotoImage from core.gui import nodeutils from core.gui.appconfig import ICONS_PATH, CustomNode @@ -19,15 +21,15 @@ if TYPE_CHECKING: class ServicesSelectDialog(Dialog): def __init__( self, master: tk.BaseWidget, app: "Application", current_services: Set[str] - ): + ) -> None: super().__init__(app, "Node Services", master=master) - self.groups = None - self.services = None - self.current = None - self.current_services = set(current_services) + self.groups: Optional[ListboxScroll] = None + self.services: Optional[CheckboxList] = None + self.current: Optional[ListboxScroll] = None + self.current_services: Set[str] = current_services self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) @@ -77,7 +79,7 @@ class ServicesSelectDialog(Dialog): # trigger group change self.groups.listbox.event_generate("<>") - def handle_group_change(self, event: tk.Event): + def handle_group_change(self, event: tk.Event) -> None: selection = self.groups.listbox.curselection() if selection: index = selection[0] @@ -87,7 +89,7 @@ class ServicesSelectDialog(Dialog): checked = name in self.current_services self.services.add(name, checked) - def service_clicked(self, name: str, var: tk.BooleanVar): + def service_clicked(self, name: str, var: tk.BooleanVar) -> None: if var.get() and name not in self.current_services: self.current_services.add(name) elif not var.get() and name in self.current_services: @@ -96,34 +98,34 @@ class ServicesSelectDialog(Dialog): for name in sorted(self.current_services): self.current.listbox.insert(tk.END, name) - def click_cancel(self): + def click_cancel(self) -> None: self.current_services = None self.destroy() class CustomNodesDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: super().__init__(app, "Custom Nodes") - self.edit_button = None - self.delete_button = None - self.nodes_list = None - self.name = tk.StringVar() - self.image_button = None - self.image = None - self.image_file = None - self.services = set() - self.selected = None - self.selected_index = None + self.edit_button: Optional[ttk.Button] = None + self.delete_button: Optional[ttk.Button] = None + self.nodes_list: Optional[ListboxScroll] = None + self.name: tk.StringVar = tk.StringVar() + self.image_button: Optional[ttk.Button] = None + self.image: Optional[PhotoImage] = None + self.image_file: Optional[str] = None + self.services: Set[str] = set() + self.selected: Optional[str] = None + self.selected_index: Optional[int] = None self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) self.draw_node_config() self.draw_node_buttons() self.draw_buttons() - def draw_node_config(self): + def draw_node_config(self) -> None: frame = ttk.LabelFrame(self.top, text="Nodes", padding=FRAME_PAD) frame.grid(sticky="nsew", pady=PADY) frame.columnconfigure(0, weight=1) @@ -147,7 +149,7 @@ class CustomNodesDialog(Dialog): button = ttk.Button(frame, text="Services", command=self.click_services) button.grid(sticky="ew") - def draw_node_buttons(self): + def draw_node_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew", pady=PADY) for i in range(3): @@ -166,7 +168,7 @@ class CustomNodesDialog(Dialog): ) self.delete_button.grid(row=0, column=2, sticky="ew") - def draw_buttons(self): + def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") for i in range(2): @@ -178,14 +180,14 @@ class CustomNodesDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def reset_values(self): + def reset_values(self) -> None: self.name.set("") self.image = None self.image_file = None self.services = set() self.image_button.config(image="") - def click_icon(self): + def click_icon(self) -> None: file_path = image_chooser(self, ICONS_PATH) if file_path: image = Images.create(file_path, nodeutils.ICON_SIZE) @@ -193,24 +195,26 @@ class CustomNodesDialog(Dialog): self.image_file = file_path self.image_button.config(image=self.image) - def click_services(self): + def click_services(self) -> None: dialog = ServicesSelectDialog(self, self.app, self.services) dialog.show() if dialog.current_services is not None: self.services.clear() self.services.update(dialog.current_services) - def click_save(self): + def click_save(self) -> None: self.app.guiconfig.nodes.clear() for name in self.app.core.custom_nodes: node_draw = self.app.core.custom_nodes[name] - custom_node = CustomNode(name, node_draw.image_file, node_draw.services) + custom_node = CustomNode( + name, node_draw.image_file, list(node_draw.services) + ) self.app.guiconfig.nodes.append(custom_node) logging.info("saving custom nodes: %s", self.app.guiconfig.nodes) self.app.save_config() self.destroy() - def click_create(self): + def click_create(self) -> None: name = self.name.get() if name not in self.app.core.custom_nodes: image_file = Path(self.image_file).stem @@ -226,7 +230,7 @@ class CustomNodesDialog(Dialog): self.nodes_list.listbox.insert(tk.END, name) self.reset_values() - def click_edit(self): + def click_edit(self) -> None: name = self.name.get() if self.selected: previous_name = self.selected @@ -247,7 +251,7 @@ class CustomNodesDialog(Dialog): self.nodes_list.listbox.insert(self.selected_index, name) self.nodes_list.listbox.selection_set(self.selected_index) - def click_delete(self): + def click_delete(self) -> None: if self.selected and self.selected in self.app.core.custom_nodes: self.nodes_list.listbox.delete(self.selected_index) del self.app.core.custom_nodes[self.selected] @@ -255,7 +259,7 @@ class CustomNodesDialog(Dialog): self.nodes_list.listbox.selection_clear(0, tk.END) self.nodes_list.listbox.event_generate("<>") - def handle_node_select(self, event: tk.Event): + def handle_node_select(self, event: tk.Event) -> None: selection = self.nodes_list.listbox.curselection() if selection: self.selected_index = selection[0] diff --git a/daemon/core/gui/dialogs/dialog.py b/daemon/core/gui/dialogs/dialog.py index f3742c50..962170e7 100644 --- a/daemon/core/gui/dialogs/dialog.py +++ b/daemon/core/gui/dialogs/dialog.py @@ -16,23 +16,23 @@ class Dialog(tk.Toplevel): title: str, modal: bool = True, master: tk.BaseWidget = None, - ): + ) -> None: if master is None: master = app super().__init__(master) self.withdraw() - self.app = app - self.modal = modal + self.app: "Application" = app + self.modal: bool = modal self.title(title) self.protocol("WM_DELETE_WINDOW", self.destroy) image = Images.get(ImageEnum.CORE, 16) self.tk.call("wm", "iconphoto", self._w, image) self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) - self.top = ttk.Frame(self, padding=DIALOG_PAD) + self.top: ttk.Frame = ttk.Frame(self, padding=DIALOG_PAD) self.top.grid(sticky="nsew") - def show(self): + def show(self) -> None: self.transient(self.master) self.focus_force() self.update() @@ -42,7 +42,7 @@ class Dialog(tk.Toplevel): self.grab_set() self.wait_window() - def draw_spacer(self, row: int = None): + def draw_spacer(self, row: int = None) -> None: frame = ttk.Frame(self.top) frame.grid(row=row, sticky="nsew") frame.rowconfigure(0, weight=1) diff --git a/daemon/core/gui/dialogs/emaneconfig.py b/daemon/core/gui/dialogs/emaneconfig.py index 8f7ca089..df6c6125 100644 --- a/daemon/core/gui/dialogs/emaneconfig.py +++ b/daemon/core/gui/dialogs/emaneconfig.py @@ -4,10 +4,12 @@ emane configuration import tkinter as tk import webbrowser from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, List, Optional import grpc +from core.api.grpc.common_pb2 import ConfigOption +from core.api.grpc.core_pb2 import Node from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum, Images from core.gui.themes import PADX, PADY @@ -19,12 +21,12 @@ if TYPE_CHECKING: class GlobalEmaneDialog(Dialog): - def __init__(self, master: tk.BaseWidget, app: "Application"): + def __init__(self, master: tk.BaseWidget, app: "Application") -> None: super().__init__(app, "EMANE Configuration", master=master) - self.config_frame = None + self.config_frame: Optional[ConfigFrame] = None self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) self.config_frame = ConfigFrame(self.top, self.app, self.app.core.emane_config) @@ -33,7 +35,7 @@ class GlobalEmaneDialog(Dialog): self.draw_spacer() self.draw_buttons() - def draw_buttons(self): + def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") for i in range(2): @@ -44,7 +46,7 @@ class GlobalEmaneDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_apply(self): + def click_apply(self) -> None: self.config_frame.parse_config() self.destroy() @@ -57,31 +59,32 @@ class EmaneModelDialog(Dialog): canvas_node: "CanvasNode", model: str, iface_id: int = None, - ): + ) -> None: super().__init__( app, f"{canvas_node.core_node.name} {model} Configuration", master=master ) - self.canvas_node = canvas_node - self.node = canvas_node.core_node - self.model = f"emane_{model}" - self.iface_id = iface_id - self.config_frame = None - self.has_error = False + self.canvas_node: "CanvasNode" = canvas_node + self.node: Node = canvas_node.core_node + self.model: str = f"emane_{model}" + self.iface_id: int = iface_id + self.config_frame: Optional[ConfigFrame] = None + self.has_error: bool = False try: - self.config = self.canvas_node.emane_model_configs.get( + config = self.canvas_node.emane_model_configs.get( (self.model, self.iface_id) ) - if not self.config: - self.config = self.app.core.get_emane_model_config( + if not config: + config = self.app.core.get_emane_model_config( self.node.id, self.model, self.iface_id ) + self.config: Dict[str, ConfigOption] = config self.draw() except grpc.RpcError as e: self.app.show_grpc_exception("Get EMANE Config Error", e) - self.has_error = True + self.has_error: bool = True self.destroy() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) self.config_frame = ConfigFrame(self.top, self.app, self.config) @@ -90,7 +93,7 @@ class EmaneModelDialog(Dialog): self.draw_spacer() self.draw_buttons() - def draw_buttons(self): + def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") for i in range(2): @@ -101,7 +104,7 @@ class EmaneModelDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_apply(self): + def click_apply(self) -> None: self.config_frame.parse_config() key = (self.model, self.iface_id) self.canvas_node.emane_model_configs[key] = self.config @@ -109,18 +112,21 @@ class EmaneModelDialog(Dialog): class EmaneConfigDialog(Dialog): - def __init__(self, app: "Application", canvas_node: "CanvasNode"): + def __init__(self, app: "Application", canvas_node: "CanvasNode") -> None: super().__init__(app, f"{canvas_node.core_node.name} EMANE Configuration") - self.canvas_node = canvas_node - self.node = canvas_node.core_node - self.radiovar = tk.IntVar() + self.canvas_node: "CanvasNode" = canvas_node + self.node: Node = canvas_node.core_node + self.radiovar: tk.IntVar = tk.IntVar() self.radiovar.set(1) - self.emane_models = [x.split("_")[1] for x in self.app.core.emane_models] - self.emane_model = tk.StringVar(value=self.node.emane.split("_")[1]) - self.emane_model_button = None + self.emane_models: List[str] = [ + x.split("_")[1] for x in self.app.core.emane_models + ] + model = self.node.emane.split("_")[1] + self.emane_model: tk.StringVar = tk.StringVar(value=model) + self.emane_model_button: Optional[ttk.Button] = None self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.draw_emane_configuration() self.draw_emane_models() @@ -128,7 +134,7 @@ class EmaneConfigDialog(Dialog): self.draw_spacer() self.draw_apply_and_cancel() - def draw_emane_configuration(self): + def draw_emane_configuration(self) -> None: """ draw the main frame for emane configuration """ @@ -153,7 +159,7 @@ class EmaneConfigDialog(Dialog): button.image = image button.grid(sticky="ew", pady=PADY) - def draw_emane_models(self): + def draw_emane_models(self) -> None: """ create a combobox that has all the known emane models """ @@ -174,7 +180,7 @@ class EmaneConfigDialog(Dialog): combobox.grid(row=0, column=1, sticky="ew") combobox.bind("<>", self.emane_model_change) - def draw_emane_buttons(self): + def draw_emane_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew", pady=PADY) for i in range(2): @@ -202,7 +208,7 @@ class EmaneConfigDialog(Dialog): button.image = image button.grid(row=0, column=1, sticky="ew") - def draw_apply_and_cancel(self): + def draw_apply_and_cancel(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") for i in range(2): @@ -214,11 +220,11 @@ class EmaneConfigDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_emane_config(self): + def click_emane_config(self) -> None: dialog = GlobalEmaneDialog(self, self.app) dialog.show() - def click_model_config(self): + def click_model_config(self) -> None: """ draw emane model configuration """ @@ -227,13 +233,13 @@ class EmaneConfigDialog(Dialog): if not dialog.has_error: dialog.show() - def emane_model_change(self, event: tk.Event): + def emane_model_change(self, event: tk.Event) -> None: """ update emane model options button """ model_name = self.emane_model.get() self.emane_model_button.config(text=f"{model_name} options") - def click_apply(self): + def click_apply(self) -> None: self.node.emane = f"emane_{self.emane_model.get()}" self.destroy() diff --git a/daemon/core/gui/dialogs/emaneinstall.py b/daemon/core/gui/dialogs/emaneinstall.py index 93cf2ac4..3ad9396b 100644 --- a/daemon/core/gui/dialogs/emaneinstall.py +++ b/daemon/core/gui/dialogs/emaneinstall.py @@ -10,7 +10,7 @@ class EmaneInstallDialog(Dialog): super().__init__(app, "EMANE Error") self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) label = ttk.Label(self.top, text="EMANE needs to be installed!") label.grid(sticky="ew", pady=PADY) @@ -21,5 +21,5 @@ class EmaneInstallDialog(Dialog): button = ttk.Button(self.top, text="Close", command=self.destroy) button.grid(sticky="ew") - def click_doc(self): + def click_doc(self) -> None: webbrowser.open_new("https://coreemu.github.io/core/emane.html") diff --git a/daemon/core/gui/dialogs/error.py b/daemon/core/gui/dialogs/error.py index 5ff1dbc5..7fb81077 100644 --- a/daemon/core/gui/dialogs/error.py +++ b/daemon/core/gui/dialogs/error.py @@ -1,5 +1,5 @@ from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum, Images @@ -13,9 +13,9 @@ if TYPE_CHECKING: class ErrorDialog(Dialog): def __init__(self, app: "Application", title: str, details: str) -> None: super().__init__(app, "CORE Exception") - self.title = title - self.details = details - self.error_message = None + self.title: str = title + self.details: str = details + self.error_message: Optional[CodeText] = None self.draw() def draw(self) -> None: diff --git a/daemon/core/gui/dialogs/executepython.py b/daemon/core/gui/dialogs/executepython.py index dd60c778..a4516df1 100644 --- a/daemon/core/gui/dialogs/executepython.py +++ b/daemon/core/gui/dialogs/executepython.py @@ -1,7 +1,7 @@ import logging import tkinter as tk from tkinter import filedialog, ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from core.gui.appconfig import SCRIPT_PATH from core.gui.dialogs.dialog import Dialog @@ -12,15 +12,15 @@ if TYPE_CHECKING: class ExecutePythonDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: super().__init__(app, "Execute Python Script") - self.with_options = tk.IntVar(value=0) - self.options = tk.StringVar(value="") - self.option_entry = None - self.file_entry = None + self.with_options: tk.IntVar = tk.IntVar(value=0) + self.options: tk.StringVar = tk.StringVar(value="") + self.option_entry: Optional[ttk.Entry] = None + self.file_entry: Optional[ttk.Entry] = None self.draw() - def draw(self): + def draw(self) -> None: i = 0 frame = ttk.Frame(self.top, padding=FRAME_PAD) frame.columnconfigure(0, weight=1) @@ -63,13 +63,13 @@ class ExecutePythonDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew", padx=PADX) - def add_options(self): + def add_options(self) -> None: if self.with_options.get(): self.option_entry.configure(state="normal") else: self.option_entry.configure(state="disabled") - def select_file(self): + def select_file(self) -> None: file = filedialog.askopenfilename( parent=self.top, initialdir=str(SCRIPT_PATH), @@ -80,7 +80,7 @@ class ExecutePythonDialog(Dialog): self.file_entry.delete(0, "end") self.file_entry.insert("end", file) - def script_execute(self): + def script_execute(self) -> None: file = self.file_entry.get() options = self.option_entry.get() logging.info("Execute %s with options %s", file, options) diff --git a/daemon/core/gui/dialogs/find.py b/daemon/core/gui/dialogs/find.py index 25da4b19..328f673e 100644 --- a/daemon/core/gui/dialogs/find.py +++ b/daemon/core/gui/dialogs/find.py @@ -1,7 +1,7 @@ import logging import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY @@ -13,8 +13,8 @@ if TYPE_CHECKING: class FindDialog(Dialog): def __init__(self, app: "Application") -> None: super().__init__(app, "Find", modal=False) - self.find_text = tk.StringVar(value="") - self.tree = None + self.find_text: tk.StringVar = tk.StringVar(value="") + self.tree: Optional[ttk.Treeview] = None self.draw() self.protocol("WM_DELETE_WINDOW", self.close_dialog) self.bind("", self.find_node) diff --git a/daemon/core/gui/dialogs/hooks.py b/daemon/core/gui/dialogs/hooks.py index 5895a2e1..08d666ba 100644 --- a/daemon/core/gui/dialogs/hooks.py +++ b/daemon/core/gui/dialogs/hooks.py @@ -1,6 +1,6 @@ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from core.api.grpc import core_pb2 from core.gui.dialogs.dialog import Dialog @@ -12,15 +12,15 @@ if TYPE_CHECKING: class HookDialog(Dialog): - def __init__(self, master: tk.BaseWidget, app: "Application"): + def __init__(self, master: tk.BaseWidget, app: "Application") -> None: super().__init__(app, "Hook", master=master) - self.name = tk.StringVar() - self.codetext = None - self.hook = core_pb2.Hook() - self.state = tk.StringVar() + self.name: tk.StringVar = tk.StringVar() + self.codetext: Optional[CodeText] = None + self.hook: core_pb2.Hook = core_pb2.Hook() + self.state: tk.StringVar = tk.StringVar() self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(1, weight=1) @@ -66,11 +66,11 @@ class HookDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=lambda: self.destroy()) button.grid(row=0, column=1, sticky="ew") - def state_change(self, event: tk.Event): + def state_change(self, event: tk.Event) -> None: state_name = self.state.get() self.name.set(f"{state_name.lower()}_hook.sh") - def set(self, hook: core_pb2.Hook): + def set(self, hook: core_pb2.Hook) -> None: self.hook = hook self.name.set(hook.file) self.codetext.text.delete(1.0, tk.END) @@ -78,7 +78,7 @@ class HookDialog(Dialog): state_name = core_pb2.SessionState.Enum.Name(hook.state) self.state.set(state_name) - def save(self): + def save(self) -> None: data = self.codetext.text.get("1.0", tk.END).strip() state_value = core_pb2.SessionState.Enum.Value(self.state.get()) self.hook.file = self.name.get() @@ -88,15 +88,15 @@ class HookDialog(Dialog): class HooksDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: super().__init__(app, "Hooks") - self.listbox = None - self.edit_button = None - self.delete_button = None - self.selected = None + self.listbox: Optional[tk.Listbox] = None + self.edit_button: Optional[ttk.Button] = None + self.delete_button: Optional[ttk.Button] = None + self.selected: Optional[str] = None self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) @@ -124,7 +124,7 @@ class HooksDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=lambda: self.destroy()) button.grid(row=0, column=3, sticky="ew") - def click_create(self): + def click_create(self) -> None: dialog = HookDialog(self, self.app) dialog.show() hook = dialog.hook @@ -132,19 +132,19 @@ class HooksDialog(Dialog): self.app.core.hooks[hook.file] = hook self.listbox.insert(tk.END, hook.file) - def click_edit(self): + def click_edit(self) -> None: hook = self.app.core.hooks[self.selected] dialog = HookDialog(self, self.app) dialog.set(hook) dialog.show() - def click_delete(self): + def click_delete(self) -> None: del self.app.core.hooks[self.selected] self.listbox.delete(tk.ANCHOR) self.edit_button.config(state=tk.DISABLED) self.delete_button.config(state=tk.DISABLED) - def select(self, event: tk.Event): + def select(self, event: tk.Event) -> None: if self.listbox.curselection(): index = self.listbox.curselection()[0] self.selected = self.listbox.get(index) diff --git a/daemon/core/gui/dialogs/ipdialog.py b/daemon/core/gui/dialogs/ipdialog.py index d31dcdff..351bfffc 100644 --- a/daemon/core/gui/dialogs/ipdialog.py +++ b/daemon/core/gui/dialogs/ipdialog.py @@ -1,6 +1,6 @@ import tkinter as tk from tkinter import messagebox, ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional import netaddr @@ -15,14 +15,14 @@ if TYPE_CHECKING: class IpConfigDialog(Dialog): def __init__(self, app: "Application") -> None: super().__init__(app, "IP Configuration") - self.ip4 = self.app.guiconfig.ips.ip4 - self.ip6 = self.app.guiconfig.ips.ip6 - self.ip4s = self.app.guiconfig.ips.ip4s - self.ip6s = self.app.guiconfig.ips.ip6s - self.ip4_entry = None - self.ip4_listbox = None - self.ip6_entry = None - self.ip6_listbox = None + self.ip4: str = self.app.guiconfig.ips.ip4 + self.ip6: str = self.app.guiconfig.ips.ip6 + self.ip4s: List[str] = self.app.guiconfig.ips.ip4s + self.ip6s: List[str] = self.app.guiconfig.ips.ip6s + self.ip4_entry: Optional[ttk.Entry] = None + self.ip4_listbox: Optional[ListboxScroll] = None + self.ip6_entry: Optional[ttk.Entry] = None + self.ip6_listbox: Optional[ListboxScroll] = None self.draw() def draw(self) -> None: diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index adf8156f..b7c618a3 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -3,7 +3,7 @@ link configuration """ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING, Optional from core.api.grpc import core_pb2 from core.gui import validation @@ -16,7 +16,7 @@ if TYPE_CHECKING: from core.gui.graph.graph import CanvasEdge -def get_int(var: tk.StringVar) -> Union[int, None]: +def get_int(var: tk.StringVar) -> Optional[int]: value = var.get() if value != "": return int(value) @@ -24,7 +24,7 @@ def get_int(var: tk.StringVar) -> Union[int, None]: return None -def get_float(var: tk.StringVar) -> Union[float, None]: +def get_float(var: tk.StringVar) -> Optional[float]: value = var.get() if value != "": return float(value) @@ -33,38 +33,39 @@ def get_float(var: tk.StringVar) -> Union[float, None]: class LinkConfigurationDialog(Dialog): - def __init__(self, app: "Application", edge: "CanvasEdge"): + def __init__(self, app: "Application", edge: "CanvasEdge") -> None: super().__init__(app, "Link Configuration") - self.edge = edge - self.is_symmetric = edge.link.options.unidirectional is False + self.edge: "CanvasEdge" = edge + self.is_symmetric: bool = edge.link.options.unidirectional is False if self.is_symmetric: - self.symmetry_var = tk.StringVar(value=">>") + symmetry_var = tk.StringVar(value=">>") else: - self.symmetry_var = tk.StringVar(value="<<") + symmetry_var = tk.StringVar(value="<<") + self.symmetry_var: tk.StringVar = symmetry_var - self.bandwidth = tk.StringVar() - self.delay = tk.StringVar() - self.jitter = tk.StringVar() - self.loss = tk.StringVar() - self.duplicate = tk.StringVar() + self.bandwidth: tk.StringVar = tk.StringVar() + self.delay: tk.StringVar = tk.StringVar() + self.jitter: tk.StringVar = tk.StringVar() + self.loss: tk.StringVar = tk.StringVar() + self.duplicate: tk.StringVar = tk.StringVar() - self.down_bandwidth = tk.StringVar() - self.down_delay = tk.StringVar() - self.down_jitter = tk.StringVar() - self.down_loss = tk.StringVar() - self.down_duplicate = tk.StringVar() + self.down_bandwidth: tk.StringVar = tk.StringVar() + self.down_delay: tk.StringVar = tk.StringVar() + self.down_jitter: tk.StringVar = tk.StringVar() + self.down_loss: tk.StringVar = tk.StringVar() + self.down_duplicate: tk.StringVar = tk.StringVar() - self.color = tk.StringVar(value="#000000") - self.color_button = None - self.width = tk.DoubleVar() + self.color: tk.StringVar = tk.StringVar(value="#000000") + self.color_button: Optional[tk.Button] = None + self.width: tk.DoubleVar = tk.DoubleVar() self.load_link_config() - self.symmetric_frame = None - self.asymmetric_frame = None + self.symmetric_frame: Optional[ttk.Frame] = None + self.asymmetric_frame: Optional[ttk.Frame] = None self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) source_name = self.app.canvas.nodes[self.edge.src].core_node.name dest_name = self.app.canvas.nodes[self.edge.dst].core_node.name @@ -207,13 +208,13 @@ class LinkConfigurationDialog(Dialog): return frame - def click_color(self): + def click_color(self) -> None: dialog = ColorPickerDialog(self, self.app, self.color.get()) color = dialog.askcolor() self.color.set(color) self.color_button.config(background=color) - def click_apply(self): + def click_apply(self) -> None: self.app.canvas.itemconfigure(self.edge.id, width=self.width.get()) self.app.canvas.itemconfigure(self.edge.id, fill=self.color.get()) link = self.edge.link @@ -288,7 +289,7 @@ class LinkConfigurationDialog(Dialog): self.destroy() - def change_symmetry(self): + def change_symmetry(self) -> None: if self.is_symmetric: self.is_symmetric = False self.symmetry_var.set("<<") @@ -304,7 +305,7 @@ class LinkConfigurationDialog(Dialog): self.asymmetric_frame.grid_forget() self.symmetric_frame.grid(row=2, column=0) - def load_link_config(self): + def load_link_config(self) -> None: """ populate link config to the table """ diff --git a/daemon/core/gui/dialogs/macdialog.py b/daemon/core/gui/dialogs/macdialog.py index 46414cf9..4d89439b 100644 --- a/daemon/core/gui/dialogs/macdialog.py +++ b/daemon/core/gui/dialogs/macdialog.py @@ -15,7 +15,7 @@ class MacConfigDialog(Dialog): def __init__(self, app: "Application") -> None: super().__init__(app, "MAC Configuration") mac = self.app.guiconfig.mac - self.mac_var = tk.StringVar(value=mac) + self.mac_var: tk.StringVar = tk.StringVar(value=mac) self.draw() def draw(self) -> None: diff --git a/daemon/core/gui/dialogs/mobilityconfig.py b/daemon/core/gui/dialogs/mobilityconfig.py index dced5e44..daaf9ea5 100644 --- a/daemon/core/gui/dialogs/mobilityconfig.py +++ b/daemon/core/gui/dialogs/mobilityconfig.py @@ -2,10 +2,12 @@ mobility configuration """ from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, Optional import grpc +from core.api.grpc.common_pb2 import ConfigOption +from core.api.grpc.core_pb2 import Node from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame @@ -16,23 +18,24 @@ if TYPE_CHECKING: class MobilityConfigDialog(Dialog): - def __init__(self, app: "Application", canvas_node: "CanvasNode"): + def __init__(self, app: "Application", canvas_node: "CanvasNode") -> None: super().__init__(app, f"{canvas_node.core_node.name} Mobility Configuration") - self.canvas_node = canvas_node - self.node = canvas_node.core_node - self.config_frame = None - self.has_error = False + self.canvas_node: "CanvasNode" = canvas_node + self.node: Node = canvas_node.core_node + self.config_frame: Optional[ConfigFrame] = None + self.has_error: bool = False try: - self.config = self.canvas_node.mobility_config - if not self.config: - self.config = self.app.core.get_mobility_config(self.node.id) + config = self.canvas_node.mobility_config + if not config: + config = self.app.core.get_mobility_config(self.node.id) + self.config: Dict[str, ConfigOption] = config self.draw() except grpc.RpcError as e: self.app.show_grpc_exception("Get Mobility Config Error", e) - self.has_error = True + self.has_error: bool = True self.destroy() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) self.config_frame = ConfigFrame(self.top, self.app, self.config) @@ -40,7 +43,7 @@ class MobilityConfigDialog(Dialog): self.config_frame.grid(sticky="nsew", pady=PADY) self.draw_apply_buttons() - def draw_apply_buttons(self): + def draw_apply_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") for i in range(2): @@ -52,7 +55,7 @@ class MobilityConfigDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_apply(self): + def click_apply(self) -> None: self.config_frame.parse_config() self.canvas_node.mobility_config = self.config self.destroy() diff --git a/daemon/core/gui/dialogs/mobilityplayer.py b/daemon/core/gui/dialogs/mobilityplayer.py index b4801bcf..e6ef62ea 100644 --- a/daemon/core/gui/dialogs/mobilityplayer.py +++ b/daemon/core/gui/dialogs/mobilityplayer.py @@ -1,9 +1,11 @@ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, Optional import grpc +from core.api.grpc.common_pb2 import ConfigOption +from core.api.grpc.core_pb2 import Node from core.api.grpc.mobility_pb2 import MobilityAction from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum @@ -13,18 +15,23 @@ if TYPE_CHECKING: from core.gui.app import Application from core.gui.graph.node import CanvasNode -ICON_SIZE = 16 +ICON_SIZE: int = 16 class MobilityPlayer: - def __init__(self, app: "Application", canvas_node: "CanvasNode", config): - self.app = app - self.canvas_node = canvas_node - self.config = config - self.dialog = None - self.state = None + def __init__( + self, + app: "Application", + canvas_node: "CanvasNode", + config: Dict[str, ConfigOption], + ) -> None: + self.app: "Application" = app + self.canvas_node: "CanvasNode" = canvas_node + self.config: Dict[str, ConfigOption] = config + self.dialog: Optional[MobilityPlayerDialog] = None + self.state: Optional[MobilityAction] = None - def show(self): + def show(self) -> None: if self.dialog: self.dialog.destroy() self.dialog = MobilityPlayerDialog(self.app, self.canvas_node, self.config) @@ -37,44 +44,49 @@ class MobilityPlayer: self.set_stop() self.dialog.show() - def close(self): + def close(self) -> None: if self.dialog: self.dialog.destroy() self.dialog = None - def set_play(self): + def set_play(self) -> None: self.state = MobilityAction.START if self.dialog: self.dialog.set_play() - def set_pause(self): + def set_pause(self) -> None: self.state = MobilityAction.PAUSE if self.dialog: self.dialog.set_pause() - def set_stop(self): + def set_stop(self) -> None: self.state = MobilityAction.STOP if self.dialog: self.dialog.set_stop() class MobilityPlayerDialog(Dialog): - def __init__(self, app: "Application", canvas_node: "CanvasNode", config): + def __init__( + self, + app: "Application", + canvas_node: "CanvasNode", + config: Dict[str, ConfigOption], + ) -> None: super().__init__( app, f"{canvas_node.core_node.name} Mobility Player", modal=False ) self.resizable(False, False) self.geometry("") - self.canvas_node = canvas_node - self.node = canvas_node.core_node - self.config = config - self.play_button = None - self.pause_button = None - self.stop_button = None - self.progressbar = None + self.canvas_node: "CanvasNode" = canvas_node + self.node: Node = canvas_node.core_node + self.config: Dict[str, ConfigOption] = config + self.play_button: Optional[ttk.Button] = None + self.pause_button: Optional[ttk.Button] = None + self.stop_button: Optional[ttk.Button] = None + self.progressbar: Optional[ttk.Progressbar] = None self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) file_name = self.config["file"].value @@ -114,27 +126,27 @@ class MobilityPlayerDialog(Dialog): label = ttk.Label(frame, text=f"rate {rate} ms") label.grid(row=0, column=4) - def clear_buttons(self): + def clear_buttons(self) -> None: self.play_button.state(["!pressed"]) self.pause_button.state(["!pressed"]) self.stop_button.state(["!pressed"]) - def set_play(self): + def set_play(self) -> None: self.clear_buttons() self.play_button.state(["pressed"]) self.progressbar.start() - def set_pause(self): + def set_pause(self) -> None: self.clear_buttons() self.pause_button.state(["pressed"]) self.progressbar.stop() - def set_stop(self): + def set_stop(self) -> None: self.clear_buttons() self.stop_button.state(["pressed"]) self.progressbar.stop() - def click_play(self): + def click_play(self) -> None: self.set_play() session_id = self.app.core.session_id try: @@ -144,7 +156,7 @@ class MobilityPlayerDialog(Dialog): except grpc.RpcError as e: self.app.show_grpc_exception("Mobility Error", e) - def click_pause(self): + def click_pause(self) -> None: self.set_pause() session_id = self.app.core.session_id try: @@ -154,7 +166,7 @@ class MobilityPlayerDialog(Dialog): except grpc.RpcError as e: self.app.show_grpc_exception("Mobility Error", e) - def click_stop(self): + def click_stop(self) -> None: self.set_stop() session_id = self.app.core.session_id try: diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index cec9e9f9..9e958283 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -2,10 +2,12 @@ import logging import tkinter as tk from functools import partial from tkinter import messagebox, ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, Optional import netaddr +from PIL.ImageTk import PhotoImage +from core.api.grpc.core_pb2 import Node from core.gui import nodeutils, validation from core.gui.appconfig import ICONS_PATH from core.gui.dialogs.dialog import Dialog @@ -86,35 +88,35 @@ class InterfaceData: mac: tk.StringVar, ip4: tk.StringVar, ip6: tk.StringVar, - ): - self.is_auto = is_auto - self.mac = mac - self.ip4 = ip4 - self.ip6 = ip6 + ) -> None: + self.is_auto: tk.BooleanVar = is_auto + self.mac: tk.StringVar = mac + self.ip4: tk.StringVar = ip4 + self.ip6: tk.StringVar = ip6 class NodeConfigDialog(Dialog): - def __init__(self, app: "Application", canvas_node: "CanvasNode"): + def __init__(self, app: "Application", canvas_node: "CanvasNode") -> None: """ create an instance of node configuration """ super().__init__(app, f"{canvas_node.core_node.name} Configuration") - self.canvas_node = canvas_node - self.node = canvas_node.core_node - self.image = canvas_node.image - self.image_file = None - self.image_button = None - self.name = tk.StringVar(value=self.node.name) - self.type = tk.StringVar(value=self.node.model) - self.container_image = tk.StringVar(value=self.node.image) + self.canvas_node: "CanvasNode" = canvas_node + self.node: Node = canvas_node.core_node + self.image: PhotoImage = canvas_node.image + self.image_file: Optional[str] = None + self.image_button: Optional[ttk.Button] = None + self.name: tk.StringVar = tk.StringVar(value=self.node.name) + self.type: tk.StringVar = tk.StringVar(value=self.node.model) + self.container_image: tk.StringVar = tk.StringVar(value=self.node.image) server = "localhost" if self.node.server: server = self.node.server - self.server = tk.StringVar(value=server) - self.ifaces = {} + self.server: tk.StringVar = tk.StringVar(value=server) + self.ifaces: Dict[int, InterfaceData] = {} self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) row = 0 @@ -202,7 +204,7 @@ class NodeConfigDialog(Dialog): self.draw_spacer() self.draw_buttons() - def draw_ifaces(self): + def draw_ifaces(self) -> None: notebook = ttk.Notebook(self.top) notebook.grid(sticky="nsew", pady=PADY) self.top.rowconfigure(notebook.grid_info()["row"], weight=1) @@ -265,7 +267,7 @@ class NodeConfigDialog(Dialog): self.ifaces[iface.id] = InterfaceData(is_auto, mac, ip4, ip6) - def draw_buttons(self): + def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") frame.columnconfigure(0, weight=1) @@ -277,20 +279,20 @@ class NodeConfigDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_emane_config(self, emane_model: str, iface_id: int): + def click_emane_config(self, emane_model: str, iface_id: int) -> None: dialog = EmaneModelDialog( self, self.app, self.canvas_node, emane_model, iface_id ) dialog.show() - def click_icon(self): + def click_icon(self) -> None: file_path = image_chooser(self, ICONS_PATH) if file_path: self.image = Images.create(file_path, nodeutils.ICON_SIZE) self.image_button.config(image=self.image) self.image_file = file_path - def click_apply(self): + def click_apply(self) -> None: error = False # update core node @@ -354,7 +356,7 @@ class NodeConfigDialog(Dialog): self.canvas_node.redraw() self.destroy() - def iface_select(self, event: tk.Event): + def iface_select(self, event: tk.Event) -> None: listbox = event.widget cur = listbox.curselection() if cur: diff --git a/daemon/core/gui/dialogs/nodeconfigservice.py b/daemon/core/gui/dialogs/nodeconfigservice.py index 5f77ece3..b5250eba 100644 --- a/daemon/core/gui/dialogs/nodeconfigservice.py +++ b/daemon/core/gui/dialogs/nodeconfigservice.py @@ -4,7 +4,7 @@ core node services import logging import tkinter as tk from tkinter import messagebox, ttk -from typing import TYPE_CHECKING, Set +from typing import TYPE_CHECKING, Optional, Set from core.gui.dialogs.configserviceconfig import ConfigServiceConfigDialog from core.gui.dialogs.dialog import Dialog @@ -19,20 +19,20 @@ if TYPE_CHECKING: class NodeConfigServiceDialog(Dialog): def __init__( self, app: "Application", canvas_node: "CanvasNode", services: Set[str] = None - ): + ) -> None: title = f"{canvas_node.core_node.name} Config Services" super().__init__(app, title) - self.canvas_node = canvas_node - self.node_id = canvas_node.core_node.id - self.groups = None - self.services = None - self.current = None + self.canvas_node: "CanvasNode" = canvas_node + self.node_id: int = canvas_node.core_node.id + self.groups: Optional[ListboxScroll] = None + self.services: Optional[CheckboxList] = None + self.current: Optional[ListboxScroll] = None if services is None: services = set(canvas_node.core_node.config_services) - self.current_services = services + self.current_services: Set[str] = services self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) @@ -86,7 +86,7 @@ class NodeConfigServiceDialog(Dialog): # trigger group change self.groups.listbox.event_generate("<>") - def handle_group_change(self, event: tk.Event = None): + def handle_group_change(self, event: tk.Event = None) -> None: selection = self.groups.listbox.curselection() if selection: index = selection[0] @@ -96,7 +96,7 @@ class NodeConfigServiceDialog(Dialog): checked = name in self.current_services self.services.add(name, checked) - def service_clicked(self, name: str, var: tk.IntVar): + def service_clicked(self, name: str, var: tk.IntVar) -> None: if var.get() and name not in self.current_services: self.current_services.add(name) elif not var.get() and name in self.current_services: @@ -104,7 +104,7 @@ class NodeConfigServiceDialog(Dialog): self.draw_current_services() self.canvas_node.core_node.config_services[:] = self.current_services - def click_configure(self): + def click_configure(self) -> None: current_selection = self.current.listbox.curselection() if len(current_selection): dialog = ConfigServiceConfigDialog( @@ -124,25 +124,25 @@ class NodeConfigServiceDialog(Dialog): parent=self, ) - def draw_current_services(self): + def draw_current_services(self) -> None: self.current.listbox.delete(0, tk.END) for name in sorted(self.current_services): self.current.listbox.insert(tk.END, name) if self.is_custom_service(name): self.current.listbox.itemconfig(tk.END, bg="green") - def click_save(self): + def click_save(self) -> None: self.canvas_node.core_node.config_services[:] = self.current_services logging.info( "saved node config services: %s", self.canvas_node.core_node.config_services ) self.destroy() - def click_cancel(self): + def click_cancel(self) -> None: self.current_services = None self.destroy() - def click_remove(self): + def click_remove(self) -> None: cur = self.current.listbox.curselection() if cur: service = self.current.listbox.get(cur[0]) diff --git a/daemon/core/gui/dialogs/nodeservice.py b/daemon/core/gui/dialogs/nodeservice.py index 13490d8c..f6f5e5cf 100644 --- a/daemon/core/gui/dialogs/nodeservice.py +++ b/daemon/core/gui/dialogs/nodeservice.py @@ -3,7 +3,7 @@ core node services """ import tkinter as tk from tkinter import messagebox, ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional, Set from core.gui.dialogs.dialog import Dialog from core.gui.dialogs.serviceconfig import ServiceConfigDialog @@ -16,19 +16,19 @@ if TYPE_CHECKING: class NodeServiceDialog(Dialog): - def __init__(self, app: "Application", canvas_node: "CanvasNode"): + def __init__(self, app: "Application", canvas_node: "CanvasNode") -> None: title = f"{canvas_node.core_node.name} Services" super().__init__(app, title) - self.canvas_node = canvas_node - self.node_id = canvas_node.core_node.id - self.groups = None - self.services = None - self.current = None + self.canvas_node: "CanvasNode" = canvas_node + self.node_id: int = canvas_node.core_node.id + self.groups: Optional[ListboxScroll] = None + self.services: Optional[CheckboxList] = None + self.current: Optional[ListboxScroll] = None services = set(canvas_node.core_node.services) - self.current_services = services + self.current_services: Set[str] = services self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) @@ -84,7 +84,7 @@ class NodeServiceDialog(Dialog): # trigger group change self.groups.listbox.event_generate("<>") - def handle_group_change(self, event: tk.Event = None): + def handle_group_change(self, event: tk.Event = None) -> None: selection = self.groups.listbox.curselection() if selection: index = selection[0] @@ -94,7 +94,7 @@ class NodeServiceDialog(Dialog): checked = name in self.current_services self.services.add(name, checked) - def service_clicked(self, name: str, var: tk.IntVar): + def service_clicked(self, name: str, var: tk.IntVar) -> None: if var.get() and name not in self.current_services: self.current_services.add(name) elif not var.get() and name in self.current_services: @@ -106,7 +106,7 @@ class NodeServiceDialog(Dialog): self.current.listbox.itemconfig(tk.END, bg="green") self.canvas_node.core_node.services[:] = self.current_services - def click_configure(self): + def click_configure(self) -> None: current_selection = self.current.listbox.curselection() if len(current_selection): dialog = ServiceConfigDialog( @@ -127,12 +127,12 @@ class NodeServiceDialog(Dialog): "Service Configuration", "Select a service to configure", parent=self ) - def click_save(self): + def click_save(self) -> None: core_node = self.canvas_node.core_node core_node.services[:] = self.current_services self.destroy() - def click_remove(self): + def click_remove(self) -> None: cur = self.current.listbox.curselection() if cur: service = self.current.listbox.get(cur[0]) diff --git a/daemon/core/gui/dialogs/observers.py b/daemon/core/gui/dialogs/observers.py index d1812b64..286fc2c9 100644 --- a/daemon/core/gui/dialogs/observers.py +++ b/daemon/core/gui/dialogs/observers.py @@ -1,6 +1,6 @@ import tkinter as tk from tkinter import messagebox, ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from core.gui.appconfig import Observer from core.gui.dialogs.dialog import Dialog @@ -12,18 +12,18 @@ if TYPE_CHECKING: class ObserverDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: super().__init__(app, "Observer Widgets") - self.observers = None - self.save_button = None - self.delete_button = None - self.selected = None - self.selected_index = None - self.name = tk.StringVar() - self.cmd = tk.StringVar() + self.observers: Optional[tk.Listbox] = None + self.save_button: Optional[ttk.Button] = None + self.delete_button: Optional[ttk.Button] = None + self.selected: Optional[str] = None + self.selected_index: Optional[int] = None + self.name: tk.StringVar = tk.StringVar() + self.cmd: tk.StringVar = tk.StringVar() self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) self.draw_listbox() @@ -31,7 +31,7 @@ class ObserverDialog(Dialog): self.draw_config_buttons() self.draw_apply_buttons() - def draw_listbox(self): + def draw_listbox(self) -> None: listbox_scroll = ListboxScroll(self.top) listbox_scroll.grid(sticky="nsew", pady=PADY) listbox_scroll.columnconfigure(0, weight=1) @@ -42,7 +42,7 @@ class ObserverDialog(Dialog): for name in sorted(self.app.core.custom_observers): self.observers.insert(tk.END, name) - def draw_form_fields(self): + def draw_form_fields(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew", pady=PADY) frame.columnconfigure(1, weight=1) @@ -57,7 +57,7 @@ class ObserverDialog(Dialog): entry = ttk.Entry(frame, textvariable=self.cmd) entry.grid(row=1, column=1, sticky="ew") - def draw_config_buttons(self): + def draw_config_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew", pady=PADY) for i in range(3): @@ -76,7 +76,7 @@ class ObserverDialog(Dialog): ) self.delete_button.grid(row=0, column=2, sticky="ew") - def draw_apply_buttons(self): + def draw_apply_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") for i in range(2): @@ -88,14 +88,14 @@ class ObserverDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_save_config(self): + def click_save_config(self) -> None: self.app.guiconfig.observers.clear() for observer in self.app.core.custom_observers.values(): self.app.guiconfig.observers.append(observer) self.app.save_config() self.destroy() - def click_create(self): + def click_create(self) -> None: name = self.name.get() if name not in self.app.core.custom_observers: cmd = self.cmd.get() @@ -109,7 +109,7 @@ class ObserverDialog(Dialog): else: messagebox.showerror("Observer Error", f"{name} already exists") - def click_save(self): + def click_save(self) -> None: name = self.name.get() if self.selected: previous_name = self.selected @@ -122,7 +122,7 @@ class ObserverDialog(Dialog): self.observers.insert(self.selected_index, name) self.observers.selection_set(self.selected_index) - def click_delete(self): + def click_delete(self) -> None: if self.selected: self.observers.delete(self.selected_index) del self.app.core.custom_observers[self.selected] @@ -136,7 +136,7 @@ class ObserverDialog(Dialog): self.app.menubar.observers_menu.draw_custom() self.app.toolbar.observers_menu.draw_custom() - def handle_observer_change(self, event: tk.Event): + def handle_observer_change(self, event: tk.Event) -> None: selection = self.observers.curselection() if selection: self.selected_index = selection[0] diff --git a/daemon/core/gui/dialogs/preferences.py b/daemon/core/gui/dialogs/preferences.py index 11d1ba95..839ebd3b 100644 --- a/daemon/core/gui/dialogs/preferences.py +++ b/daemon/core/gui/dialogs/preferences.py @@ -12,27 +12,27 @@ from core.gui.validation import LARGEST_SCALE, SMALLEST_SCALE if TYPE_CHECKING: from core.gui.app import Application -SCALE_INTERVAL = 0.01 +SCALE_INTERVAL: float = 0.01 class PreferencesDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: super().__init__(app, "Preferences") - self.gui_scale = tk.DoubleVar(value=self.app.app_scale) + self.gui_scale: tk.DoubleVar = tk.DoubleVar(value=self.app.app_scale) preferences = self.app.guiconfig.preferences - self.editor = tk.StringVar(value=preferences.editor) - self.theme = tk.StringVar(value=preferences.theme) - self.terminal = tk.StringVar(value=preferences.terminal) - self.gui3d = tk.StringVar(value=preferences.gui3d) + self.editor: tk.StringVar = tk.StringVar(value=preferences.editor) + self.theme: tk.StringVar = tk.StringVar(value=preferences.theme) + self.terminal: tk.StringVar = tk.StringVar(value=preferences.terminal) + self.gui3d: tk.StringVar = tk.StringVar(value=preferences.gui3d) self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) self.draw_preferences() self.draw_buttons() - def draw_preferences(self): + def draw_preferences(self) -> None: frame = ttk.LabelFrame(self.top, text="Preferences", padding=FRAME_PAD) frame.grid(sticky="nsew", pady=PADY) frame.columnconfigure(1, weight=1) @@ -88,7 +88,7 @@ class PreferencesDialog(Dialog): scrollbar = ttk.Scrollbar(scale_frame, command=self.adjust_scale) scrollbar.grid(row=0, column=2) - def draw_buttons(self): + def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") for i in range(2): @@ -100,12 +100,12 @@ class PreferencesDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def theme_change(self, event: tk.Event): + def theme_change(self, event: tk.Event) -> None: theme = self.theme.get() logging.info("changing theme: %s", theme) self.app.style.theme_use(theme) - def click_save(self): + def click_save(self) -> None: preferences = self.app.guiconfig.preferences preferences.terminal = self.terminal.get() preferences.editor = self.editor.get() @@ -118,7 +118,7 @@ class PreferencesDialog(Dialog): self.scale_adjust() self.destroy() - def scale_adjust(self): + def scale_adjust(self) -> None: app_scale = self.gui_scale.get() self.app.app_scale = app_scale self.app.master.tk.call("tk", "scaling", app_scale) @@ -136,7 +136,7 @@ class PreferencesDialog(Dialog): self.app.toolbar.scale() self.app.canvas.scale_graph() - def adjust_scale(self, arg1: str, arg2: str, arg3: str): + def adjust_scale(self, arg1: str, arg2: str, arg3: str) -> None: scale_value = self.gui_scale.get() if arg2 == "-1": if scale_value <= LARGEST_SCALE - SCALE_INTERVAL: diff --git a/daemon/core/gui/dialogs/runtool.py b/daemon/core/gui/dialogs/runtool.py index 98be730f..c66fea8f 100644 --- a/daemon/core/gui/dialogs/runtool.py +++ b/daemon/core/gui/dialogs/runtool.py @@ -1,6 +1,6 @@ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, Optional from core.gui.dialogs.dialog import Dialog from core.gui.nodeutils import NodeUtils @@ -14,10 +14,10 @@ if TYPE_CHECKING: class RunToolDialog(Dialog): def __init__(self, app: "Application") -> None: super().__init__(app, "Run Tool") - self.cmd = tk.StringVar(value="ps ax") - self.result = None - self.node_list = None - self.executable_nodes = {} + self.cmd: tk.StringVar = tk.StringVar(value="ps ax") + self.result: Optional[CodeText] = None + self.node_list: Optional[ListboxScroll] = None + self.executable_nodes: Dict[str, int] = {} self.store_nodes() self.draw() diff --git a/daemon/core/gui/dialogs/servers.py b/daemon/core/gui/dialogs/servers.py index 7ca96e9f..45121a20 100644 --- a/daemon/core/gui/dialogs/servers.py +++ b/daemon/core/gui/dialogs/servers.py @@ -1,6 +1,6 @@ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from core.gui.appconfig import CoreServer from core.gui.dialogs.dialog import Dialog @@ -10,24 +10,24 @@ from core.gui.widgets import ListboxScroll if TYPE_CHECKING: from core.gui.app import Application -DEFAULT_NAME = "example" -DEFAULT_ADDRESS = "127.0.0.1" -DEFAULT_PORT = 50051 +DEFAULT_NAME: str = "example" +DEFAULT_ADDRESS: str = "127.0.0.1" +DEFAULT_PORT: int = 50051 class ServersDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: super().__init__(app, "CORE Servers") - self.name = tk.StringVar(value=DEFAULT_NAME) - self.address = tk.StringVar(value=DEFAULT_ADDRESS) - self.servers = None - self.selected_index = None - self.selected = None - self.save_button = None - self.delete_button = None + self.name: tk.StringVar = tk.StringVar(value=DEFAULT_NAME) + self.address: tk.StringVar = tk.StringVar(value=DEFAULT_ADDRESS) + self.servers: Optional[tk.Listbox] = None + self.selected_index: Optional[int] = None + self.selected: Optional[str] = None + self.save_button: Optional[ttk.Button] = None + self.delete_button: Optional[ttk.Button] = None self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) self.draw_servers() @@ -35,7 +35,7 @@ class ServersDialog(Dialog): self.draw_server_configuration() self.draw_apply_buttons() - def draw_servers(self): + def draw_servers(self) -> None: listbox_scroll = ListboxScroll(self.top) listbox_scroll.grid(pady=PADY, sticky="nsew") listbox_scroll.columnconfigure(0, weight=1) @@ -48,7 +48,7 @@ class ServersDialog(Dialog): for server in self.app.core.servers: self.servers.insert(tk.END, server) - def draw_server_configuration(self): + def draw_server_configuration(self) -> None: frame = ttk.LabelFrame(self.top, text="Server Configuration", padding=FRAME_PAD) frame.grid(pady=PADY, sticky="ew") frame.columnconfigure(1, weight=1) @@ -64,7 +64,7 @@ class ServersDialog(Dialog): entry = ttk.Entry(frame, textvariable=self.address) entry.grid(row=0, column=3, sticky="ew") - def draw_servers_buttons(self): + def draw_servers_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(pady=PADY, sticky="ew") for i in range(3): @@ -83,7 +83,7 @@ class ServersDialog(Dialog): ) self.delete_button.grid(row=0, column=2, sticky="ew") - def draw_apply_buttons(self): + def draw_apply_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") for i in range(2): @@ -104,7 +104,7 @@ class ServersDialog(Dialog): self.app.save_config() self.destroy() - def click_create(self): + def click_create(self) -> None: name = self.name.get() if name not in self.app.core.servers: address = self.address.get() @@ -112,7 +112,7 @@ class ServersDialog(Dialog): self.app.core.servers[name] = server self.servers.insert(tk.END, name) - def click_save(self): + def click_save(self) -> None: name = self.name.get() if self.selected: previous_name = self.selected @@ -125,7 +125,7 @@ class ServersDialog(Dialog): self.servers.insert(self.selected_index, name) self.servers.selection_set(self.selected_index) - def click_delete(self): + def click_delete(self) -> None: if self.selected: self.servers.delete(self.selected_index) del self.app.core.servers[self.selected] @@ -137,7 +137,7 @@ class ServersDialog(Dialog): self.save_button.config(state=tk.DISABLED) self.delete_button.config(state=tk.DISABLED) - def handle_server_change(self, event: tk.Event): + def handle_server_change(self, event: tk.Event) -> None: selection = self.servers.curselection() if selection: self.selected_index = selection[0] diff --git a/daemon/core/gui/dialogs/serviceconfig.py b/daemon/core/gui/dialogs/serviceconfig.py index efeefa09..5faface7 100644 --- a/daemon/core/gui/dialogs/serviceconfig.py +++ b/daemon/core/gui/dialogs/serviceconfig.py @@ -2,11 +2,12 @@ import logging import os import tkinter as tk from tkinter import filedialog, ttk -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple import grpc +from PIL.ImageTk import PhotoImage -from core.api.grpc.services_pb2 import ServiceValidationMode +from core.api.grpc.services_pb2 import NodeServiceData, ServiceValidationMode from core.gui.dialogs.copyserviceconfig import CopyServiceConfigDialog from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum, Images @@ -16,8 +17,9 @@ from core.gui.widgets import CodeText, ListboxScroll if TYPE_CHECKING: from core.gui.app import Application from core.gui.graph.node import CanvasNode + from core.gui.coreclient import CoreClient -ICON_SIZE = 16 +ICON_SIZE: int = 16 class ServiceConfigDialog(Dialog): @@ -28,54 +30,57 @@ class ServiceConfigDialog(Dialog): service_name: str, canvas_node: "CanvasNode", node_id: int, - ): + ) -> None: title = f"{service_name} Service" super().__init__(app, title, master=master) - self.core = app.core - self.canvas_node = canvas_node - self.node_id = node_id - self.service_name = service_name - self.radiovar = tk.IntVar() - self.radiovar.set(2) - self.metadata = "" - self.filenames = [] - self.dependencies = [] - self.executables = [] - self.startup_commands = [] - self.validation_commands = [] - self.shutdown_commands = [] - self.default_startup = [] - self.default_validate = [] - self.default_shutdown = [] - self.validation_mode = None - self.validation_time = None - self.validation_period = None - self.directory_entry = None - self.default_directories = [] - self.temp_directories = [] - self.documentnew_img = self.app.get_icon(ImageEnum.DOCUMENTNEW, ICON_SIZE) - self.editdelete_img = self.app.get_icon(ImageEnum.EDITDELETE, ICON_SIZE) - self.notebook = None - self.metadata_entry = None - self.filename_combobox = None - self.dir_list = None - self.startup_commands_listbox = None - self.shutdown_commands_listbox = None - self.validate_commands_listbox = None - self.validation_time_entry = None - self.validation_mode_entry = None - self.service_file_data = None - self.validation_period_entry = None - self.original_service_files = {} - self.default_config = None - self.temp_service_files = {} - self.modified_files = set() - self.has_error = False + self.core: "CoreClient" = app.core + self.canvas_node: "CanvasNode" = canvas_node + self.node_id: int = node_id + self.service_name: str = service_name + self.radiovar: tk.IntVar = tk.IntVar(value=2) + self.metadata: str = "" + self.filenames: List[str] = [] + self.dependencies: List[str] = [] + self.executables: List[str] = [] + self.startup_commands: List[str] = [] + self.validation_commands: List[str] = [] + self.shutdown_commands: List[str] = [] + self.default_startup: List[str] = [] + self.default_validate: List[str] = [] + self.default_shutdown: List[str] = [] + self.validation_mode: Optional[ServiceValidationMode] = None + self.validation_time: Optional[int] = None + self.validation_period: Optional[float] = None + self.directory_entry: Optional[ttk.Entry] = None + self.default_directories: List[str] = [] + self.temp_directories: List[str] = [] + self.documentnew_img: PhotoImage = self.app.get_icon( + ImageEnum.DOCUMENTNEW, ICON_SIZE + ) + self.editdelete_img: PhotoImage = self.app.get_icon( + ImageEnum.EDITDELETE, ICON_SIZE + ) + self.notebook: Optional[ttk.Notebook] = None + self.metadata_entry: Optional[ttk.Entry] = None + self.filename_combobox: Optional[ttk.Combobox] = None + self.dir_list: Optional[ListboxScroll] = None + self.startup_commands_listbox: Optional[tk.Listbox] = None + self.shutdown_commands_listbox: Optional[tk.Listbox] = None + self.validate_commands_listbox: Optional[tk.Listbox] = None + self.validation_time_entry: Optional[ttk.Entry] = None + self.validation_mode_entry: Optional[ttk.Entry] = None + self.service_file_data: Optional[CodeText] = None + self.validation_period_entry: Optional[ttk.Entry] = None + self.original_service_files: Dict[str, str] = {} + self.default_config: NodeServiceData = None + self.temp_service_files: Dict[str, str] = {} + self.modified_files: Set[str] = set() + self.has_error: bool = False self.load() if not self.has_error: self.draw() - def load(self): + def load(self) -> None: try: self.app.core.create_nodes_and_links() default_config = self.app.core.get_node_service( @@ -119,7 +124,7 @@ class ServiceConfigDialog(Dialog): self.app.show_grpc_exception("Get Node Service Error", e) self.has_error = True - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(1, weight=1) @@ -142,7 +147,7 @@ class ServiceConfigDialog(Dialog): self.draw_buttons() - def draw_tab_files(self): + def draw_tab_files(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) tab.grid(sticky="nsew") tab.columnconfigure(0, weight=1) @@ -222,7 +227,7 @@ class ServiceConfigDialog(Dialog): "", self.update_temp_service_file_data ) - def draw_tab_directories(self): + def draw_tab_directories(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) tab.grid(sticky="nsew") tab.columnconfigure(0, weight=1) @@ -257,7 +262,7 @@ class ServiceConfigDialog(Dialog): button = ttk.Button(frame, text="Remove", command=self.remove_directory) button.grid(row=0, column=1, sticky="ew") - def draw_tab_startstop(self): + def draw_tab_startstop(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) tab.grid(sticky="nsew") tab.columnconfigure(0, weight=1) @@ -311,7 +316,7 @@ class ServiceConfigDialog(Dialog): elif i == 2: self.validate_commands_listbox = listbox_scroll.listbox - def draw_tab_configuration(self): + def draw_tab_configuration(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) tab.grid(sticky="nsew") tab.columnconfigure(0, weight=1) @@ -370,7 +375,7 @@ class ServiceConfigDialog(Dialog): for dependency in self.dependencies: listbox_scroll.listbox.insert("end", dependency) - def draw_buttons(self): + def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="ew") for i in range(4): @@ -384,7 +389,7 @@ class ServiceConfigDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=3, sticky="ew") - def add_filename(self): + def add_filename(self) -> None: filename = self.filename_combobox.get() if filename not in self.filename_combobox["values"]: self.filename_combobox["values"] += (filename,) @@ -395,7 +400,7 @@ class ServiceConfigDialog(Dialog): else: logging.debug("file already existed") - def delete_filename(self): + def delete_filename(self) -> None: cbb = self.filename_combobox filename = cbb.get() if filename in cbb["values"]: @@ -407,7 +412,7 @@ class ServiceConfigDialog(Dialog): self.modified_files.remove(filename) @classmethod - def add_command(cls, event: tk.Event): + def add_command(cls, event: tk.Event) -> None: frame_contains_button = event.widget.master listbox = frame_contains_button.master.grid_slaves(row=1, column=0)[0].listbox command_to_add = frame_contains_button.grid_slaves(row=0, column=0)[0].get() @@ -419,7 +424,7 @@ class ServiceConfigDialog(Dialog): listbox.insert(tk.END, command_to_add) @classmethod - def update_entry(cls, event: tk.Event): + def update_entry(cls, event: tk.Event) -> None: listbox = event.widget current_selection = listbox.curselection() if len(current_selection) > 0: @@ -431,7 +436,7 @@ class ServiceConfigDialog(Dialog): entry.insert(0, cmd) @classmethod - def delete_command(cls, event: tk.Event): + def delete_command(cls, event: tk.Event) -> None: button = event.widget frame_contains_button = button.master listbox = frame_contains_button.master.grid_slaves(row=1, column=0)[0].listbox @@ -441,7 +446,7 @@ class ServiceConfigDialog(Dialog): entry = frame_contains_button.grid_slaves(row=0, column=0)[0] entry.delete(0, tk.END) - def click_apply(self): + def click_apply(self) -> None: if ( not self.is_custom_command() and not self.is_custom_service_file() @@ -484,12 +489,12 @@ class ServiceConfigDialog(Dialog): self.app.show_grpc_exception("Save Service Config Error", e) self.destroy() - def display_service_file_data(self, event: tk.Event): + def display_service_file_data(self, event: tk.Event) -> None: filename = self.filename_combobox.get() self.service_file_data.text.delete(1.0, "end") self.service_file_data.text.insert("end", self.temp_service_files[filename]) - def update_temp_service_file_data(self, event: tk.Event): + def update_temp_service_file_data(self, event: tk.Event) -> None: filename = self.filename_combobox.get() self.temp_service_files[filename] = self.service_file_data.text.get(1.0, "end") if self.temp_service_files[filename] != self.original_service_files.get( @@ -499,7 +504,7 @@ class ServiceConfigDialog(Dialog): else: self.modified_files.discard(filename) - def is_custom_command(self): + def is_custom_command(self) -> bool: startup, validate, shutdown = self.get_commands() return ( set(self.default_startup) != set(startup) @@ -507,16 +512,16 @@ class ServiceConfigDialog(Dialog): or set(self.default_shutdown) != set(shutdown) ) - def has_new_files(self): + def has_new_files(self) -> bool: return set(self.filenames) != set(self.filename_combobox["values"]) - def is_custom_service_file(self): + def is_custom_service_file(self) -> bool: return len(self.modified_files) > 0 - def is_custom_directory(self): + def is_custom_directory(self) -> bool: return set(self.default_directories) != set(self.dir_list.listbox.get(0, "end")) - def click_defaults(self): + def click_defaults(self) -> None: """ clears out any custom configuration permanently """ @@ -557,37 +562,37 @@ class ServiceConfigDialog(Dialog): self.current_service_color("") - def click_copy(self): + def click_copy(self) -> None: dialog = CopyServiceConfigDialog(self, self.app, self.node_id) dialog.show() @classmethod def append_commands( cls, commands: List[str], listbox: tk.Listbox, to_add: List[str] - ): + ) -> None: for cmd in to_add: commands.append(cmd) listbox.insert(tk.END, cmd) - def get_commands(self): + def get_commands(self) -> Tuple[List[str], List[str], List[str]]: startup = self.startup_commands_listbox.get(0, "end") shutdown = self.shutdown_commands_listbox.get(0, "end") validate = self.validate_commands_listbox.get(0, "end") return startup, validate, shutdown - def find_directory_button(self): + def find_directory_button(self) -> None: d = filedialog.askdirectory(initialdir="/") self.directory_entry.delete(0, "end") self.directory_entry.insert("end", d) - def add_directory(self): + def add_directory(self) -> None: d = self.directory_entry.get() if os.path.isdir(d): if d not in self.temp_directories: self.dir_list.listbox.insert("end", d) self.temp_directories.append(d) - def remove_directory(self): + def remove_directory(self) -> None: d = self.directory_entry.get() dirs = self.dir_list.listbox.get(0, "end") if d and d in self.temp_directories: @@ -599,14 +604,14 @@ class ServiceConfigDialog(Dialog): logging.debug("directory is not in the list") self.directory_entry.delete(0, "end") - def directory_select(self, event): + def directory_select(self, event) -> None: i = self.dir_list.listbox.curselection() if i: d = self.dir_list.listbox.get(i) self.directory_entry.delete(0, "end") self.directory_entry.insert("end", d) - def current_service_color(self, color=""): + def current_service_color(self, color="") -> None: """ change the current service label color """ diff --git a/daemon/core/gui/dialogs/sessionoptions.py b/daemon/core/gui/dialogs/sessionoptions.py index d31a5fb5..8138d854 100644 --- a/daemon/core/gui/dialogs/sessionoptions.py +++ b/daemon/core/gui/dialogs/sessionoptions.py @@ -1,9 +1,10 @@ import logging from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, Optional import grpc +from core.api.grpc.common_pb2 import ConfigOption from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame @@ -13,15 +14,15 @@ if TYPE_CHECKING: class SessionOptionsDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: super().__init__(app, "Session Options") - self.config_frame = None - self.has_error = False - self.config = self.get_config() + self.config_frame: Optional[ConfigFrame] = None + self.has_error: bool = False + self.config: Dict[str, ConfigOption] = self.get_config() if not self.has_error: self.draw() - def get_config(self): + def get_config(self) -> Dict[str, ConfigOption]: try: session_id = self.app.core.session_id response = self.app.core.client.get_session_options(session_id) @@ -31,7 +32,7 @@ class SessionOptionsDialog(Dialog): self.has_error = True self.destroy() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) @@ -48,7 +49,7 @@ class SessionOptionsDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def save(self): + def save(self) -> None: config = self.config_frame.parse_config() try: session_id = self.app.core.session_id diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index 9aa71a13..a7d702eb 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -1,11 +1,12 @@ import logging import tkinter as tk from tkinter import messagebox, ttk -from typing import TYPE_CHECKING, List +from typing import TYPE_CHECKING, List, Optional import grpc from core.api.grpc import core_pb2 +from core.api.grpc.core_pb2 import SessionSummary from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum, Images from core.gui.task import ProgressTask @@ -18,17 +19,17 @@ if TYPE_CHECKING: class SessionsDialog(Dialog): def __init__(self, app: "Application", is_start_app: bool = False) -> None: super().__init__(app, "Sessions") - self.is_start_app = is_start_app - self.selected_session = None - self.selected_id = None - self.tree = None - self.sessions = self.get_sessions() - self.connect_button = None - self.delete_button = None + self.is_start_app: bool = is_start_app + self.selected_session: Optional[int] = None + self.selected_id: Optional[int] = None + self.tree: Optional[ttk.Treeview] = None + self.sessions: List[SessionSummary] = self.get_sessions() + self.connect_button: Optional[ttk.Button] = None + self.delete_button: Optional[ttk.Button] = None self.protocol("WM_DELETE_WINDOW", self.on_closing) self.draw() - def get_sessions(self) -> List[core_pb2.SessionSummary]: + def get_sessions(self) -> List[SessionSummary]: try: response = self.app.core.client.get_sessions() logging.info("sessions: %s", response) diff --git a/daemon/core/gui/dialogs/shapemod.py b/daemon/core/gui/dialogs/shapemod.py index 4c84991b..2ca06772 100644 --- a/daemon/core/gui/dialogs/shapemod.py +++ b/daemon/core/gui/dialogs/shapemod.py @@ -3,7 +3,7 @@ shape input dialog """ import tkinter as tk from tkinter import font, ttk -from typing import TYPE_CHECKING, List, Union +from typing import TYPE_CHECKING, List, Optional, Union from core.gui.dialogs.colorpicker import ColorPickerDialog from core.gui.dialogs.dialog import Dialog @@ -13,40 +13,41 @@ from core.gui.themes import FRAME_PAD, PADX, PADY if TYPE_CHECKING: from core.gui.app import Application + from core.gui.graph.graph import CanvasGraph from core.gui.graph.shape import Shape -FONT_SIZES = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72] -BORDER_WIDTH = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] +FONT_SIZES: List[int] = [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72] +BORDER_WIDTH: List[int] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] class ShapeDialog(Dialog): - def __init__(self, app: "Application", shape: "Shape"): + def __init__(self, app: "Application", shape: "Shape") -> None: if is_draw_shape(shape.shape_type): title = "Add Shape" else: title = "Add Text" super().__init__(app, title) - self.canvas = app.canvas - self.fill = None - self.border = None - self.shape = shape + self.canvas: "CanvasGraph" = app.canvas + self.fill: Optional[ttk.Label] = None + self.border: Optional[ttk.Label] = None + self.shape: "Shape" = shape data = shape.shape_data - self.shape_text = tk.StringVar(value=data.text) - self.font = tk.StringVar(value=data.font) - self.font_size = tk.IntVar(value=data.font_size) - self.text_color = data.text_color + self.shape_text: tk.StringVar = tk.StringVar(value=data.text) + self.font: tk.StringVar = tk.StringVar(value=data.font) + self.font_size: tk.IntVar = tk.IntVar(value=data.font_size) + self.text_color: str = data.text_color fill_color = data.fill_color if not fill_color: fill_color = "#CFCFFF" - self.fill_color = fill_color - self.border_color = data.border_color - self.border_width = tk.IntVar(value=0) - self.bold = tk.BooleanVar(value=data.bold) - self.italic = tk.BooleanVar(value=data.italic) - self.underline = tk.BooleanVar(value=data.underline) + self.fill_color: str = fill_color + self.border_color: str = data.border_color + self.border_width: tk.IntVar = tk.IntVar(value=0) + self.bold: tk.BooleanVar = tk.BooleanVar(value=data.bold) + self.italic: tk.BooleanVar = tk.BooleanVar(value=data.italic) + self.underline: tk.BooleanVar = tk.BooleanVar(value=data.underline) self.draw() - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.draw_label_options() if is_draw_shape(self.shape.shape_type): @@ -54,7 +55,7 @@ class ShapeDialog(Dialog): self.draw_spacer() self.draw_buttons() - def draw_label_options(self): + def draw_label_options(self) -> None: label_frame = ttk.LabelFrame(self.top, text="Label", padding=FRAME_PAD) label_frame.grid(sticky="ew") label_frame.columnconfigure(0, weight=1) @@ -94,7 +95,7 @@ class ShapeDialog(Dialog): button = ttk.Checkbutton(frame, variable=self.underline, text="Underline") button.grid(row=0, column=2, sticky="ew") - def draw_shape_options(self): + def draw_shape_options(self) -> None: label_frame = ttk.LabelFrame(self.top, text="Shape", padding=FRAME_PAD) label_frame.grid(sticky="ew", pady=PADY) label_frame.columnconfigure(0, weight=1) @@ -129,7 +130,7 @@ class ShapeDialog(Dialog): ) combobox.grid(row=0, column=1, sticky="nsew") - def draw_buttons(self): + def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.grid(sticky="nsew") frame.columnconfigure(0, weight=1) @@ -139,28 +140,28 @@ class ShapeDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.cancel) button.grid(row=0, column=1, sticky="ew") - def choose_text_color(self): + def choose_text_color(self) -> None: color_picker = ColorPickerDialog(self, self.app, self.text_color) self.text_color = color_picker.askcolor() - def choose_fill_color(self): + def choose_fill_color(self) -> None: color_picker = ColorPickerDialog(self, self.app, self.fill_color) color = color_picker.askcolor() self.fill_color = color self.fill.config(background=color, text=color) - def choose_border_color(self): + def choose_border_color(self) -> None: color_picker = ColorPickerDialog(self, self.app, self.border_color) color = color_picker.askcolor() self.border_color = color self.border.config(background=color, text=color) - def cancel(self): + def cancel(self) -> None: self.shape.delete() self.canvas.shapes.pop(self.shape.id) self.destroy() - def click_add(self): + def click_add(self) -> None: if is_draw_shape(self.shape.shape_type): self.add_shape() elif is_shape_text(self.shape.shape_type): @@ -181,7 +182,7 @@ class ShapeDialog(Dialog): text_font.append("underline") return text_font - def save_text(self): + def save_text(self) -> None: """ save info related to text or shape label """ @@ -194,7 +195,7 @@ class ShapeDialog(Dialog): data.italic = self.italic.get() data.underline = self.underline.get() - def save_shape(self): + def save_shape(self) -> None: """ save info related to shape """ @@ -203,7 +204,7 @@ class ShapeDialog(Dialog): data.border_color = self.border_color data.border_width = int(self.border_width.get()) - def add_text(self): + def add_text(self) -> None: """ add text to canvas """ @@ -214,7 +215,7 @@ class ShapeDialog(Dialog): ) self.save_text() - def add_shape(self): + def add_shape(self) -> None: self.canvas.itemconfig( self.shape.id, fill=self.fill_color, diff --git a/daemon/core/gui/dialogs/throughput.py b/daemon/core/gui/dialogs/throughput.py index 5210fe59..5b3cc9b3 100644 --- a/daemon/core/gui/dialogs/throughput.py +++ b/daemon/core/gui/dialogs/throughput.py @@ -3,10 +3,11 @@ throughput dialog """ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional from core.gui.dialogs.colorpicker import ColorPickerDialog from core.gui.dialogs.dialog import Dialog +from core.gui.graph.graph import CanvasGraph from core.gui.themes import FRAME_PAD, PADX, PADY if TYPE_CHECKING: @@ -14,21 +15,23 @@ if TYPE_CHECKING: class ThroughputDialog(Dialog): - def __init__(self, app: "Application"): + def __init__(self, app: "Application") -> None: super().__init__(app, "Throughput Config") - self.canvas = app.canvas - self.show_throughput = tk.IntVar(value=1) - self.exponential_weight = tk.IntVar(value=1) - self.transmission = tk.IntVar(value=1) - self.reception = tk.IntVar(value=1) - self.threshold = tk.DoubleVar(value=self.canvas.throughput_threshold) - self.width = tk.IntVar(value=self.canvas.throughput_width) - self.color = self.canvas.throughput_color - self.color_button = None + self.canvas: CanvasGraph = app.canvas + self.show_throughput: tk.IntVar = tk.IntVar(value=1) + self.exponential_weight: tk.IntVar = tk.IntVar(value=1) + self.transmission: tk.IntVar = tk.IntVar(value=1) + self.reception: tk.IntVar = tk.IntVar(value=1) + self.threshold: tk.DoubleVar = tk.DoubleVar( + value=self.canvas.throughput_threshold + ) + self.width: tk.IntVar = tk.IntVar(value=self.canvas.throughput_width) + self.color: str = self.canvas.throughput_color + self.color_button: Optional[tk.Button] = None self.top.columnconfigure(0, weight=1) self.draw() - def draw(self): + def draw(self) -> None: button = ttk.Checkbutton( self.top, variable=self.show_throughput, @@ -97,12 +100,12 @@ class ThroughputDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_color(self): + def click_color(self) -> None: color_picker = ColorPickerDialog(self, self.app, self.color) self.color = color_picker.askcolor() self.color_button.config(bg=self.color, text=self.color, bd=0) - def click_save(self): + def click_save(self) -> None: self.canvas.throughput_threshold = self.threshold.get() self.canvas.throughput_width = self.width.get() self.canvas.throughput_color = self.color diff --git a/daemon/core/gui/dialogs/wlanconfig.py b/daemon/core/gui/dialogs/wlanconfig.py index b0435a2f..326b3195 100644 --- a/daemon/core/gui/dialogs/wlanconfig.py +++ b/daemon/core/gui/dialogs/wlanconfig.py @@ -1,8 +1,10 @@ from tkinter import ttk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict, Optional import grpc +from core.api.grpc.common_pb2 import ConfigOption +from core.api.grpc.core_pb2 import Node from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame @@ -10,34 +12,36 @@ from core.gui.widgets import ConfigFrame if TYPE_CHECKING: from core.gui.app import Application from core.gui.graph.node import CanvasNode + from core.gui.graph.graph import CanvasGraph -RANGE_COLOR = "#009933" -RANGE_WIDTH = 3 +RANGE_COLOR: str = "#009933" +RANGE_WIDTH: int = 3 class WlanConfigDialog(Dialog): - def __init__(self, app: "Application", canvas_node: "CanvasNode"): + def __init__(self, app: "Application", canvas_node: "CanvasNode") -> None: super().__init__(app, f"{canvas_node.core_node.name} WLAN Configuration") - self.canvas_node = canvas_node - self.node = canvas_node.core_node - self.config_frame = None - self.range_entry = None - self.has_error = False - self.canvas = app.canvas - self.ranges = {} - self.positive_int = self.app.master.register(self.validate_and_update) + self.canvas: "CanvasGraph" = app.canvas + self.canvas_node: "CanvasNode" = canvas_node + self.node: Node = canvas_node.core_node + self.config_frame: Optional[ConfigFrame] = None + self.range_entry: Optional[ttk.Entry] = None + self.has_error: bool = False + self.ranges: Dict[int, int] = {} + self.positive_int: int = self.app.master.register(self.validate_and_update) try: - self.config = self.canvas_node.wlan_config - if not self.config: - self.config = self.app.core.get_wlan_config(self.node.id) + config = self.canvas_node.wlan_config + if not config: + config = self.app.core.get_wlan_config(self.node.id) + self.config: Dict[str, ConfigOption] = config self.init_draw_range() self.draw() except grpc.RpcError as e: self.app.show_grpc_exception("WLAN Config Error", e) - self.has_error = True + self.has_error: bool = True self.destroy() - def init_draw_range(self): + def init_draw_range(self) -> None: if self.canvas_node.id in self.canvas.wireless_network: for cid in self.canvas.wireless_network[self.canvas_node.id]: x, y = self.canvas.coords(cid) @@ -46,7 +50,7 @@ class WlanConfigDialog(Dialog): ) self.ranges[cid] = range_id - def draw(self): + def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) self.config_frame = ConfigFrame(self.top, self.app, self.config) @@ -55,7 +59,7 @@ class WlanConfigDialog(Dialog): self.draw_apply_buttons() self.top.bind("", self.remove_ranges) - def draw_apply_buttons(self): + def draw_apply_buttons(self) -> None: """ create node configuration options """ @@ -75,7 +79,7 @@ class WlanConfigDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") - def click_apply(self): + def click_apply(self) -> None: """ retrieve user's wlan configuration and store the new configuration values """ @@ -87,7 +91,7 @@ class WlanConfigDialog(Dialog): self.remove_ranges() self.destroy() - def remove_ranges(self, event=None): + def remove_ranges(self, event=None) -> None: for cid in self.canvas.find_withtag("range"): self.canvas.delete(cid) self.ranges.clear() diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index f936bc79..833011d8 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -57,7 +57,9 @@ class CanvasNode: self.antennas: List[int] = [] self.antenna_images: Dict[int, PhotoImage] = {} # possible configurations - self.emane_model_configs: Dict[Tuple[str, Optional[int]], ConfigOption] = {} + self.emane_model_configs: Dict[ + Tuple[str, Optional[int]], Dict[str, ConfigOption] + ] = {} self.wlan_config: Dict[str, ConfigOption] = {} self.mobility_config: Dict[str, ConfigOption] = {} self.service_configs: Dict[str, NodeServiceData] = {} @@ -135,7 +137,7 @@ class CanvasNode: new_y = self._get_label_y() self.canvas.move(self.text_id, 0, new_y - prev_y) - def move(self, x: int, y: int) -> None: + def move(self, x: float, y: float) -> None: x, y = self.canvas.get_scaled_coords(x, y) current_x, current_y = self.canvas.coords(self.id) x_offset = x - current_x diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index 523f8f11..75312e95 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -49,7 +49,7 @@ class Menubar(tk.Menu): self.canvas: CanvasGraph = app.canvas self.recent_menu: Optional[tk.Menu] = None self.edit_menu: Optional[tk.Menu] = None - self.observers_menu: Optional[tk.Menu] = None + self.observers_menu: Optional[ObserversMenu] = None self.draw() def draw(self) -> None: diff --git a/daemon/core/gui/nodeutils.py b/daemon/core/gui/nodeutils.py index 402eca4d..dbb403df 100644 --- a/daemon/core/gui/nodeutils.py +++ b/daemon/core/gui/nodeutils.py @@ -14,7 +14,7 @@ ANTENNA_SIZE: int = 32 class NodeDraw: def __init__(self) -> None: self.custom: bool = False - self.image: Optional[str] = None + self.image: Optional[PhotoImage] = None self.image_enum: Optional[ImageEnum] = None self.image_file: Optional[str] = None self.node_type: NodeType = None From 344f35e93e8ab9a4f5e988b652a344f77a1af774 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 22 Jun 2020 19:04:55 -0700 Subject: [PATCH 187/929] pygui: updated ConfigFrame to have a disabled display option, updated nodes to stil show emane config during runtime, updated emane dialog and config dialogs to be in a viewable but disabled state during runtime --- daemon/core/gui/dialogs/emaneconfig.py | 33 ++++++++++++++------------ daemon/core/gui/graph/node.py | 4 ++++ daemon/core/gui/widgets.py | 28 +++++++++++++++------- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/daemon/core/gui/dialogs/emaneconfig.py b/daemon/core/gui/dialogs/emaneconfig.py index df6c6125..bb334757 100644 --- a/daemon/core/gui/dialogs/emaneconfig.py +++ b/daemon/core/gui/dialogs/emaneconfig.py @@ -24,12 +24,15 @@ class GlobalEmaneDialog(Dialog): def __init__(self, master: tk.BaseWidget, app: "Application") -> None: super().__init__(app, "EMANE Configuration", master=master) self.config_frame: Optional[ConfigFrame] = None + self.enabled: bool = not self.app.core.is_runtime() self.draw() def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) - self.config_frame = ConfigFrame(self.top, self.app, self.app.core.emane_config) + self.config_frame = ConfigFrame( + self.top, self.app, self.app.core.emane_config, self.enabled + ) self.config_frame.draw_config() self.config_frame.grid(sticky="nsew", pady=PADY) self.draw_spacer() @@ -40,9 +43,9 @@ class GlobalEmaneDialog(Dialog): frame.grid(sticky="ew") for i in range(2): frame.columnconfigure(i, weight=1) - button = ttk.Button(frame, text="Apply", command=self.click_apply) + state = tk.NORMAL if self.enabled else tk.DISABLED + button = ttk.Button(frame, text="Apply", command=self.click_apply, state=state) button.grid(row=0, column=0, sticky="ew", padx=PADX) - button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") @@ -68,6 +71,7 @@ class EmaneModelDialog(Dialog): self.model: str = f"emane_{model}" self.iface_id: int = iface_id self.config_frame: Optional[ConfigFrame] = None + self.enabled: bool = not self.app.core.is_runtime() self.has_error: bool = False try: config = self.canvas_node.emane_model_configs.get( @@ -87,7 +91,7 @@ class EmaneModelDialog(Dialog): def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) - self.config_frame = ConfigFrame(self.top, self.app, self.config) + self.config_frame = ConfigFrame(self.top, self.app, self.config, self.enabled) self.config_frame.draw_config() self.config_frame.grid(sticky="nsew", pady=PADY) self.draw_spacer() @@ -98,9 +102,9 @@ class EmaneModelDialog(Dialog): frame.grid(sticky="ew") for i in range(2): frame.columnconfigure(i, weight=1) - button = ttk.Button(frame, text="Apply", command=self.click_apply) + state = tk.NORMAL if self.enabled else tk.DISABLED + button = ttk.Button(frame, text="Apply", command=self.click_apply, state=state) button.grid(row=0, column=0, sticky="ew", padx=PADX) - button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") @@ -124,6 +128,7 @@ class EmaneConfigDialog(Dialog): model = self.node.emane.split("_")[1] self.emane_model: tk.StringVar = tk.StringVar(value=model) self.emane_model_button: Optional[ttk.Button] = None + self.enabled: bool = not self.app.core.is_runtime() self.draw() def draw(self) -> None: @@ -140,8 +145,9 @@ class EmaneConfigDialog(Dialog): """ label = ttk.Label( self.top, - text="The EMANE emulation system provides more complex wireless radio emulation " - "\nusing pluggable MAC and PHY modules. Refer to the wiki for configuration option details", + text="The EMANE emulation system provides more complex wireless radio " + "emulation \nusing pluggable MAC and PHY modules. Refer to the wiki " + "for configuration option details", justify=tk.CENTER, ) label.grid(pady=PADY) @@ -171,11 +177,9 @@ class EmaneConfigDialog(Dialog): label.grid(row=0, column=0, sticky="w") # create combo box and its binding + state = "readonly" if self.enabled else tk.DISABLED combobox = ttk.Combobox( - frame, - textvariable=self.emane_model, - values=self.emane_models, - state="readonly", + frame, textvariable=self.emane_model, values=self.emane_models, state=state ) combobox.grid(row=0, column=1, sticky="ew") combobox.bind("<>", self.emane_model_change) @@ -213,10 +217,9 @@ class EmaneConfigDialog(Dialog): frame.grid(sticky="ew") for i in range(2): frame.columnconfigure(i, weight=1) - - button = ttk.Button(frame, text="Apply", command=self.click_apply) + state = tk.NORMAL if self.enabled else tk.DISABLED + button = ttk.Button(frame, text="Apply", command=self.click_apply, state=state) button.grid(row=0, column=0, padx=PADX, sticky="ew") - button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 833011d8..a86ce4a3 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -202,6 +202,10 @@ class CanvasNode: is_emane = self.core_node.type == NodeType.EMANE if self.app.core.is_runtime(): self.context.add_command(label="Configure", command=self.show_config) + if is_emane: + self.context.add_command( + label="EMANE Config", command=self.show_emane_config + ) if is_wlan: self.context.add_command( label="WLAN Config", command=self.show_wlan_config diff --git a/daemon/core/gui/widgets.py b/daemon/core/gui/widgets.py index 2eded212..81bad0f5 100644 --- a/daemon/core/gui/widgets.py +++ b/daemon/core/gui/widgets.py @@ -85,12 +85,14 @@ class ConfigFrame(ttk.Notebook): master: tk.Widget, app: "Application", config: Dict[str, ConfigOption], + enabled: bool = True, **kw: Any ) -> None: super().__init__(master, **kw) self.app: "Application" = app self.config: Dict[str, ConfigOption] = config self.values: Dict[str, tk.StringVar] = {} + self.enabled: bool = enabled def draw_config(self) -> None: group_mapping = {} @@ -110,8 +112,9 @@ class ConfigFrame(ttk.Notebook): value = tk.StringVar() if option.type == core_pb2.ConfigOptionType.BOOL: select = ("On", "Off") + state = "readonly" if self.enabled else tk.DISABLED combobox = ttk.Combobox( - tab.frame, textvariable=value, values=select, state="readonly" + tab.frame, textvariable=value, values=select, state=state ) combobox.grid(row=index, column=1, sticky="ew") if option.value == "1": @@ -121,32 +124,41 @@ class ConfigFrame(ttk.Notebook): elif option.select: value.set(option.value) select = tuple(option.select) + state = "readonly" if self.enabled else tk.DISABLED combobox = ttk.Combobox( - tab.frame, textvariable=value, values=select, state="readonly" + tab.frame, textvariable=value, values=select, state=state ) combobox.grid(row=index, column=1, sticky="ew") elif option.type == core_pb2.ConfigOptionType.STRING: value.set(option.value) + state = tk.NORMAL if self.enabled else tk.DISABLED if "file" in option.label: file_frame = ttk.Frame(tab.frame) file_frame.grid(row=index, column=1, sticky="ew") file_frame.columnconfigure(0, weight=1) - entry = ttk.Entry(file_frame, textvariable=value) + entry = ttk.Entry(file_frame, textvariable=value, state=state) entry.grid(row=0, column=0, sticky="ew", padx=PADX) func = partial(file_button_click, value, self) - button = ttk.Button(file_frame, text="...", command=func) + button = ttk.Button( + file_frame, text="...", command=func, state=state + ) button.grid(row=0, column=1) else: - entry = ttk.Entry(tab.frame, textvariable=value) + entry = ttk.Entry(tab.frame, textvariable=value, state=state) entry.grid(row=index, column=1, sticky="ew") - elif option.type in INT_TYPES: value.set(option.value) - entry = validation.PositiveIntEntry(tab.frame, textvariable=value) + state = tk.NORMAL if self.enabled else tk.DISABLED + entry = validation.PositiveIntEntry( + tab.frame, textvariable=value, state=state + ) entry.grid(row=index, column=1, sticky="ew") elif option.type == core_pb2.ConfigOptionType.FLOAT: value.set(option.value) - entry = validation.PositiveFloatEntry(tab.frame, textvariable=value) + state = tk.NORMAL if self.enabled else tk.DISABLED + entry = validation.PositiveFloatEntry( + tab.frame, textvariable=value, state=state + ) entry.grid(row=index, column=1, sticky="ew") else: logging.error("unhandled config option type: %s", option.type) From 27e35a52135795c4b3e6da7cf968db08a924cad3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 22 Jun 2020 19:40:42 -0700 Subject: [PATCH 188/929] pygui: session options dialog is disabled during runtime --- daemon/core/gui/dialogs/sessionoptions.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/daemon/core/gui/dialogs/sessionoptions.py b/daemon/core/gui/dialogs/sessionoptions.py index 8138d854..fd021fee 100644 --- a/daemon/core/gui/dialogs/sessionoptions.py +++ b/daemon/core/gui/dialogs/sessionoptions.py @@ -1,4 +1,5 @@ import logging +import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING, Dict, Optional @@ -19,6 +20,7 @@ class SessionOptionsDialog(Dialog): self.config_frame: Optional[ConfigFrame] = None self.has_error: bool = False self.config: Dict[str, ConfigOption] = self.get_config() + self.enabled: bool = not self.app.core.is_runtime() if not self.has_error: self.draw() @@ -35,8 +37,7 @@ class SessionOptionsDialog(Dialog): def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) - - self.config_frame = ConfigFrame(self.top, self.app, config=self.config) + self.config_frame = ConfigFrame(self.top, self.app, self.config, self.enabled) self.config_frame.draw_config() self.config_frame.grid(sticky="nsew", pady=PADY) @@ -44,7 +45,8 @@ class SessionOptionsDialog(Dialog): frame.grid(sticky="ew") for i in range(2): frame.columnconfigure(i, weight=1) - button = ttk.Button(frame, text="Save", command=self.save) + state = tk.NORMAL if self.enabled else tk.DISABLED + button = ttk.Button(frame, text="Save", command=self.save, state=state) button.grid(row=0, column=0, padx=PADX, sticky="ew") button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") From f39ab1dee65d811aa6aa077c4378b18a1434f830 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 22 Jun 2020 21:13:24 -0700 Subject: [PATCH 189/929] pygui: limit rj45 node to 1 link --- daemon/core/gui/graph/graph.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 53115750..fdf9ba21 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -428,8 +428,9 @@ class CanvasGraph(tk.Canvas): # edge dst must be a node logging.debug("current selected: %s", self.selected) + src_node = self.nodes.get(edge.src) dst_node = self.nodes.get(self.selected) - if not dst_node: + if not dst_node or not src_node: edge.delete() return @@ -444,15 +445,21 @@ class CanvasGraph(tk.Canvas): edge.delete() return + # rj45 nodes can only support one link + if NodeUtils.is_rj45_node(src_node.core_node.type) and src_node.edges: + edge.delete() + return + if NodeUtils.is_rj45_node(dst_node.core_node.type) and dst_node.edges: + edge.delete() + return + # set dst node and snap edge to center edge.complete(self.selected) self.edges[edge.token] = edge - node_src = self.nodes[edge.src] - node_src.edges.add(edge) - node_dst = self.nodes[edge.dst] - node_dst.edges.add(edge) - self.core.create_link(edge, node_src, node_dst) + src_node.edges.add(edge) + dst_node.edges.add(edge) + self.core.create_link(edge, src_node, dst_node) def select_object(self, object_id: int, choose_multiple: bool = False) -> None: """ From 2145c07cb797f4c74bf84f0114d237c84737e8a4 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 22 Jun 2020 21:36:39 -0700 Subject: [PATCH 190/929] daemon: moved FRR_STATE_DIR from constants.py to frr service files --- daemon/core/configservices/frrservices/services.py | 4 ++-- daemon/core/constants.py.in | 1 - daemon/core/services/frr.py | 5 +++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/daemon/core/configservices/frrservices/services.py b/daemon/core/configservices/frrservices/services.py index ce8c305c..72050077 100644 --- a/daemon/core/configservices/frrservices/services.py +++ b/daemon/core/configservices/frrservices/services.py @@ -1,7 +1,6 @@ import abc from typing import Any, Dict, List -from core import constants from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode from core.emane.nodes import EmaneNet @@ -10,6 +9,7 @@ from core.nodes.interface import CoreInterface from core.nodes.network import WlanNode GROUP: str = "FRR" +FRR_STATE_DIR: str = "/var/run/frr" def has_mtu_mismatch(iface: CoreInterface) -> bool: @@ -110,7 +110,7 @@ class FRRZebra(ConfigService): frr_conf=frr_conf, frr_sbin_search=frr_sbin_search, frr_bin_search=frr_bin_search, - frr_state_dir=constants.FRR_STATE_DIR, + frr_state_dir=FRR_STATE_DIR, ifaces=ifaces, want_ip4=want_ip4, want_ip6=want_ip6, diff --git a/daemon/core/constants.py.in b/daemon/core/constants.py.in index 54f3a1c3..4bf600f3 100644 --- a/daemon/core/constants.py.in +++ b/daemon/core/constants.py.in @@ -4,7 +4,6 @@ COREDPY_VERSION = "@PACKAGE_VERSION@" CORE_CONF_DIR = "@CORE_CONF_DIR@" CORE_DATA_DIR = "@CORE_DATA_DIR@" QUAGGA_STATE_DIR = "@CORE_STATE_DIR@/run/quagga" -FRR_STATE_DIR = "@CORE_STATE_DIR@/run/frr" VNODED_BIN = which("vnoded", required=True) VCMD_BIN = which("vcmd", required=True) diff --git a/daemon/core/services/frr.py b/daemon/core/services/frr.py index 13569772..ceb04f93 100644 --- a/daemon/core/services/frr.py +++ b/daemon/core/services/frr.py @@ -6,7 +6,6 @@ from typing import Optional, Tuple import netaddr -from core import constants from core.emane.nodes import EmaneNet from core.nodes.base import CoreNode from core.nodes.interface import CoreInterface @@ -14,6 +13,8 @@ from core.nodes.network import PtpNet, WlanNode from core.nodes.physical import Rj45Node from core.services.coreservices import CoreService +FRR_STATE_DIR: str = "/var/run/frr" + class FRRZebra(CoreService): name: str = "FRRzebra" @@ -236,7 +237,7 @@ bootfrr cls.configs[0], frr_sbin_search, frr_bin_search, - constants.FRR_STATE_DIR, + FRR_STATE_DIR, ) for iface in node.get_ifaces(): cfg += f"ip link set dev {iface.name} down\n" From 1ef66181c6674c5352693141395053a9f137ca07 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 22 Jun 2020 21:39:29 -0700 Subject: [PATCH 191/929] daemon: moved QUAGGA_STATE_DIR from constants.py to quagga service files --- daemon/core/configservices/quaggaservices/services.py | 4 ++-- daemon/core/constants.py.in | 1 - daemon/core/services/quagga.py | 5 +++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/daemon/core/configservices/quaggaservices/services.py b/daemon/core/configservices/quaggaservices/services.py index e18e8a1a..19430664 100644 --- a/daemon/core/configservices/quaggaservices/services.py +++ b/daemon/core/configservices/quaggaservices/services.py @@ -2,7 +2,6 @@ import abc import logging from typing import Any, Dict, List -from core import constants from core.config import Configuration from core.configservice.base import ConfigService, ConfigServiceMode from core.emane.nodes import EmaneNet @@ -11,6 +10,7 @@ from core.nodes.interface import CoreInterface from core.nodes.network import WlanNode GROUP: str = "Quagga" +QUAGGA_STATE_DIR: str = "/var/run/quagga" def has_mtu_mismatch(iface: CoreInterface) -> bool: @@ -79,7 +79,7 @@ class Zebra(ConfigService): quagga_sbin_search = self.node.session.options.get_config( "quagga_sbin_search", default="/usr/local/sbin /usr/sbin /usr/lib/quagga" ).strip('"') - quagga_state_dir = constants.QUAGGA_STATE_DIR + quagga_state_dir = QUAGGA_STATE_DIR quagga_conf = self.files[0] services = [] diff --git a/daemon/core/constants.py.in b/daemon/core/constants.py.in index 4bf600f3..dfefb128 100644 --- a/daemon/core/constants.py.in +++ b/daemon/core/constants.py.in @@ -3,7 +3,6 @@ from core.utils import which COREDPY_VERSION = "@PACKAGE_VERSION@" CORE_CONF_DIR = "@CORE_CONF_DIR@" CORE_DATA_DIR = "@CORE_DATA_DIR@" -QUAGGA_STATE_DIR = "@CORE_STATE_DIR@/run/quagga" VNODED_BIN = which("vnoded", required=True) VCMD_BIN = which("vcmd", required=True) diff --git a/daemon/core/services/quagga.py b/daemon/core/services/quagga.py index cb9e6b08..9e2c7cc0 100644 --- a/daemon/core/services/quagga.py +++ b/daemon/core/services/quagga.py @@ -5,7 +5,6 @@ from typing import Optional, Tuple import netaddr -from core import constants from core.emane.nodes import EmaneNet from core.emulator.enumerations import LinkTypes from core.nodes.base import CoreNode @@ -14,6 +13,8 @@ from core.nodes.network import PtpNet, WlanNode from core.nodes.physical import Rj45Node from core.services.coreservices import CoreService +QUAGGA_STATE_DIR: str = "/var/run/quagga" + class Zebra(CoreService): name: str = "zebra" @@ -226,7 +227,7 @@ bootquagga cls.configs[0], quagga_sbin_search, quagga_bin_search, - constants.QUAGGA_STATE_DIR, + QUAGGA_STATE_DIR, ) From c43dd60a42c36bc2a2516ff54deeda0ec2438136 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 22 Jun 2020 21:47:03 -0700 Subject: [PATCH 192/929] daemon: small adjustment in sdt.py --- daemon/core/plugins/sdt.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/daemon/core/plugins/sdt.py b/daemon/core/plugins/sdt.py index ef36b0a4..27e54ff3 100644 --- a/daemon/core/plugins/sdt.py +++ b/daemon/core/plugins/sdt.py @@ -8,8 +8,7 @@ import threading from typing import IO, TYPE_CHECKING, Dict, Optional, Set, Tuple from urllib.parse import urlparse -from core import constants -from core.constants import CORE_DATA_DIR +from core.constants import CORE_CONF_DIR, CORE_DATA_DIR from core.emane.nodes import EmaneNet from core.emulator.data import LinkData, NodeData from core.emulator.enumerations import EventTypes, MessageFlags @@ -264,8 +263,8 @@ class Sdt: icon = node.icon if icon: node_type = node.name - icon = icon.replace("$CORE_DATA_DIR", constants.CORE_DATA_DIR) - icon = icon.replace("$CORE_CONF_DIR", constants.CORE_CONF_DIR) + icon = icon.replace("$CORE_DATA_DIR", CORE_DATA_DIR) + icon = icon.replace("$CORE_CONF_DIR", CORE_CONF_DIR) self.cmd(f"sprite {node_type} image {icon}") self.cmd( f'node {node.id} nodeLayer "{NODE_LAYER}" ' From e0c9f9c8326228aed4134a51cb391fb5cb650d42 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 23 Jun 2020 09:11:37 -0700 Subject: [PATCH 193/929] daemon: moved executable check to CoreEmu and separated them into their own module core.executables --- daemon/core/configservice/manager.py | 6 ++---- daemon/core/constants.py.in | 14 -------------- daemon/core/emulator/coreemu.py | 28 +++++++++++++++++++++++++++- daemon/core/executables.py | 24 ++++++++++++++++++++++++ daemon/core/nodes/base.py | 4 ++-- daemon/core/nodes/client.py | 2 +- daemon/core/nodes/netclient.py | 2 +- daemon/core/nodes/network.py | 2 +- daemon/core/nodes/physical.py | 4 ++-- daemon/core/services/coreservices.py | 10 ++++++---- daemon/core/services/utility.py | 17 ++++++----------- daemon/core/utils.py | 4 ++-- daemon/core/xml/corexmldeployment.py | 2 +- 13 files changed, 75 insertions(+), 44 deletions(-) create mode 100644 daemon/core/executables.py diff --git a/daemon/core/configservice/manager.py b/daemon/core/configservice/manager.py index ecea6e68..83657655 100644 --- a/daemon/core/configservice/manager.py +++ b/daemon/core/configservice/manager.py @@ -52,10 +52,8 @@ class ConfigServiceManager: for executable in service.executables: try: utils.which(executable, required=True) - except ValueError: - raise CoreError( - f"service({service.name}) missing executable {executable}" - ) + except CoreError as e: + raise CoreError(f"config service({service.name}): {e}") # make service available self.services[name] = service diff --git a/daemon/core/constants.py.in b/daemon/core/constants.py.in index dfefb128..cb566e40 100644 --- a/daemon/core/constants.py.in +++ b/daemon/core/constants.py.in @@ -1,17 +1,3 @@ -from core.utils import which - COREDPY_VERSION = "@PACKAGE_VERSION@" CORE_CONF_DIR = "@CORE_CONF_DIR@" CORE_DATA_DIR = "@CORE_DATA_DIR@" - -VNODED_BIN = which("vnoded", required=True) -VCMD_BIN = which("vcmd", required=True) -SYSCTL_BIN = which("sysctl", required=True) -IP_BIN = which("ip", required=True) -ETHTOOL_BIN = which("ethtool", required=True) -TC_BIN = which("tc", required=True) -EBTABLES_BIN = which("ebtables", required=True) -MOUNT_BIN = which("mount", required=True) -UMOUNT_BIN = which("umount", required=True) -OVS_BIN = which("ovs-vsctl", required=False) -OVS_FLOW_BIN = which("ovs-ofctl", required=False) diff --git a/daemon/core/emulator/coreemu.py b/daemon/core/emulator/coreemu.py index 6a7f8b80..86652013 100644 --- a/daemon/core/emulator/coreemu.py +++ b/daemon/core/emulator/coreemu.py @@ -6,9 +6,10 @@ import sys from typing import Dict, List, Type import core.services -from core import configservices +from core import configservices, utils from core.configservice.manager import ConfigServiceManager from core.emulator.session import Session +from core.executables import COMMON_REQUIREMENTS, OVS_REQUIREMENTS, VCMD_REQUIREMENTS from core.services.coreservices import ServiceManager @@ -65,10 +66,35 @@ class CoreEmu: if custom_dir: self.service_manager.load(custom_dir) + # check executables exist on path + self._validate_env() + # catch exit event atexit.register(self.shutdown) + def _validate_env(self) -> None: + """ + Validates executables CORE depends on exist on path. + + :return: nothing + :raises core.errors.CoreError: when an executable does not exist on path + """ + for requirement in COMMON_REQUIREMENTS: + utils.which(requirement, required=True) + use_ovs = self.config.get("ovs") == "True" + if use_ovs: + for requirement in OVS_REQUIREMENTS: + utils.which(requirement, required=True) + else: + for requirement in VCMD_REQUIREMENTS: + utils.which(requirement, required=True) + def load_services(self) -> None: + """ + Loads default and custom services for use within CORE. + + :return: nothing + """ # load default services self.service_errors = core.services.load() diff --git a/daemon/core/executables.py b/daemon/core/executables.py new file mode 100644 index 00000000..00d9b40f --- /dev/null +++ b/daemon/core/executables.py @@ -0,0 +1,24 @@ +from typing import List + +VNODED_BIN: str = "vnoded" +VCMD_BIN: str = "vcmd" +SYSCTL_BIN: str = "sysctl" +IP_BIN: str = "ip" +ETHTOOL_BIN: str = "ethtool" +TC_BIN: str = "tc" +EBTABLES_BIN: str = "ebtables" +MOUNT_BIN: str = "mount" +UMOUNT_BIN: str = "umount" +OVS_BIN: str = "ovs-vsctl" + +COMMON_REQUIREMENTS: List[str] = [ + SYSCTL_BIN, + IP_BIN, + ETHTOOL_BIN, + TC_BIN, + EBTABLES_BIN, + MOUNT_BIN, + UMOUNT_BIN, +] +VCMD_REQUIREMENTS: List[str] = [VNODED_BIN, VCMD_BIN] +OVS_REQUIREMENTS: List[str] = [OVS_BIN] diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index aae59b70..a691e4f5 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -13,10 +13,10 @@ import netaddr from core import utils from core.configservice.dependencies import ConfigServiceDependencies -from core.constants import MOUNT_BIN, VNODED_BIN from core.emulator.data import InterfaceData, LinkData, LinkOptions from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes from core.errors import CoreCommandError, CoreError +from core.executables import MOUNT_BIN, VNODED_BIN from core.nodes.client import VnodeClient from core.nodes.interface import CoreInterface, TunTap, Veth from core.nodes.netclient import LinuxNetClient, get_net_client @@ -753,7 +753,7 @@ class CoreNode(CoreNodeBase): iface = self.get_iface(iface_id) iface.set_mac(mac) if self.up: - self.node_net_client.device_mac(iface.name, mac) + self.node_net_client.device_mac(iface.name, str(iface.mac)) def add_ip(self, iface_id: int, ip: str) -> None: """ diff --git a/daemon/core/nodes/client.py b/daemon/core/nodes/client.py index c004b814..f8cd3813 100644 --- a/daemon/core/nodes/client.py +++ b/daemon/core/nodes/client.py @@ -5,7 +5,7 @@ The control channel can be accessed via calls using the vcmd shell. """ from core import utils -from core.constants import VCMD_BIN +from core.executables import VCMD_BIN class VnodeClient: diff --git a/daemon/core/nodes/netclient.py b/daemon/core/nodes/netclient.py index b6c164b5..4486bd8f 100644 --- a/daemon/core/nodes/netclient.py +++ b/daemon/core/nodes/netclient.py @@ -5,7 +5,7 @@ from typing import Callable import netaddr -from core.constants import ETHTOOL_BIN, IP_BIN, OVS_BIN, SYSCTL_BIN, TC_BIN +from core.executables import ETHTOOL_BIN, IP_BIN, OVS_BIN, SYSCTL_BIN, TC_BIN class LinuxNetClient: diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 7d8f805e..2c0c1cca 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Type import netaddr from core import utils -from core.constants import EBTABLES_BIN, TC_BIN from core.emulator.data import InterfaceData, LinkData, LinkOptions from core.emulator.enumerations import ( LinkTypes, @@ -20,6 +19,7 @@ from core.emulator.enumerations import ( RegisterTlvs, ) from core.errors import CoreCommandError, CoreError +from core.executables import EBTABLES_BIN, TC_BIN from core.nodes.base import CoreNetworkBase from core.nodes.interface import CoreInterface, GreTap, Veth from core.nodes.netclient import get_net_client diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index 3751d9ee..a025a496 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -7,11 +7,11 @@ import os import threading from typing import IO, TYPE_CHECKING, List, Optional, Tuple -from core.constants import MOUNT_BIN, UMOUNT_BIN from core.emulator.data import InterfaceData, LinkOptions from core.emulator.distributed import DistributedServer from core.emulator.enumerations import NodeTypes, TransportType from core.errors import CoreCommandError, CoreError +from core.executables import MOUNT_BIN, UMOUNT_BIN from core.nodes.base import CoreNetworkBase, CoreNodeBase from core.nodes.interface import CoreInterface from core.nodes.network import CoreNetwork, GreTap @@ -76,7 +76,7 @@ class PhysicalNode(CoreNodeBase): iface = self.get_iface(iface_id) iface.set_mac(mac) if self.up: - self.net_client.device_mac(iface.name, mac) + self.net_client.device_mac(iface.name, str(iface.mac)) def add_ip(self, iface_id: int, ip: str) -> None: """ diff --git a/daemon/core/services/coreservices.py b/daemon/core/services/coreservices.py index d22bc7a5..8c41c57d 100644 --- a/daemon/core/services/coreservices.py +++ b/daemon/core/services/coreservices.py @@ -13,10 +13,9 @@ import time from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Tuple, Type from core import utils -from core.constants import which from core.emulator.data import FileData from core.emulator.enumerations import ExceptionLevels, MessageFlags, RegisterTlvs -from core.errors import CoreCommandError +from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNode if TYPE_CHECKING: @@ -262,7 +261,10 @@ class ServiceManager: # validate dependent executables are present for executable in service.executables: - which(executable, required=True) + try: + utils.which(executable, required=True) + except CoreError as e: + raise CoreError(f"service({name}): {e}") # validate service on load succeeds try: @@ -300,7 +302,7 @@ class ServiceManager: try: cls.add(service) - except ValueError as e: + except (CoreError, ValueError) as e: service_errors.append(service.name) logging.debug("not loading service(%s): %s", service.name, e) return service_errors diff --git a/daemon/core/services/utility.py b/daemon/core/services/utility.py index 414f994e..cf76b092 100644 --- a/daemon/core/services/utility.py +++ b/daemon/core/services/utility.py @@ -5,8 +5,9 @@ from typing import Optional, Tuple import netaddr -from core import constants, utils +from core import utils from core.errors import CoreCommandError +from core.executables import SYSCTL_BIN from core.nodes.base import CoreNode from core.services.coreservices import CoreService, ServiceMode @@ -47,19 +48,13 @@ class IPForwardService(UtilService): %(sysctl)s -w net.ipv4.conf.all.rp_filter=0 %(sysctl)s -w net.ipv4.conf.default.rp_filter=0 """ % { - "sysctl": constants.SYSCTL_BIN + "sysctl": SYSCTL_BIN } for iface in node.get_ifaces(): name = utils.sysctl_devname(iface.name) - cfg += "%s -w net.ipv4.conf.%s.forwarding=1\n" % ( - constants.SYSCTL_BIN, - name, - ) - cfg += "%s -w net.ipv4.conf.%s.send_redirects=0\n" % ( - constants.SYSCTL_BIN, - name, - ) - cfg += "%s -w net.ipv4.conf.%s.rp_filter=0\n" % (constants.SYSCTL_BIN, name) + cfg += "%s -w net.ipv4.conf.%s.forwarding=1\n" % (SYSCTL_BIN, name) + cfg += "%s -w net.ipv4.conf.%s.send_redirects=0\n" % (SYSCTL_BIN, name) + cfg += "%s -w net.ipv4.conf.%s.rp_filter=0\n" % (SYSCTL_BIN, name) return cfg diff --git a/daemon/core/utils.py b/daemon/core/utils.py index 0e082187..459b7d56 100644 --- a/daemon/core/utils.py +++ b/daemon/core/utils.py @@ -33,7 +33,7 @@ from typing import ( import netaddr -from core.errors import CoreCommandError +from core.errors import CoreCommandError, CoreError if TYPE_CHECKING: from core.emulator.session import Session @@ -154,7 +154,7 @@ def which(command: str, required: bool) -> str: """ found_path = shutil.which(command) if found_path is None and required: - raise ValueError(f"failed to find required executable({command}) in path") + raise CoreError(f"failed to find required executable({command}) in path") return found_path diff --git a/daemon/core/xml/corexmldeployment.py b/daemon/core/xml/corexmldeployment.py index 6035bd26..2235a798 100644 --- a/daemon/core/xml/corexmldeployment.py +++ b/daemon/core/xml/corexmldeployment.py @@ -6,8 +6,8 @@ import netaddr from lxml import etree from core import utils -from core.constants import IP_BIN from core.emane.nodes import EmaneNet +from core.executables import IP_BIN from core.nodes.base import CoreNodeBase, NodeBase from core.nodes.interface import CoreInterface From 8f19ad057c5305c9e24975a4462e80f11c08228a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 23 Jun 2020 09:24:40 -0700 Subject: [PATCH 194/929] daemon: cleaned up requirement check, updated github workflow to modify correct file --- .github/workflows/daemon-checks.yml | 2 +- daemon/core/emulator/coreemu.py | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/daemon-checks.yml b/.github/workflows/daemon-checks.yml index 85409568..d955ee58 100644 --- a/.github/workflows/daemon-checks.yml +++ b/.github/workflows/daemon-checks.yml @@ -18,7 +18,7 @@ jobs: cd daemon cp setup.py.in setup.py cp core/constants.py.in core/constants.py - sed -i 's/True/False/g' core/constants.py + sed -i 's/required=True/required=False/g' core/emulator/coreemu.py pipenv sync --dev - name: isort run: | diff --git a/daemon/core/emulator/coreemu.py b/daemon/core/emulator/coreemu.py index 86652013..71723268 100644 --- a/daemon/core/emulator/coreemu.py +++ b/daemon/core/emulator/coreemu.py @@ -79,15 +79,14 @@ class CoreEmu: :return: nothing :raises core.errors.CoreError: when an executable does not exist on path """ - for requirement in COMMON_REQUIREMENTS: - utils.which(requirement, required=True) + requirements = COMMON_REQUIREMENTS use_ovs = self.config.get("ovs") == "True" if use_ovs: - for requirement in OVS_REQUIREMENTS: - utils.which(requirement, required=True) + requirements += OVS_REQUIREMENTS else: - for requirement in VCMD_REQUIREMENTS: - utils.which(requirement, required=True) + requirements += VCMD_REQUIREMENTS + for requirement in requirements: + utils.which(requirement, required=True) def load_services(self) -> None: """ From 6dd6bc87abcd0045e0a729ee871499c60a31f3cc Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 23 Jun 2020 09:35:11 -0700 Subject: [PATCH 195/929] daemon: renamed executable variables to be simpler --- daemon/core/executables.py | 34 +++++------- daemon/core/nodes/base.py | 6 +-- daemon/core/nodes/client.py | 4 +- daemon/core/nodes/netclient.py | 78 ++++++++++++++-------------- daemon/core/nodes/network.py | 18 +++---- daemon/core/nodes/physical.py | 6 +-- daemon/core/services/utility.py | 10 ++-- daemon/core/xml/corexmldeployment.py | 4 +- 8 files changed, 74 insertions(+), 86 deletions(-) diff --git a/daemon/core/executables.py b/daemon/core/executables.py index 00d9b40f..17aecc1d 100644 --- a/daemon/core/executables.py +++ b/daemon/core/executables.py @@ -1,24 +1,16 @@ from typing import List -VNODED_BIN: str = "vnoded" -VCMD_BIN: str = "vcmd" -SYSCTL_BIN: str = "sysctl" -IP_BIN: str = "ip" -ETHTOOL_BIN: str = "ethtool" -TC_BIN: str = "tc" -EBTABLES_BIN: str = "ebtables" -MOUNT_BIN: str = "mount" -UMOUNT_BIN: str = "umount" -OVS_BIN: str = "ovs-vsctl" +VNODED: str = "vnoded" +VCMD: str = "vcmd" +SYSCTL: str = "sysctl" +IP: str = "ip" +ETHTOOL: str = "ethtool" +TC: str = "tc" +EBTABLES: str = "ebtables" +MOUNT: str = "mount" +UMOUNT: str = "umount" +OVS_VSCTL: str = "ovs-vsctl" -COMMON_REQUIREMENTS: List[str] = [ - SYSCTL_BIN, - IP_BIN, - ETHTOOL_BIN, - TC_BIN, - EBTABLES_BIN, - MOUNT_BIN, - UMOUNT_BIN, -] -VCMD_REQUIREMENTS: List[str] = [VNODED_BIN, VCMD_BIN] -OVS_REQUIREMENTS: List[str] = [OVS_BIN] +COMMON_REQUIREMENTS: List[str] = [SYSCTL, IP, ETHTOOL, TC, EBTABLES, MOUNT, UMOUNT] +VCMD_REQUIREMENTS: List[str] = [VNODED, VCMD] +OVS_REQUIREMENTS: List[str] = [OVS_VSCTL] diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index a691e4f5..3999046d 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -16,7 +16,7 @@ from core.configservice.dependencies import ConfigServiceDependencies from core.emulator.data import InterfaceData, LinkData, LinkOptions from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes from core.errors import CoreCommandError, CoreError -from core.executables import MOUNT_BIN, VNODED_BIN +from core.executables import MOUNT, VNODED from core.nodes.client import VnodeClient from core.nodes.interface import CoreInterface, TunTap, Veth from core.nodes.netclient import LinuxNetClient, get_net_client @@ -511,7 +511,7 @@ class CoreNode(CoreNodeBase): # create a new namespace for this node using vnoded vnoded = ( - f"{VNODED_BIN} -v -c {self.ctrlchnlname} -l {self.ctrlchnlname}.log " + f"{VNODED} -v -c {self.ctrlchnlname} -l {self.ctrlchnlname}.log " f"-p {self.ctrlchnlname}.pid" ) if self.nodedir: @@ -640,7 +640,7 @@ class CoreNode(CoreNodeBase): source = os.path.abspath(source) logging.debug("node(%s) mounting: %s at %s", self.name, source, target) self.cmd(f"mkdir -p {target}") - self.cmd(f"{MOUNT_BIN} -n --bind {source} {target}") + self.cmd(f"{MOUNT} -n --bind {source} {target}") self._mounts.append((source, target)) def next_iface_id(self) -> int: diff --git a/daemon/core/nodes/client.py b/daemon/core/nodes/client.py index f8cd3813..93e099cf 100644 --- a/daemon/core/nodes/client.py +++ b/daemon/core/nodes/client.py @@ -5,7 +5,7 @@ The control channel can be accessed via calls using the vcmd shell. """ from core import utils -from core.executables import VCMD_BIN +from core.executables import VCMD class VnodeClient: @@ -50,7 +50,7 @@ class VnodeClient: pass def create_cmd(self, args: str) -> str: - return f"{VCMD_BIN} -c {self.ctrlchnlname} -- {args}" + return f"{VCMD} -c {self.ctrlchnlname} -- {args}" def check_cmd(self, args: str, wait: bool = True, shell: bool = False) -> str: """ diff --git a/daemon/core/nodes/netclient.py b/daemon/core/nodes/netclient.py index 4486bd8f..96a1f4be 100644 --- a/daemon/core/nodes/netclient.py +++ b/daemon/core/nodes/netclient.py @@ -5,7 +5,7 @@ from typing import Callable import netaddr -from core.executables import ETHTOOL_BIN, IP_BIN, OVS_BIN, SYSCTL_BIN, TC_BIN +from core.executables import ETHTOOL, IP, OVS_VSCTL, SYSCTL, TC class LinuxNetClient: @@ -38,7 +38,7 @@ class LinuxNetClient: :param device: device to add route to :return: nothing """ - self.run(f"{IP_BIN} route add {route} dev {device}") + self.run(f"{IP} route add {route} dev {device}") def device_up(self, device: str) -> None: """ @@ -47,7 +47,7 @@ class LinuxNetClient: :param device: device to bring up :return: nothing """ - self.run(f"{IP_BIN} link set {device} up") + self.run(f"{IP} link set {device} up") def device_down(self, device: str) -> None: """ @@ -56,7 +56,7 @@ class LinuxNetClient: :param device: device to bring down :return: nothing """ - self.run(f"{IP_BIN} link set {device} down") + self.run(f"{IP} link set {device} down") def device_name(self, device: str, name: str) -> None: """ @@ -66,7 +66,7 @@ class LinuxNetClient: :param name: name to set :return: nothing """ - self.run(f"{IP_BIN} link set {device} name {name}") + self.run(f"{IP} link set {device} name {name}") def device_show(self, device: str) -> str: """ @@ -75,7 +75,7 @@ class LinuxNetClient: :param device: device to get information for :return: device information """ - return self.run(f"{IP_BIN} link show {device}") + return self.run(f"{IP} link show {device}") def address_show(self, device: str) -> str: """ @@ -84,7 +84,7 @@ class LinuxNetClient: :param device: device name :return: address information """ - return self.run(f"{IP_BIN} address show {device}") + return self.run(f"{IP} address show {device}") def get_mac(self, device: str) -> str: """ @@ -112,7 +112,7 @@ class LinuxNetClient: :param namespace: namespace to set device to :return: nothing """ - self.run(f"{IP_BIN} link set {device} netns {namespace}") + self.run(f"{IP} link set {device} netns {namespace}") def device_flush(self, device: str) -> None: """ @@ -123,7 +123,7 @@ class LinuxNetClient: """ self.run( f"[ -e /sys/class/net/{device} ] && " - f"{IP_BIN} address flush dev {device} || true", + f"{IP} address flush dev {device} || true", shell=True, ) @@ -135,7 +135,7 @@ class LinuxNetClient: :param mac: mac to set :return: nothing """ - self.run(f"{IP_BIN} link set dev {device} address {mac}") + self.run(f"{IP} link set dev {device} address {mac}") def delete_device(self, device: str) -> None: """ @@ -144,7 +144,7 @@ class LinuxNetClient: :param device: device to delete :return: nothing """ - self.run(f"{IP_BIN} link delete {device}") + self.run(f"{IP} link delete {device}") def delete_tc(self, device: str) -> None: """ @@ -153,7 +153,7 @@ class LinuxNetClient: :param device: device to remove tc :return: nothing """ - self.run(f"{TC_BIN} qdisc delete dev {device} root") + self.run(f"{TC} qdisc delete dev {device} root") def checksums_off(self, iface_name: str) -> None: """ @@ -162,7 +162,7 @@ class LinuxNetClient: :param iface_name: interface to update :return: nothing """ - self.run(f"{ETHTOOL_BIN} -K {iface_name} rx off tx off") + self.run(f"{ETHTOOL} -K {iface_name} rx off tx off") def create_address(self, device: str, address: str, broadcast: str = None) -> None: """ @@ -174,15 +174,13 @@ class LinuxNetClient: :return: nothing """ if broadcast is not None: - self.run( - f"{IP_BIN} address add {address} broadcast {broadcast} dev {device}" - ) + self.run(f"{IP} address add {address} broadcast {broadcast} dev {device}") else: - self.run(f"{IP_BIN} address add {address} dev {device}") + self.run(f"{IP} address add {address} dev {device}") if netaddr.valid_ipv6(address.split("/")[0]): # IPv6 addresses are removed by default on interface down. # Make sure that the IPv6 address we add is not removed - self.run(f"{SYSCTL_BIN} -w net.ipv6.conf.{device}.keep_addr_on_down=1") + self.run(f"{SYSCTL} -w net.ipv6.conf.{device}.keep_addr_on_down=1") def delete_address(self, device: str, address: str) -> None: """ @@ -192,7 +190,7 @@ class LinuxNetClient: :param address: address to remove :return: nothing """ - self.run(f"{IP_BIN} address delete {address} dev {device}") + self.run(f"{IP} address delete {address} dev {device}") def create_veth(self, name: str, peer: str) -> None: """ @@ -202,7 +200,7 @@ class LinuxNetClient: :param peer: peer name :return: nothing """ - self.run(f"{IP_BIN} link add name {name} type veth peer name {peer}") + self.run(f"{IP} link add name {name} type veth peer name {peer}") def create_gretap( self, device: str, address: str, local: str, ttl: int, key: int @@ -217,7 +215,7 @@ class LinuxNetClient: :param key: key for tap :return: nothing """ - cmd = f"{IP_BIN} link add {device} type gretap remote {address}" + cmd = f"{IP} link add {device} type gretap remote {address}" if local is not None: cmd += f" local {local}" if ttl is not None: @@ -233,11 +231,11 @@ class LinuxNetClient: :param name: bridge name :return: nothing """ - self.run(f"{IP_BIN} link add name {name} type bridge") - self.run(f"{IP_BIN} link set {name} type bridge stp_state 0") - self.run(f"{IP_BIN} link set {name} type bridge forward_delay 0") - self.run(f"{IP_BIN} link set {name} type bridge mcast_snooping 0") - self.run(f"{IP_BIN} link set {name} type bridge group_fwd_mask 65528") + self.run(f"{IP} link add name {name} type bridge") + self.run(f"{IP} link set {name} type bridge stp_state 0") + self.run(f"{IP} link set {name} type bridge forward_delay 0") + self.run(f"{IP} link set {name} type bridge mcast_snooping 0") + self.run(f"{IP} link set {name} type bridge group_fwd_mask 65528") self.device_up(name) def delete_bridge(self, name: str) -> None: @@ -248,7 +246,7 @@ class LinuxNetClient: :return: nothing """ self.device_down(name) - self.run(f"{IP_BIN} link delete {name} type bridge") + self.run(f"{IP} link delete {name} type bridge") def set_iface_master(self, bridge_name: str, iface_name: str) -> None: """ @@ -258,7 +256,7 @@ class LinuxNetClient: :param iface_name: interface name :return: nothing """ - self.run(f"{IP_BIN} link set dev {iface_name} master {bridge_name}") + self.run(f"{IP} link set dev {iface_name} master {bridge_name}") self.device_up(iface_name) def delete_iface(self, bridge_name: str, iface_name: str) -> None: @@ -269,7 +267,7 @@ class LinuxNetClient: :param iface_name: interface name :return: nothing """ - self.run(f"{IP_BIN} link set dev {iface_name} nomaster") + self.run(f"{IP} link set dev {iface_name} nomaster") def existing_bridges(self, _id: int) -> bool: """ @@ -278,7 +276,7 @@ class LinuxNetClient: :param _id: node id to check bridges for :return: True if there are existing bridges, False otherwise """ - output = self.run(f"{IP_BIN} -o link show type bridge") + output = self.run(f"{IP} -o link show type bridge") lines = output.split("\n") for line in lines: values = line.split(":") @@ -299,7 +297,7 @@ class LinuxNetClient: :param name: bridge name :return: nothing """ - self.run(f"{IP_BIN} link set {name} type bridge ageing_time 0") + self.run(f"{IP} link set {name} type bridge ageing_time 0") class OvsNetClient(LinuxNetClient): @@ -314,10 +312,10 @@ class OvsNetClient(LinuxNetClient): :param name: bridge name :return: nothing """ - self.run(f"{OVS_BIN} add-br {name}") - self.run(f"{OVS_BIN} set bridge {name} stp_enable=false") - self.run(f"{OVS_BIN} set bridge {name} other_config:stp-max-age=6") - self.run(f"{OVS_BIN} set bridge {name} other_config:stp-forward-delay=4") + self.run(f"{OVS_VSCTL} add-br {name}") + self.run(f"{OVS_VSCTL} set bridge {name} stp_enable=false") + self.run(f"{OVS_VSCTL} set bridge {name} other_config:stp-max-age=6") + self.run(f"{OVS_VSCTL} set bridge {name} other_config:stp-forward-delay=4") self.device_up(name) def delete_bridge(self, name: str) -> None: @@ -328,7 +326,7 @@ class OvsNetClient(LinuxNetClient): :return: nothing """ self.device_down(name) - self.run(f"{OVS_BIN} del-br {name}") + self.run(f"{OVS_VSCTL} del-br {name}") def set_iface_master(self, bridge_name: str, iface_name: str) -> None: """ @@ -338,7 +336,7 @@ class OvsNetClient(LinuxNetClient): :param iface_name: interface name :return: nothing """ - self.run(f"{OVS_BIN} add-port {bridge_name} {iface_name}") + self.run(f"{OVS_VSCTL} add-port {bridge_name} {iface_name}") self.device_up(iface_name) def delete_iface(self, bridge_name: str, iface_name: str) -> None: @@ -349,7 +347,7 @@ class OvsNetClient(LinuxNetClient): :param iface_name: interface name :return: nothing """ - self.run(f"{OVS_BIN} del-port {bridge_name} {iface_name}") + self.run(f"{OVS_VSCTL} del-port {bridge_name} {iface_name}") def existing_bridges(self, _id: int) -> bool: """ @@ -358,7 +356,7 @@ class OvsNetClient(LinuxNetClient): :param _id: node id to check bridges for :return: True if there are existing bridges, False otherwise """ - output = self.run(f"{OVS_BIN} list-br") + output = self.run(f"{OVS_VSCTL} list-br") if output: for line in output.split("\n"): fields = line.split(".") @@ -373,7 +371,7 @@ class OvsNetClient(LinuxNetClient): :param name: bridge name :return: nothing """ - self.run(f"{OVS_BIN} set bridge {name} other_config:mac-aging-time=0") + self.run(f"{OVS_VSCTL} set bridge {name} other_config:mac-aging-time=0") def get_net_client(use_ovs: bool, run: Callable[..., str]) -> LinuxNetClient: diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index 2c0c1cca..d418a42c 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -19,7 +19,7 @@ from core.emulator.enumerations import ( RegisterTlvs, ) from core.errors import CoreCommandError, CoreError -from core.executables import EBTABLES_BIN, TC_BIN +from core.executables import EBTABLES, TC from core.nodes.base import CoreNetworkBase from core.nodes.interface import CoreInterface, GreTap, Veth from core.nodes.netclient import get_net_client @@ -104,7 +104,7 @@ class EbtablesQueue: :param cmd: ebtable command :return: ebtable atomic command """ - return f"{EBTABLES_BIN} --atomic-file {self.atomic_file} {cmd}" + return f"{EBTABLES} --atomic-file {self.atomic_file} {cmd}" def lastupdate(self, wlan: "CoreNetwork") -> float: """ @@ -338,8 +338,8 @@ class CoreNetwork(CoreNetworkBase): self.net_client.delete_bridge(self.brname) if self.has_ebtables_chain: cmds = [ - f"{EBTABLES_BIN} -D FORWARD --logical-in {self.brname} -j {self.brname}", - f"{EBTABLES_BIN} -X {self.brname}", + f"{EBTABLES} -D FORWARD --logical-in {self.brname} -j {self.brname}", + f"{EBTABLES} -X {self.brname}", ] ebtablescmds(self.host_cmd, cmds) except CoreCommandError: @@ -448,7 +448,7 @@ class CoreNetwork(CoreNetworkBase): :return: nothing """ devname = iface.localname - tc = f"{TC_BIN} qdisc replace dev {devname}" + tc = f"{TC} qdisc replace dev {devname}" parent = "root" changed = False bw = options.bandwidth @@ -466,7 +466,7 @@ class CoreNetwork(CoreNetworkBase): changed = True elif iface.getparam("has_tbf") and bw <= 0: if self.up: - cmd = f"{TC_BIN} qdisc delete dev {devname} {parent}" + cmd = f"{TC} qdisc delete dev {devname} {parent}" iface.host_cmd(cmd) iface.setparam("has_tbf", False) # removing the parent removes the child @@ -512,14 +512,12 @@ class CoreNetwork(CoreNetworkBase): if not iface.getparam("has_netem"): return if self.up: - cmd = f"{TC_BIN} qdisc delete dev {devname} {parent} handle 10:" + cmd = f"{TC} qdisc delete dev {devname} {parent} handle 10:" iface.host_cmd(cmd) iface.setparam("has_netem", False) elif len(netem) > 1: if self.up: - cmd = ( - f"{TC_BIN} qdisc replace dev {devname} {parent} handle 10: {netem}" - ) + cmd = f"{TC} qdisc replace dev {devname} {parent} handle 10: {netem}" iface.host_cmd(cmd) iface.setparam("has_netem", True) diff --git a/daemon/core/nodes/physical.py b/daemon/core/nodes/physical.py index a025a496..f48a0d10 100644 --- a/daemon/core/nodes/physical.py +++ b/daemon/core/nodes/physical.py @@ -11,7 +11,7 @@ from core.emulator.data import InterfaceData, LinkOptions from core.emulator.distributed import DistributedServer from core.emulator.enumerations import NodeTypes, TransportType from core.errors import CoreCommandError, CoreError -from core.executables import MOUNT_BIN, UMOUNT_BIN +from core.executables import MOUNT, UMOUNT from core.nodes.base import CoreNetworkBase, CoreNodeBase from core.nodes.interface import CoreInterface from core.nodes.network import CoreNetwork, GreTap @@ -186,13 +186,13 @@ class PhysicalNode(CoreNodeBase): source = os.path.abspath(source) logging.info("mounting %s at %s", source, target) os.makedirs(target) - self.host_cmd(f"{MOUNT_BIN} --bind {source} {target}", cwd=self.nodedir) + self.host_cmd(f"{MOUNT} --bind {source} {target}", cwd=self.nodedir) self._mounts.append((source, target)) def umount(self, target: str) -> None: logging.info("unmounting '%s'", target) try: - self.host_cmd(f"{UMOUNT_BIN} -l {target}", cwd=self.nodedir) + self.host_cmd(f"{UMOUNT} -l {target}", cwd=self.nodedir) except CoreCommandError: logging.exception("unmounting failed for %s", target) diff --git a/daemon/core/services/utility.py b/daemon/core/services/utility.py index cf76b092..774c4104 100644 --- a/daemon/core/services/utility.py +++ b/daemon/core/services/utility.py @@ -7,7 +7,7 @@ import netaddr from core import utils from core.errors import CoreCommandError -from core.executables import SYSCTL_BIN +from core.executables import SYSCTL from core.nodes.base import CoreNode from core.services.coreservices import CoreService, ServiceMode @@ -48,13 +48,13 @@ class IPForwardService(UtilService): %(sysctl)s -w net.ipv4.conf.all.rp_filter=0 %(sysctl)s -w net.ipv4.conf.default.rp_filter=0 """ % { - "sysctl": SYSCTL_BIN + "sysctl": SYSCTL } for iface in node.get_ifaces(): name = utils.sysctl_devname(iface.name) - cfg += "%s -w net.ipv4.conf.%s.forwarding=1\n" % (SYSCTL_BIN, name) - cfg += "%s -w net.ipv4.conf.%s.send_redirects=0\n" % (SYSCTL_BIN, name) - cfg += "%s -w net.ipv4.conf.%s.rp_filter=0\n" % (SYSCTL_BIN, name) + cfg += "%s -w net.ipv4.conf.%s.forwarding=1\n" % (SYSCTL, name) + cfg += "%s -w net.ipv4.conf.%s.send_redirects=0\n" % (SYSCTL, name) + cfg += "%s -w net.ipv4.conf.%s.rp_filter=0\n" % (SYSCTL, name) return cfg diff --git a/daemon/core/xml/corexmldeployment.py b/daemon/core/xml/corexmldeployment.py index 2235a798..51201787 100644 --- a/daemon/core/xml/corexmldeployment.py +++ b/daemon/core/xml/corexmldeployment.py @@ -7,7 +7,7 @@ from lxml import etree from core import utils from core.emane.nodes import EmaneNet -from core.executables import IP_BIN +from core.executables import IP from core.nodes.base import CoreNodeBase, NodeBase from core.nodes.interface import CoreInterface @@ -83,7 +83,7 @@ def get_address_type(address: str) -> str: def get_ipv4_addresses(hostname: str) -> List[Tuple[str, str]]: if hostname == "localhost": addresses = [] - args = f"{IP_BIN} -o -f inet address show" + args = f"{IP} -o -f inet address show" output = utils.cmd(args) for line in output.split(os.linesep): split = line.split() From 8e2cfa61c90413b9bfbb560dbcde89134d6c0380 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 23 Jun 2020 10:09:16 -0700 Subject: [PATCH 196/929] pygui: size and scale meter width and height are no longer editable, but will dynamically update with changes to related size/scale values --- daemon/core/gui/dialogs/canvassizeandscale.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/daemon/core/gui/dialogs/canvassizeandscale.py b/daemon/core/gui/dialogs/canvassizeandscale.py index b93bd920..38cecc83 100644 --- a/daemon/core/gui/dialogs/canvassizeandscale.py +++ b/daemon/core/gui/dialogs/canvassizeandscale.py @@ -66,10 +66,12 @@ class SizeAndScaleDialog(Dialog): label.grid(row=0, column=0, sticky="w", padx=PADX) entry = validation.PositiveIntEntry(frame, textvariable=self.pixel_width) entry.grid(row=0, column=1, sticky="ew", padx=PADX) + entry.bind("", self.size_scale_keyup) label = ttk.Label(frame, text="x Height") label.grid(row=0, column=2, sticky="w", padx=PADX) entry = validation.PositiveIntEntry(frame, textvariable=self.pixel_height) entry.grid(row=0, column=3, sticky="ew", padx=PADX) + entry.bind("", self.size_scale_keyup) label = ttk.Label(frame, text="Pixels") label.grid(row=0, column=4, sticky="w") @@ -80,11 +82,15 @@ class SizeAndScaleDialog(Dialog): frame.columnconfigure(3, weight=1) label = ttk.Label(frame, text="Width") label.grid(row=0, column=0, sticky="w", padx=PADX) - entry = validation.PositiveFloatEntry(frame, textvariable=self.meters_width) + entry = validation.PositiveFloatEntry( + frame, textvariable=self.meters_width, state=tk.DISABLED + ) entry.grid(row=0, column=1, sticky="ew", padx=PADX) label = ttk.Label(frame, text="x Height") label.grid(row=0, column=2, sticky="w", padx=PADX) - entry = validation.PositiveFloatEntry(frame, textvariable=self.meters_height) + entry = validation.PositiveFloatEntry( + frame, textvariable=self.meters_height, state=tk.DISABLED + ) entry.grid(row=0, column=3, sticky="ew", padx=PADX) label = ttk.Label(frame, text="Meters") label.grid(row=0, column=4, sticky="w") @@ -101,6 +107,7 @@ class SizeAndScaleDialog(Dialog): label.grid(row=0, column=0, sticky="w", padx=PADX) entry = validation.PositiveFloatEntry(frame, textvariable=self.scale) entry.grid(row=0, column=1, sticky="ew", padx=PADX) + entry.bind("", self.size_scale_keyup) label = ttk.Label(frame, text="Meters") label.grid(row=0, column=2, sticky="w") @@ -173,6 +180,13 @@ class SizeAndScaleDialog(Dialog): button = ttk.Button(frame, text="Cancel", command=self.destroy) button.grid(row=0, column=1, sticky="ew") + def size_scale_keyup(self, _event: tk.Event) -> None: + scale = self.scale.get() + width = self.pixel_width.get() + height = self.pixel_height.get() + self.meters_width.set(width / PIXEL_SCALE * scale) + self.meters_height.set(height / PIXEL_SCALE * scale) + def click_apply(self) -> None: width, height = self.pixel_width.get(), self.pixel_height.get() self.canvas.redraw_canvas((width, height)) From 14573184e01ea727ce958640a92be41025433ed3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 23 Jun 2020 13:28:41 -0700 Subject: [PATCH 197/929] pygui: fixed syning session location settings when not in runtime mode, for saving xml --- daemon/core/gui/coreclient.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 39ee486a..8050d7f0 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -718,7 +718,7 @@ class CoreClient: def send_data(self) -> None: """ - send to daemon all session info, but don't start the session + Send to daemon all session info, but don't start the session """ self.create_nodes_and_links() for config_proto in self.get_wlan_configs_proto(): @@ -759,6 +759,17 @@ class CoreClient: if self.emane_config: config = {x: self.emane_config[x].value for x in self.emane_config} self.client.set_emane_config(self.session_id, config) + if self.location: + self.client.set_session_location( + self.session_id, + self.location.x, + self.location.y, + self.location.z, + self.location.lat, + self.location.lon, + self.location.alt, + self.location.scale, + ) self.set_metadata() def close(self) -> None: From 9649337f185af4e7cd90ef93029871b2dcb92e60 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 23 Jun 2020 13:31:31 -0700 Subject: [PATCH 198/929] daemon: updated xml to save links using consistent iface1/2 naming, still fallback to reading interface_one/two --- daemon/core/xml/corexml.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index d3cc85d8..340d81d0 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -520,14 +520,14 @@ class CoreXmlWriter: # check for interface one if link_data.iface1 is not None: iface1 = self.create_iface_element( - "interface1", link_data.node1_id, link_data.iface1 + "iface1", link_data.node1_id, link_data.iface1 ) link_element.append(iface1) # check for interface two if link_data.iface2 is not None: iface2 = self.create_iface_element( - "interface2", link_data.node2_id, link_data.iface2 + "iface2", link_data.node2_id, link_data.iface2 ) link_element.append(iface2) @@ -907,14 +907,14 @@ class CoreXmlReader: node2_id = get_int(link_element, "node_two") node_set = frozenset((node1_id, node2_id)) - iface1_element = link_element.find("interface1") + iface1_element = link_element.find("iface1") if iface1_element is None: iface1_element = link_element.find("interface_one") iface1_data = None if iface1_element is not None: iface1_data = create_iface_data(iface1_element) - iface2_element = link_element.find("interface2") + iface2_element = link_element.find("iface2") if iface2_element is None: iface2_element = link_element.find("interface_two") iface2_data = None From 7215f852b8b591d20b4eaab39fbb8582ad46557e Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 23 Jun 2020 13:34:40 -0700 Subject: [PATCH 199/929] grpc: added check for emane pathloss when nem id is None and throw an error --- daemon/core/api/grpc/grpcutils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index b63cb895..8df545cd 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -487,4 +487,8 @@ def get_nem_id(node: CoreNode, iface_id: int, context: ServicerContext) -> int: if not isinstance(net, EmaneNet): message = f"{node.name} interface {iface_id} is not an EMANE network" context.abort(grpc.StatusCode.INVALID_ARGUMENT, message) - return net.getnemid(iface) + nem_id = net.getnemid(iface) + if nem_id is None: + message = f"{node.name} interface {iface_id} nem id does not exist" + context.abort(grpc.StatusCode.INVALID_ARGUMENT, message) + return nem_id From 60d9fe2026add397e9bf004a9ee7dd7057ad21c1 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 23 Jun 2020 14:48:27 -0700 Subject: [PATCH 200/929] pygui: clear throughput labels when disabling throughput --- daemon/core/gui/coreclient.py | 1 + daemon/core/gui/graph/edges.py | 7 +++++-- daemon/core/gui/graph/graph.py | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 8050d7f0..d35f62e5 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -221,6 +221,7 @@ class CoreClient: if self.handling_throughputs: self.handling_throughputs.cancel() self.handling_throughputs = None + self.app.canvas.clear_throughputs() def cancel_events(self) -> None: if self.handling_events: diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index e9ac2587..29632086 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -145,6 +145,10 @@ class Edge: else: self.canvas.itemconfig(self.middle_label, text=text) + def clear_middle_label(self) -> None: + self.canvas.delete(self.middle_label) + self.middle_label = None + def node_label_positions(self) -> Tuple[Tuple[float, float], Tuple[float, float]]: src_x, src_y, _, _, dst_x, dst_y = self.canvas.coords(self.id) v1 = dst_x - src_x @@ -216,11 +220,10 @@ class Edge: logging.debug("deleting canvas edge, id: %s", self.id) self.canvas.delete(self.id) self.canvas.delete(self.src_label) - self.canvas.delete(self.middle_label) self.canvas.delete(self.dst_label) + self.clear_middle_label() self.id = None self.src_label = None - self.middle_label = None self.dst_label = None diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index fdf9ba21..07519c3f 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -997,6 +997,10 @@ class CanvasGraph(tk.Canvas): ) self.tag_raise(tags.NODE) + def clear_throughputs(self) -> None: + for edge in self.edges.values(): + edge.clear_middle_label() + def scale_graph(self) -> None: for nid, canvas_node in self.nodes.items(): img = None From 6490b5b9cbe6e39adb57aeb00675d6b828355746 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 23 Jun 2020 16:11:39 -0700 Subject: [PATCH 201/929] pygui: fixed and changed custom service copy to focus only on copying the current file displayed from any other nodes with a customized version --- daemon/core/gui/dialogs/copyserviceconfig.py | 226 ++++++------------- daemon/core/gui/dialogs/serviceconfig.py | 6 +- 2 files changed, 77 insertions(+), 155 deletions(-) diff --git a/daemon/core/gui/dialogs/copyserviceconfig.py b/daemon/core/gui/dialogs/copyserviceconfig.py index 35559cb9..2a01249d 100644 --- a/daemon/core/gui/dialogs/copyserviceconfig.py +++ b/daemon/core/gui/dialogs/copyserviceconfig.py @@ -4,80 +4,58 @@ copy service config dialog import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING, Tuple +from typing import TYPE_CHECKING, Dict, Optional from core.gui.dialogs.dialog import Dialog -from core.gui.themes import FRAME_PAD, PADX -from core.gui.widgets import CodeText +from core.gui.themes import PADX, PADY +from core.gui.widgets import CodeText, ListboxScroll if TYPE_CHECKING: from core.gui.app import Application + from core.gui.dialogs.serviceconfig import ServiceConfigDialog class CopyServiceConfigDialog(Dialog): - def __init__(self, master: tk.BaseWidget, app: "Application", node_id: int) -> None: - super().__init__(app, f"Copy services to node {node_id}", master=master) - self.parent = master - self.node_id = node_id - self.service_configs = app.core.service_configs - self.file_configs = app.core.file_configs - self.tree = None + def __init__( + self, + app: "Application", + dialog: "ServiceConfigDialog", + name: str, + service: str, + file_name: str, + ) -> None: + super().__init__(app, f"Copy Custom File to {name}", master=dialog) + self.dialog: "ServiceConfigDialog" = dialog + self.service: str = service + self.file_name: str = file_name + self.listbox: Optional[tk.Listbox] = None + self.nodes: Dict[str, int] = {} self.draw() def draw(self) -> None: self.top.columnconfigure(0, weight=1) - self.tree = ttk.Treeview(self.top) - self.tree.grid(row=0, column=0, sticky="ew", padx=PADX) - self.tree["columns"] = () - self.tree.column("#0", width=270, minwidth=270, stretch=tk.YES) - self.tree.heading("#0", text="Service configuration items", anchor=tk.CENTER) - custom_nodes = set(self.service_configs).union(set(self.file_configs)) - for nid in custom_nodes: - treeid = self.tree.insert("", "end", text=f"n{nid}", tags="node") - services = self.service_configs.get(nid, None) - files = self.file_configs.get(nid, None) - tree_ids = {} - if services: - for service, config in services.items(): - serviceid = self.tree.insert( - treeid, "end", text=service, tags="service" - ) - tree_ids[service] = serviceid - cmdup = config.startup[:] - cmddown = config.shutdown[:] - cmdval = config.validate[:] - self.tree.insert( - serviceid, - "end", - text=f"cmdup=({str(cmdup)[1:-1]})", - tags=("cmd", "up"), - ) - self.tree.insert( - serviceid, - "end", - text=f"cmddown=({str(cmddown)[1:-1]})", - tags=("cmd", "down"), - ) - self.tree.insert( - serviceid, - "end", - text=f"cmdval=({str(cmdval)[1:-1]})", - tags=("cmd", "val"), - ) - if files: - for service, configs in files.items(): - if service in tree_ids: - serviceid = tree_ids[service] - else: - serviceid = self.tree.insert( - treeid, "end", text=service, tags="service" - ) - tree_ids[service] = serviceid - for filename, data in configs.items(): - self.tree.insert(serviceid, "end", text=filename, tags="file") + self.top.rowconfigure(1, weight=1) + label = ttk.Label( + self.top, text=f"{self.service} - {self.file_name}", anchor=tk.CENTER + ) + label.grid(sticky="ew", pady=PADY) + + listbox_scroll = ListboxScroll(self.top) + listbox_scroll.grid(sticky="nsew", pady=PADY) + self.listbox = listbox_scroll.listbox + for canvas_node in self.app.canvas.nodes.values(): + file_configs = canvas_node.service_file_configs.get(self.service) + if not file_configs: + continue + data = file_configs.get(self.file_name) + if not data: + continue + name = canvas_node.core_node.name + self.nodes[name] = canvas_node.id + self.listbox.insert(tk.END, name) frame = ttk.Frame(self.top) - frame.grid(row=1, column=0) + frame.grid(sticky="ew") for i in range(3): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Copy", command=self.click_copy) @@ -85,118 +63,58 @@ class CopyServiceConfigDialog(Dialog): button = ttk.Button(frame, text="View", command=self.click_view) button.grid(row=0, column=1, sticky="ew", padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=2, sticky="ew", padx=PADX) + button.grid(row=0, column=2, sticky="ew") def click_copy(self) -> None: - selected = self.tree.selection() - if selected: - item = self.tree.item(selected[0]) - if "file" in item["tags"]: - filename = item["text"] - nid, service = self.get_node_service(selected) - data = self.file_configs[nid][service][filename] - if service == self.parent.service_name: - self.parent.temp_service_files[filename] = data - self.parent.modified_files.add(filename) - if self.parent.filename_combobox.get() == filename: - self.parent.service_file_data.text.delete(1.0, "end") - self.parent.service_file_data.text.insert("end", data) - if "cmd" in item["tags"]: - nid, service = self.get_node_service(selected) - if service == self.master.service_name: - cmds = self.service_configs[nid][service] - if "up" in item["tags"]: - self.master.append_commands( - self.master.startup_commands, - self.master.startup_commands_listbox, - cmds.startup, - ) - elif "down" in item["tags"]: - self.master.append_commands( - self.master.shutdown_commands, - self.master.shutdown_commands_listbox, - cmds.shutdown, - ) - - elif "val" in item["tags"]: - self.master.append_commands( - self.master.validate_commands, - self.master.validate_commands_listbox, - cmds.validate, - ) + selection = self.listbox.curselection() + if not selection: + return + name = self.listbox.get(selection) + canvas_node_id = self.nodes[name] + canvas_node = self.app.canvas.nodes[canvas_node_id] + data = canvas_node.service_file_configs[self.service][self.file_name] + self.dialog.temp_service_files[self.file_name] = data + self.dialog.modified_files.add(self.file_name) + self.dialog.service_file_data.text.delete(1.0, tk.END) + self.dialog.service_file_data.text.insert(tk.END, data) self.destroy() def click_view(self) -> None: - selected = self.tree.selection() - data = "" - if selected: - item = self.tree.item(selected[0]) - if "file" in item["tags"]: - nid, service = self.get_node_service(selected) - data = self.file_configs[nid][service][item["text"]] - dialog = ViewConfigDialog( - self, self.app, nid, data, item["text"].split("/")[-1] - ) - dialog.show() - if "cmd" in item["tags"]: - nid, service = self.get_node_service(selected) - cmds = self.service_configs[nid][service] - if "up" in item["tags"]: - data = f"({str(cmds.startup[:])[1:-1]})" - dialog = ViewConfigDialog( - self, self.app, self.node_id, data, "cmdup" - ) - elif "down" in item["tags"]: - data = f"({str(cmds.shutdown[:])[1:-1]})" - dialog = ViewConfigDialog( - self, self.app, self.node_id, data, "cmdup" - ) - elif "val" in item["tags"]: - data = f"({str(cmds.validate[:])[1:-1]})" - dialog = ViewConfigDialog( - self, self.app, self.node_id, data, "cmdup" - ) - dialog.show() - - def get_node_service(self, selected: Tuple[str]) -> Tuple[int, str]: - service_tree_id = self.tree.parent(selected[0]) - service_name = self.tree.item(service_tree_id)["text"] - node_tree_id = self.tree.parent(service_tree_id) - node_id = int(self.tree.item(node_tree_id)["text"][1:]) - return node_id, service_name + selection = self.listbox.curselection() + if not selection: + return + name = self.listbox.get(selection) + canvas_node_id = self.nodes[name] + canvas_node = self.app.canvas.nodes[canvas_node_id] + data = canvas_node.service_file_configs[self.service][self.file_name] + dialog = ViewConfigDialog( + self.app, self, name, self.service, self.file_name, data + ) + dialog.show() class ViewConfigDialog(Dialog): def __init__( self, - master: tk.BaseWidget, app: "Application", - node_id: int, + master: tk.BaseWidget, + name: str, + service: str, + file_name: str, data: str, - filename: str = None, ) -> None: - super().__init__(app, f"n{node_id} config data", master=master) + title = f"{name} Service({service}) File({file_name})" + super().__init__(app, title, master=master) self.data = data self.service_data = None - self.filepath = tk.StringVar(value=f"/tmp/services.tmp-n{node_id}-{filename}") self.draw() def draw(self) -> None: self.top.columnconfigure(0, weight=1) - frame = ttk.Frame(self.top, padding=FRAME_PAD) - frame.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=10) - frame.grid(row=0, column=0, sticky="ew") - label = ttk.Label(frame, text="File: ") - label.grid(row=0, column=0, sticky="ew", padx=PADX) - entry = ttk.Entry(frame, textvariable=self.filepath) - entry.config(state="disabled") - entry.grid(row=0, column=1, sticky="ew") - + self.top.rowconfigure(0, weight=1) self.service_data = CodeText(self.top) - self.service_data.grid(row=1, column=0, sticky="nsew") - self.service_data.text.insert("end", self.data) - self.service_data.text.config(state="disabled") - + self.service_data.grid(sticky="nsew", pady=PADY) + self.service_data.text.insert(tk.END, self.data) + self.service_data.text.config(state=tk.DISABLED) button = ttk.Button(self.top, text="Close", command=self.destroy) - button.grid(row=2, column=0, sticky="ew", padx=PADX) + button.grid(sticky="ew") diff --git a/daemon/core/gui/dialogs/serviceconfig.py b/daemon/core/gui/dialogs/serviceconfig.py index 5faface7..4e615db0 100644 --- a/daemon/core/gui/dialogs/serviceconfig.py +++ b/daemon/core/gui/dialogs/serviceconfig.py @@ -563,7 +563,11 @@ class ServiceConfigDialog(Dialog): self.current_service_color("") def click_copy(self) -> None: - dialog = CopyServiceConfigDialog(self, self.app, self.node_id) + file_name = self.filename_combobox.get() + name = self.canvas_node.core_node.name + dialog = CopyServiceConfigDialog( + self.app, self, name, self.service_name, file_name + ) dialog.show() @classmethod From bb2ceaf99307ca75e21018712faf658c29187064 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 23 Jun 2020 22:53:48 -0700 Subject: [PATCH 202/929] pygui: draw link options on edges --- daemon/core/gui/dialogs/linkconfig.py | 2 ++ daemon/core/gui/graph/edges.py | 35 +++++++++++++++++++++++++++ daemon/core/gui/graph/graph.py | 1 + 3 files changed, 38 insertions(+) diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index b7c618a3..28798ec1 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -287,6 +287,8 @@ class LinkConfigurationDialog(Dialog): iface2_id, ) + # update edge label + self.edge.draw_link_options() self.destroy() def change_symmetry(self) -> None: diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index 29632086..6c79787f 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -57,6 +57,18 @@ def arc_edges(edges) -> None: edge.redraw() +def bandwidth_label(bandwidth: int) -> str: + size = {0: "bps", 1: "Kbps", 2: "Mbps", 3: "Gbps"} + unit = 1000 + i = 0 + while bandwidth > unit: + bandwidth /= unit + i += 1 + if i == 3: + break + return f"{bandwidth} {size[i]}" + + class Edge: tag: str = tags.EDGE @@ -140,6 +152,7 @@ class Edge: font=self.canvas.app.edge_font, text=text, tags=tags.LINK_LABEL, + justify=tk.CENTER, state=self.canvas.show_link_labels.state(), ) else: @@ -312,6 +325,7 @@ class CanvasEdge(Edge): src_text, dst_text = self.create_node_labels() self.src_label_text(src_text) self.dst_label_text(dst_text) + self.draw_link_options() def redraw(self) -> None: super().redraw() @@ -393,3 +407,24 @@ class CanvasEdge(Edge): def click_configure(self) -> None: dialog = LinkConfigurationDialog(self.canvas.app, self) dialog.show() + + def draw_link_options(self): + options = self.link.options + lines = [] + bandwidth = options.bandwidth + if bandwidth > 0: + lines.append(bandwidth_label(bandwidth)) + delay = options.delay + jitter = options.jitter + if delay > 0 and jitter > 0: + lines.append(f"{delay} us (\u00B1{jitter} us)") + elif jitter > 0: + lines.append(f"0 us (\u00B1{jitter} us)") + loss = options.loss + if loss > 0: + lines.append(f"loss={loss}%") + dup = options.dup + if dup > 0: + lines.append(f"dup={dup}%") + label = "\n".join(lines) + self.middle_label_text(label) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 07519c3f..7d8ec019 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -1000,6 +1000,7 @@ class CanvasGraph(tk.Canvas): def clear_throughputs(self) -> None: for edge in self.edges.values(): edge.clear_middle_label() + edge.draw_link_options() def scale_graph(self) -> None: for nid, canvas_node in self.nodes.items(): From f582306bb97e5f2281093b9dfe3cd00e4e1285d3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 25 Jun 2020 10:35:01 -0700 Subject: [PATCH 203/929] pygui: added support for a details pane, can be toggled on/off, can be used to quickly view details for nodes or links --- daemon/core/gui/app.py | 47 ++++++++++++++++++++++-- daemon/core/gui/frames/__init__.py | 0 daemon/core/gui/frames/base.py | 36 +++++++++++++++++++ daemon/core/gui/frames/default.py | 19 ++++++++++ daemon/core/gui/frames/link.py | 58 ++++++++++++++++++++++++++++++ daemon/core/gui/frames/node.py | 33 +++++++++++++++++ daemon/core/gui/graph/edges.py | 20 ++++------- daemon/core/gui/graph/graph.py | 1 + daemon/core/gui/graph/node.py | 6 ++++ daemon/core/gui/menubar.py | 11 ++++++ daemon/core/gui/task.py | 2 +- daemon/core/gui/utils.py | 10 ++++++ 12 files changed, 226 insertions(+), 17 deletions(-) create mode 100644 daemon/core/gui/frames/__init__.py create mode 100644 daemon/core/gui/frames/base.py create mode 100644 daemon/core/gui/frames/default.py create mode 100644 daemon/core/gui/frames/link.py create mode 100644 daemon/core/gui/frames/node.py create mode 100644 daemon/core/gui/utils.py diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index cb385e9e..e0121d14 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -3,7 +3,7 @@ import math import tkinter as tk from tkinter import PhotoImage, font, ttk from tkinter.ttk import Progressbar -from typing import Dict, Optional +from typing import Any, Dict, Optional, Type import grpc @@ -11,11 +11,14 @@ from core.gui import appconfig, themes from core.gui.appconfig import GuiConfig from core.gui.coreclient import CoreClient from core.gui.dialogs.error import ErrorDialog +from core.gui.frames.base import InfoFrameBase +from core.gui.frames.default import DefaultInfoFrame from core.gui.graph.graph import CanvasGraph from core.gui.images import ImageEnum, Images from core.gui.menubar import Menubar from core.gui.nodeutils import NodeUtils from core.gui.statusbar import StatusBar +from core.gui.themes import PADY from core.gui.toolbar import Toolbar WIDTH: int = 1000 @@ -35,6 +38,9 @@ class Application(ttk.Frame): self.canvas: Optional[CanvasGraph] = None self.statusbar: Optional[StatusBar] = None self.progress: Optional[Progressbar] = None + self.infobar: Optional[ttk.Frame] = None + self.info_frame: Optional[InfoFrameBase] = None + self.show_infobar: tk.BooleanVar = tk.BooleanVar(value=False) # fonts self.fonts_size: Dict[str, int] = {} @@ -113,16 +119,27 @@ class Application(ttk.Frame): self.right_frame.rowconfigure(0, weight=1) self.right_frame.grid(row=0, column=1, sticky="nsew") self.draw_canvas() + self.draw_infobar() self.draw_status() self.progress = Progressbar(self.right_frame, mode="indeterminate") self.menubar = Menubar(self) self.master.config(menu=self.menubar) + def draw_infobar(self) -> None: + self.infobar = ttk.Frame(self.right_frame, padding=5, relief=tk.RAISED) + self.infobar.columnconfigure(0, weight=1) + self.infobar.rowconfigure(1, weight=1) + label_font = font.Font(weight=font.BOLD, underline=tk.TRUE) + label = ttk.Label( + self.infobar, text="Details", anchor=tk.CENTER, font=label_font + ) + label.grid(sticky=tk.EW, pady=PADY) + def draw_canvas(self) -> None: canvas_frame = ttk.Frame(self.right_frame) canvas_frame.rowconfigure(0, weight=1) canvas_frame.columnconfigure(0, weight=1) - canvas_frame.grid(sticky="nsew", pady=1) + canvas_frame.grid(row=0, column=0, sticky="nsew", pady=1) self.canvas = CanvasGraph(canvas_frame, self, self.core) self.canvas.grid(sticky="nsew") scroll_y = ttk.Scrollbar(canvas_frame, command=self.canvas.yview) @@ -136,7 +153,31 @@ class Application(ttk.Frame): def draw_status(self) -> None: self.statusbar = StatusBar(self.right_frame, self) - self.statusbar.grid(sticky="ew") + self.statusbar.grid(sticky="ew", columnspan=2) + + def display_info(self, frame_class: Type[InfoFrameBase], **kwargs: Any) -> None: + if not self.show_infobar.get(): + return + self.clear_info() + self.info_frame = frame_class(self.infobar, **kwargs) + self.info_frame.draw() + self.info_frame.grid(sticky="nsew") + + def clear_info(self) -> None: + if self.info_frame: + self.info_frame.destroy() + self.info_frame = None + + def default_info(self) -> None: + self.clear_info() + self.display_info(DefaultInfoFrame, app=self) + + def show_info(self) -> None: + self.default_info() + self.infobar.grid(row=0, column=1, sticky="nsew") + + def hide_info(self) -> None: + self.infobar.grid_forget() def show_grpc_exception(self, title: str, e: grpc.RpcError) -> None: logging.exception("app grpc exception", exc_info=e) diff --git a/daemon/core/gui/frames/__init__.py b/daemon/core/gui/frames/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/daemon/core/gui/frames/base.py b/daemon/core/gui/frames/base.py new file mode 100644 index 00000000..8db952f1 --- /dev/null +++ b/daemon/core/gui/frames/base.py @@ -0,0 +1,36 @@ +import tkinter as tk +from tkinter import ttk +from typing import TYPE_CHECKING + +from core.gui.themes import FRAME_PAD, PADX, PADY + +if TYPE_CHECKING: + from core.gui.app import Application + + +class InfoFrameBase(ttk.Frame): + def __init__(self, master: tk.BaseWidget, app: "Application") -> None: + super().__init__(master, padding=FRAME_PAD) + self.app: "Application" = app + + def draw(self) -> None: + raise NotImplementedError + + +class DetailsFrame(ttk.Frame): + def __init__(self, master: tk.BaseWidget) -> None: + super().__init__(master) + self.columnconfigure(1, weight=1) + self.row = 0 + + def add_detail(self, label: str, value: str) -> None: + label = ttk.Label(self, text=label, anchor=tk.W) + label.grid(row=self.row, sticky=tk.EW, column=0, padx=PADX) + label = ttk.Label(self, text=value, anchor=tk.W, state=tk.DISABLED) + label.grid(row=self.row, sticky=tk.EW, column=1) + self.row += 1 + + def add_separator(self) -> None: + separator = ttk.Separator(self) + separator.grid(row=self.row, sticky=tk.EW, columnspan=2, pady=PADY) + self.row += 1 diff --git a/daemon/core/gui/frames/default.py b/daemon/core/gui/frames/default.py new file mode 100644 index 00000000..e84edb87 --- /dev/null +++ b/daemon/core/gui/frames/default.py @@ -0,0 +1,19 @@ +import tkinter as tk +from tkinter import ttk +from typing import TYPE_CHECKING + +from core.gui.frames.base import InfoFrameBase + +if TYPE_CHECKING: + from core.gui.app import Application + + +class DefaultInfoFrame(InfoFrameBase): + def __init__(self, master: tk.BaseWidget, app: "Application") -> None: + super().__init__(master, app) + + def draw(self) -> None: + label = ttk.Label(self, text="Click a Node/Link", anchor=tk.CENTER) + label.grid(sticky=tk.EW) + label = ttk.Label(self, text="to see details", anchor=tk.CENTER) + label.grid(sticky=tk.EW) diff --git a/daemon/core/gui/frames/link.py b/daemon/core/gui/frames/link.py new file mode 100644 index 00000000..29b3df45 --- /dev/null +++ b/daemon/core/gui/frames/link.py @@ -0,0 +1,58 @@ +import tkinter as tk +from typing import TYPE_CHECKING + +from core.gui.frames.base import DetailsFrame, InfoFrameBase +from core.gui.utils import bandwidth_text + +if TYPE_CHECKING: + from core.gui.app import Application + from core.gui.graph.edges import CanvasEdge + + +class EdgeInfoFrame(InfoFrameBase): + def __init__( + self, master: tk.BaseWidget, app: "Application", edge: "CanvasEdge" + ) -> None: + super().__init__(master, app) + self.edge: "CanvasEdge" = edge + + def draw(self) -> None: + self.columnconfigure(0, weight=1) + link = self.edge.link + options = link.options + src_canvas_node = self.app.core.canvas_nodes[link.node1_id] + src_node = src_canvas_node.core_node + dst_canvas_node = self.app.core.canvas_nodes[link.node2_id] + dst_node = dst_canvas_node.core_node + + frame = DetailsFrame(self) + frame.grid(sticky="ew") + frame.add_detail("Source", src_node.name) + iface1 = link.iface1 + if iface1: + mac = iface1.mac if iface1.mac else "auto" + frame.add_detail("MAC", mac) + ip4 = f"{iface1.ip4}/{iface1.ip4_mask}" if iface1.ip4 else "" + frame.add_detail("IP4", ip4) + ip6 = f"{iface1.ip6}/{iface1.ip6_mask}" if iface1.ip6 else "" + frame.add_detail("IP6", ip6) + + frame.add_separator() + frame.add_detail("Destination", dst_node.name) + iface2 = link.iface2 + if iface2: + mac = iface2.mac if iface2.mac else "auto" + frame.add_detail("MAC", mac) + ip4 = f"{iface2.ip4}/{iface2.ip4_mask}" if iface2.ip4 else "" + frame.add_detail("IP4", ip4) + ip6 = f"{iface2.ip6}/{iface2.ip6_mask}" if iface2.ip6 else "" + frame.add_detail("IP6", ip6) + + if link.HasField("options"): + frame.add_separator() + bandwidth = bandwidth_text(options.bandwidth) + frame.add_detail("Bandwidth", bandwidth) + frame.add_detail("Delay", f"{options.delay} us") + frame.add_detail("Jitter", f"\u00B1{options.jitter} us") + frame.add_detail("Loss", f"{options.loss}%") + frame.add_detail("Duplicate", f"{options.dup}%") diff --git a/daemon/core/gui/frames/node.py b/daemon/core/gui/frames/node.py new file mode 100644 index 00000000..44724f36 --- /dev/null +++ b/daemon/core/gui/frames/node.py @@ -0,0 +1,33 @@ +from typing import TYPE_CHECKING + +from core.api.grpc.core_pb2 import NodeType +from core.gui.frames.base import DetailsFrame, InfoFrameBase +from core.gui.nodeutils import NodeUtils + +if TYPE_CHECKING: + from core.gui.app import Application + from core.gui.graph.node import CanvasNode + + +class NodeInfoFrame(InfoFrameBase): + def __init__(self, master, app: "Application", canvas_node: "CanvasNode") -> None: + super().__init__(master, app) + self.canvas_node: "CanvasNode" = canvas_node + + def draw(self) -> None: + self.columnconfigure(0, weight=1) + node = self.canvas_node.core_node + frame = DetailsFrame(self) + frame.grid(sticky="ew") + frame.add_detail("ID", node.id) + frame.add_detail("Name", node.name) + if NodeUtils.is_model_node(node.type): + frame.add_detail("Type", node.model) + if node.type == NodeType.EMANE: + emane = node.emane.split("_")[1:] + frame.add_detail("EMANE", emane) + if NodeUtils.is_image_node(node.type): + frame.add_detail("Image", node.image) + if NodeUtils.is_container_node(node.type): + server = node.server if node.server else "localhost" + frame.add_detail("Server", server) diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index 6c79787f..de063bac 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -7,8 +7,10 @@ from core.api.grpc import core_pb2 from core.api.grpc.core_pb2 import Interface, Link from core.gui import themes from core.gui.dialogs.linkconfig import LinkConfigurationDialog +from core.gui.frames.link import EdgeInfoFrame from core.gui.graph import tags from core.gui.nodeutils import NodeUtils +from core.gui.utils import bandwidth_text if TYPE_CHECKING: from core.gui.graph.graph import CanvasGraph @@ -57,18 +59,6 @@ def arc_edges(edges) -> None: edge.redraw() -def bandwidth_label(bandwidth: int) -> str: - size = {0: "bps", 1: "Kbps", 2: "Mbps", 3: "Gbps"} - unit = 1000 - i = 0 - while bandwidth > unit: - bandwidth /= unit - i += 1 - if i == 3: - break - return f"{bandwidth} {size[i]}" - - class Edge: tag: str = tags.EDGE @@ -295,6 +285,7 @@ class CanvasEdge(Edge): def set_binding(self) -> None: self.canvas.tag_bind(self.id, "", self.show_context) + self.canvas.tag_bind(self.id, "", self.show_info) def set_link(self, link: Link) -> None: self.link = link @@ -396,6 +387,9 @@ class CanvasEdge(Edge): self.middle_label = None self.canvas.itemconfig(self.id, fill=self.color, width=self.scaled_width()) + def show_info(self, _event: tk.Event) -> None: + self.canvas.app.display_info(EdgeInfoFrame, app=self.canvas.app, edge=self) + def show_context(self, event: tk.Event) -> None: state = tk.DISABLED if self.canvas.core.is_runtime() else tk.NORMAL self.context.entryconfigure(1, state=state) @@ -413,7 +407,7 @@ class CanvasEdge(Edge): lines = [] bandwidth = options.bandwidth if bandwidth > 0: - lines.append(bandwidth_label(bandwidth)) + lines.append(bandwidth_text(bandwidth)) delay = options.delay jitter = options.jitter if delay > 0 and jitter > 0: diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 7d8ec019..1588f920 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -715,6 +715,7 @@ class CanvasGraph(tk.Canvas): logging.debug("press delete key") if not self.app.core.is_runtime(): self.delete_selected_objects() + self.app.default_info() else: logging.debug("node deletion is disabled during runtime state") diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index a86ce4a3..d98c4e48 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -16,6 +16,7 @@ from core.gui.dialogs.nodeconfig import NodeConfigDialog from core.gui.dialogs.nodeconfigservice import NodeConfigServiceDialog from core.gui.dialogs.nodeservice import NodeServiceDialog from core.gui.dialogs.wlanconfig import WlanConfigDialog +from core.gui.frames.node import NodeInfoFrame from core.gui.graph import tags from core.gui.graph.edges import CanvasEdge, CanvasWirelessEdge from core.gui.graph.tooltip import CanvasTooltip @@ -80,6 +81,7 @@ class CanvasNode: self.canvas.tag_bind(self.id, "", self.on_enter) self.canvas.tag_bind(self.id, "", self.on_leave) self.canvas.tag_bind(self.id, "", self.show_context) + self.canvas.tag_bind(self.id, "", self.show_info) def delete(self) -> None: logging.debug("Delete canvas node for %s", self.core_node) @@ -195,6 +197,9 @@ class CanvasNode: else: self.show_config() + def show_info(self, _event: tk.Event) -> None: + self.app.display_info(NodeInfoFrame, app=self.app, canvas_node=self) + def show_context(self, event: tk.Event) -> None: # clear existing menu self.context.delete(0, tk.END) @@ -262,6 +267,7 @@ class CanvasNode: def click_unlink(self, edge: CanvasEdge) -> None: self.canvas.delete_edge(edge) + self.app.default_info() def canvas_delete(self) -> None: self.canvas.clear_selection() diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index 75312e95..3b85ac6f 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -138,6 +138,11 @@ class Menubar(tk.Menu): Create view menu """ menu = tk.Menu(self) + menu.add_checkbutton( + label="Details Panel", + command=self.click_infobar_change, + variable=self.app.show_infobar, + ) menu.add_checkbutton( label="Interface Names", command=self.click_edge_label_change, @@ -443,6 +448,12 @@ class Menubar(tk.Menu): y = (row * layout_size) + padding node.move(x, y) + def click_infobar_change(self) -> None: + if self.app.show_infobar.get(): + self.app.show_info() + else: + self.app.hide_info() + def click_edge_label_change(self) -> None: for edge in self.canvas.edges.values(): edge.draw_labels() diff --git a/daemon/core/gui/task.py b/daemon/core/gui/task.py index b4a5f68f..c60350f9 100644 --- a/daemon/core/gui/task.py +++ b/daemon/core/gui/task.py @@ -26,7 +26,7 @@ class ProgressTask: self.time: Optional[float] = None def start(self) -> None: - self.app.progress.grid(sticky="ew") + self.app.progress.grid(sticky="ew", columnspan=2) self.app.progress.start() self.time = time.perf_counter() thread = threading.Thread(target=self.run, daemon=True) diff --git a/daemon/core/gui/utils.py b/daemon/core/gui/utils.py new file mode 100644 index 00000000..ee5ad8cb --- /dev/null +++ b/daemon/core/gui/utils.py @@ -0,0 +1,10 @@ +def bandwidth_text(bandwidth: int) -> str: + size = {0: "bps", 1: "Kbps", 2: "Mbps", 3: "Gbps"} + unit = 1000 + i = 0 + while bandwidth > unit: + bandwidth /= unit + i += 1 + if i == 3: + break + return f"{bandwidth} {size[i]}" From 98e4baca046f4e8ea0b4ba254bd1dec444d067c2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 25 Jun 2020 15:05:24 -0700 Subject: [PATCH 204/929] pygui: added services to node info panel --- daemon/core/gui/frames/node.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/daemon/core/gui/frames/node.py b/daemon/core/gui/frames/node.py index 44724f36..7480e056 100644 --- a/daemon/core/gui/frames/node.py +++ b/daemon/core/gui/frames/node.py @@ -23,6 +23,12 @@ class NodeInfoFrame(InfoFrameBase): frame.add_detail("Name", node.name) if NodeUtils.is_model_node(node.type): frame.add_detail("Type", node.model) + if NodeUtils.is_container_node(node.type): + for index, service in enumerate(sorted(node.services)): + if index == 0: + frame.add_detail("Services", service) + else: + frame.add_detail("", service) if node.type == NodeType.EMANE: emane = node.emane.split("_")[1:] frame.add_detail("EMANE", emane) From 3bfc299bfd96343b0a4afed6c7d19c95fec0c700 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 25 Jun 2020 16:22:56 -0700 Subject: [PATCH 205/929] daemon: fixed typo in core.configservices.securityservices --- .../{sercurityservices => securityservices}/__init__.py | 0 .../{sercurityservices => securityservices}/services.py | 0 .../{sercurityservices => securityservices}/templates/firewall.sh | 0 .../{sercurityservices => securityservices}/templates/ipsec.sh | 0 .../{sercurityservices => securityservices}/templates/nat.sh | 0 .../templates/vpnclient.sh | 0 .../templates/vpnserver.sh | 0 7 files changed, 0 insertions(+), 0 deletions(-) rename daemon/core/configservices/{sercurityservices => securityservices}/__init__.py (100%) rename daemon/core/configservices/{sercurityservices => securityservices}/services.py (100%) rename daemon/core/configservices/{sercurityservices => securityservices}/templates/firewall.sh (100%) rename daemon/core/configservices/{sercurityservices => securityservices}/templates/ipsec.sh (100%) rename daemon/core/configservices/{sercurityservices => securityservices}/templates/nat.sh (100%) rename daemon/core/configservices/{sercurityservices => securityservices}/templates/vpnclient.sh (100%) rename daemon/core/configservices/{sercurityservices => securityservices}/templates/vpnserver.sh (100%) diff --git a/daemon/core/configservices/sercurityservices/__init__.py b/daemon/core/configservices/securityservices/__init__.py similarity index 100% rename from daemon/core/configservices/sercurityservices/__init__.py rename to daemon/core/configservices/securityservices/__init__.py diff --git a/daemon/core/configservices/sercurityservices/services.py b/daemon/core/configservices/securityservices/services.py similarity index 100% rename from daemon/core/configservices/sercurityservices/services.py rename to daemon/core/configservices/securityservices/services.py diff --git a/daemon/core/configservices/sercurityservices/templates/firewall.sh b/daemon/core/configservices/securityservices/templates/firewall.sh similarity index 100% rename from daemon/core/configservices/sercurityservices/templates/firewall.sh rename to daemon/core/configservices/securityservices/templates/firewall.sh diff --git a/daemon/core/configservices/sercurityservices/templates/ipsec.sh b/daemon/core/configservices/securityservices/templates/ipsec.sh similarity index 100% rename from daemon/core/configservices/sercurityservices/templates/ipsec.sh rename to daemon/core/configservices/securityservices/templates/ipsec.sh diff --git a/daemon/core/configservices/sercurityservices/templates/nat.sh b/daemon/core/configservices/securityservices/templates/nat.sh similarity index 100% rename from daemon/core/configservices/sercurityservices/templates/nat.sh rename to daemon/core/configservices/securityservices/templates/nat.sh diff --git a/daemon/core/configservices/sercurityservices/templates/vpnclient.sh b/daemon/core/configservices/securityservices/templates/vpnclient.sh similarity index 100% rename from daemon/core/configservices/sercurityservices/templates/vpnclient.sh rename to daemon/core/configservices/securityservices/templates/vpnclient.sh diff --git a/daemon/core/configservices/sercurityservices/templates/vpnserver.sh b/daemon/core/configservices/securityservices/templates/vpnserver.sh similarity index 100% rename from daemon/core/configservices/sercurityservices/templates/vpnserver.sh rename to daemon/core/configservices/securityservices/templates/vpnserver.sh From b94d4d35071b8e07f01fbf4469ada87df054ad18 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 25 Jun 2020 21:34:45 -0700 Subject: [PATCH 206/929] daemon: updated open xml with start flag to set instantiation state before running instantiate to be consistent with other cases --- daemon/core/emulator/session.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 630e1a0f..92d4b5e1 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -637,19 +637,16 @@ class Session: # clear out existing session self.clear() - if start: - state = EventTypes.CONFIGURATION_STATE - else: - state = EventTypes.DEFINITION_STATE + # set state and read xml + state = EventTypes.CONFIGURATION_STATE if start else EventTypes.DEFINITION_STATE self.set_state(state) self.name = os.path.basename(file_name) self.file_name = file_name - - # write out xml file CoreXmlReader(self).read(file_name) # start session if needed if start: + self.set_state(EventTypes.INSTANTIATION_STATE) self.instantiate() def save_xml(self, file_name: str) -> None: From f4224d1b80060fd7e5c1bc4fd771be29c01f23b5 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 25 Jun 2020 22:05:10 -0700 Subject: [PATCH 207/929] daemon: updated ovs option to be a formal session option, will now display within gui, save to and be read from xml --- daemon/core/emulator/coreemu.py | 2 +- daemon/core/emulator/session.py | 3 +++ daemon/core/emulator/sessionconfig.py | 3 +++ daemon/core/nodes/base.py | 10 ++++++---- daemon/core/nodes/interface.py | 5 +++-- daemon/core/nodes/network.py | 2 +- daemon/scripts/core-daemon | 3 +++ 7 files changed, 20 insertions(+), 8 deletions(-) diff --git a/daemon/core/emulator/coreemu.py b/daemon/core/emulator/coreemu.py index 71723268..016f2e5b 100644 --- a/daemon/core/emulator/coreemu.py +++ b/daemon/core/emulator/coreemu.py @@ -80,7 +80,7 @@ class CoreEmu: :raises core.errors.CoreError: when an executable does not exist on path """ requirements = COMMON_REQUIREMENTS - use_ovs = self.config.get("ovs") == "True" + use_ovs = self.config.get("ovs") == "1" if use_ovs: requirements += OVS_REQUIREMENTS else: diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 92d4b5e1..c2573578 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -217,6 +217,9 @@ class Session: else: common_network.unlink(iface1, iface2) + def use_ovs(self) -> bool: + return self.options.get_config("ovs") == "1" + def add_link( self, node1_id: int, diff --git a/daemon/core/emulator/sessionconfig.py b/daemon/core/emulator/sessionconfig.py index e22e852e..9b22bcc7 100644 --- a/daemon/core/emulator/sessionconfig.py +++ b/daemon/core/emulator/sessionconfig.py @@ -56,6 +56,9 @@ class SessionConfig(ConfigurableManager, ConfigurableOptions): default=Sdt.DEFAULT_SDT_URL, label="SDT3D URL", ), + Configuration( + _id="ovs", _type=ConfigDataTypes.BOOL, default="0", label="Enable OVS" + ), ] config_type: RegisterTlvs = RegisterTlvs.UTILITY diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 3999046d..05ec87dc 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -73,8 +73,9 @@ class NodeBase(abc.ABC): self.icon: Optional[str] = None self.position: Position = Position() self.up: bool = False - use_ovs = session.options.get_config("ovs") == "True" - self.net_client: LinuxNetClient = get_net_client(use_ovs, self.host_cmd) + self.net_client: LinuxNetClient = get_net_client( + self.session.use_ovs(), self.host_cmd + ) @abc.abstractmethod def startup(self) -> None: @@ -471,8 +472,9 @@ class CoreNode(CoreNodeBase): self.pid: Optional[int] = None self.lock: RLock = RLock() self._mounts: List[Tuple[str, str]] = [] - use_ovs = session.options.get_config("ovs") == "True" - self.node_net_client: LinuxNetClient = self.create_node_net_client(use_ovs) + self.node_net_client: LinuxNetClient = self.create_node_net_client( + self.session.use_ovs() + ) def create_node_net_client(self, use_ovs: bool) -> LinuxNetClient: """ diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index 22ecb620..e4d4d0ac 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -68,8 +68,9 @@ class CoreInterface: # id used to find flow data self.flow_id: Optional[int] = None self.server: Optional["DistributedServer"] = server - use_ovs = session.options.get_config("ovs") == "True" - self.net_client: LinuxNetClient = get_net_client(use_ovs, self.host_cmd) + self.net_client: LinuxNetClient = get_net_client( + self.session.use_ovs(), self.host_cmd + ) def host_cmd( self, diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index d418a42c..a55de4cf 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -756,7 +756,7 @@ class CtrlNet(CoreNetwork): :param index: starting address index :return: nothing """ - use_ovs = self.session.options.get_config("ovs") == "True" + use_ovs = self.session.use_ovs() address = self.prefix[index] current = f"{address}/{self.prefix.prefixlen}" net_client = get_net_client(use_ovs, utils.cmd) diff --git a/daemon/scripts/core-daemon b/daemon/scripts/core-daemon index a95e59fa..16b0ac59 100755 --- a/daemon/scripts/core-daemon +++ b/daemon/scripts/core-daemon @@ -118,6 +118,9 @@ def get_merged_config(filename): # parse command line options args = parser.parse_args() + # convert ovs to internal format + args.ovs = "1" if args.ovs else "0" + # read the config file if args.configfile is not None: filename = args.configfile From eac941ce7265eb850ae9f153fd9ea0a88f8eedf1 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 26 Jun 2020 09:13:38 -0700 Subject: [PATCH 208/929] pygui: updates to show wireless edges in details panel, increased edge thickness to be the same as normal edges for selection to be easier --- daemon/core/gui/frames/link.py | 57 +++++++++++++++++++++++++++++++++- daemon/core/gui/graph/edges.py | 20 ++++++++++-- daemon/core/gui/graph/graph.py | 6 +--- 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/daemon/core/gui/frames/link.py b/daemon/core/gui/frames/link.py index 29b3df45..57b1bf66 100644 --- a/daemon/core/gui/frames/link.py +++ b/daemon/core/gui/frames/link.py @@ -1,12 +1,26 @@ import tkinter as tk -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional +from core.api.grpc.core_pb2 import Interface from core.gui.frames.base import DetailsFrame, InfoFrameBase from core.gui.utils import bandwidth_text if TYPE_CHECKING: from core.gui.app import Application from core.gui.graph.edges import CanvasEdge + from core.gui.graph.node import CanvasNode + from core.gui.graph.edges import CanvasWirelessEdge + + +def get_iface(canvas_node: "CanvasNode", net_id: int) -> Optional[Interface]: + iface = None + for edge in canvas_node.edges: + link = edge.link + if link.node1_id == net_id: + iface = link.iface2 + elif link.node2_id == net_id: + iface = link.iface1 + return iface class EdgeInfoFrame(InfoFrameBase): @@ -56,3 +70,44 @@ class EdgeInfoFrame(InfoFrameBase): frame.add_detail("Jitter", f"\u00B1{options.jitter} us") frame.add_detail("Loss", f"{options.loss}%") frame.add_detail("Duplicate", f"{options.dup}%") + + +class WirelessEdgeInfoFrame(InfoFrameBase): + def __init__( + self, master: tk.BaseWidget, app: "Application", edge: "CanvasWirelessEdge" + ) -> None: + super().__init__(master, app) + self.edge: "CanvasWirelessEdge" = edge + + def draw(self) -> None: + link = self.edge.link + src_canvas_node = self.app.core.canvas_nodes[link.node1_id] + src_node = src_canvas_node.core_node + dst_canvas_node = self.app.core.canvas_nodes[link.node2_id] + dst_node = dst_canvas_node.core_node + + # find interface for each node connected to network + net_id = link.network_id + iface1 = get_iface(src_canvas_node, net_id) + iface2 = get_iface(dst_canvas_node, net_id) + + frame = DetailsFrame(self) + frame.grid(sticky="ew") + frame.add_detail("Source", src_node.name) + if iface1: + mac = iface1.mac if iface1.mac else "auto" + frame.add_detail("MAC", mac) + ip4 = f"{iface1.ip4}/{iface1.ip4_mask}" if iface1.ip4 else "" + frame.add_detail("IP4", ip4) + ip6 = f"{iface1.ip6}/{iface1.ip6_mask}" if iface1.ip6 else "" + frame.add_detail("IP6", ip6) + + frame.add_separator() + frame.add_detail("Destination", dst_node.name) + if iface2: + mac = iface2.mac if iface2.mac else "auto" + frame.add_detail("MAC", mac) + ip4 = f"{iface2.ip4}/{iface2.ip4_mask}" if iface2.ip4 else "" + frame.add_detail("IP4", ip4) + ip6 = f"{iface2.ip6}/{iface2.ip6_mask}" if iface2.ip6 else "" + frame.add_detail("IP6", ip6) diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index de063bac..d9085910 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -7,7 +7,7 @@ from core.api.grpc import core_pb2 from core.api.grpc.core_pb2 import Interface, Link from core.gui import themes from core.gui.dialogs.linkconfig import LinkConfigurationDialog -from core.gui.frames.link import EdgeInfoFrame +from core.gui.frames.link import EdgeInfoFrame, WirelessEdgeInfoFrame from core.gui.graph import tags from core.gui.nodeutils import NodeUtils from core.gui.utils import bandwidth_text @@ -18,7 +18,7 @@ if TYPE_CHECKING: TEXT_DISTANCE: float = 0.30 EDGE_WIDTH: int = 3 EDGE_COLOR: str = "#ff0000" -WIRELESS_WIDTH: float = 1.5 +WIRELESS_WIDTH: float = 3 WIRELESS_COLOR: str = "#009933" ARC_DISTANCE: int = 50 @@ -241,13 +241,27 @@ class CanvasWirelessEdge(Edge): src_pos: Tuple[float, float], dst_pos: Tuple[float, float], token: Tuple[int, ...], + link: Link, ) -> None: logging.debug("drawing wireless link from node %s to node %s", src, dst) super().__init__(canvas, src, dst) + self.link: Link = link self.token: Tuple[int, ...] = token self.width: float = WIRELESS_WIDTH - self.color: str = WIRELESS_COLOR + color = link.color if link.color else WIRELESS_COLOR + self.color: str = color self.draw(src_pos, dst_pos) + if link.label: + self.middle_label_text(link.label) + self.set_binding() + + def set_binding(self) -> None: + self.canvas.tag_bind(self.id, "", self.show_info) + + def show_info(self, _event: tk.Event) -> None: + self.canvas.app.display_info( + WirelessEdgeInfoFrame, app=self.canvas.app, edge=self + ) class CanvasEdge(Edge): diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 1588f920..a3520d22 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -233,11 +233,7 @@ class CanvasGraph(tk.Canvas): return src_pos = self.coords(src.id) dst_pos = self.coords(dst.id) - edge = CanvasWirelessEdge(self, src.id, dst.id, src_pos, dst_pos, token) - if link.label: - edge.middle_label_text(link.label) - if link.color: - edge.color = link.color + edge = CanvasWirelessEdge(self, src.id, dst.id, src_pos, dst_pos, token, link) self.wireless_edges[token] = edge src.wireless_edges.add(edge) dst.wireless_edges.add(edge) From aebbff8c224a90a818417e5a512ad43204c6a493 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 26 Jun 2020 14:39:12 -0700 Subject: [PATCH 209/929] grpc/pygui: shifted source field in node events to base event message to apply to all events, updated add_link/delete_link rpc calls to broadcast events, updated pygui to handle these events --- daemon/core/api/grpc/events.py | 132 ++++++++++++++------------ daemon/core/api/grpc/grpcutils.py | 18 ++++ daemon/core/api/grpc/server.py | 30 +++++- daemon/core/emulator/data.py | 1 + daemon/core/emulator/session.py | 3 +- daemon/core/gui/coreclient.py | 33 +++++-- daemon/core/gui/graph/graph.py | 69 ++++++++------ daemon/proto/core/api/grpc/core.proto | 4 +- 8 files changed, 185 insertions(+), 105 deletions(-) diff --git a/daemon/core/api/grpc/events.py b/daemon/core/api/grpc/events.py index 75f9eb2e..5c873a43 100644 --- a/daemon/core/api/grpc/events.py +++ b/daemon/core/api/grpc/events.py @@ -1,6 +1,6 @@ import logging from queue import Empty, Queue -from typing import Iterable +from typing import Iterable, Optional from core.api.grpc import core_pb2 from core.api.grpc.grpcutils import convert_link @@ -15,7 +15,7 @@ from core.emulator.data import ( from core.emulator.session import Session -def handle_node_event(node_data: NodeData) -> core_pb2.NodeEvent: +def handle_node_event(node_data: NodeData) -> core_pb2.Event: """ Handle node event when there is a node event @@ -36,98 +36,105 @@ def handle_node_event(node_data: NodeData) -> core_pb2.NodeEvent: geo=geo, services=services, ) - return core_pb2.NodeEvent(node=node_proto, source=node_data.source) + node_event = core_pb2.NodeEvent(node=node_proto) + return core_pb2.Event(node_event=node_event, source=node_data.source) -def handle_link_event(event: LinkData) -> core_pb2.LinkEvent: +def handle_link_event(link_data: LinkData) -> core_pb2.Event: """ Handle link event when there is a link event - :param event: link data + :param link_data: link data :return: link event that has message type and link information """ - link = convert_link(event) - return core_pb2.LinkEvent(message_type=event.message_type.value, link=link) + link = convert_link(link_data) + message_type = link_data.message_type.value + link_event = core_pb2.LinkEvent(message_type=message_type, link=link) + return core_pb2.Event(link_event=link_event, source=link_data.source) -def handle_session_event(event: EventData) -> core_pb2.SessionEvent: +def handle_session_event(event_data: EventData) -> core_pb2.Event: """ Handle session event when there is a session event - :param event: event data + :param event_data: event data :return: session event """ - event_time = event.time + event_time = event_data.time if event_time is not None: event_time = float(event_time) - return core_pb2.SessionEvent( - node_id=event.node, - event=event.event_type.value, - name=event.name, - data=event.data, + session_event = core_pb2.SessionEvent( + node_id=event_data.node, + event=event_data.event_type.value, + name=event_data.name, + data=event_data.data, time=event_time, ) + return core_pb2.Event(session_event=session_event) -def handle_config_event(event: ConfigData) -> core_pb2.ConfigEvent: +def handle_config_event(config_data: ConfigData) -> core_pb2.Event: """ Handle configuration event when there is configuration event - :param event: configuration data + :param config_data: configuration data :return: configuration event """ - return core_pb2.ConfigEvent( - message_type=event.message_type, - node_id=event.node, - object=event.object, - type=event.type, - captions=event.captions, - bitmap=event.bitmap, - data_values=event.data_values, - possible_values=event.possible_values, - groups=event.groups, - iface_id=event.iface_id, - network_id=event.network_id, - opaque=event.opaque, - data_types=event.data_types, + config_event = core_pb2.ConfigEvent( + message_type=config_data.message_type, + node_id=config_data.node, + object=config_data.object, + type=config_data.type, + captions=config_data.captions, + bitmap=config_data.bitmap, + data_values=config_data.data_values, + possible_values=config_data.possible_values, + groups=config_data.groups, + iface_id=config_data.iface_id, + network_id=config_data.network_id, + opaque=config_data.opaque, + data_types=config_data.data_types, ) + return core_pb2.Event(config_event=config_event) -def handle_exception_event(event: ExceptionData) -> core_pb2.ExceptionEvent: +def handle_exception_event(exception_data: ExceptionData) -> core_pb2.Event: """ Handle exception event when there is exception event - :param event: exception data + :param exception_data: exception data :return: exception event """ - return core_pb2.ExceptionEvent( - node_id=event.node, - level=event.level.value, - source=event.source, - date=event.date, - text=event.text, - opaque=event.opaque, + exception_event = core_pb2.ExceptionEvent( + node_id=exception_data.node, + level=exception_data.level.value, + source=exception_data.source, + date=exception_data.date, + text=exception_data.text, + opaque=exception_data.opaque, ) + return core_pb2.Event(exception_event=exception_event) -def handle_file_event(event: FileData) -> core_pb2.FileEvent: +def handle_file_event(file_data: FileData) -> core_pb2.Event: """ Handle file event - :param event: file data + :param file_data: file data :return: file event """ - return core_pb2.FileEvent( - message_type=event.message_type.value, - node_id=event.node, - name=event.name, - mode=event.mode, - number=event.number, - type=event.type, - source=event.source, - data=event.data, - compressed_data=event.compressed_data, + file_event = core_pb2.FileEvent( + message_type=file_data.message_type.value, + node_id=file_data.node, + name=file_data.name, + mode=file_data.mode, + number=file_data.number, + type=file_data.type, + source=file_data.source, + data=file_data.data, + compressed_data=file_data.compressed_data, ) + return core_pb2.Event(file_event=file_event) class EventStreamer: @@ -168,32 +175,33 @@ class EventStreamer: if core_pb2.EventType.SESSION in self.event_types: self.session.event_handlers.append(self.queue.put) - def process(self) -> core_pb2.Event: + def process(self) -> Optional[core_pb2.Event]: """ Process the next event in the queue. :return: grpc event, or None when invalid event or queue timeout """ - event = core_pb2.Event(session_id=self.session.id) + event = None try: data = self.queue.get(timeout=1) if isinstance(data, NodeData): - event.node_event.CopyFrom(handle_node_event(data)) + event = handle_node_event(data) elif isinstance(data, LinkData): - event.link_event.CopyFrom(handle_link_event(data)) + event = handle_link_event(data) elif isinstance(data, EventData): - event.session_event.CopyFrom(handle_session_event(data)) + event = handle_session_event(data) elif isinstance(data, ConfigData): - event.config_event.CopyFrom(handle_config_event(data)) + event = handle_config_event(data) elif isinstance(data, ExceptionData): - event.exception_event.CopyFrom(handle_exception_event(data)) + event = handle_exception_event(data) elif isinstance(data, FileData): - event.file_event.CopyFrom(handle_file_event(data)) + event = handle_file_event(data) else: logging.error("unknown event: %s", data) - event = None except Empty: - event = None + pass + if event: + event.session_id = self.session.id return event def remove_handlers(self) -> None: diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 8df545cd..ed40a75b 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -435,6 +435,24 @@ def get_service_configuration(service: CoreService) -> NodeServiceData: ) +def iface_to_data(iface: CoreInterface) -> InterfaceData: + ip4 = iface.get_ip4() + ip4_addr = str(ip4.ip) if ip4 else None + ip4_mask = ip4.prefixlen if ip4 else None + ip6 = iface.get_ip6() + ip6_addr = str(ip6.ip) if ip6 else None + ip6_mask = ip6.prefixlen if ip6 else None + return InterfaceData( + id=iface.node_id, + name=iface.name, + mac=str(iface.mac), + ip4=ip4_addr, + ip4_mask=ip4_mask, + ip6=ip6_addr, + ip6_mask=ip6_mask, + ) + + def iface_to_proto(iface: CoreInterface) -> core_pb2.Interface: """ Convenience for converting a core interface to the protobuf representation. diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 1964b6e8..2883103d 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -108,7 +108,7 @@ from core.api.grpc.wlan_pb2 import ( WlanLinkResponse, ) from core.emulator.coreemu import CoreEmu -from core.emulator.data import LinkData, LinkOptions, NodeOptions +from core.emulator.data import InterfaceData, LinkData, LinkOptions, NodeOptions from core.emulator.enumerations import EventTypes, LinkTypes, MessageFlags from core.emulator.session import NT, Session from core.errors import CoreCommandError, CoreError @@ -853,6 +853,22 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): node1_iface, node2_iface = session.add_link( node1_id, node2_id, iface1_data, iface2_data, options, link_type ) + iface1_data = None + if node1_iface: + iface1_data = grpcutils.iface_to_data(node1_iface) + iface2_data = None + if node2_iface: + iface2_data = grpcutils.iface_to_data(node2_iface) + source = request.source if request.source else None + link_data = LinkData( + message_type=MessageFlags.ADD, + node1_id=node1_id, + node2_id=node2_id, + iface1=iface1_data, + iface2=iface2_data, + source=source, + ) + session.broadcast_link(link_data) iface1_proto = None iface2_proto = None if node1_iface: @@ -912,6 +928,18 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): iface1_id = request.iface1_id iface2_id = request.iface2_id session.delete_link(node1_id, node2_id, iface1_id, iface2_id) + iface1 = InterfaceData(id=iface1_id) + iface2 = InterfaceData(id=iface2_id) + source = request.source if request.source else None + link_data = LinkData( + message_type=MessageFlags.DELETE, + node1_id=node1_id, + node2_id=node2_id, + iface1=iface1, + iface2=iface2, + source=source, + ) + session.broadcast_link(link_data) return core_pb2.DeleteLinkResponse(result=True) def GetHooks( diff --git a/daemon/core/emulator/data.py b/daemon/core/emulator/data.py index 22d10d2d..15d922a9 100644 --- a/daemon/core/emulator/data.py +++ b/daemon/core/emulator/data.py @@ -190,6 +190,7 @@ class LinkData: iface2: InterfaceData = None options: LinkOptions = LinkOptions() color: str = None + source: str = None class IpPrefixes: diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index c2573578..d2f64dde 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -833,11 +833,12 @@ class Session: for handler in self.config_handlers: handler(config_data) - def broadcast_link(self, link_data: LinkData) -> None: + def broadcast_link(self, link_data: LinkData, source: str = None) -> None: """ Handle link data that should be provided to link handlers. :param link_data: link data to send out + :param source: source of broadcast, None by default :return: nothing """ for handler in self.link_handlers: diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index d35f62e5..a5b96e17 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -148,6 +148,8 @@ class CoreClient: self.custom_observers[observer.name] = observer def handle_events(self, event: Event) -> None: + if event.source == GUI_SOURCE: + return if event.session_id != self.session_id: logging.warning( "ignoring event session(%s) current(%s)", @@ -193,19 +195,32 @@ class CoreClient: return canvas_node1 = self.canvas_nodes[node1_id] canvas_node2 = self.canvas_nodes[node2_id] - if event.message_type == MessageType.ADD: - self.app.canvas.add_wireless_edge(canvas_node1, canvas_node2, event.link) - elif event.message_type == MessageType.DELETE: - self.app.canvas.delete_wireless_edge(canvas_node1, canvas_node2, event.link) - elif event.message_type == MessageType.NONE: - self.app.canvas.update_wireless_edge(canvas_node1, canvas_node2, event.link) + if event.link.type == LinkType.WIRELESS: + if event.message_type == MessageType.ADD: + self.app.canvas.add_wireless_edge( + canvas_node1, canvas_node2, event.link + ) + elif event.message_type == MessageType.DELETE: + self.app.canvas.delete_wireless_edge( + canvas_node1, canvas_node2, event.link + ) + elif event.message_type == MessageType.NONE: + self.app.canvas.update_wireless_edge( + canvas_node1, canvas_node2, event.link + ) + else: + logging.warning("unknown link event: %s", event) else: - logging.warning("unknown link event: %s", event) + if event.message_type == MessageType.ADD: + self.app.canvas.add_wired_edge(canvas_node1, canvas_node2, event.link) + self.app.canvas.organize() + elif event.message_type == MessageType.DELETE: + self.app.canvas.delete_wired_edge(canvas_node1, canvas_node2) + else: + logging.warning("unknown link event: %s", event) def handle_node_event(self, event: NodeEvent) -> None: logging.debug("node event: %s", event) - if event.source == GUI_SOURCE: - return node_id = event.node.id x = event.node.position.x y = event.node.position.y diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index a3520d22..4e0358a5 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -225,6 +225,43 @@ class CanvasGraph(tk.Canvas): self.tag_lower(tags.GRIDLINE) self.tag_lower(self.rect) + def add_wired_edge(self, src: CanvasNode, dst: CanvasNode, link: Link) -> None: + token = create_edge_token(src.id, dst.id) + if token in self.edges and link.options.unidirectional: + edge = self.edges[token] + edge.asymmetric_link = link + elif token not in self.edges: + node1 = src.core_node + node2 = dst.core_node + src_pos = (node1.position.x, node1.position.y) + dst_pos = (node2.position.x, node2.position.y) + edge = CanvasEdge(self, src.id, src_pos, dst_pos) + edge.token = token + edge.dst = dst.id + edge.set_link(link) + edge.check_wireless() + src.edges.add(edge) + dst.edges.add(edge) + self.edges[edge.token] = edge + self.core.links[edge.token] = edge + if link.HasField("iface1"): + iface1 = link.iface1 + self.core.iface_to_edge[(node1.id, iface1.id)] = token + src.ifaces[iface1.id] = iface1 + edge.src_iface = iface1 + if link.HasField("iface2"): + iface2 = link.iface2 + self.core.iface_to_edge[(node2.id, iface2.id)] = edge.token + dst.ifaces[iface2.id] = iface2 + edge.dst_iface = iface2 + + def delete_wired_edge(self, src: CanvasNode, dst: CanvasNode) -> None: + token = create_edge_token(src.id, dst.id) + edge = self.edges.get(token) + if not edge: + return + self.delete_edge(edge) + def add_wireless_edge(self, src: CanvasNode, dst: CanvasNode, link: Link) -> None: network_id = link.network_id if link.network_id else None token = create_edge_token(src.id, dst.id, network_id) @@ -297,41 +334,11 @@ class CanvasGraph(tk.Canvas): for link in session.links: logging.debug("drawing link: %s", link) canvas_node1 = self.core.canvas_nodes[link.node1_id] - node1 = canvas_node1.core_node canvas_node2 = self.core.canvas_nodes[link.node2_id] - node2 = canvas_node2.core_node - token = create_edge_token(canvas_node1.id, canvas_node2.id) - if link.type == LinkType.WIRELESS: self.add_wireless_edge(canvas_node1, canvas_node2, link) else: - if token not in self.edges: - src_pos = (node1.position.x, node1.position.y) - dst_pos = (node2.position.x, node2.position.y) - edge = CanvasEdge(self, canvas_node1.id, src_pos, dst_pos) - edge.token = token - edge.dst = canvas_node2.id - edge.set_link(link) - edge.check_wireless() - canvas_node1.edges.add(edge) - canvas_node2.edges.add(edge) - self.edges[edge.token] = edge - self.core.links[edge.token] = edge - if link.HasField("iface1"): - iface1 = link.iface1 - self.core.iface_to_edge[(node1.id, iface1.id)] = token - canvas_node1.ifaces[iface1.id] = iface1 - edge.src_iface = iface1 - if link.HasField("iface2"): - iface2 = link.iface2 - self.core.iface_to_edge[(node2.id, iface2.id)] = edge.token - canvas_node2.ifaces[iface2.id] = iface2 - edge.dst_iface = iface2 - elif link.options.unidirectional: - edge = self.edges[token] - edge.asymmetric_link = link - else: - logging.error("duplicate link received: %s", link) + self.add_wired_edge(canvas_node1, canvas_node2, link) def stopped_session(self) -> None: # clear wireless edges diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 46e1da91..6b1b304c 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -343,11 +343,11 @@ message Event { FileEvent file_event = 6; } int32 session_id = 7; + string source = 8; } message NodeEvent { Node node = 1; - string source = 2; } message LinkEvent { @@ -488,6 +488,7 @@ message GetNodeLinksResponse { message AddLinkRequest { int32 session_id = 1; Link link = 2; + string source = 3; } message AddLinkResponse { @@ -515,6 +516,7 @@ message DeleteLinkRequest { int32 node2_id = 3; int32 iface1_id = 4; int32 iface2_id = 5; + string source = 6; } message DeleteLinkResponse { From f921fa45c549ea95932a28497f12094c7a18d92a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 26 Jun 2020 14:44:13 -0700 Subject: [PATCH 210/929] grpc: updated client methods to allow passing source for add_link/delete_link, None by default --- daemon/core/api/grpc/client.py | 9 ++++++++- daemon/core/emulator/session.py | 3 +-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 5aa6713d..939d7eef 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -614,6 +614,7 @@ class CoreGrpcClient: iface1: core_pb2.Interface = None, iface2: core_pb2.Interface = None, options: core_pb2.LinkOptions = None, + source: str = None, ) -> core_pb2.AddLinkResponse: """ Add a link between nodes. @@ -624,6 +625,7 @@ class CoreGrpcClient: :param iface1: node one interface data :param iface2: node two interface data :param options: options for link (jitter, bandwidth, etc) + :param source: application source adding link :return: response with result of success or failure :raises grpc.RpcError: when session or one of the nodes don't exist """ @@ -635,7 +637,9 @@ class CoreGrpcClient: iface2=iface2, options=options, ) - request = core_pb2.AddLinkRequest(session_id=session_id, link=link) + request = core_pb2.AddLinkRequest( + session_id=session_id, link=link, source=source + ) return self.stub.AddLink(request) def edit_link( @@ -676,6 +680,7 @@ class CoreGrpcClient: node2_id: int, iface1_id: int = None, iface2_id: int = None, + source: str = None, ) -> core_pb2.DeleteLinkResponse: """ Delete a link between nodes. @@ -685,6 +690,7 @@ class CoreGrpcClient: :param node2_id: node two id :param iface1_id: node one interface id :param iface2_id: node two interface id + :param source: application source deleting link :return: response with result of success or failure :raises grpc.RpcError: when session doesn't exist """ @@ -694,6 +700,7 @@ class CoreGrpcClient: node2_id=node2_id, iface1_id=iface1_id, iface2_id=iface2_id, + source=source, ) return self.stub.DeleteLink(request) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index d2f64dde..c2573578 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -833,12 +833,11 @@ class Session: for handler in self.config_handlers: handler(config_data) - def broadcast_link(self, link_data: LinkData, source: str = None) -> None: + def broadcast_link(self, link_data: LinkData) -> None: """ Handle link data that should be provided to link handlers. :param link_data: link data to send out - :param source: source of broadcast, None by default :return: nothing """ for handler in self.link_handlers: From f4a3fe6b7b0ee2dc060cb8758cc4d1c4414c454e Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 26 Jun 2020 15:14:30 -0700 Subject: [PATCH 211/929] grpc/pygui: edit_link will now broadcast link changes, pygui now handles receiving this data --- daemon/core/api/grpc/client.py | 9 ++++++--- daemon/core/api/grpc/server.py | 14 ++++++++++++++ daemon/core/gui/coreclient.py | 4 ++++ daemon/core/gui/graph/graph.py | 12 +++++++++--- daemon/proto/core/api/grpc/core.proto | 1 + 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 939d7eef..e73b9fc2 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -509,7 +509,7 @@ class CoreGrpcClient: :param node_id: node id :param position: position to set node to :param icon: path to icon for gui to use for node - :param source: application source editing node + :param source: application source :param geo: lon,lat,alt location for node :return: response with result of success or failure :raises grpc.RpcError: when session or node doesn't exist @@ -625,7 +625,7 @@ class CoreGrpcClient: :param iface1: node one interface data :param iface2: node two interface data :param options: options for link (jitter, bandwidth, etc) - :param source: application source adding link + :param source: application source :return: response with result of success or failure :raises grpc.RpcError: when session or one of the nodes don't exist """ @@ -650,6 +650,7 @@ class CoreGrpcClient: options: core_pb2.LinkOptions, iface1_id: int = None, iface2_id: int = None, + source: str = None, ) -> core_pb2.EditLinkResponse: """ Edit a link between nodes. @@ -660,6 +661,7 @@ class CoreGrpcClient: :param options: options for link (jitter, bandwidth, etc) :param iface1_id: node one interface id :param iface2_id: node two interface id + :param source: application source :return: response with result of success or failure :raises grpc.RpcError: when session or one of the nodes don't exist """ @@ -670,6 +672,7 @@ class CoreGrpcClient: options=options, iface1_id=iface1_id, iface2_id=iface2_id, + source=source, ) return self.stub.EditLink(request) @@ -690,7 +693,7 @@ class CoreGrpcClient: :param node2_id: node two id :param iface1_id: node one interface id :param iface2_id: node two interface id - :param source: application source deleting link + :param source: application source :return: response with result of success or failure :raises grpc.RpcError: when session doesn't exist """ diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 2883103d..65c7281e 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -866,6 +866,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): node2_id=node2_id, iface1=iface1_data, iface2=iface2_data, + options=options, source=source, ) session.broadcast_link(link_data) @@ -909,6 +910,19 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): key=options_proto.key, ) session.update_link(node1_id, node2_id, iface1_id, iface2_id, options) + iface1 = InterfaceData(id=iface1_id) + iface2 = InterfaceData(id=iface2_id) + source = request.source if request.source else None + link_data = LinkData( + message_type=MessageFlags.NONE, + node1_id=node1_id, + node2_id=node2_id, + iface1=iface1, + iface2=iface2, + options=options, + source=source, + ) + session.broadcast_link(link_data) return core_pb2.EditLinkResponse(result=True) def DeleteLink( diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index a5b96e17..b29c044e 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -216,6 +216,10 @@ class CoreClient: self.app.canvas.organize() elif event.message_type == MessageType.DELETE: self.app.canvas.delete_wired_edge(canvas_node1, canvas_node2) + elif event.message_type == MessageType.NONE: + self.app.canvas.update_wired_edge( + canvas_node1, canvas_node2, event.link + ) else: logging.warning("unknown link event: %s", event) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 4e0358a5..436de383 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -262,6 +262,13 @@ class CanvasGraph(tk.Canvas): return self.delete_edge(edge) + def update_wired_edge(self, src: CanvasNode, dst: CanvasNode, link: Link) -> None: + token = create_edge_token(src.id, dst.id) + edge = self.edges.get(token) + if not edge: + return + edge.link.options.CopyFrom(link.options) + def add_wireless_edge(self, src: CanvasNode, dst: CanvasNode, link: Link) -> None: network_id = link.network_id if link.network_id else None token = create_edge_token(src.id, dst.id, network_id) @@ -350,9 +357,8 @@ class CanvasGraph(tk.Canvas): dst_node.wireless_edges.remove(edge) self.wireless_edges.clear() - # clear all middle edge labels - for edge in self.edges.values(): - edge.reset() + # clear throughputs + self.clear_throughputs() def canvas_xy(self, event: tk.Event) -> Tuple[float, float]: """ diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 6b1b304c..7d7592b1 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -504,6 +504,7 @@ message EditLinkRequest { int32 iface1_id = 4; int32 iface2_id = 5; LinkOptions options = 6; + string source = 7; } message EditLinkResponse { From e79645013be7733fabb9e0aaa68ce6873805e89b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 26 Jun 2020 21:45:29 -0700 Subject: [PATCH 212/929] grpc/pygui: updated delete_node to use the source, updated pygui to support delete node events --- daemon/core/api/grpc/client.py | 9 +++++++-- daemon/core/api/grpc/events.py | 3 ++- daemon/core/api/grpc/server.py | 9 ++++++++- daemon/core/gui/coreclient.py | 13 ++++++++++--- daemon/core/gui/graph/node.py | 4 ++-- daemon/proto/core/api/grpc/core.proto | 2 ++ 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index e73b9fc2..2740e770 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -536,16 +536,21 @@ class CoreGrpcClient: """ return self.stub.MoveNodes(move_iterator) - def delete_node(self, session_id: int, node_id: int) -> core_pb2.DeleteNodeResponse: + def delete_node( + self, session_id: int, node_id: int, source: str = None + ) -> core_pb2.DeleteNodeResponse: """ Delete node from session. :param session_id: session id :param node_id: node id + :param source: application source :return: response with result of success or failure :raises grpc.RpcError: when session doesn't exist """ - request = core_pb2.DeleteNodeRequest(session_id=session_id, node_id=node_id) + request = core_pb2.DeleteNodeRequest( + session_id=session_id, node_id=node_id, source=source + ) return self.stub.DeleteNode(request) def node_command( diff --git a/daemon/core/api/grpc/events.py b/daemon/core/api/grpc/events.py index 5c873a43..fb6eaff8 100644 --- a/daemon/core/api/grpc/events.py +++ b/daemon/core/api/grpc/events.py @@ -36,7 +36,8 @@ def handle_node_event(node_data: NodeData) -> core_pb2.Event: geo=geo, services=services, ) - node_event = core_pb2.NodeEvent(node=node_proto) + message_type = node_data.message_type.value + node_event = core_pb2.NodeEvent(message_type=message_type, node=node_proto) return core_pb2.Event(node_event=node_event, source=node_data.source) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 65c7281e..8851116d 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -775,7 +775,14 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("delete node: %s", request) session = self.get_session(request.session_id, context) - result = session.delete_node(request.node_id) + result = False + try: + node = self.get_node(session, request.node_id, context, NodeBase) + result = session.delete_node(node.id) + source = request.source if request.source else None + session.broadcast_node(node, MessageFlags.DELETE, source) + except grpc.RpcError: + pass return core_pb2.DeleteNodeResponse(result=result) def NodeCommand( diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index b29c044e..cf870cf2 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -226,10 +226,17 @@ class CoreClient: def handle_node_event(self, event: NodeEvent) -> None: logging.debug("node event: %s", event) node_id = event.node.id - x = event.node.position.x - y = event.node.position.y canvas_node = self.canvas_nodes[node_id] - canvas_node.move(x, y) + if event.message_type == MessageType.NONE: + x = event.node.position.x + y = event.node.position.y + canvas_node.move(x, y) + elif event.message_type == MessageType.DELETE: + self.app.canvas.clear_selection() + self.app.canvas.select_object(canvas_node.id) + self.app.canvas.delete_selected_objects() + else: + logging.warning("unknown node event: %s", event) def enable_throughputs(self) -> None: self.handling_throughputs = self.client.throughputs( diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index d98c4e48..6e8185b8 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -271,12 +271,12 @@ class CanvasNode: def canvas_delete(self) -> None: self.canvas.clear_selection() - self.canvas.selection[self.id] = self + self.canvas.select_object(self.id) self.canvas.delete_selected_objects() def canvas_copy(self) -> None: self.canvas.clear_selection() - self.canvas.selection[self.id] = self + self.canvas.select_object(self.id) self.canvas.copy() def show_config(self) -> None: diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 7d7592b1..22cd9c54 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -348,6 +348,7 @@ message Event { message NodeEvent { Node node = 1; + MessageType.Enum message_type = 2; } message LinkEvent { @@ -435,6 +436,7 @@ message EditNodeResponse { message DeleteNodeRequest { int32 session_id = 1; int32 node_id = 2; + string source = 3; } message DeleteNodeResponse { From 5eae67aac59fb6e93259750517588901acb651bb Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 26 Jun 2020 22:11:36 -0700 Subject: [PATCH 213/929] grpc/pygui: updated add_node source support, updated pygui to handle add_node events --- daemon/core/api/grpc/client.py | 7 +++-- daemon/core/api/grpc/server.py | 2 ++ daemon/core/gui/coreclient.py | 6 +++-- daemon/core/gui/graph/graph.py | 39 +++++++++++++++++---------- daemon/proto/core/api/grpc/core.proto | 1 + 5 files changed, 37 insertions(+), 18 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 2740e770..82164fe3 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -468,17 +468,20 @@ class CoreGrpcClient: return stream def add_node( - self, session_id: int, node: core_pb2.Node + self, session_id: int, node: core_pb2.Node, source: str = None ) -> core_pb2.AddNodeResponse: """ Add node to session. :param session_id: session id :param node: node to add + :param source: source application :return: response with node id :raises grpc.RpcError: when session doesn't exist """ - request = core_pb2.AddNodeRequest(session_id=session_id, node=node) + request = core_pb2.AddNodeRequest( + session_id=session_id, node=node, source=source + ) return self.stub.AddNode(request) def get_node(self, session_id: int, node_id: int) -> core_pb2.GetNodeResponse: diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 8851116d..27702629 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -668,6 +668,8 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): _type, _id, options = grpcutils.add_node_data(request.node) _class = session.get_node_class(_type) node = session.add_node(_class, _id, options) + source = request.source if request.source else None + session.broadcast_node(node, MessageFlags.ADD, source) return core_pb2.AddNodeResponse(node_id=node.id) def GetNode( diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index cf870cf2..cf331676 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -225,16 +225,18 @@ class CoreClient: def handle_node_event(self, event: NodeEvent) -> None: logging.debug("node event: %s", event) - node_id = event.node.id - canvas_node = self.canvas_nodes[node_id] if event.message_type == MessageType.NONE: + canvas_node = self.canvas_nodes[event.node.id] x = event.node.position.x y = event.node.position.y canvas_node.move(x, y) elif event.message_type == MessageType.DELETE: + canvas_node = self.canvas_nodes[event.node.id] self.app.canvas.clear_selection() self.app.canvas.select_object(canvas_node.id) self.app.canvas.delete_selected_objects() + elif event.message_type == MessageType.ADD: + self.app.canvas.add_core_node(event.node) else: logging.warning("unknown node event: %s", event) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 436de383..9cb3b109 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -7,7 +7,14 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple from PIL import Image from PIL.ImageTk import PhotoImage -from core.api.grpc.core_pb2 import Interface, Link, LinkType, Session, ThroughputsEvent +from core.api.grpc.core_pb2 import ( + Interface, + Link, + LinkType, + Node, + Session, + ThroughputsEvent, +) from core.gui.dialogs.shapemod import ShapeDialog from core.gui.graph import tags from core.gui.graph.edges import ( @@ -315,29 +322,33 @@ class CanvasGraph(tk.Canvas): edge = self.wireless_edges[token] edge.middle_label_text(link.label) + def add_core_node(self, core_node: Node) -> None: + if core_node.id in self.core.canvas_nodes: + logging.error("core node already exists: %s", core_node) + return + logging.debug("adding node %s", core_node) + # if the gui can't find node's image, default to the "edit-node" image + image = NodeUtils.node_image(core_node, self.app.guiconfig, self.app.app_scale) + if not image: + image = self.app.get_icon(ImageEnum.EDITNODE, ICON_SIZE) + x = core_node.position.x + y = core_node.position.y + node = CanvasNode(self.app, x, y, core_node, image) + self.nodes[node.id] = node + self.core.canvas_nodes[core_node.id] = node + def draw_session(self, session: Session) -> None: """ Draw existing session. """ # draw existing nodes for core_node in session.nodes: - logging.debug("drawing node %s", core_node) # peer to peer node is not drawn on the GUI if NodeUtils.is_ignore_node(core_node.type): continue - image = NodeUtils.node_image( - core_node, self.app.guiconfig, self.app.app_scale - ) - # if the gui can't find node's image, default to the "edit-node" image - if not image: - image = self.app.get_icon(ImageEnum.EDITNODE, ICON_SIZE) - x = core_node.position.x - y = core_node.position.y - node = CanvasNode(self.app, x, y, core_node, image) - self.nodes[node.id] = node - self.core.canvas_nodes[core_node.id] = node + self.add_core_node(core_node) - # draw existing links + # draw existing links for link in session.links: logging.debug("drawing link: %s", link) canvas_node1 = self.core.canvas_nodes[link.node1_id] diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 22cd9c54..8112c9d1 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -404,6 +404,7 @@ message FileEvent { message AddNodeRequest { int32 session_id = 1; Node node = 2; + string source = 3; } message AddNodeResponse { From c8daeb02d82db91914e13753db3d785ad145df1a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 26 Jun 2020 22:29:17 -0700 Subject: [PATCH 214/929] grpc: fixed issue with not catching error in delete_node from broadcast changes --- daemon/core/api/grpc/server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 27702629..e8469177 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -778,13 +778,11 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): logging.debug("delete node: %s", request) session = self.get_session(request.session_id, context) result = False - try: + if request.node_id in session.nodes: node = self.get_node(session, request.node_id, context, NodeBase) result = session.delete_node(node.id) source = request.source if request.source else None session.broadcast_node(node, MessageFlags.DELETE, source) - except grpc.RpcError: - pass return core_pb2.DeleteNodeResponse(result=result) def NodeCommand( From 59e7395a4f30cccbaaac0e693922c11333bd7f5a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 29 Jun 2020 23:00:33 -0700 Subject: [PATCH 215/929] initial addition of core-cli script that can be used to run commands and query information with sessions using grpc, similar in concept to coresendmsg --- daemon/scripts/core-cli | 447 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100755 daemon/scripts/core-cli diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli new file mode 100755 index 00000000..ca9011b5 --- /dev/null +++ b/daemon/scripts/core-cli @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +import sys +from argparse import ( + ArgumentDefaultsHelpFormatter, + ArgumentParser, + ArgumentTypeError, + Namespace, + _SubParsersAction, +) +from typing import Tuple + +import netaddr +from google.protobuf.json_format import MessageToJson + +from core.api.grpc.client import CoreGrpcClient +from core.api.grpc.core_pb2 import ( + Geo, + Interface, + LinkOptions, + Node, + NodeType, + Position, + SessionState, +) + +NODE_TYPES = [k for k, v in NodeType.Enum.items() if v != NodeType.PEER_TO_PEER] + + +def mac_type(value: str) -> str: + if not netaddr.valid_mac(value): + raise ArgumentTypeError("invalid mac address") + return value + + +def ip4_type(value: str) -> str: + if not netaddr.valid_ipv4(value): + raise ArgumentTypeError("invalid ip4 address") + return value + + +def ip6_type(value: str) -> str: + if not netaddr.valid_ipv6(value): + raise ArgumentTypeError("invalid ip6 address") + return value + + +def position_type(value: str) -> Tuple[float, float]: + error = "invalid position, must be in the format: float,float" + try: + values = [float(x) for x in value.split(",")] + except ValueError: + raise ArgumentTypeError(error) + if len(values) != 2: + raise ArgumentTypeError(error) + x, y = values + return x, y + + +def geo_type(value: str) -> Tuple[float, float, float]: + error = "invalid geo, must be in the format: float,float,float" + try: + values = [float(x) for x in value.split(",")] + except ValueError: + raise ArgumentTypeError(error) + if len(values) != 3: + raise ArgumentTypeError(error) + lon, lat, alt = values + return lon, lat, alt + + +def get_current_session() -> int: + core = CoreGrpcClient() + with core.context_connect(): + response = core.get_sessions() + if not response.sessions: + print("no current session to interact with") + sys.exit(1) + return response.sessions[0].id + + +def print_interface_header() -> None: + print("ID | MAC Address | IP4 Address | IP6 Address") + + +def print_interface(iface: Interface) -> None: + iface_ip4 = f"{iface.ip4}/{iface.ip4_mask}" if iface.ip4 else None + iface_ip6 = f"{iface.ip6}/{iface.ip6_mask}" if iface.ip6 else None + print(f"{iface.id:<3} | {iface.mac:<11} | {iface_ip4:<18} | {iface_ip6}") + + +def query_sessions(args: Namespace) -> None: + core = CoreGrpcClient() + with core.context_connect(): + response = core.get_sessions() + if args.json: + json = MessageToJson(response, preserving_proto_field_name=True) + print(json) + else: + print("Session ID | Session State | Nodes") + for s in response.sessions: + state = SessionState.Enum.Name(s.state) + print(f"{s.id:<10} | {state:<13} | {s.nodes}") + + +def query_session(args: Namespace) -> None: + core = CoreGrpcClient() + with core.context_connect(): + response = core.get_session(args.id) + if args.json: + json = MessageToJson(response, preserving_proto_field_name=True) + print(json) + else: + print("Nodes") + print("Node ID | Node Name | Node Type") + names = {} + for node in response.session.nodes: + names[node.id] = node.name + node_type = NodeType.Enum.Name(node.type) + print(f"{node.id:<7} | {node.name:<9} | {node_type}") + + print("\nLinks") + for link in response.session.links: + n1 = names[link.node1_id] + n2 = names[link.node2_id] + print(f"Node | ", end="") + print_interface_header() + if link.HasField("iface1"): + print(f"{n1:<6} | ", end="") + print_interface(link.iface1) + if link.HasField("iface2"): + print(f"{n2:<6} | ", end="") + print_interface(link.iface2) + print() + + +def query_node(args: Namespace) -> None: + core = CoreGrpcClient() + with core.context_connect(): + response = core.get_node(args.id, args.node) + if args.json: + json = MessageToJson(response, preserving_proto_field_name=True) + print(json) + else: + node = response.node + node_type = NodeType.Enum.Name(node.type) + print("ID | Name | Type") + print(f"{node.id:<4} | {node.name:<7} | {node_type}") + print("Interfaces") + print_interface_header() + for iface in response.ifaces: + print_interface(iface) + + +def add_node(args: Namespace) -> None: + session_id = get_current_session() + node_type = NodeType.Enum.Value(args.type) + pos = None + if args.pos: + x, y = args.pos + pos = Position(x=x, y=y) + geo = None + if args.geo: + lon, lat, alt = args.geo + geo = Geo(lon=lon, lat=lat, alt=alt) + core = CoreGrpcClient() + with core.context_connect(): + node = Node( + id=args.id, + name=args.name, + type=node_type, + model=args.model, + emane=args.emane, + icon=args.icon, + image=args.image, + position=pos, + geo=geo, + ) + response = core.add_node(session_id, node) + if args.json: + json = MessageToJson(response, preserving_proto_field_name=True) + print(json) + else: + print(f"created node: {response.node_id}") + + +def edit_node(args: Namespace) -> None: + session_id = get_current_session() + pos = None + if args.pos: + x, y = args.pos + pos = Position(x=x, y=y) + geo = None + if args.geo: + lon, lat, alt = args.geo + geo = Geo(lon=lon, lat=lat, alt=alt) + core = CoreGrpcClient() + with core.context_connect(): + response = core.edit_node(session_id, args.id, pos, args.icon, geo=geo) + if args.json: + json = MessageToJson(response, preserving_proto_field_name=True) + print(json) + else: + print(f"edit node: {response.result}") + + +def delete_node(args: Namespace) -> None: + session_id = get_current_session() + core = CoreGrpcClient() + with core.context_connect(): + response = core.delete_node(session_id, args.id) + if args.json: + json = MessageToJson(response, preserving_proto_field_name=True) + print(json) + else: + print(f"deleted node: {response.result}") + + +def add_link(args: Namespace) -> None: + session_id = get_current_session() + iface1 = None + if args.iface1_id is not None: + iface1 = Interface( + id=args.iface1_id, + mac=args.iface1_mac, + ip4=args.iface1_ip4, + ip4_mask=args.iface1_ip4_mask, + ip6=args.iface1_ip4, + ip6_mask=args.iface1_ip6_mask, + ) + iface2 = None + if args.iface2_id is not None: + iface2 = Interface( + id=args.iface2_id, + mac=args.iface2_mac, + ip4=args.iface2_ip4, + ip4_mask=args.iface2_ip4_mask, + ip6=args.iface2_ip4, + ip6_mask=args.iface2_ip6_mask, + ) + options = LinkOptions( + bandwidth=args.bandwidth, + loss=args.loss, + jitter=args.jitter, + delay=args.delay, + dup=args.dup, + unidirectional=args.uni, + ) + core = CoreGrpcClient() + with core.context_connect(): + response = core.add_link(session_id, args.node1, args.node2, iface1, iface2, options) + if args.json: + json = MessageToJson(response, preserving_proto_field_name=True) + print(json) + else: + print(f"edit link: {response.result}") + + +def edit_link(args: Namespace) -> None: + session_id = get_current_session() + options = LinkOptions( + bandwidth=args.bandwidth, + loss=args.loss, + jitter=args.jitter, + delay=args.delay, + dup=args.dup, + unidirectional=args.uni, + ) + core = CoreGrpcClient() + with core.context_connect(): + response = core.edit_link( + session_id, args.node1, args.node2, options, args.iface1, args.iface2 + ) + if args.json: + json = MessageToJson(response, preserving_proto_field_name=True) + print(json) + else: + print(f"edit link: {response.result}") + + +def delete_link(args: Namespace) -> None: + session_id = get_current_session() + core = CoreGrpcClient() + with core.context_connect(): + response = core.delete_link(session_id, args.node1, args.node2, args.iface1, args.iface2) + if args.json: + json = MessageToJson(response, preserving_proto_field_name=True) + print(json) + else: + print(f"delete link: {response.result}") + + +def setup_node_parser(parent: _SubParsersAction) -> None: + parser = parent.add_parser("node", help="node interactions") + parser.add_argument("--session", type=int, help="session to interact with") + subparsers = parser.add_subparsers(help="node commands") + subparsers.required = True + subparsers.dest = "command" + + add_parser = subparsers.add_parser("add", help="add a node") + add_parser.formatter_class = ArgumentDefaultsHelpFormatter + add_parser.add_argument("--id", type=int, help="id to use, optional") + add_parser.add_argument("--name", help="name to use, optional") + add_parser.add_argument( + "--type", choices=NODE_TYPES, default="DEFAULT", help="type of node" + ) + add_parser.add_argument("--model", help="used to determine services, optional") + group = add_parser.add_mutually_exclusive_group(required=True) + group.add_argument("--pos", type=position_type, help="x,y position") + group.add_argument("--geo", type=geo_type, help="lon,lat,alt position") + add_parser.add_argument("--icon", help="icon to use, optional") + add_parser.add_argument("--image", help="container image, optional") + add_parser.add_argument( + "--emane", help="emane model, only required for emane nodes" + ) + add_parser.set_defaults(func=add_node) + + edit_parser = subparsers.add_parser("edit", help="edit a node") + edit_parser.formatter_class = ArgumentDefaultsHelpFormatter + edit_parser.add_argument("--id", type=int, help="id to use, optional") + group = edit_parser.add_mutually_exclusive_group(required=True) + group.add_argument("--pos", type=position_type, help="x,y position") + group.add_argument("--geo", type=geo_type, help="lon,lat,alt position") + edit_parser.add_argument("--icon", help="icon to use, optional") + edit_parser.set_defaults(func=edit_node) + + delete_parser = subparsers.add_parser("delete", help="delete a node") + delete_parser.formatter_class = ArgumentDefaultsHelpFormatter + delete_parser.add_argument( + "--id", type=int, help="node id to delete", required=True + ) + delete_parser.set_defaults(func=delete_node) + + +def setup_link_parser(parent: _SubParsersAction) -> None: + parser = parent.add_parser("link", help="link interactions") + parser.add_argument("--session", type=int, help="session to interact with") + subparsers = parser.add_subparsers(help="link commands") + subparsers.required = True + subparsers.dest = "command" + + add_parser = subparsers.add_parser("add", help="add a node") + add_parser.formatter_class = ArgumentDefaultsHelpFormatter + add_parser.add_argument( + "--node1", type=int, help="node1 id for link", required=True + ) + add_parser.add_argument( + "--node2", type=int, help="node1 id for link", required=True + ) + add_parser.add_argument("--iface1-id", type=int, help="node1 interface id for link") + add_parser.add_argument("--iface1-mac", type=mac_type, help="node1 interface mac") + add_parser.add_argument("--iface1-ip4", type=ip4_type, help="node1 interface ip4") + add_parser.add_argument( + "--iface1-ip4-mask", type=int, help="node1 interface ip4 mask" + ) + add_parser.add_argument("--iface1-ip6", type=ip6_type, help="node1 interface ip6") + add_parser.add_argument( + "--iface1-ip6-mask", type=int, help="node1 interface ip6 mask" + ) + add_parser.add_argument("--iface2-id", type=int, help="node1 interface id for link") + add_parser.add_argument("--iface2-mac", type=mac_type, help="node1 interface mac") + add_parser.add_argument("--iface2-ip4", type=ip4_type, help="node1 interface ip4") + add_parser.add_argument( + "--iface2-ip4-mask", type=int, help="node1 interface ip4 mask" + ) + add_parser.add_argument("--iface2-ip6", type=ip6_type, help="node1 interface ip6") + add_parser.add_argument( + "--iface2-ip6-mask", type=int, help="node1 interface ip6 mask" + ) + add_parser.add_argument("--bandwidth", type=int, help="bandwidth (bps) for link") + add_parser.add_argument("--loss", type=float, help="loss (%) for link") + add_parser.add_argument("--jitter", type=int, help="jitter (us) for link") + add_parser.add_argument("--delay", type=int, help="delay (us) for link") + add_parser.add_argument("--dup", type=int, help="duplicate (%) for link") + add_parser.add_argument( + "--uni", action="store_true", help="is link unidirectional?" + ) + add_parser.set_defaults(func=add_link) + + edit_parser = subparsers.add_parser("edit", help="edit a link") + edit_parser.formatter_class = ArgumentDefaultsHelpFormatter + edit_parser.add_argument( + "--node1", type=int, help="node1 id for link", required=True + ) + edit_parser.add_argument( + "--node2", type=int, help="node1 id for link", required=True + ) + edit_parser.add_argument("--iface1", type=int, help="node1 interface id for link") + edit_parser.add_argument("--iface2", type=int, help="node2 interface id for link") + edit_parser.add_argument("--bandwidth", type=int, help="bandwidth (bps) for link") + edit_parser.add_argument("--loss", type=float, help="loss (%) for link") + edit_parser.add_argument("--jitter", type=int, help="jitter (us) for link") + edit_parser.add_argument("--delay", type=int, help="delay (us) for link") + edit_parser.add_argument("--dup", type=int, help="duplicate (%) for link") + edit_parser.add_argument( + "--uni", action="store_true", help="is link unidirectional?" + ) + edit_parser.set_defaults(func=edit_link) + + delete_parser = subparsers.add_parser("delete", help="delete a link") + delete_parser.formatter_class = ArgumentDefaultsHelpFormatter + delete_parser.add_argument( + "--node1", type=int, help="node1 id for link", required=True + ) + delete_parser.add_argument( + "--node2", type=int, help="node1 id for link", required=True + ) + delete_parser.add_argument("--iface1", type=int, help="node1 interface id for link") + delete_parser.add_argument("--iface2", type=int, help="node2 interface id for link") + delete_parser.set_defaults(func=delete_link) + + +def setup_query_parser(parent: _SubParsersAction) -> None: + parser = parent.add_parser("query", help="query interactions") + subparsers = parser.add_subparsers(help="query commands") + subparsers.required = True + subparsers.dest = "command" + + sessions_parser = subparsers.add_parser("sessions", help="query current sessions") + sessions_parser.set_defaults(func=query_sessions) + + session_parser = subparsers.add_parser("session", help="query session") + session_parser.add_argument("--id", type=int, help="session to query", required=True) + session_parser.set_defaults(func=query_session) + + node_parser = subparsers.add_parser("node", help="query node") + node_parser.add_argument("--id", type=int, help="session to query", required=True) + node_parser.add_argument("--node", type=int, help="node to query", required=True) + node_parser.set_defaults(func=query_node) + + +def main() -> None: + parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) + parser.add_argument( + "-j", "--json", action="store_true", help="print responses to terminal as json" + ) + subparsers = parser.add_subparsers(help="supported commands") + subparsers.required = True + subparsers.dest = "command" + setup_node_parser(subparsers) + setup_link_parser(subparsers) + setup_query_parser(subparsers) + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() From ec845b920c4b55340fb579dbafa849b60470b6e4 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 30 Jun 2020 08:27:40 -0700 Subject: [PATCH 216/929] removed ip mask options from core-cli add link, combined with ip and will parse input to provide simpler interface --- daemon/scripts/core-cli | 92 ++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index ca9011b5..a8354f65 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -11,6 +11,7 @@ from typing import Tuple import netaddr from google.protobuf.json_format import MessageToJson +from netaddr import EUI, AddrFormatError, IPNetwork from core.api.grpc.client import CoreGrpcClient from core.api.grpc.core_pb2 import ( @@ -27,21 +28,31 @@ NODE_TYPES = [k for k, v in NodeType.Enum.items() if v != NodeType.PEER_TO_PEER] def mac_type(value: str) -> str: - if not netaddr.valid_mac(value): - raise ArgumentTypeError("invalid mac address") - return value + try: + mac = EUI(value, dialect=netaddr.mac_unix_expanded) + return str(mac) + except AddrFormatError: + raise ArgumentTypeError(f"invalid mac address: {value}") -def ip4_type(value: str) -> str: - if not netaddr.valid_ipv4(value): - raise ArgumentTypeError("invalid ip4 address") - return value +def ip4_type(value: str) -> IPNetwork: + try: + ip = IPNetwork(value) + if not netaddr.valid_ipv4(str(ip.ip)): + raise ArgumentTypeError(f"invalid ip4 address: {value}") + return ip + except AddrFormatError: + raise ArgumentTypeError(f"invalid ip4 address: {value}") -def ip6_type(value: str) -> str: - if not netaddr.valid_ipv6(value): - raise ArgumentTypeError("invalid ip6 address") - return value +def ip6_type(value: str) -> IPNetwork: + try: + ip = IPNetwork(value) + if not netaddr.valid_ipv6(str(ip.ip)): + raise ArgumentTypeError(f"invalid ip6 address: {value}") + return ip + except AddrFormatError: + raise ArgumentTypeError(f"invalid ip6 address: {value}") def position_type(value: str) -> Tuple[float, float]: @@ -78,11 +89,26 @@ def get_current_session() -> int: return response.sessions[0].id -def print_interface_header() -> None: +def create_iface(iface_id: int, mac: str, ip4_net: IPNetwork, ip6_net: IPNetwork) -> Interface: + ip4 = str(ip4_net.ip) if ip4_net else None + ip4_mask = ip4_net.prefixlen if ip4_net else None + ip6 = str(ip6_net.ip) if ip6_net else None + ip6_mask = ip6_net.prefixlen if ip6_net else None + return Interface( + id=iface_id, + mac=mac, + ip4=ip4, + ip4_mask=ip4_mask, + ip6=ip6, + ip6_mask=ip6_mask, + ) + + +def print_iface_header() -> None: print("ID | MAC Address | IP4 Address | IP6 Address") -def print_interface(iface: Interface) -> None: +def print_iface(iface: Interface) -> None: iface_ip4 = f"{iface.ip4}/{iface.ip4_mask}" if iface.ip4 else None iface_ip6 = f"{iface.ip6}/{iface.ip6_mask}" if iface.ip6 else None print(f"{iface.id:<3} | {iface.mac:<11} | {iface_ip4:<18} | {iface_ip6}") @@ -123,13 +149,13 @@ def query_session(args: Namespace) -> None: n1 = names[link.node1_id] n2 = names[link.node2_id] print(f"Node | ", end="") - print_interface_header() + print_iface_header() if link.HasField("iface1"): print(f"{n1:<6} | ", end="") - print_interface(link.iface1) + print_iface(link.iface1) if link.HasField("iface2"): print(f"{n2:<6} | ", end="") - print_interface(link.iface2) + print_iface(link.iface2) print() @@ -146,9 +172,9 @@ def query_node(args: Namespace) -> None: print("ID | Name | Type") print(f"{node.id:<4} | {node.name:<7} | {node_type}") print("Interfaces") - print_interface_header() + print_iface_header() for iface in response.ifaces: - print_interface(iface) + print_iface(iface) def add_node(args: Namespace) -> None: @@ -219,24 +245,10 @@ def add_link(args: Namespace) -> None: session_id = get_current_session() iface1 = None if args.iface1_id is not None: - iface1 = Interface( - id=args.iface1_id, - mac=args.iface1_mac, - ip4=args.iface1_ip4, - ip4_mask=args.iface1_ip4_mask, - ip6=args.iface1_ip4, - ip6_mask=args.iface1_ip6_mask, - ) + iface1 = create_iface(args.iface1_id, args.iface1_mac, args.iface1_ip4, args.iface1_ip6) iface2 = None if args.iface2_id is not None: - iface2 = Interface( - id=args.iface2_id, - mac=args.iface2_mac, - ip4=args.iface2_ip4, - ip4_mask=args.iface2_ip4_mask, - ip6=args.iface2_ip4, - ip6_mask=args.iface2_ip6_mask, - ) + iface2 = create_iface(args.iface2_id, args.iface2_mac, args.iface2_ip4, args.iface2_ip6) options = LinkOptions( bandwidth=args.bandwidth, loss=args.loss, @@ -349,23 +361,11 @@ def setup_link_parser(parent: _SubParsersAction) -> None: add_parser.add_argument("--iface1-id", type=int, help="node1 interface id for link") add_parser.add_argument("--iface1-mac", type=mac_type, help="node1 interface mac") add_parser.add_argument("--iface1-ip4", type=ip4_type, help="node1 interface ip4") - add_parser.add_argument( - "--iface1-ip4-mask", type=int, help="node1 interface ip4 mask" - ) add_parser.add_argument("--iface1-ip6", type=ip6_type, help="node1 interface ip6") - add_parser.add_argument( - "--iface1-ip6-mask", type=int, help="node1 interface ip6 mask" - ) add_parser.add_argument("--iface2-id", type=int, help="node1 interface id for link") add_parser.add_argument("--iface2-mac", type=mac_type, help="node1 interface mac") add_parser.add_argument("--iface2-ip4", type=ip4_type, help="node1 interface ip4") - add_parser.add_argument( - "--iface2-ip4-mask", type=int, help="node1 interface ip4 mask" - ) add_parser.add_argument("--iface2-ip6", type=ip6_type, help="node1 interface ip6") - add_parser.add_argument( - "--iface2-ip6-mask", type=int, help="node1 interface ip6 mask" - ) add_parser.add_argument("--bandwidth", type=int, help="bandwidth (bps) for link") add_parser.add_argument("--loss", type=float, help="loss (%) for link") add_parser.add_argument("--jitter", type=int, help="jitter (us) for link") From aef3fe8d50e47089abf974e4a28f6bafa65e14c4 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 30 Jun 2020 09:25:36 -0700 Subject: [PATCH 217/929] updated core-cli to use consistent shorthand options and existing longform options --- daemon/scripts/core-cli | 122 +++++++++++++++++----------------------- 1 file changed, 52 insertions(+), 70 deletions(-) diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index a8354f65..65a92994 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -254,7 +254,7 @@ def add_link(args: Namespace) -> None: loss=args.loss, jitter=args.jitter, delay=args.delay, - dup=args.dup, + dup=args.duplicate, unidirectional=args.uni, ) core = CoreGrpcClient() @@ -274,7 +274,7 @@ def edit_link(args: Namespace) -> None: loss=args.loss, jitter=args.jitter, delay=args.delay, - dup=args.dup, + dup=args.duplicate, unidirectional=args.uni, ) core = CoreGrpcClient() @@ -303,109 +303,91 @@ def delete_link(args: Namespace) -> None: def setup_node_parser(parent: _SubParsersAction) -> None: parser = parent.add_parser("node", help="node interactions") - parser.add_argument("--session", type=int, help="session to interact with") + parser.add_argument("-s", "--session", type=int, help="session to interact with") subparsers = parser.add_subparsers(help="node commands") subparsers.required = True subparsers.dest = "command" add_parser = subparsers.add_parser("add", help="add a node") add_parser.formatter_class = ArgumentDefaultsHelpFormatter - add_parser.add_argument("--id", type=int, help="id to use, optional") - add_parser.add_argument("--name", help="name to use, optional") + add_parser.add_argument("-i", "--id", type=int, help="id to use, optional") + add_parser.add_argument("-n", "--name", help="name to use, optional") add_parser.add_argument( - "--type", choices=NODE_TYPES, default="DEFAULT", help="type of node" + "-t", "--type", choices=NODE_TYPES, default="DEFAULT", help="type of node" ) - add_parser.add_argument("--model", help="used to determine services, optional") + add_parser.add_argument("-m", "--model", help="used to determine services, optional") group = add_parser.add_mutually_exclusive_group(required=True) - group.add_argument("--pos", type=position_type, help="x,y position") - group.add_argument("--geo", type=geo_type, help="lon,lat,alt position") - add_parser.add_argument("--icon", help="icon to use, optional") - add_parser.add_argument("--image", help="container image, optional") - add_parser.add_argument( - "--emane", help="emane model, only required for emane nodes" - ) + group.add_argument("-p", "--pos", type=position_type, help="x,y position") + group.add_argument("-g", "--geo", type=geo_type, help="lon,lat,alt position") + add_parser.add_argument("-ic", "--icon", help="icon to use, optional") + add_parser.add_argument("-im", "--image", help="container image, optional") + add_parser.add_argument("-e", "--emane", help="emane model, only required for emane nodes") add_parser.set_defaults(func=add_node) edit_parser = subparsers.add_parser("edit", help="edit a node") edit_parser.formatter_class = ArgumentDefaultsHelpFormatter - edit_parser.add_argument("--id", type=int, help="id to use, optional") + edit_parser.add_argument("-i", "--id", type=int, help="id to use, optional") group = edit_parser.add_mutually_exclusive_group(required=True) - group.add_argument("--pos", type=position_type, help="x,y position") - group.add_argument("--geo", type=geo_type, help="lon,lat,alt position") - edit_parser.add_argument("--icon", help="icon to use, optional") + group.add_argument("-p", "--pos", type=position_type, help="x,y position") + group.add_argument("-g", "--geo", type=geo_type, help="lon,lat,alt position") + edit_parser.add_argument("-ic", "--icon", help="icon to use, optional") edit_parser.set_defaults(func=edit_node) delete_parser = subparsers.add_parser("delete", help="delete a node") delete_parser.formatter_class = ArgumentDefaultsHelpFormatter - delete_parser.add_argument( - "--id", type=int, help="node id to delete", required=True - ) + delete_parser.add_argument("-i", "--id", type=int, help="node id", required=True) delete_parser.set_defaults(func=delete_node) def setup_link_parser(parent: _SubParsersAction) -> None: parser = parent.add_parser("link", help="link interactions") - parser.add_argument("--session", type=int, help="session to interact with") + parser.add_argument("-s", "--session", type=int, help="session to interact with") subparsers = parser.add_subparsers(help="link commands") subparsers.required = True subparsers.dest = "command" add_parser = subparsers.add_parser("add", help="add a node") add_parser.formatter_class = ArgumentDefaultsHelpFormatter - add_parser.add_argument( - "--node1", type=int, help="node1 id for link", required=True - ) - add_parser.add_argument( - "--node2", type=int, help="node1 id for link", required=True - ) - add_parser.add_argument("--iface1-id", type=int, help="node1 interface id for link") - add_parser.add_argument("--iface1-mac", type=mac_type, help="node1 interface mac") - add_parser.add_argument("--iface1-ip4", type=ip4_type, help="node1 interface ip4") - add_parser.add_argument("--iface1-ip6", type=ip6_type, help="node1 interface ip6") - add_parser.add_argument("--iface2-id", type=int, help="node1 interface id for link") - add_parser.add_argument("--iface2-mac", type=mac_type, help="node1 interface mac") - add_parser.add_argument("--iface2-ip4", type=ip4_type, help="node1 interface ip4") - add_parser.add_argument("--iface2-ip6", type=ip6_type, help="node1 interface ip6") - add_parser.add_argument("--bandwidth", type=int, help="bandwidth (bps) for link") - add_parser.add_argument("--loss", type=float, help="loss (%) for link") - add_parser.add_argument("--jitter", type=int, help="jitter (us) for link") - add_parser.add_argument("--delay", type=int, help="delay (us) for link") - add_parser.add_argument("--dup", type=int, help="duplicate (%) for link") - add_parser.add_argument( - "--uni", action="store_true", help="is link unidirectional?" - ) + add_parser.add_argument("-n1", "--node1", type=int, help="node1 id", required=True) + add_parser.add_argument("-n2", "--node2", type=int, help="node2 id", required=True) + add_parser.add_argument("-i1-i", "--iface1-id", type=int, help="node1 interface id") + add_parser.add_argument("-i1-m", "--iface1-mac", type=mac_type, help="node1 interface mac") + add_parser.add_argument("-i1-4", "--iface1-ip4", type=ip4_type, help="node1 interface ip4") + add_parser.add_argument("-i1-6", "--iface1-ip6", type=ip6_type, help="node1 interface ip6") + add_parser.add_argument("-i2-i", "--iface2-id", type=int, help="node2 interface id") + add_parser.add_argument("-i2-m", "--iface2-mac", type=mac_type, help="node2 interface mac") + add_parser.add_argument("-i2-4", "--iface2-ip4", type=ip4_type, help="node2 interface ip4") + add_parser.add_argument("-i2-6", "--iface2-ip6", type=ip6_type, help="node2 interface ip6") + add_parser.add_argument("-b", "--bandwidth", type=int, help="bandwidth (bps)") + add_parser.add_argument("-l", "--loss", type=float, help="loss (%%)") + add_parser.add_argument("-j", "--jitter", type=int, help="jitter (us)") + add_parser.add_argument("-de", "--delay", type=int, help="delay (us)") + add_parser.add_argument("-du", "--duplicate", type=int, help="duplicate (%%)") + add_parser.add_argument("-u", "--uni", action="store_true", help="is link unidirectional?") add_parser.set_defaults(func=add_link) edit_parser = subparsers.add_parser("edit", help="edit a link") edit_parser.formatter_class = ArgumentDefaultsHelpFormatter + edit_parser.add_argument("-n1", "--node1", type=int, help="node1 id", required=True) + edit_parser.add_argument("-n2", "--node2", type=int, help="node2 id", required=True) + edit_parser.add_argument("-i1", "--iface1", type=int, help="node1 interface id") + edit_parser.add_argument("-i2", "--iface2", type=int, help="node2 interface id") + edit_parser.add_argument("-b", "--bandwidth", type=int, help="bandwidth (bps)") + edit_parser.add_argument("-l", "--loss", type=float, help="loss (%%)") + edit_parser.add_argument("-j", "--jitter", type=int, help="jitter (us)") + edit_parser.add_argument("-de", "--delay", type=int, help="delay (us)") + edit_parser.add_argument("-du", "--duplicate", type=int, help="duplicate (%%)") edit_parser.add_argument( - "--node1", type=int, help="node1 id for link", required=True - ) - edit_parser.add_argument( - "--node2", type=int, help="node1 id for link", required=True - ) - edit_parser.add_argument("--iface1", type=int, help="node1 interface id for link") - edit_parser.add_argument("--iface2", type=int, help="node2 interface id for link") - edit_parser.add_argument("--bandwidth", type=int, help="bandwidth (bps) for link") - edit_parser.add_argument("--loss", type=float, help="loss (%) for link") - edit_parser.add_argument("--jitter", type=int, help="jitter (us) for link") - edit_parser.add_argument("--delay", type=int, help="delay (us) for link") - edit_parser.add_argument("--dup", type=int, help="duplicate (%) for link") - edit_parser.add_argument( - "--uni", action="store_true", help="is link unidirectional?" + "-u", "--uni", action="store_true", help="is link unidirectional?" ) edit_parser.set_defaults(func=edit_link) delete_parser = subparsers.add_parser("delete", help="delete a link") delete_parser.formatter_class = ArgumentDefaultsHelpFormatter - delete_parser.add_argument( - "--node1", type=int, help="node1 id for link", required=True - ) - delete_parser.add_argument( - "--node2", type=int, help="node1 id for link", required=True - ) - delete_parser.add_argument("--iface1", type=int, help="node1 interface id for link") - delete_parser.add_argument("--iface2", type=int, help="node2 interface id for link") + delete_parser.add_argument("-n1", "--node1", type=int, help="node1 id", required=True) + delete_parser.add_argument("-n2", "--node2", type=int, help="node1 id", required=True) + delete_parser.add_argument("-i1", "--iface1", type=int, help="node1 interface id") + delete_parser.add_argument("-i2", "--iface2", type=int, help="node2 interface id") delete_parser.set_defaults(func=delete_link) @@ -419,19 +401,19 @@ def setup_query_parser(parent: _SubParsersAction) -> None: sessions_parser.set_defaults(func=query_sessions) session_parser = subparsers.add_parser("session", help="query session") - session_parser.add_argument("--id", type=int, help="session to query", required=True) + session_parser.add_argument("-i", "--id", type=int, help="session to query", required=True) session_parser.set_defaults(func=query_session) node_parser = subparsers.add_parser("node", help="query node") - node_parser.add_argument("--id", type=int, help="session to query", required=True) - node_parser.add_argument("--node", type=int, help="node to query", required=True) + node_parser.add_argument("-i", "--id", type=int, help="session to query", required=True) + node_parser.add_argument("-n", "--node", type=int, help="node to query", required=True) node_parser.set_defaults(func=query_node) def main() -> None: parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) parser.add_argument( - "-j", "--json", action="store_true", help="print responses to terminal as json" + "-js", "--json", action="store_true", help="print responses to terminal as json" ) subparsers = parser.add_subparsers(help="supported commands") subparsers.required = True From 69721dc1290a53340df120e4bb27af3993911f5b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 30 Jun 2020 09:32:56 -0700 Subject: [PATCH 218/929] grpc: updated client edit_node to have source as last parameter to be consistent with source placement on all other functions --- daemon/core/api/grpc/client.py | 4 ++-- daemon/scripts/core-cli | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 82164fe3..20e193eb 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -502,8 +502,8 @@ class CoreGrpcClient: node_id: int, position: core_pb2.Position = None, icon: str = None, - source: str = None, geo: core_pb2.Geo = None, + source: str = None, ) -> core_pb2.EditNodeResponse: """ Edit a node, currently only changes position. @@ -512,8 +512,8 @@ class CoreGrpcClient: :param node_id: node id :param position: position to set node to :param icon: path to icon for gui to use for node - :param source: application source :param geo: lon,lat,alt location for node + :param source: application source :return: response with result of success or failure :raises grpc.RpcError: when session or node doesn't exist """ diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index 65a92994..df4535ac 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -221,7 +221,7 @@ def edit_node(args: Namespace) -> None: geo = Geo(lon=lon, lat=lat, alt=alt) core = CoreGrpcClient() with core.context_connect(): - response = core.edit_node(session_id, args.id, pos, args.icon, geo=geo) + response = core.edit_node(session_id, args.id, pos, args.icon, geo) if args.json: json = MessageToJson(response, preserving_proto_field_name=True) print(json) From d480a1dd4c92458bf3236fa1dd03dedfeb088146 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 30 Jun 2020 09:38:22 -0700 Subject: [PATCH 219/929] grpc: removed LinkOptions opaque as it was not being used --- daemon/proto/core/api/grpc/core.proto | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 8112c9d1..3828d474 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -719,17 +719,16 @@ message Link { } message LinkOptions { - string opaque = 1; - int64 jitter = 2; - int32 key = 3; - int32 mburst = 4; - int32 mer = 5; - float loss = 6; - int64 bandwidth = 7; - int32 burst = 8; - int64 delay = 9; - int32 dup = 10; - bool unidirectional = 11; + int64 jitter = 1; + int32 key = 2; + int32 mburst = 3; + int32 mer = 4; + float loss = 5; + int64 bandwidth = 6; + int32 burst = 7; + int64 delay = 8; + int32 dup = 9; + bool unidirectional = 10; } message Interface { From ab17cb1053facc612ed22b2547d0f77686cae304 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 30 Jun 2020 09:50:28 -0700 Subject: [PATCH 220/929] grpc: grpc get_session will no longer return peer to peer nodes, they should be invisible to users, updated core-cli to print human readable links better --- daemon/core/api/grpc/server.py | 4 ++-- daemon/core/gui/nodeutils.py | 2 +- daemon/scripts/core-cli | 8 ++++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index e8469177..4f741b22 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -114,7 +114,7 @@ from core.emulator.session import NT, Session from core.errors import CoreCommandError, CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility from core.nodes.base import CoreNode, CoreNodeBase, NodeBase -from core.nodes.network import WlanNode +from core.nodes.network import PtpNet, WlanNode from core.services.coreservices import ServiceManager _ONE_DAY_IN_SECONDS: int = 60 * 60 * 24 @@ -543,7 +543,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): nodes = [] for _id in session.nodes: node = session.nodes[_id] - if not isinstance(node.id, int): + if isinstance(node, PtpNet): continue node_proto = grpcutils.get_node_proto(session, node) nodes.append(node_proto) diff --git a/daemon/core/gui/nodeutils.py b/daemon/core/gui/nodeutils.py index dbb403df..08c8f31c 100644 --- a/daemon/core/gui/nodeutils.py +++ b/daemon/core/gui/nodeutils.py @@ -62,7 +62,7 @@ class NodeUtils: IMAGE_NODES: Set[NodeType] = {NodeType.DOCKER, NodeType.LXC} WIRELESS_NODES: Set[NodeType] = {NodeType.WIRELESS_LAN, NodeType.EMANE} RJ45_NODES: Set[NodeType] = {NodeType.RJ45} - IGNORE_NODES: Set[NodeType] = {NodeType.CONTROL_NET, NodeType.PEER_TO_PEER} + IGNORE_NODES: Set[NodeType] = {NodeType.CONTROL_NET} NODE_MODELS: Set[str] = {"router", "host", "PC", "mdr", "prouter"} ROUTER_NODES: Set[str] = {"router", "mdr"} ANTENNA_ICON: PhotoImage = None diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index df4535ac..05e495e5 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -150,12 +150,16 @@ def query_session(args: Namespace) -> None: n2 = names[link.node2_id] print(f"Node | ", end="") print_iface_header() + print(f"{n1:<6} | ", end="") if link.HasField("iface1"): - print(f"{n1:<6} | ", end="") print_iface(link.iface1) + else: + print() + print(f"{n2:<6} | ", end="") if link.HasField("iface2"): - print(f"{n2:<6} | ", end="") print_iface(link.iface2) + else: + print() print() From beaebcfa2496703308bfc7d7fa732155e5d11020 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 30 Jun 2020 12:34:20 -0700 Subject: [PATCH 221/929] grpc: added node_id and net2_id data to interface protos to allow querying a node to provide the node and networks an interface is associated with --- daemon/core/api/grpc/grpcutils.py | 35 +++++++++++++++------------ daemon/core/api/grpc/server.py | 6 ++--- daemon/proto/core/api/grpc/core.proto | 2 ++ daemon/scripts/core-cli | 22 ++++++++++++++--- 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index ed40a75b..bd9e808d 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -453,32 +453,35 @@ def iface_to_data(iface: CoreInterface) -> InterfaceData: ) -def iface_to_proto(iface: CoreInterface) -> core_pb2.Interface: +def iface_to_proto(node_id: int, iface: CoreInterface) -> core_pb2.Interface: """ Convenience for converting a core interface to the protobuf representation. + + :param node_id: id of node to convert interface for :param iface: interface to convert :return: interface proto """ - net_id = None - if iface.net: - net_id = iface.net.id - ip4 = None - ip4_mask = None + if iface.node and iface.node.id == node_id: + _id = iface.node_id + else: + _id = iface.net_id + net_id = iface.net.id if iface.net else None + node_id = iface.node.id if iface.node else None + net2_id = iface.othernet.id if iface.othernet else None ip4_net = iface.get_ip4() - if ip4_net: - ip4 = str(ip4_net.ip) - ip4_mask = ip4_net.prefixlen - ip6 = None - ip6_mask = None + ip4 = str(ip4_net.ip) if ip4_net else None + ip4_mask = ip4_net.prefixlen if ip4_net else None ip6_net = iface.get_ip6() - if ip6_net: - ip6 = str(ip6_net.ip) - ip6_mask = ip6_net.prefixlen + ip6 = str(ip6_net.ip) if ip6_net else None + ip6_mask = ip6_net.prefixlen if ip6_net else None + mac = str(iface.mac) if iface.mac else None return core_pb2.Interface( - id=iface.node_id, + id=_id, net_id=net_id, + net2_id=net2_id, + node_id=node_id, name=iface.name, - mac=str(iface.mac), + mac=mac, mtu=iface.mtu, flow_id=iface.flow_id, ip4=ip4, diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 4f741b22..c447ee7c 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -688,7 +688,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): ifaces = [] for iface_id in node.ifaces: iface = node.ifaces[iface_id] - iface_proto = grpcutils.iface_to_proto(iface) + iface_proto = grpcutils.iface_to_proto(request.node_id, iface) ifaces.append(iface_proto) node_proto = grpcutils.get_node_proto(session, node) return core_pb2.GetNodeResponse(node=node_proto, ifaces=ifaces) @@ -880,9 +880,9 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): iface1_proto = None iface2_proto = None if node1_iface: - iface1_proto = grpcutils.iface_to_proto(node1_iface) + iface1_proto = grpcutils.iface_to_proto(node1_id, node1_iface) if node2_iface: - iface2_proto = grpcutils.iface_to_proto(node2_iface) + iface2_proto = grpcutils.iface_to_proto(node2_id, node2_iface) return core_pb2.AddLinkResponse( result=True, iface1=iface1_proto, iface2=iface2_proto ) diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 3828d474..f01fca50 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -742,6 +742,8 @@ message Interface { int32 net_id = 8; int32 flow_id = 9; int32 mtu = 10; + int32 node_id = 11; + int32 net2_id = 12; } message SessionLocation { diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index 05e495e5..c4d97a8a 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -109,9 +109,9 @@ def print_iface_header() -> None: def print_iface(iface: Interface) -> None: - iface_ip4 = f"{iface.ip4}/{iface.ip4_mask}" if iface.ip4 else None - iface_ip6 = f"{iface.ip6}/{iface.ip6_mask}" if iface.ip6 else None - print(f"{iface.id:<3} | {iface.mac:<11} | {iface_ip4:<18} | {iface_ip6}") + iface_ip4 = f"{iface.ip4}/{iface.ip4_mask}" if iface.ip4 else "" + iface_ip6 = f"{iface.ip6}/{iface.ip6_mask}" if iface.ip6 else "" + print(f"{iface.id:<3} | {iface.mac:<17} | {iface_ip4:<18} | {iface_ip6}") def query_sessions(args: Namespace) -> None: @@ -166,6 +166,11 @@ def query_session(args: Namespace) -> None: def query_node(args: Namespace) -> None: core = CoreGrpcClient() with core.context_connect(): + names = {} + response = core.get_session(args.id) + for node in response.session.nodes: + names[node.id] = node.name + response = core.get_node(args.id, args.node) if args.json: json = MessageToJson(response, preserving_proto_field_name=True) @@ -176,8 +181,17 @@ def query_node(args: Namespace) -> None: print("ID | Name | Type") print(f"{node.id:<4} | {node.name:<7} | {node_type}") print("Interfaces") + print("Connected To | ", end="") print_iface_header() for iface in response.ifaces: + if iface.net_id == node.id: + if iface.node_id: + name = names[iface.node_id] + else: + name = names[iface.net2_id] + else: + name = names[iface.net_id] + print(f"{name:<12} | ", end="") print_iface(iface) @@ -268,7 +282,7 @@ def add_link(args: Namespace) -> None: json = MessageToJson(response, preserving_proto_field_name=True) print(json) else: - print(f"edit link: {response.result}") + print(f"add link: {response.result}") def edit_link(args: Namespace) -> None: From 4a0fdf3307347065cb41052581efa673af08b106 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 30 Jun 2020 15:21:33 -0700 Subject: [PATCH 222/929] core-cli: add function for printing protobuf responses as json --- daemon/scripts/core-cli | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index c4d97a8a..6fe83cf0 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -7,7 +7,7 @@ from argparse import ( Namespace, _SubParsersAction, ) -from typing import Tuple +from typing import Any, Tuple import netaddr from google.protobuf.json_format import MessageToJson @@ -114,13 +114,17 @@ def print_iface(iface: Interface) -> None: print(f"{iface.id:<3} | {iface.mac:<17} | {iface_ip4:<18} | {iface_ip6}") +def print_json(message: Any) -> None: + json = MessageToJson(message, preserving_proto_field_name=True) + print(json) + + def query_sessions(args: Namespace) -> None: core = CoreGrpcClient() with core.context_connect(): response = core.get_sessions() if args.json: - json = MessageToJson(response, preserving_proto_field_name=True) - print(json) + print_json(response) else: print("Session ID | Session State | Nodes") for s in response.sessions: @@ -133,8 +137,7 @@ def query_session(args: Namespace) -> None: with core.context_connect(): response = core.get_session(args.id) if args.json: - json = MessageToJson(response, preserving_proto_field_name=True) - print(json) + print_json(response) else: print("Nodes") print("Node ID | Node Name | Node Type") @@ -173,8 +176,7 @@ def query_node(args: Namespace) -> None: response = core.get_node(args.id, args.node) if args.json: - json = MessageToJson(response, preserving_proto_field_name=True) - print(json) + print_json(response) else: node = response.node node_type = NodeType.Enum.Name(node.type) @@ -221,8 +223,7 @@ def add_node(args: Namespace) -> None: ) response = core.add_node(session_id, node) if args.json: - json = MessageToJson(response, preserving_proto_field_name=True) - print(json) + print_json(response) else: print(f"created node: {response.node_id}") @@ -241,8 +242,7 @@ def edit_node(args: Namespace) -> None: with core.context_connect(): response = core.edit_node(session_id, args.id, pos, args.icon, geo) if args.json: - json = MessageToJson(response, preserving_proto_field_name=True) - print(json) + print_json(response) else: print(f"edit node: {response.result}") @@ -253,8 +253,7 @@ def delete_node(args: Namespace) -> None: with core.context_connect(): response = core.delete_node(session_id, args.id) if args.json: - json = MessageToJson(response, preserving_proto_field_name=True) - print(json) + print_json(response) else: print(f"deleted node: {response.result}") @@ -279,8 +278,7 @@ def add_link(args: Namespace) -> None: with core.context_connect(): response = core.add_link(session_id, args.node1, args.node2, iface1, iface2, options) if args.json: - json = MessageToJson(response, preserving_proto_field_name=True) - print(json) + print_json(response) else: print(f"add link: {response.result}") @@ -301,8 +299,7 @@ def edit_link(args: Namespace) -> None: session_id, args.node1, args.node2, options, args.iface1, args.iface2 ) if args.json: - json = MessageToJson(response, preserving_proto_field_name=True) - print(json) + print_json(response) else: print(f"edit link: {response.result}") @@ -313,8 +310,7 @@ def delete_link(args: Namespace) -> None: with core.context_connect(): response = core.delete_link(session_id, args.node1, args.node2, args.iface1, args.iface2) if args.json: - json = MessageToJson(response, preserving_proto_field_name=True) - print(json) + print_json(response) else: print(f"delete link: {response.result}") From f22edd1d25b9d003385751e491c693989bdc956e Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 30 Jun 2020 16:16:58 -0700 Subject: [PATCH 223/929] grpc: fixed accidental breakage for get_session ptp links --- daemon/core/api/grpc/server.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index c447ee7c..50b15771 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -543,10 +543,9 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): nodes = [] for _id in session.nodes: node = session.nodes[_id] - if isinstance(node, PtpNet): - continue - node_proto = grpcutils.get_node_proto(session, node) - nodes.append(node_proto) + if not isinstance(node, PtpNet): + node_proto = grpcutils.get_node_proto(session, node) + nodes.append(node_proto) node_links = get_links(node) links.extend(node_links) From 537291b219e731454423fdd2c63775d8ce7c01ba Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 30 Jun 2020 22:16:00 -0700 Subject: [PATCH 224/929] core-cli: added open xml command to a session xml and optionally start it --- daemon/scripts/core-cli | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index 6fe83cf0..e4c72996 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -7,6 +7,7 @@ from argparse import ( Namespace, _SubParsersAction, ) +from pathlib import Path from typing import Any, Tuple import netaddr @@ -79,6 +80,13 @@ def geo_type(value: str) -> Tuple[float, float, float]: return lon, lat, alt +def file_type(value: str) -> str: + path = Path(value) + if not path.is_file(): + raise ArgumentTypeError(f"invalid file: {value}") + return str(path.absolute()) + + def get_current_session() -> int: core = CoreGrpcClient() with core.context_connect(): @@ -119,6 +127,16 @@ def print_json(message: Any) -> None: print(json) +def open_xml(args: Namespace) -> None: + core = CoreGrpcClient() + with core.context_connect(): + response = core.open_xml(args.file, args.start) + if args.json: + print_json(response) + else: + print(f"opened xml: {response.result}") + + def query_sessions(args: Namespace) -> None: core = CoreGrpcClient() with core.context_connect(): @@ -317,6 +335,7 @@ def delete_link(args: Namespace) -> None: def setup_node_parser(parent: _SubParsersAction) -> None: parser = parent.add_parser("node", help="node interactions") + parser.formatter_class = ArgumentDefaultsHelpFormatter parser.add_argument("-s", "--session", type=int, help="session to interact with") subparsers = parser.add_subparsers(help="node commands") subparsers.required = True @@ -355,6 +374,7 @@ def setup_node_parser(parent: _SubParsersAction) -> None: def setup_link_parser(parent: _SubParsersAction) -> None: parser = parent.add_parser("link", help="link interactions") + parser.formatter_class = ArgumentDefaultsHelpFormatter parser.add_argument("-s", "--session", type=int, help="session to interact with") subparsers = parser.add_subparsers(help="link commands") subparsers.required = True @@ -412,18 +432,29 @@ def setup_query_parser(parent: _SubParsersAction) -> None: subparsers.dest = "command" sessions_parser = subparsers.add_parser("sessions", help="query current sessions") + sessions_parser.formatter_class = ArgumentDefaultsHelpFormatter sessions_parser.set_defaults(func=query_sessions) session_parser = subparsers.add_parser("session", help="query session") + session_parser.formatter_class = ArgumentDefaultsHelpFormatter session_parser.add_argument("-i", "--id", type=int, help="session to query", required=True) session_parser.set_defaults(func=query_session) node_parser = subparsers.add_parser("node", help="query node") + node_parser.formatter_class = ArgumentDefaultsHelpFormatter node_parser.add_argument("-i", "--id", type=int, help="session to query", required=True) node_parser.add_argument("-n", "--node", type=int, help="node to query", required=True) node_parser.set_defaults(func=query_node) +def setup_xml_parser(parent: _SubParsersAction) -> None: + parser = parent.add_parser("xml", help="open session xml") + parser.formatter_class = ArgumentDefaultsHelpFormatter + parser.add_argument("file", type=file_type, help="xml file to open") + parser.add_argument("-s", "--start", action="store_true", help="start the session?") + parser.set_defaults(func=open_xml) + + def main() -> None: parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) parser.add_argument( @@ -435,6 +466,7 @@ def main() -> None: setup_node_parser(subparsers) setup_link_parser(subparsers) setup_query_parser(subparsers) + setup_xml_parser(subparsers) args = parser.parse_args() args.func(args) From 3477e84e9d029bed25fe4d6398e2a515cccb79bb Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 1 Jul 2020 09:30:05 -0700 Subject: [PATCH 225/929] core-cli: added wlan set/get config, fixed session option for node/link interactions --- daemon/core/api/grpc/server.py | 11 ++--- daemon/scripts/core-cli | 88 ++++++++++++++++++++++++++++++---- 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 50b15771..aa5ec539 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -1343,13 +1343,12 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("set wlan config: %s", request) session = self.get_session(request.session_id, context) - wlan_config = request.wlan_config - session.mobility.set_model_config( - wlan_config.node_id, BasicRangeModel.name, wlan_config.config - ) + node_id = request.wlan_config.node_id + config = request.wlan_config.config + session.mobility.set_model_config(node_id, BasicRangeModel.name, config) if session.state == EventTypes.RUNTIME_STATE: - node = self.get_node(session, wlan_config.node_id, context, WlanNode) - node.updatemodel(wlan_config.config) + node = self.get_node(session, node_id, context, WlanNode) + node.updatemodel(config) return SetWlanConfigResponse(result=True) def GetEmaneConfig( diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index e4c72996..9dfadfcc 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -8,8 +8,9 @@ from argparse import ( _SubParsersAction, ) from pathlib import Path -from typing import Any, Tuple +from typing import Any, Optional, Tuple +import grpc import netaddr from google.protobuf.json_format import MessageToJson from netaddr import EUI, AddrFormatError, IPNetwork @@ -87,7 +88,9 @@ def file_type(value: str) -> str: return str(path.absolute()) -def get_current_session() -> int: +def get_current_session(session_id: Optional[int]) -> int: + if session_id: + return session_id core = CoreGrpcClient() with core.context_connect(): response = core.get_sessions() @@ -127,6 +130,50 @@ def print_json(message: Any) -> None: print(json) +def get_wlan_config(args: Namespace) -> None: + session_id = get_current_session(args.session) + core = CoreGrpcClient() + try: + with core.context_connect(): + response = core.get_wlan_config(session_id, args.node) + if args.json: + print_json(response) + else: + size = 0 + for option in response.config.values(): + size = max(size, len(option.name)) + print(f"{'Name':<{size}.{size}} | Value") + for option in response.config.values(): + print(f"{option.name:<{size}.{size}} | {option.value}") + except grpc.RpcError as e: + print(f"grpc error: {e.details()}") + + +def set_wlan_config(args: Namespace) -> None: + session_id = get_current_session(args.session) + config = {} + if args.bandwidth: + config["bandwidth"] = str(args.bandwidth) + if args.delay: + config["delay"] = str(args.delay) + if args.loss: + config["error"] = str(args.loss) + if args.jitter: + config["jitter"] = str(args.jitter) + if args.range: + config["range"] = str(args.range) + core = CoreGrpcClient() + try: + with core.context_connect(): + response = core.set_wlan_config(session_id, args.node, config) + if args.json: + print_json(response) + else: + print(f"set wlan config: {response.result}") + except grpc.RpcError as e: + print(f"grpc error: {e.details()}") + + def open_xml(args: Namespace) -> None: core = CoreGrpcClient() with core.context_connect(): @@ -216,7 +263,7 @@ def query_node(args: Namespace) -> None: def add_node(args: Namespace) -> None: - session_id = get_current_session() + session_id = get_current_session(args.session) node_type = NodeType.Enum.Value(args.type) pos = None if args.pos: @@ -247,7 +294,7 @@ def add_node(args: Namespace) -> None: def edit_node(args: Namespace) -> None: - session_id = get_current_session() + session_id = get_current_session(args.session) pos = None if args.pos: x, y = args.pos @@ -266,7 +313,7 @@ def edit_node(args: Namespace) -> None: def delete_node(args: Namespace) -> None: - session_id = get_current_session() + session_id = get_current_session(args.session) core = CoreGrpcClient() with core.context_connect(): response = core.delete_node(session_id, args.id) @@ -277,7 +324,7 @@ def delete_node(args: Namespace) -> None: def add_link(args: Namespace) -> None: - session_id = get_current_session() + session_id = get_current_session(args.session) iface1 = None if args.iface1_id is not None: iface1 = create_iface(args.iface1_id, args.iface1_mac, args.iface1_ip4, args.iface1_ip6) @@ -302,7 +349,7 @@ def add_link(args: Namespace) -> None: def edit_link(args: Namespace) -> None: - session_id = get_current_session() + session_id = get_current_session(args.session) options = LinkOptions( bandwidth=args.bandwidth, loss=args.loss, @@ -323,7 +370,7 @@ def edit_link(args: Namespace) -> None: def delete_link(args: Namespace) -> None: - session_id = get_current_session() + session_id = get_current_session(args.session) core = CoreGrpcClient() with core.context_connect(): response = core.delete_link(session_id, args.node1, args.node2, args.iface1, args.iface2) @@ -455,6 +502,30 @@ def setup_xml_parser(parent: _SubParsersAction) -> None: parser.set_defaults(func=open_xml) +def setup_wlan_parser(parent: _SubParsersAction) -> None: + parser = parent.add_parser("wlan", help="wlan specific interactions") + parser.formatter_class = ArgumentDefaultsHelpFormatter + parser.add_argument("-s", "--session", type=int, help="session to interact with") + subparsers = parser.add_subparsers(help="link commands") + subparsers.required = True + subparsers.dest = "command" + + get_parser = subparsers.add_parser("get", help="get wlan configuration") + get_parser.formatter_class = ArgumentDefaultsHelpFormatter + get_parser.add_argument("-n", "--node", type=int, help="wlan node", required=True) + get_parser.set_defaults(func=get_wlan_config) + + set_parser = subparsers.add_parser("set", help="set wlan configuration") + set_parser.formatter_class = ArgumentDefaultsHelpFormatter + set_parser.add_argument("-n", "--node", type=int, help="wlan node", required=True) + set_parser.add_argument("-b", "--bandwidth", type=int, help="bandwidth (bps)") + set_parser.add_argument("-d", "--delay", type=int, help="delay (us)") + set_parser.add_argument("-l", "--loss", type=float, help="loss (%%)") + set_parser.add_argument("-j", "--jitter", type=int, help="jitter (us)") + set_parser.add_argument("-r", "--range", type=int, help="range (pixels)") + set_parser.set_defaults(func=set_wlan_config) + + def main() -> None: parser = ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) parser.add_argument( @@ -467,6 +538,7 @@ def main() -> None: setup_link_parser(subparsers) setup_query_parser(subparsers) setup_xml_parser(subparsers) + setup_wlan_parser(subparsers) args = parser.parse_args() args.func(args) From 7a6c602369ebb44777df7502e63dac348b693b45 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 1 Jul 2020 11:01:44 -0700 Subject: [PATCH 226/929] core-cli: cleaned up core client usage by way of a decorator, helps provide convenient grpc error catching --- daemon/scripts/core-cli | 346 ++++++++++++++++++++-------------------- 1 file changed, 170 insertions(+), 176 deletions(-) diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index 9dfadfcc..61b47ae4 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -7,6 +7,7 @@ from argparse import ( Namespace, _SubParsersAction, ) +from functools import wraps from pathlib import Path from typing import Any, Optional, Tuple @@ -29,6 +30,19 @@ from core.api.grpc.core_pb2 import ( NODE_TYPES = [k for k, v in NodeType.Enum.items() if v != NodeType.PEER_TO_PEER] +def coreclient(func): + @wraps(func) + def wrapper(*args, **kwargs): + core = CoreGrpcClient() + try: + with core.context_connect(): + return func(core, *args, **kwargs) + except grpc.RpcError as e: + print(f"grpc error: {e.details()}") + + return wrapper + + def mac_type(value: str) -> str: try: mac = EUI(value, dialect=netaddr.mac_unix_expanded) @@ -88,12 +102,10 @@ def file_type(value: str) -> str: return str(path.absolute()) -def get_current_session(session_id: Optional[int]) -> int: +def get_current_session(core: CoreGrpcClient, session_id: Optional[int]) -> int: if session_id: return session_id - core = CoreGrpcClient() - with core.context_connect(): - response = core.get_sessions() + response = core.get_sessions() if not response.sessions: print("no current session to interact with") sys.exit(1) @@ -130,27 +142,24 @@ def print_json(message: Any) -> None: print(json) -def get_wlan_config(args: Namespace) -> None: - session_id = get_current_session(args.session) - core = CoreGrpcClient() - try: - with core.context_connect(): - response = core.get_wlan_config(session_id, args.node) - if args.json: - print_json(response) - else: - size = 0 - for option in response.config.values(): - size = max(size, len(option.name)) - print(f"{'Name':<{size}.{size}} | Value") - for option in response.config.values(): - print(f"{option.name:<{size}.{size}} | {option.value}") - except grpc.RpcError as e: - print(f"grpc error: {e.details()}") +@coreclient +def get_wlan_config(core: CoreGrpcClient, args: Namespace) -> None: + session_id = get_current_session(core, args.session) + response = core.get_wlan_config(session_id, args.node) + if args.json: + print_json(response) + else: + size = 0 + for option in response.config.values(): + size = max(size, len(option.name)) + print(f"{'Name':<{size}.{size}} | Value") + for option in response.config.values(): + print(f"{option.name:<{size}.{size}} | {option.value}") -def set_wlan_config(args: Namespace) -> None: - session_id = get_current_session(args.session) +@coreclient +def set_wlan_config(core: CoreGrpcClient, args: Namespace) -> None: + session_id = get_current_session(core, args.session) config = {} if args.bandwidth: config["bandwidth"] = str(args.bandwidth) @@ -162,108 +171,100 @@ def set_wlan_config(args: Namespace) -> None: config["jitter"] = str(args.jitter) if args.range: config["range"] = str(args.range) - core = CoreGrpcClient() - try: - with core.context_connect(): - response = core.set_wlan_config(session_id, args.node, config) - if args.json: - print_json(response) - else: - print(f"set wlan config: {response.result}") - except grpc.RpcError as e: - print(f"grpc error: {e.details()}") + response = core.set_wlan_config(session_id, args.node, config) + if args.json: + print_json(response) + else: + print(f"set wlan config: {response.result}") -def open_xml(args: Namespace) -> None: - core = CoreGrpcClient() - with core.context_connect(): - response = core.open_xml(args.file, args.start) - if args.json: - print_json(response) - else: - print(f"opened xml: {response.result}") +@coreclient +def open_xml(core: CoreGrpcClient, args: Namespace) -> None: + response = core.open_xml(args.file, args.start) + if args.json: + print_json(response) + else: + print(f"opened xml: {response.result}") -def query_sessions(args: Namespace) -> None: - core = CoreGrpcClient() - with core.context_connect(): - response = core.get_sessions() - if args.json: - print_json(response) - else: - print("Session ID | Session State | Nodes") - for s in response.sessions: - state = SessionState.Enum.Name(s.state) - print(f"{s.id:<10} | {state:<13} | {s.nodes}") +@coreclient +def query_sessions(core: CoreGrpcClient, args: Namespace) -> None: + response = core.get_sessions() + if args.json: + print_json(response) + else: + print("Session ID | Session State | Nodes") + for s in response.sessions: + state = SessionState.Enum.Name(s.state) + print(f"{s.id:<10} | {state:<13} | {s.nodes}") -def query_session(args: Namespace) -> None: - core = CoreGrpcClient() - with core.context_connect(): - response = core.get_session(args.id) - if args.json: - print_json(response) - else: - print("Nodes") - print("Node ID | Node Name | Node Type") - names = {} - for node in response.session.nodes: - names[node.id] = node.name - node_type = NodeType.Enum.Name(node.type) - print(f"{node.id:<7} | {node.name:<9} | {node_type}") - - print("\nLinks") - for link in response.session.links: - n1 = names[link.node1_id] - n2 = names[link.node2_id] - print(f"Node | ", end="") - print_iface_header() - print(f"{n1:<6} | ", end="") - if link.HasField("iface1"): - print_iface(link.iface1) - else: - print() - print(f"{n2:<6} | ", end="") - if link.HasField("iface2"): - print_iface(link.iface2) - else: - print() - print() - - -def query_node(args: Namespace) -> None: - core = CoreGrpcClient() - with core.context_connect(): +@coreclient +def query_session(core: CoreGrpcClient, args: Namespace) -> None: + response = core.get_session(args.id) + if args.json: + print_json(response) + else: + print("Nodes") + print("Node ID | Node Name | Node Type") names = {} - response = core.get_session(args.id) for node in response.session.nodes: names[node.id] = node.name - - response = core.get_node(args.id, args.node) - if args.json: - print_json(response) - else: - node = response.node node_type = NodeType.Enum.Name(node.type) - print("ID | Name | Type") - print(f"{node.id:<4} | {node.name:<7} | {node_type}") - print("Interfaces") - print("Connected To | ", end="") + print(f"{node.id:<7} | {node.name:<9} | {node_type}") + + print("\nLinks") + for link in response.session.links: + n1 = names[link.node1_id] + n2 = names[link.node2_id] + print(f"Node | ", end="") print_iface_header() - for iface in response.ifaces: - if iface.net_id == node.id: - if iface.node_id: - name = names[iface.node_id] - else: - name = names[iface.net2_id] + print(f"{n1:<6} | ", end="") + if link.HasField("iface1"): + print_iface(link.iface1) + else: + print() + print(f"{n2:<6} | ", end="") + if link.HasField("iface2"): + print_iface(link.iface2) + else: + print() + print() + + +@coreclient +def query_node(core: CoreGrpcClient, args: Namespace) -> None: + names = {} + response = core.get_session(args.id) + for node in response.session.nodes: + names[node.id] = node.name + + response = core.get_node(args.id, args.node) + if args.json: + print_json(response) + else: + node = response.node + node_type = NodeType.Enum.Name(node.type) + print("ID | Name | Type") + print(f"{node.id:<4} | {node.name:<7} | {node_type}") + print("Interfaces") + print("Connected To | ", end="") + print_iface_header() + for iface in response.ifaces: + if iface.net_id == node.id: + if iface.node_id: + name = names[iface.node_id] else: - name = names[iface.net_id] - print(f"{name:<12} | ", end="") - print_iface(iface) + name = names[iface.net2_id] + else: + name = names[iface.net_id] + print(f"{name:<12} | ", end="") + print_iface(iface) -def add_node(args: Namespace) -> None: - session_id = get_current_session(args.session) +@coreclient +def add_node(core: CoreGrpcClient, args: Namespace) -> None: + session_id = get_current_session(core, args.session) node_type = NodeType.Enum.Value(args.type) pos = None if args.pos: @@ -273,28 +274,27 @@ def add_node(args: Namespace) -> None: if args.geo: lon, lat, alt = args.geo geo = Geo(lon=lon, lat=lat, alt=alt) - core = CoreGrpcClient() - with core.context_connect(): - node = Node( - id=args.id, - name=args.name, - type=node_type, - model=args.model, - emane=args.emane, - icon=args.icon, - image=args.image, - position=pos, - geo=geo, - ) - response = core.add_node(session_id, node) - if args.json: - print_json(response) - else: - print(f"created node: {response.node_id}") + node = Node( + id=args.id, + name=args.name, + type=node_type, + model=args.model, + emane=args.emane, + icon=args.icon, + image=args.image, + position=pos, + geo=geo, + ) + response = core.add_node(session_id, node) + if args.json: + print_json(response) + else: + print(f"created node: {response.node_id}") -def edit_node(args: Namespace) -> None: - session_id = get_current_session(args.session) +@coreclient +def edit_node(core: CoreGrpcClient, args: Namespace) -> None: + session_id = get_current_session(core, args.session) pos = None if args.pos: x, y = args.pos @@ -303,28 +303,26 @@ def edit_node(args: Namespace) -> None: if args.geo: lon, lat, alt = args.geo geo = Geo(lon=lon, lat=lat, alt=alt) - core = CoreGrpcClient() - with core.context_connect(): - response = core.edit_node(session_id, args.id, pos, args.icon, geo) - if args.json: - print_json(response) - else: - print(f"edit node: {response.result}") + response = core.edit_node(session_id, args.id, pos, args.icon, geo) + if args.json: + print_json(response) + else: + print(f"edit node: {response.result}") -def delete_node(args: Namespace) -> None: - session_id = get_current_session(args.session) - core = CoreGrpcClient() - with core.context_connect(): - response = core.delete_node(session_id, args.id) - if args.json: - print_json(response) - else: - print(f"deleted node: {response.result}") +@coreclient +def delete_node(core: CoreGrpcClient, args: Namespace) -> None: + session_id = get_current_session(core, args.session) + response = core.delete_node(session_id, args.id) + if args.json: + print_json(response) + else: + print(f"deleted node: {response.result}") -def add_link(args: Namespace) -> None: - session_id = get_current_session(args.session) +@coreclient +def add_link(core: CoreGrpcClient, args: Namespace) -> None: + session_id = get_current_session(core, args.session) iface1 = None if args.iface1_id is not None: iface1 = create_iface(args.iface1_id, args.iface1_mac, args.iface1_ip4, args.iface1_ip6) @@ -339,17 +337,16 @@ def add_link(args: Namespace) -> None: dup=args.duplicate, unidirectional=args.uni, ) - core = CoreGrpcClient() - with core.context_connect(): - response = core.add_link(session_id, args.node1, args.node2, iface1, iface2, options) - if args.json: - print_json(response) - else: - print(f"add link: {response.result}") + response = core.add_link(session_id, args.node1, args.node2, iface1, iface2, options) + if args.json: + print_json(response) + else: + print(f"add link: {response.result}") -def edit_link(args: Namespace) -> None: - session_id = get_current_session(args.session) +@coreclient +def edit_link(core: CoreGrpcClient, args: Namespace) -> None: + session_id = get_current_session(core, args.session) options = LinkOptions( bandwidth=args.bandwidth, loss=args.loss, @@ -358,26 +355,23 @@ def edit_link(args: Namespace) -> None: dup=args.duplicate, unidirectional=args.uni, ) - core = CoreGrpcClient() - with core.context_connect(): - response = core.edit_link( - session_id, args.node1, args.node2, options, args.iface1, args.iface2 - ) - if args.json: - print_json(response) - else: - print(f"edit link: {response.result}") + response = core.edit_link( + session_id, args.node1, args.node2, options, args.iface1, args.iface2 + ) + if args.json: + print_json(response) + else: + print(f"edit link: {response.result}") -def delete_link(args: Namespace) -> None: - session_id = get_current_session(args.session) - core = CoreGrpcClient() - with core.context_connect(): - response = core.delete_link(session_id, args.node1, args.node2, args.iface1, args.iface2) - if args.json: - print_json(response) - else: - print(f"delete link: {response.result}") +@coreclient +def delete_link(core: CoreGrpcClient, args: Namespace) -> None: + session_id = get_current_session(core, args.session) + response = core.delete_link(session_id, args.node1, args.node2, args.iface1, args.iface2) + if args.json: + print_json(response) + else: + print(f"delete link: {response.result}") def setup_node_parser(parent: _SubParsersAction) -> None: From 08bbaf463b576270b617e16fbaa58996d3f1311d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 1 Jul 2020 11:06:09 -0700 Subject: [PATCH 227/929] core-cli: updated xml command to use a flag argument to be consistent for now --- daemon/scripts/core-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index 61b47ae4..471facb3 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -491,7 +491,7 @@ def setup_query_parser(parent: _SubParsersAction) -> None: def setup_xml_parser(parent: _SubParsersAction) -> None: parser = parent.add_parser("xml", help="open session xml") parser.formatter_class = ArgumentDefaultsHelpFormatter - parser.add_argument("file", type=file_type, help="xml file to open") + parser.add_argument("-f", "--file", type=file_type, help="xml file to open", required=True) parser.add_argument("-s", "--start", action="store_true", help="start the session?") parser.set_defaults(func=open_xml) From a870c15b43a386a439ee6ef7815714d1a9bf4f07 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 1 Jul 2020 12:11:34 -0700 Subject: [PATCH 228/929] pygui: fixed joining sessions with mobility players --- daemon/core/gui/coreclient.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index cf331676..7cf8b123 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -377,7 +377,7 @@ class CoreClient: # organize canvas self.app.canvas.organize() - + self.show_mobility_players() # update ui to represent current state self.app.after(0, self.app.joined_session_update) From da9c0d066083e580482822cbe2c42ad08d55c036 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 1 Jul 2020 14:40:19 -0700 Subject: [PATCH 229/929] daemon: initial changes to breakout custom interface creation for networks that require it, without being emane specific --- daemon/core/emane/nodes.py | 36 +++++++++++++++++++++++++++++++++--- daemon/core/nodes/base.py | 36 +++++++++++++++++++----------------- 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index 8cc9cd87..be95e6d0 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -6,9 +6,10 @@ share the same MAC+PHY model. import logging from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type -from core.emulator.data import LinkData, LinkOptions +from core.emulator.data import InterfaceData, LinkData, LinkOptions from core.emulator.distributed import DistributedServer from core.emulator.enumerations import ( + EventTypes, LinkTypes, MessageFlags, NodeTypes, @@ -16,7 +17,7 @@ from core.emulator.enumerations import ( TransportType, ) from core.errors import CoreError -from core.nodes.base import CoreNetworkBase +from core.nodes.base import CoreNetworkBase, CoreNode from core.nodes.interface import CoreInterface, TunTap if TYPE_CHECKING: @@ -47,7 +48,7 @@ class EmaneNet(CoreNetworkBase): apitype: NodeTypes = NodeTypes.EMANE linktype: LinkTypes = LinkTypes.WIRED type: str = "wlan" - is_emane: bool = True + has_custom_iface: bool = True def __init__( self, @@ -262,3 +263,32 @@ class EmaneNet(CoreNetworkBase): if link: links.append(link) return links + + def custom_iface(self, node: CoreNode, iface_data: InterfaceData) -> CoreInterface: + # TUN/TAP is not ready for addressing yet; the device may + # take some time to appear, and installing it into a + # namespace after it has been bound removes addressing; + # save addresses with the interface now + iface_id = node.newtuntap(iface_data.id, iface_data.name) + node.attachnet(iface_id, self) + iface = node.get_iface(iface_id) + iface.set_mac(iface_data.mac) + for ip in iface_data.get_ips(): + iface.add_ip(ip) + # TODO: if added during runtime start EMANE + if self.session.state == EventTypes.RUNTIME_STATE: + logging.info("startup emane for node: %s", node.name) + # create specific xml if needed + config = self.session.emane.get_iface_config( + self.model.id, iface, self.model.name + ) + if config: + self.model.build_xml_files(config, iface) + + # start emane daemon + + # install netif + + # add nem to nemfile + + return iface diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 05ec87dc..039008ef 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -812,30 +812,29 @@ class CoreNode(CoreNodeBase): :param iface_data: interface data for new interface :return: interface index """ - ips = iface_data.get_ips() with self.lock: - # TODO: emane specific code - if net.is_emane is True: - iface_id = self.newtuntap(iface_data.id, iface_data.name) - # TUN/TAP is not ready for addressing yet; the device may - # take some time to appear, and installing it into a - # namespace after it has been bound removes addressing; - # save addresses with the interface now - self.attachnet(iface_id, net) - iface = self.get_iface(iface_id) - iface.set_mac(iface_data.mac) - for ip in ips: - iface.add_ip(ip) + if net.has_custom_iface: + return net.custom_iface(self, iface_data) + # if net.is_emane is True: + # iface_id = self.newtuntap(iface_data.id, iface_data.name) + # # TUN/TAP is not ready for addressing yet; the device may + # # take some time to appear, and installing it into a + # # namespace after it has been bound removes addressing; + # # save addresses with the interface now + # self.attachnet(iface_id, net) + # iface = self.get_iface(iface_id) + # iface.set_mac(iface_data.mac) + # for ip in ips: + # iface.add_ip(ip) else: iface_id = self.newveth(iface_data.id, iface_data.name) self.attachnet(iface_id, net) if iface_data.mac: self.set_mac(iface_id, iface_data.mac) - for ip in ips: + for ip in iface_data.get_ips(): self.add_ip(iface_id, ip) self.ifup(iface_id) - iface = self.get_iface(iface_id) - return iface + return self.get_iface(iface_id) def addfile(self, srcname: str, filename: str) -> None: """ @@ -925,7 +924,7 @@ class CoreNetworkBase(NodeBase): """ linktype: LinkTypes = LinkTypes.WIRED - is_emane: bool = False + has_custom_iface: bool = False def __init__( self, @@ -990,6 +989,9 @@ class CoreNetworkBase(NodeBase): """ raise NotImplementedError + def custom_iface(self, node: CoreNode, iface_data: InterfaceData) -> CoreInterface: + raise NotImplementedError + def get_linked_iface(self, net: "CoreNetworkBase") -> Optional[CoreInterface]: """ Return the interface that links this net with another net. From e549830e3342effb9ad5aaeea9d2972fa355902b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 1 Jul 2020 15:20:53 -0700 Subject: [PATCH 230/929] core-cli: fix to avoid errors for querying nodes with peer to peer links, until there is a proper way to get the other ends node name --- daemon/scripts/core-cli | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/scripts/core-cli b/daemon/scripts/core-cli index 471facb3..a7571471 100755 --- a/daemon/scripts/core-cli +++ b/daemon/scripts/core-cli @@ -257,7 +257,7 @@ def query_node(core: CoreGrpcClient, args: Namespace) -> None: else: name = names[iface.net2_id] else: - name = names[iface.net_id] + name = names.get(iface.net_id, "") print(f"{name:<12} | ", end="") print_iface(iface) From bd48e14348c05800930618aaa02f1bf616fb393c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 2 Jul 2020 15:37:51 -0700 Subject: [PATCH 231/929] daemon: initial changes to rework logic to start emane for a given interface --- daemon/core/emane/commeffect.py | 19 +- daemon/core/emane/emanemanager.py | 279 +++++++++-------------- daemon/core/emane/emanemodel.py | 27 +-- daemon/core/emane/nodes.py | 66 +----- daemon/core/emulator/session.py | 4 +- daemon/core/xml/emanexml.py | 363 +++++++++++------------------- 6 files changed, 262 insertions(+), 496 deletions(-) diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index 610099f1..100af9a7 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -62,9 +62,7 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): def config_groups(cls) -> List[ConfigGroup]: return [ConfigGroup("CommEffect SHIM Parameters", 1, len(cls.configurations()))] - def build_xml_files( - self, config: Dict[str, str], iface: CoreInterface = None - ) -> None: + def build_xml_files(self, config: Dict[str, str], iface: CoreInterface) -> None: """ Build the necessary nem and commeffect XMLs in the given path. If an individual NEM has a nonstandard config, we need to build @@ -75,22 +73,25 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): :param iface: interface for the emane node :return: nothing """ + # interface node + node = iface.node + # retrieve xml names - nem_name = emanexml.nem_file_name(self, iface) - shim_name = emanexml.shim_file_name(self, iface) + nem_name = emanexml.nem_file_name(iface) + shim_name = emanexml.shim_file_name(iface) # create and write nem document nem_element = etree.Element("nem", name=f"{self.name} NEM", type="unstructured") transport_type = TransportType.VIRTUAL - if iface and iface.transport_type == TransportType.RAW: + if iface.transport_type == TransportType.RAW: transport_type = TransportType.RAW - transport_file = emanexml.transport_file_name(self.id, transport_type) + transport_file = emanexml.transport_file_name(iface, transport_type) etree.SubElement(nem_element, "transport", definition=transport_file) # set shim configuration etree.SubElement(nem_element, "shim", definition=shim_name) - nem_file = os.path.join(self.session.session_dir, nem_name) + nem_file = os.path.join(node.nodedir, nem_name) emanexml.create_file(nem_element, "nem", nem_file) # create and write shim document @@ -111,7 +112,7 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): if ff.strip() != "": emanexml.add_param(shim_element, "filterfile", ff) - shim_file = os.path.join(self.session.session_dir, shim_name) + shim_file = os.path.join(node.nodedir, shim_name) emanexml.create_file(shim_element, "shim", shim_file) def linkconfig( diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index fc561b5f..3317a5db 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -6,6 +6,7 @@ import logging import os import threading from collections import OrderedDict +from enum import Enum from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple, Type from core import utils @@ -25,11 +26,11 @@ from core.emulator.enumerations import ( LinkTypes, MessageFlags, RegisterTlvs, + TransportType, ) from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNode, NodeBase -from core.nodes.interface import CoreInterface -from core.nodes.network import CtrlNet +from core.nodes.interface import CoreInterface, TunTap from core.nodes.physical import Rj45Node from core.xml import emanexml @@ -63,6 +64,12 @@ DEFAULT_EMANE_PREFIX = "/usr" DEFAULT_DEV = "ctrl0" +class EmaneState(Enum): + SUCCESS = 0 + NOT_NEEDED = 1 + NOT_READY = 2 + + class EmaneManager(ModelManager): """ EMANE controller object. Lives in a Session instance and is used for @@ -72,8 +79,6 @@ class EmaneManager(ModelManager): name: str = "emane" config_type: RegisterTlvs = RegisterTlvs.EMULATION_SERVER - SUCCESS: int = 0 - NOT_NEEDED: int = 1 NOT_READY: int = 2 EVENTCFGVAR: str = "LIBEMANEEVENTSERVICECONFIG" DEFAULT_LOG_LEVEL: int = 3 @@ -87,6 +92,7 @@ class EmaneManager(ModelManager): """ super().__init__() self.session: "Session" = session + self.nems: Dict[int, CoreInterface] = {} self._emane_nets: Dict[int, EmaneNet] = {} self._emane_node_lock: threading.Lock = threading.Lock() # port numbers are allocated from these counters @@ -111,46 +117,47 @@ class EmaneManager(ModelManager): self.event_device: Optional[str] = None self.emane_check() + def next_nem_id(self) -> int: + nem_id = int(self.get_config("nem_id_start")) + while nem_id in self.nems: + nem_id += 1 + return nem_id + def get_iface_config( - self, node_id: int, iface: CoreInterface, model_name: str + self, emane_net: EmaneNet, iface: CoreInterface ) -> Dict[str, str]: """ - Retrieve interface configuration or node configuration if not provided. + Retrieve configuration for a given interface. - :param node_id: node id - :param iface: node interface - :param model_name: model to get configuration for - :return: node/interface model configuration + :param emane_net: emane network the interface is connected to + :param iface: interface running emane + :return: net, node, or interface model configuration """ + model_name = emane_net.model.name # use the network-wide config values or interface(NEM)-specific values? if iface is None: - return self.get_configs(node_id=node_id, config_type=model_name) + return self.get_configs(node_id=emane_net.id, config_type=model_name) else: # don"t use default values when interface config is the same as net # note here that using iface.node.id as key allows for only one type # of each model per node; # TODO: use both node and interface as key - # Adamson change: first check for iface config keyed by "node:iface.name" # (so that nodes w/ multiple interfaces of same conftype can have # different configs for each separate interface) key = 1000 * iface.node.id if iface.node_id is not None: key += iface.node_id - # try retrieve interface specific configuration, avoid getting defaults config = self.get_configs(node_id=key, config_type=model_name) - # otherwise retrieve the interfaces node configuration, avoid using defaults if not config: config = self.get_configs(node_id=iface.node.id, config_type=model_name) - # get non interface config, when none found if not config: # with EMANE 0.9.2+, we need an extra NEM XML from # model.buildnemxmlfiles(), so defaults are returned here - config = self.get_configs(node_id=node_id, config_type=model_name) - + config = self.get_configs(node_id=emane_net.id, config_type=model_name) return config def config_reset(self, node_id: int = None) -> None: @@ -260,14 +267,13 @@ class EmaneManager(ModelManager): Return a set of CoreNodes that are linked to an EMANE network, e.g. containers having one or more radio interfaces. """ - # assumes self._objslock already held nodes = set() for emane_net in self._emane_nets.values(): for iface in emane_net.get_ifaces(): nodes.add(iface.node) return nodes - def setup(self) -> int: + def setup(self) -> EmaneState: """ Setup duties for EMANE manager. @@ -288,7 +294,7 @@ class EmaneManager(ModelManager): if not self._emane_nets: logging.debug("no emane nodes in session") - return EmaneManager.NOT_NEEDED + return EmaneState.NOT_NEEDED # check if bindings were installed if EventService is None: @@ -304,7 +310,7 @@ class EmaneManager(ModelManager): "EMANE cannot start, check core config. invalid OTA device provided: %s", otadev, ) - return EmaneManager.NOT_READY + return EmaneState.NOT_READY self.session.add_remove_control_net( net_index=netidx, remove=False, conf_required=False @@ -319,16 +325,16 @@ class EmaneManager(ModelManager): "EMANE cannot start, check core config. invalid event service device: %s", eventdev, ) - return EmaneManager.NOT_READY + return EmaneState.NOT_READY self.session.add_remove_control_net( net_index=netidx, remove=False, conf_required=False ) self.check_node_models() - return EmaneManager.SUCCESS + return EmaneState.SUCCESS - def startup(self) -> int: + def startup(self) -> EmaneState: """ After all the EMANE networks have been added, build XML files and start the daemons. @@ -337,39 +343,49 @@ class EmaneManager(ModelManager): instantiation """ self.reset() - r = self.setup() - - # NOT_NEEDED or NOT_READY - if r != EmaneManager.SUCCESS: - return r - - nems = [] + status = self.setup() + if status != EmaneState.SUCCESS: + return status + self.starteventmonitor() + self.buildeventservicexml() with self._emane_node_lock: - self.buildxml() - self.starteventmonitor() - - if self.numnems() > 0: - self.startdaemons() - self.install_ifaces() - - for node_id in self._emane_nets: - emane_node = self._emane_nets[node_id] - for iface in emane_node.get_ifaces(): - nems.append( - (iface.node.name, iface.name, emane_node.getnemid(iface)) + # on master, control network bridge added earlier in startup() + control_net = self.session.add_remove_control_net( + 0, remove=False, conf_required=False + ) + logging.info("emane building xmls...") + for node_id in sorted(self._emane_nets): + emane_net = self._emane_nets[node_id] + if not emane_net.model: + logging.error("emane net(%s) has no model", emane_net.name) + continue + for iface in emane_net.get_ifaces(): + if not iface.node: + logging.error( + "emane net(%s) connected interface missing node", + emane_net.name, + ) + continue + nem_id = self.next_nem_id() + self.nems[nem_id] = iface + self.write_nem(iface, nem_id) + emanexml.build_platform_xml( + self, control_net, emane_net, iface, nem_id ) - - if nems: - emane_nems_filename = os.path.join(self.session.session_dir, "emane_nems") - try: - with open(emane_nems_filename, "w") as f: - for nodename, ifname, nemid in nems: - f.write(f"{nodename} {ifname} {nemid}\n") - except IOError: - logging.exception("Error writing EMANE NEMs file: %s") + emanexml.build_model_xmls(self, emane_net, iface) + self.start_daemon(iface) + self.install_iface(emane_net, iface) if self.links_enabled(): self.link_monitor.start() - return EmaneManager.SUCCESS + return EmaneState.SUCCESS + + def write_nem(self, iface: CoreInterface, nem_id: int) -> None: + path = os.path.join(self.session.session_dir, "emane_nems") + try: + with open(path, "a") as f: + f.write(f"{iface.node.name} {iface.name} {nem_id}\n") + except IOError: + logging.exception("error writing to emane nem file") def links_enabled(self) -> bool: return self.get_config("link_enabled") == "1" @@ -380,17 +396,14 @@ class EmaneManager(ModelManager): """ if not self.genlocationevents(): return - with self._emane_node_lock: - for key in sorted(self._emane_nets.keys()): - emane_node = self._emane_nets[key] + for node_id in sorted(self._emane_nets): + emane_net = self._emane_nets[node_id] logging.debug( - "post startup for emane node: %s - %s", - emane_node.id, - emane_node.name, + "post startup for emane node: %s - %s", emane_net.id, emane_net.name ) - emane_node.model.post_startup() - for iface in emane_node.get_ifaces(): + emane_net.model.post_startup() + for iface in emane_net.get_ifaces(): iface.setposition() def reset(self) -> None: @@ -400,13 +413,7 @@ class EmaneManager(ModelManager): """ with self._emane_node_lock: self._emane_nets.clear() - - self.platformport = self.session.options.get_config_int( - "emane_platform_port", 8100 - ) - self.transformport = self.session.options.get_config_int( - "emane_transform_port", 8200 - ) + self.nems.clear() def shutdown(self) -> None: """ @@ -422,40 +429,23 @@ class EmaneManager(ModelManager): self.stopdaemons() self.stopeventmonitor() - def buildxml(self) -> None: - """ - Build XML files required to run EMANE on each node. - NEMs run inside containers using the control network for passing - events and data. - """ - # assume self._objslock is already held here - logging.info("emane building xml...") - # on master, control network bridge added earlier in startup() - ctrlnet = self.session.add_remove_control_net( - net_index=0, remove=False, conf_required=False - ) - self.buildplatformxml(ctrlnet) - self.buildnemxml() - self.buildeventservicexml() - def check_node_models(self) -> None: """ Associate EMANE model classes with EMANE network nodes. """ for node_id in self._emane_nets: - emane_node = self._emane_nets[node_id] + emane_net = self._emane_nets[node_id] logging.debug("checking emane model for node: %s", node_id) # skip nodes that already have a model set - if emane_node.model: + if emane_net.model: logging.debug( - "node(%s) already has model(%s)", - emane_node.id, - emane_node.model.name, + "node(%s) already has model(%s)", emane_net.id, emane_net.model.name ) continue - # set model configured for node, due to legacy messaging configuration before nodes exist + # set model configured for node, due to legacy messaging configuration + # before nodes exist model_name = self.node_models.get(node_id) if not model_name: logging.error("emane node(%s) has no node model", node_id) @@ -464,7 +454,7 @@ class EmaneManager(ModelManager): config = self.get_model_config(node_id=node_id, model_name=model_name) logging.debug("setting emane model(%s) config(%s)", model_name, config) model_class = self.models[model_name] - emane_node.setmodel(model_class, config) + emane_net.setmodel(model_class, config) def nemlookup(self, nemid) -> Tuple[Optional[EmaneNet], Optional[CoreInterface]]: """ @@ -473,7 +463,6 @@ class EmaneManager(ModelManager): """ emane_node = None iface = None - for node_id in self._emane_nets: emane_node = self._emane_nets[node_id] iface = emane_node.get_nem_iface(nemid) @@ -481,7 +470,6 @@ class EmaneManager(ModelManager): break else: emane_node = None - return emane_node, iface def get_nem_link( @@ -507,38 +495,6 @@ class EmaneManager(ModelManager): color=color, ) - def numnems(self) -> int: - """ - Return the number of NEMs emulated locally. - """ - count = 0 - for node_id in self._emane_nets: - emane_node = self._emane_nets[node_id] - count += len(emane_node.ifaces) - return count - - def buildplatformxml(self, ctrlnet: CtrlNet) -> None: - """ - Build a platform.xml file now that all nodes are configured. - """ - nemid = int(self.get_config("nem_id_start")) - platform_xmls = {} - - # assume self._objslock is already held here - for key in sorted(self._emane_nets.keys()): - emane_node = self._emane_nets[key] - nemid = emanexml.build_node_platform_xml( - self, ctrlnet, emane_node, nemid, platform_xmls - ) - - def buildnemxml(self) -> None: - """ - Builds the nem, mac, and phy xml files for each EMANE network. - """ - for key in sorted(self._emane_nets): - emane_net = self._emane_nets[key] - emanexml.build_xml_files(self, emane_net) - def buildeventservicexml(self) -> None: """ Build the libemaneeventservice.xml file if event service options @@ -571,7 +527,7 @@ class EmaneManager(ModelManager): ) ) - def startdaemons(self) -> None: + def start_daemon(self, iface: CoreInterface) -> None: """ Start one EMANE daemon per node having a radio. Add a control network even if the user has not configured one. @@ -583,69 +539,51 @@ class EmaneManager(ModelManager): if cfgloglevel: logging.info("setting user-defined EMANE log level: %d", cfgloglevel) loglevel = str(cfgloglevel) - emanecmd = f"emane -d -l {loglevel}" if realtime: emanecmd += " -r" - otagroup, _otaport = self.get_config("otamanagergroup").split(":") otadev = self.get_config("otamanagerdevice") otanetidx = self.session.get_control_net_index(otadev) - eventgroup, _eventport = self.get_config("eventservicegroup").split(":") eventdev = self.get_config("eventservicedevice") eventservicenetidx = self.session.get_control_net_index(eventdev) - - run_emane_on_host = False - for node in self.getnodes(): - if isinstance(node, Rj45Node): - run_emane_on_host = True - continue - path = self.session.session_dir - n = node.id - + node = iface.node + if not isinstance(node, Rj45Node): # control network not yet started here self.session.add_remove_control_iface( node, 0, remove=False, conf_required=False ) - if otanetidx > 0: logging.info("adding ota device ctrl%d", otanetidx) self.session.add_remove_control_iface( node, otanetidx, remove=False, conf_required=False ) - if eventservicenetidx >= 0: logging.info("adding event service device ctrl%d", eventservicenetidx) self.session.add_remove_control_iface( node, eventservicenetidx, remove=False, conf_required=False ) - # multicast route is needed for OTA data node.node_net_client.create_route(otagroup, otadev) - # multicast route is also needed for event data if on control network if eventservicenetidx >= 0 and eventgroup != otagroup: node.node_net_client.create_route(eventgroup, eventdev) - # start emane - log_file = os.path.join(path, f"emane{n}.log") - platform_xml = os.path.join(path, f"platform{n}.xml") + log_file = os.path.join(node.nodedir, f"{iface.name}-emane.log") + platform_xml = os.path.join(node.nodedir, f"{iface.name}-platform.xml") args = f"{emanecmd} -f {log_file} {platform_xml}" output = node.cmd(args) logging.info("node(%s) emane daemon running: %s", node.name, args) logging.debug("node(%s) emane daemon output: %s", node.name, output) - - if not run_emane_on_host: - return - - path = self.session.session_dir - log_file = os.path.join(path, "emane.log") - platform_xml = os.path.join(path, "platform.xml") - emanecmd += f" -f {log_file} {platform_xml}" - utils.cmd(emanecmd, cwd=path) - self.session.distributed.execute(lambda x: x.remote_cmd(emanecmd, cwd=path)) - logging.info("host emane daemon running: %s", emanecmd) + else: + path = self.session.session_dir + log_file = os.path.join(path, f"{iface.name}-emane.log") + platform_xml = os.path.join(path, f"{iface.name}-platform.xml") + emanecmd += f" -f {log_file} {platform_xml}" + utils.cmd(emanecmd, cwd=path) + self.session.distributed.execute(lambda x: x.remote_cmd(emanecmd, cwd=path)) + logging.info("host emane daemon running: %s", emanecmd) def stopdaemons(self) -> None: """ @@ -674,23 +612,27 @@ class EmaneManager(ModelManager): except CoreCommandError: logging.exception("error shutting down emane daemons") - def install_ifaces(self) -> None: - """ - Install TUN/TAP virtual interfaces into their proper namespaces - now that the EMANE daemons are running. - """ - for key in sorted(self._emane_nets.keys()): - node = self._emane_nets[key] - logging.info("emane install interface for node(%s): %d", node.name, key) - node.install_ifaces() + def install_iface(self, emane_net: EmaneNet, iface: CoreInterface) -> None: + config = self.get_iface_config(emane_net, iface) + external = config.get("external", "0") + if isinstance(iface, TunTap) and external == "0": + iface.set_ips() + # at this point we register location handlers for generating + # EMANE location events + if self.genlocationevents(): + iface.poshook = emane_net.setnemposition + iface.setposition() def deinstall_ifaces(self) -> None: """ Uninstall TUN/TAP virtual interfaces. """ - for key in sorted(self._emane_nets.keys()): - emane_node = self._emane_nets[key] - emane_node.deinstall_ifaces() + for key in sorted(self._emane_nets): + emane_net = self._emane_nets[key] + for iface in emane_net.get_ifaces(): + if iface.transport_type == TransportType.VIRTUAL: + iface.shutdown() + iface.poshook = None def doeventmonitor(self) -> bool: """ @@ -718,7 +660,6 @@ class EmaneManager(ModelManager): logging.info("emane start event monitor") if not self.doeventmonitor(): return - if self.service is None: logging.error( "Warning: EMANE events will not be generated " diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 43fbc0fb..0576d6c3 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -96,9 +96,7 @@ class EmaneModel(WirelessModel): ConfigGroup("External Parameters", phy_len + 1, config_len), ] - def build_xml_files( - self, config: Dict[str, str], iface: CoreInterface = None - ) -> None: + def build_xml_files(self, config: Dict[str, str], iface: CoreInterface) -> None: """ Builds xml files for this emane model. Creates a nem.xml file that points to both mac.xml and phy.xml definitions. @@ -107,33 +105,30 @@ class EmaneModel(WirelessModel): :param iface: interface for the emane node :return: nothing """ - nem_name = emanexml.nem_file_name(self, iface) - mac_name = emanexml.mac_file_name(self, iface) - phy_name = emanexml.phy_file_name(self, iface) - - # remote server for file - server = None - if iface is not None: - server = iface.node.server + nem_name = emanexml.nem_file_name(iface) + mac_name = emanexml.mac_file_name(iface) + phy_name = emanexml.phy_file_name(iface) # check if this is external transport_type = TransportType.VIRTUAL - if iface and iface.transport_type == TransportType.RAW: + if iface.transport_type == TransportType.RAW: transport_type = TransportType.RAW - transport_name = emanexml.transport_file_name(self.id, transport_type) + transport_name = emanexml.transport_file_name(iface, transport_type) + node = iface.node + server = node.server # create nem xml file - nem_file = os.path.join(self.session.session_dir, nem_name) + nem_file = os.path.join(node.nodedir, nem_name) emanexml.create_nem_xml( self, config, nem_file, transport_name, mac_name, phy_name, server ) # create mac xml file - mac_file = os.path.join(self.session.session_dir, mac_name) + mac_file = os.path.join(node.nodedir, mac_name) emanexml.create_mac_xml(self, config, mac_file, server) # create phy xml file - phy_file = os.path.join(self.session.session_dir, phy_name) + phy_file = os.path.join(node.nodedir, phy_name) emanexml.create_phy_xml(self, config, phy_file, server) def post_startup(self) -> None: diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index be95e6d0..dca85785 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -8,17 +8,10 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type from core.emulator.data import InterfaceData, LinkData, LinkOptions from core.emulator.distributed import DistributedServer -from core.emulator.enumerations import ( - EventTypes, - LinkTypes, - MessageFlags, - NodeTypes, - RegisterTlvs, - TransportType, -) +from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes, RegisterTlvs from core.errors import CoreError from core.nodes.base import CoreNetworkBase, CoreNode -from core.nodes.interface import CoreInterface, TunTap +from core.nodes.interface import CoreInterface if TYPE_CHECKING: from core.emane.emanemodel import EmaneModel @@ -139,45 +132,6 @@ class EmaneNet(CoreNetworkBase): return iface return None - def install_ifaces(self) -> None: - """ - Install TAP devices into their namespaces. This is done after - EMANE daemons have been started, because that is their only chance - to bind to the TAPs. - """ - if ( - self.session.emane.genlocationevents() - and self.session.emane.service is None - ): - warntxt = "unable to publish EMANE events because the eventservice " - warntxt += "Python bindings failed to load" - logging.error(warntxt) - for iface in self.get_ifaces(): - config = self.session.emane.get_iface_config( - self.id, iface, self.model.name - ) - external = config.get("external", "0") - if isinstance(iface, TunTap) and external == "0": - iface.set_ips() - if not self.session.emane.genlocationevents(): - iface.poshook = None - continue - # at this point we register location handlers for generating - # EMANE location events - iface.poshook = self.setnemposition - iface.setposition() - - def deinstall_ifaces(self) -> None: - """ - Uninstall TAP devices. This invokes their shutdown method for - any required cleanup; the device may be actually removed when - emanetransportd terminates. - """ - for iface in self.get_ifaces(): - if iface.transport_type == TransportType.VIRTUAL: - iface.shutdown() - iface.poshook = None - def _nem_position( self, iface: CoreInterface ) -> Optional[Tuple[int, float, float, float]]: @@ -275,20 +229,4 @@ class EmaneNet(CoreNetworkBase): iface.set_mac(iface_data.mac) for ip in iface_data.get_ips(): iface.add_ip(ip) - # TODO: if added during runtime start EMANE - if self.session.state == EventTypes.RUNTIME_STATE: - logging.info("startup emane for node: %s", node.name) - # create specific xml if needed - config = self.session.emane.get_iface_config( - self.model.id, iface, self.model.name - ) - if config: - self.model.build_xml_files(config, iface) - - # start emane daemon - - # install netif - - # add nem to nemfile - return iface diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index c2573578..9f5364b9 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -15,7 +15,7 @@ from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Type, TypeVa from core import constants, utils from core.configservice.manager import ConfigServiceManager -from core.emane.emanemanager import EmaneManager +from core.emane.emanemanager import EmaneManager, EmaneState from core.emane.nodes import EmaneNet from core.emulator.data import ( ConfigData, @@ -1181,7 +1181,7 @@ class Session: self.distributed.start() # instantiate will be invoked again upon emane configure - if self.emane.startup() == self.emane.NOT_READY: + if self.emane.startup() == EmaneState.NOT_READY: return [] # boot node services and then start mobility diff --git a/daemon/core/xml/emanexml.py b/daemon/core/xml/emanexml.py index eece57c9..32ca0f67 100644 --- a/daemon/core/xml/emanexml.py +++ b/daemon/core/xml/emanexml.py @@ -10,6 +10,7 @@ from core.config import Configuration from core.emane.nodes import EmaneNet from core.emulator.distributed import DistributedServer from core.emulator.enumerations import TransportType +from core.errors import CoreError from core.nodes.interface import CoreInterface from core.nodes.network import CtrlNet from core.xml import corexml @@ -40,15 +41,11 @@ def _value_to_params(value: str) -> Optional[Tuple[str]]: """ try: values = utils.make_tuple_fromstr(value, str) - if not hasattr(values, "__iter__"): return None - if len(values) < 2: return None - return values - except SyntaxError: logging.exception("error in value string to param list") return None @@ -127,13 +124,13 @@ def add_configurations( add_param(xml_element, name, value) -def build_node_platform_xml( +def build_platform_xml( emane_manager: "EmaneManager", control_net: CtrlNet, - node: EmaneNet, + emane_net: EmaneNet, + iface: CoreInterface, nem_id: int, - platform_xmls: Dict[str, etree.Element], -) -> int: +) -> None: """ Create platform xml for a specific node. @@ -141,175 +138,121 @@ def build_node_platform_xml( configurations :param control_net: control net node for this emane network - :param node: node to write platform xml for - :param nem_id: nem id to use for interfaces for this node - :param platform_xmls: stores platform xml elements to append nem entries to + :param emane_net: emane network associated with interface + :param iface: interface running emane + :param nem_id: nem id to use for this interface :return: the next nem id that can be used for creating platform xml files """ - logging.debug( - "building emane platform xml for node(%s) nem_id(%s): %s", - node, - nem_id, - node.name, + # build nem xml + nem_definition = nem_file_name(iface) + nem_element = etree.Element( + "nem", id=str(nem_id), name=iface.localname, definition=nem_definition ) - nem_entries = {} - if node.model is None: - logging.warning("warning: EMANE network %s has no associated model", node.name) - return nem_id - - for iface in node.get_ifaces(): - logging.debug( - "building platform xml for interface(%s) nem_id(%s)", iface.name, nem_id - ) - # build nem xml - nem_definition = nem_file_name(node.model, iface) - nem_element = etree.Element( - "nem", id=str(nem_id), name=iface.localname, definition=nem_definition + # check if this is an external transport, get default config if an interface + # specific one does not exist + config = emane_manager.get_iface_config(emane_net, iface) + if is_external(config): + nem_element.set("transport", "external") + platform_endpoint = "platformendpoint" + add_param(nem_element, platform_endpoint, config[platform_endpoint]) + transport_endpoint = "transportendpoint" + add_param(nem_element, transport_endpoint, config[transport_endpoint]) + else: + # build transport xml + transport_type = iface.transport_type + if not transport_type: + logging.info("warning: %s interface type unsupported!", iface.name) + transport_type = TransportType.RAW + transport_file = transport_file_name(iface, transport_type) + transport_element = etree.SubElement( + nem_element, "transport", definition=transport_file ) + add_param(transport_element, "device", iface.name) - # check if this is an external transport, get default config if an interface - # specific one does not exist - config = emane_manager.get_iface_config(node.model.id, iface, node.model.name) - - if is_external(config): - nem_element.set("transport", "external") - platform_endpoint = "platformendpoint" - add_param(nem_element, platform_endpoint, config[platform_endpoint]) - transport_endpoint = "transportendpoint" - add_param(nem_element, transport_endpoint, config[transport_endpoint]) - else: - # build transport xml - transport_type = iface.transport_type - if not transport_type: - logging.info("warning: %s interface type unsupported!", iface.name) - transport_type = TransportType.RAW - transport_file = transport_file_name(node.id, transport_type) - transport_element = etree.SubElement( - nem_element, "transport", definition=transport_file - ) - - # add transport parameter - add_param(transport_element, "device", iface.name) - - # add nem entry - nem_entries[iface] = nem_element - - # merging code - key = iface.node.id - if iface.transport_type == TransportType.RAW: - key = "host" - otadev = control_net.brname - eventdev = control_net.brname - else: - otadev = None - eventdev = None - - platform_element = platform_xmls.get(key) - if platform_element is None: - platform_element = etree.Element("platform") - - if otadev: - emane_manager.set_config("otamanagerdevice", otadev) - - if eventdev: - emane_manager.set_config("eventservicedevice", eventdev) - - # append all platform options (except starting id) to doc - for configuration in emane_manager.emane_config.emulator_config: - name = configuration.id - if name == "platform_id_start": - continue - - value = emane_manager.get_config(name) - add_param(platform_element, name, value) - - # add platform xml - platform_xmls[key] = platform_element - - platform_element.append(nem_element) - - node.setnemid(iface, nem_id) - mac = _MAC_PREFIX + ":00:00:" - mac += f"{(nem_id >> 8) & 0xFF:02X}:{nem_id & 0xFF:02X}" - iface.set_mac(mac) - - # increment nem id - nem_id += 1 + # determine platform element to add xml to + key = iface.node.id + if iface.transport_type == TransportType.RAW: + key = "host" + otadev = control_net.brname + eventdev = control_net.brname + else: + otadev = None + eventdev = None + platform_element = etree.Element("platform") + if otadev: + emane_manager.set_config("otamanagerdevice", otadev) + if eventdev: + emane_manager.set_config("eventservicedevice", eventdev) + for configuration in emane_manager.emane_config.emulator_config: + name = configuration.id + value = emane_manager.get_config(name) + add_param(platform_element, name, value) + platform_element.append(nem_element) + emane_net.setnemid(iface, nem_id) + mac = _MAC_PREFIX + ":00:00:" + mac += f"{(nem_id >> 8) & 0xFF:02X}:{nem_id & 0xFF:02X}" + iface.set_mac(mac) doc_name = "platform" - for key in sorted(platform_xmls.keys()): - platform_element = platform_xmls[key] - if key == "host": - file_name = "platform.xml" - file_path = os.path.join(emane_manager.session.session_dir, file_name) - create_file(platform_element, doc_name, file_path) - else: - file_name = f"platform{key}.xml" - file_path = os.path.join(emane_manager.session.session_dir, file_name) - linked_node = emane_manager.session.nodes[key] - create_file(platform_element, doc_name, file_path, linked_node.server) - - return nem_id + server = None + if key == "host": + file_name = "platform.xml" + file_path = os.path.join(emane_manager.session.session_dir, file_name) + else: + node = iface.node + file_name = f"{iface.name}-platform.xml" + file_path = os.path.join(node.nodedir, file_name) + server = node.server + create_file(platform_element, doc_name, file_path, server) -def build_xml_files(emane_manager: "EmaneManager", node: EmaneNet) -> None: +def build_model_xmls( + manager: "EmaneManager", emane_net: EmaneNet, iface: CoreInterface +) -> None: """ Generate emane xml files required for node. - :param emane_manager: emane manager with emane + :param manager: emane manager with emane configurations - :param node: node to write platform xml for + :param emane_net: emane network associated with interface + :param iface: interface to create emane xml for :return: nothing """ - logging.debug("building all emane xml for node(%s): %s", node, node.name) - if node.model is None: - return - - # get model configurations - config = emane_manager.get_configs(node.model.id, node.model.name) - if not config: - return - - # build XML for overall network EMANE configs - node.model.build_xml_files(config) - # build XML for specific interface (NEM) configs + # check for interface specific emane configuration and write xml files + config = manager.get_iface_config(emane_net, iface) + emane_net.model.build_xml_files(config, iface) + + # check transport type needed for interface need_virtual = False need_raw = False vtype = TransportType.VIRTUAL rtype = TransportType.RAW - - for iface in node.get_ifaces(): - # check for interface specific emane configuration and write xml files - config = emane_manager.get_iface_config(node.model.id, iface, node.model.name) - if config: - node.model.build_xml_files(config, iface) - - # check transport type needed for interface - if iface.transport_type == TransportType.VIRTUAL: - need_virtual = True - vtype = iface.transport_type - else: - need_raw = True - rtype = iface.transport_type - + if iface.transport_type == TransportType.VIRTUAL: + need_virtual = True + vtype = iface.transport_type + else: + need_raw = True + rtype = iface.transport_type if need_virtual: - build_transport_xml(emane_manager, node, vtype) - + build_transport_xml(manager, emane_net, iface, vtype) if need_raw: - build_transport_xml(emane_manager, node, rtype) + build_transport_xml(manager, emane_net, iface, rtype) def build_transport_xml( - emane_manager: "EmaneManager", node: EmaneNet, transport_type: TransportType + manager: "EmaneManager", + emane_net: EmaneNet, + iface: CoreInterface, + transport_type: TransportType, ) -> None: """ Build transport xml file for node and transport type. - :param emane_manager: emane manager with emane - configurations - :param node: node to write platform xml for + :param manager: emane manager with emane configurations + :param emane_net: emane network associated with interface + :param iface: interface to build transport xml for :param transport_type: transport type to build xml for :return: nothing """ @@ -318,28 +261,24 @@ def build_transport_xml( name=f"{transport_type.value.capitalize()} Transport", library=f"trans{transport_type.value.lower()}", ) - - # add bitrate add_param(transport_element, "bitrate", "0") # get emane model cnfiguration - config = emane_manager.get_configs(node.id, node.model.name) + config = manager.get_iface_config(emane_net, iface) flowcontrol = config.get("flowcontrolenable", "0") == "1" - if transport_type == TransportType.VIRTUAL: device_path = "/dev/net/tun_flowctl" if not os.path.exists(device_path): device_path = "/dev/net/tun" add_param(transport_element, "devicepath", device_path) - if flowcontrol: add_param(transport_element, "flowcontrolenable", "on") - doc_name = "transport" - file_name = transport_file_name(node.id, transport_type) - file_path = os.path.join(emane_manager.session.session_dir, file_name) + node = iface.node + file_name = transport_file_name(iface, transport_type) + file_path = os.path.join(node.nodedir, file_name) create_file(transport_element, doc_name, file_path) - emane_manager.session.distributed.execute( + manager.session.distributed.execute( lambda x: create_file(transport_element, doc_name, file_path, x) ) @@ -348,7 +287,7 @@ def create_phy_xml( emane_model: "EmaneModel", config: Dict[str, str], file_path: str, - server: DistributedServer, + server: Optional[DistributedServer], ) -> None: """ Create the phy xml document. @@ -363,25 +302,17 @@ def create_phy_xml( phy_element = etree.Element("phy", name=f"{emane_model.name} PHY") if emane_model.phy_library: phy_element.set("library", emane_model.phy_library) - add_configurations( phy_element, emane_model.phy_config, config, emane_model.config_ignore ) - create_file(phy_element, "phy", file_path) - if server is not None: - create_file(phy_element, "phy", file_path, server) - else: - create_file(phy_element, "phy", file_path) - emane_model.session.distributed.execute( - lambda x: create_file(phy_element, "phy", file_path, x) - ) + create_file(phy_element, "phy", file_path, server) def create_mac_xml( emane_model: "EmaneModel", config: Dict[str, str], file_path: str, - server: DistributedServer, + server: Optional[DistributedServer], ) -> None: """ Create the mac xml document. @@ -394,22 +325,14 @@ def create_mac_xml( :return: nothing """ if not emane_model.mac_library: - raise ValueError("must define emane model library") - + raise CoreError("must define emane model library") mac_element = etree.Element( "mac", name=f"{emane_model.name} MAC", library=emane_model.mac_library ) add_configurations( mac_element, emane_model.mac_config, config, emane_model.config_ignore ) - create_file(mac_element, "mac", file_path) - if server is not None: - create_file(mac_element, "mac", file_path, server) - else: - create_file(mac_element, "mac", file_path) - emane_model.session.distributed.execute( - lambda x: create_file(mac_element, "mac", file_path, x) - ) + create_file(mac_element, "mac", file_path, server) def create_nem_xml( @@ -419,7 +342,7 @@ def create_nem_xml( transport_definition: str, mac_definition: str, phy_definition: str, - server: DistributedServer, + server: Optional[DistributedServer], ) -> None: """ Create the nem xml document. @@ -441,13 +364,7 @@ def create_nem_xml( etree.SubElement(nem_element, "transport", definition=transport_definition) etree.SubElement(nem_element, "mac", definition=mac_definition) etree.SubElement(nem_element, "phy", definition=phy_definition) - if server is not None: - create_file(nem_element, "nem", nem_file, server) - else: - create_file(nem_element, "nem", nem_file) - emane_model.session.distributed.execute( - lambda x: create_file(nem_element, "nem", nem_file, x) - ) + create_file(nem_element, "nem", nem_file, server) def create_event_service_xml( @@ -483,81 +400,55 @@ def create_event_service_xml( create_file(event_element, "emaneeventmsgsvc", file_path, server) -def transport_file_name(node_id: int, transport_type: TransportType) -> str: +def transport_file_name(iface: CoreInterface, transport_type: TransportType) -> str: """ Create name for a transport xml file. - :param node_id: node id to generate transport file name for + :param iface: interface running emane :param transport_type: transport type to generate transport file - :return: + :return: transport xml file name """ - return f"n{node_id}trans{transport_type.value}.xml" + return f"{iface.name}-trans-{transport_type.value}.xml" -def _basename(emane_model: "EmaneModel", iface: CoreInterface = None) -> str: +def nem_file_name(iface: CoreInterface) -> str: """ - Create name that is leveraged for configuration file creation. + Return the string name for the NEM XML file, e.g. "eth0-nem.xml" - :param emane_model: emane model to create name for - :param iface: interface for this model - :return: basename used for file creation + :param iface: interface running emane + :return: nem xm file name """ - name = f"n{emane_model.id}" - - if iface: - node_id = iface.node.id - if emane_model.session.emane.get_iface_config(node_id, iface, emane_model.name): - name = iface.localname.replace(".", "_") - - return f"{name}{emane_model.name}" - - -def nem_file_name(emane_model: "EmaneModel", iface: CoreInterface = None) -> str: - """ - Return the string name for the NEM XML file, e.g. "n3rfpipenem.xml" - - :param emane_model: emane model to create file - :param iface: interface for this model - :return: nem xml filename - """ - basename = _basename(emane_model, iface) append = "" if iface and iface.transport_type == TransportType.RAW: - append = "_raw" - return f"{basename}nem{append}.xml" + append = "-raw" + return f"{iface.name}-nem{append}.xml" -def shim_file_name(emane_model: "EmaneModel", iface: CoreInterface = None) -> str: +def shim_file_name(iface: CoreInterface = None) -> str: """ - Return the string name for the SHIM XML file, e.g. "commeffectshim.xml" + Return the string name for the SHIM XML file, e.g. "eth0-shim.xml" - :param emane_model: emane model to create file - :param iface: interface for this model - :return: shim xml filename + :param iface: interface running emane + :return: shim xml file name """ - name = _basename(emane_model, iface) - return f"{name}shim.xml" + return f"{iface.name}-shim.xml" -def mac_file_name(emane_model: "EmaneModel", iface: CoreInterface = None) -> str: +def mac_file_name(iface: CoreInterface) -> str: """ - Return the string name for the MAC XML file, e.g. "n3rfpipemac.xml" + Return the string name for the MAC XML file, e.g. "eth0-mac.xml" - :param emane_model: emane model to create file - :param iface: interface for this model - :return: mac xml filename + :param iface: interface running emane + :return: mac xml file name """ - name = _basename(emane_model, iface) - return f"{name}mac.xml" + return f"{iface.name}-mac.xml" -def phy_file_name(emane_model: "EmaneModel", iface: CoreInterface = None) -> str: +def phy_file_name(iface: CoreInterface) -> str: """ - Return the string name for the PHY XML file, e.g. "n3rfpipephy.xml" + Return the string name for the PHY XML file, e.g. "eth0-phy.xml" - :param emane_model: emane model to create file - :param iface: interface for this model - :return: phy xml filename + :param iface: interface running emane + :return: phy xml file name """ - name = _basename(emane_model, iface) - return f"{name}phy.xml" + return f"{iface.name}-phy.xml" From ce4b61d3b21afe5a4ac4402698f0eb09af1c57ac Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 2 Jul 2020 17:49:56 -0700 Subject: [PATCH 232/929] daemon: further heavy cleanup to how emane generates and runs xml files --- daemon/core/emane/commeffect.py | 26 ++--- daemon/core/emane/emanemanager.py | 46 ++++---- daemon/core/emane/emanemodel.py | 34 ++---- daemon/core/emane/nodes.py | 1 - daemon/core/xml/emanexml.py | 175 +++++++++++------------------- 5 files changed, 103 insertions(+), 179 deletions(-) diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index 100af9a7..a812b66d 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -12,7 +12,6 @@ from core.config import ConfigGroup, Configuration from core.emane import emanemanifest, emanemodel from core.emane.nodes import EmaneNet from core.emulator.data import LinkOptions -from core.emulator.enumerations import TransportType from core.nodes.interface import CoreInterface from core.xml import emanexml @@ -73,26 +72,16 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): :param iface: interface for the emane node :return: nothing """ - # interface node - node = iface.node - - # retrieve xml names - nem_name = emanexml.nem_file_name(iface) - shim_name = emanexml.shim_file_name(iface) - # create and write nem document nem_element = etree.Element("nem", name=f"{self.name} NEM", type="unstructured") - transport_type = TransportType.VIRTUAL - if iface.transport_type == TransportType.RAW: - transport_type = TransportType.RAW - transport_file = emanexml.transport_file_name(iface, transport_type) - etree.SubElement(nem_element, "transport", definition=transport_file) + transport_name = emanexml.transport_file_name(iface) + etree.SubElement(nem_element, "transport", definition=transport_name) # set shim configuration + nem_name = emanexml.nem_file_name(iface) + shim_name = emanexml.shim_file_name(iface) etree.SubElement(nem_element, "shim", definition=shim_name) - - nem_file = os.path.join(node.nodedir, nem_name) - emanexml.create_file(nem_element, "nem", nem_file) + emanexml.create_iface_file(iface, nem_element, "nem", nem_name) # create and write shim document shim_element = etree.Element( @@ -111,9 +100,10 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): ff = config["filterfile"] if ff.strip() != "": emanexml.add_param(shim_element, "filterfile", ff) + emanexml.create_iface_file(iface, shim_element, "shim", shim_name) - shim_file = os.path.join(node.nodedir, shim_name) - emanexml.create_file(shim_element, "shim", shim_file) + # create transport xml + emanexml.create_transport_xml(iface, config) def linkconfig( self, iface: CoreInterface, options: LinkOptions, iface2: CoreInterface = None diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 3317a5db..4e2984a0 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -281,8 +281,6 @@ class EmaneManager(ModelManager): instantiation """ logging.debug("emane setup") - - # TODO: drive this from the session object with self.session.nodes_lock: for node_id in self.session.nodes: node = self.session.nodes[node_id] @@ -291,7 +289,6 @@ class EmaneManager(ModelManager): "adding emane node: id(%s) name(%s)", node.id, node.name ) self.add_node(node) - if not self._emane_nets: logging.debug("no emane nodes in session") return EmaneState.NOT_NEEDED @@ -322,7 +319,7 @@ class EmaneManager(ModelManager): logging.debug("emane event service device index: %s", netidx) if netidx < 0: logging.error( - "EMANE cannot start, check core config. invalid event service device: %s", + "emane cannot start due to invalid event service device: %s", eventdev, ) return EmaneState.NOT_READY @@ -330,7 +327,6 @@ class EmaneManager(ModelManager): self.session.add_remove_control_net( net_index=netidx, remove=False, conf_required=False ) - self.check_node_models() return EmaneState.SUCCESS @@ -349,10 +345,6 @@ class EmaneManager(ModelManager): self.starteventmonitor() self.buildeventservicexml() with self._emane_node_lock: - # on master, control network bridge added earlier in startup() - control_net = self.session.add_remove_control_net( - 0, remove=False, conf_required=False - ) logging.info("emane building xmls...") for node_id in sorted(self._emane_nets): emane_net = self._emane_nets[node_id] @@ -360,25 +352,31 @@ class EmaneManager(ModelManager): logging.error("emane net(%s) has no model", emane_net.name) continue for iface in emane_net.get_ifaces(): - if not iface.node: - logging.error( - "emane net(%s) connected interface missing node", - emane_net.name, - ) - continue - nem_id = self.next_nem_id() - self.nems[nem_id] = iface - self.write_nem(iface, nem_id) - emanexml.build_platform_xml( - self, control_net, emane_net, iface, nem_id - ) - emanexml.build_model_xmls(self, emane_net, iface) - self.start_daemon(iface) - self.install_iface(emane_net, iface) + self.start_iface(emane_net, iface) if self.links_enabled(): self.link_monitor.start() return EmaneState.SUCCESS + def start_iface(self, emane_net: EmaneNet, iface: CoreInterface) -> None: + if not iface.node: + logging.error( + "emane net(%s) connected interface(%s) missing node", + emane_net.name, + iface.name, + ) + return + control_net = self.session.add_remove_control_net( + 0, remove=False, conf_required=False + ) + nem_id = self.next_nem_id() + self.nems[nem_id] = iface + self.write_nem(iface, nem_id) + emanexml.build_platform_xml(self, control_net, emane_net, iface, nem_id) + config = self.get_iface_config(emane_net, iface) + emane_net.model.build_xml_files(config, iface) + self.start_daemon(iface) + self.install_iface(emane_net, iface) + def write_nem(self, iface: CoreInterface, nem_id: int) -> None: path = os.path.join(self.session.session_dir, "emane_nems") try: diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 0576d6c3..8672163d 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -9,7 +9,7 @@ from core.config import ConfigGroup, Configuration from core.emane import emanemanifest from core.emane.nodes import EmaneNet from core.emulator.data import LinkOptions -from core.emulator.enumerations import ConfigDataTypes, TransportType +from core.emulator.enumerations import ConfigDataTypes from core.errors import CoreError from core.location.mobility import WirelessModel from core.nodes.base import CoreNode @@ -102,34 +102,14 @@ class EmaneModel(WirelessModel): both mac.xml and phy.xml definitions. :param config: emane model configuration for the node and interface - :param iface: interface for the emane node + :param iface: interface to run emane for :return: nothing """ - nem_name = emanexml.nem_file_name(iface) - mac_name = emanexml.mac_file_name(iface) - phy_name = emanexml.phy_file_name(iface) - - # check if this is external - transport_type = TransportType.VIRTUAL - if iface.transport_type == TransportType.RAW: - transport_type = TransportType.RAW - transport_name = emanexml.transport_file_name(iface, transport_type) - - node = iface.node - server = node.server - # create nem xml file - nem_file = os.path.join(node.nodedir, nem_name) - emanexml.create_nem_xml( - self, config, nem_file, transport_name, mac_name, phy_name, server - ) - - # create mac xml file - mac_file = os.path.join(node.nodedir, mac_name) - emanexml.create_mac_xml(self, config, mac_file, server) - - # create phy xml file - phy_file = os.path.join(node.nodedir, phy_name) - emanexml.create_phy_xml(self, config, phy_file, server) + # create nem, mac, and phy xml files + emanexml.create_nem_xml(self, iface, config) + emanexml.create_mac_xml(self, iface, config) + emanexml.create_phy_xml(self, iface, config) + emanexml.create_transport_xml(iface, config) def post_startup(self) -> None: """ diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index dca85785..7e8a0a4f 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -96,7 +96,6 @@ class EmaneNet(CoreNetworkBase): """ set the EmaneModel associated with this node """ - logging.info("adding model: %s", model.name) if model.config_type == RegisterTlvs.WIRELESS: # EmaneModel really uses values from ConfigurableManager # when buildnemxml() is called, not during init() diff --git a/daemon/core/xml/emanexml.py b/daemon/core/xml/emanexml.py index 32ca0f67..cb605b21 100644 --- a/daemon/core/xml/emanexml.py +++ b/daemon/core/xml/emanexml.py @@ -13,6 +13,7 @@ from core.emulator.enumerations import TransportType from core.errors import CoreError from core.nodes.interface import CoreInterface from core.nodes.network import CtrlNet +from core.nodes.physical import Rj45Node from core.xml import corexml if TYPE_CHECKING: @@ -63,16 +64,15 @@ def create_file( :param xml_element: root element to write to file :param doc_name: name to use in the emane doctype :param file_path: file path to write xml file to - :param server: remote server node - will run on, default is None for localhost + :param server: remote server to create file on :return: nothing """ doctype = ( f'' ) - if server is not None: + if server: temp = NamedTemporaryFile(delete=False) - create_file(xml_element, doc_name, temp.name) + corexml.write_xml_file(xml_element, temp.name, doctype=doctype) temp.close() server.remote_put(temp.name, file_path) os.unlink(temp.name) @@ -80,6 +80,26 @@ def create_file( corexml.write_xml_file(xml_element, file_path, doctype=doctype) +def create_iface_file( + iface: CoreInterface, xml_element: etree.Element, doc_name: str, file_name: str +) -> None: + """ + Create emane xml for an interface. + + :param iface: interface running emane + :param xml_element: root element to write to file + :param doc_name: name to use in the emane doctype + :param file_name: name of xml file + :return: + """ + node = iface.node + if isinstance(node, Rj45Node): + file_path = os.path.join(node.session.session_dir, file_name) + else: + file_path = os.path.join(node.nodedir, file_name) + create_file(xml_element, doc_name, file_path, node.server) + + def add_param(xml_element: etree.Element, name: str, value: str) -> None: """ Add emane configuration parameter to xml element. @@ -159,21 +179,14 @@ def build_platform_xml( transport_endpoint = "transportendpoint" add_param(nem_element, transport_endpoint, config[transport_endpoint]) else: - # build transport xml - transport_type = iface.transport_type - if not transport_type: - logging.info("warning: %s interface type unsupported!", iface.name) - transport_type = TransportType.RAW - transport_file = transport_file_name(iface, transport_type) + transport_name = transport_file_name(iface) transport_element = etree.SubElement( - nem_element, "transport", definition=transport_file + nem_element, "transport", definition=transport_name ) add_param(transport_element, "device", iface.name) # determine platform element to add xml to - key = iface.node.id if iface.transport_type == TransportType.RAW: - key = "host" otadev = control_net.brname eventdev = control_net.brname else: @@ -195,67 +208,19 @@ def build_platform_xml( iface.set_mac(mac) doc_name = "platform" - server = None - if key == "host": - file_name = "platform.xml" - file_path = os.path.join(emane_manager.session.session_dir, file_name) - else: - node = iface.node - file_name = f"{iface.name}-platform.xml" - file_path = os.path.join(node.nodedir, file_name) - server = node.server - create_file(platform_element, doc_name, file_path, server) + file_name = f"{iface.name}-platform.xml" + create_iface_file(iface, platform_element, doc_name, file_name) -def build_model_xmls( - manager: "EmaneManager", emane_net: EmaneNet, iface: CoreInterface -) -> None: - """ - Generate emane xml files required for node. - - :param manager: emane manager with emane - configurations - :param emane_net: emane network associated with interface - :param iface: interface to create emane xml for - :return: nothing - """ - # build XML for specific interface (NEM) configs - # check for interface specific emane configuration and write xml files - config = manager.get_iface_config(emane_net, iface) - emane_net.model.build_xml_files(config, iface) - - # check transport type needed for interface - need_virtual = False - need_raw = False - vtype = TransportType.VIRTUAL - rtype = TransportType.RAW - if iface.transport_type == TransportType.VIRTUAL: - need_virtual = True - vtype = iface.transport_type - else: - need_raw = True - rtype = iface.transport_type - if need_virtual: - build_transport_xml(manager, emane_net, iface, vtype) - if need_raw: - build_transport_xml(manager, emane_net, iface, rtype) - - -def build_transport_xml( - manager: "EmaneManager", - emane_net: EmaneNet, - iface: CoreInterface, - transport_type: TransportType, -) -> None: +def create_transport_xml(iface: CoreInterface, config: Dict[str, str]) -> None: """ Build transport xml file for node and transport type. - :param manager: emane manager with emane configurations - :param emane_net: emane network associated with interface :param iface: interface to build transport xml for - :param transport_type: transport type to build xml for + :param config: all current configuration values :return: nothing """ + transport_type = get_transport_type(iface) transport_element = etree.Element( "transport", name=f"{transport_type.value.capitalize()} Transport", @@ -264,7 +229,6 @@ def build_transport_xml( add_param(transport_element, "bitrate", "0") # get emane model cnfiguration - config = manager.get_iface_config(emane_net, iface) flowcontrol = config.get("flowcontrolenable", "0") == "1" if transport_type == TransportType.VIRTUAL: device_path = "/dev/net/tun_flowctl" @@ -274,29 +238,19 @@ def build_transport_xml( if flowcontrol: add_param(transport_element, "flowcontrolenable", "on") doc_name = "transport" - node = iface.node - file_name = transport_file_name(iface, transport_type) - file_path = os.path.join(node.nodedir, file_name) - create_file(transport_element, doc_name, file_path) - manager.session.distributed.execute( - lambda x: create_file(transport_element, doc_name, file_path, x) - ) + transport_name = transport_file_name(iface) + create_iface_file(iface, transport_element, doc_name, transport_name) def create_phy_xml( - emane_model: "EmaneModel", - config: Dict[str, str], - file_path: str, - server: Optional[DistributedServer], + emane_model: "EmaneModel", iface: CoreInterface, config: Dict[str, str] ) -> None: """ Create the phy xml document. :param emane_model: emane model to create xml + :param iface: interface to create xml for :param config: all current configuration values - :param file_path: path to write file to - :param server: remote server node - will run on, default is None for localhost :return: nothing """ phy_element = etree.Element("phy", name=f"{emane_model.name} PHY") @@ -305,23 +259,19 @@ def create_phy_xml( add_configurations( phy_element, emane_model.phy_config, config, emane_model.config_ignore ) - create_file(phy_element, "phy", file_path, server) + file_name = phy_file_name(iface) + create_iface_file(iface, phy_element, "phy", file_name) def create_mac_xml( - emane_model: "EmaneModel", - config: Dict[str, str], - file_path: str, - server: Optional[DistributedServer], + emane_model: "EmaneModel", iface: CoreInterface, config: Dict[str, str] ) -> None: """ Create the mac xml document. :param emane_model: emane model to create xml + :param iface: interface to create xml for :param config: all current configuration values - :param file_path: path to write file to - :param server: remote server node - will run on, default is None for localhost :return: nothing """ if not emane_model.mac_library: @@ -332,39 +282,33 @@ def create_mac_xml( add_configurations( mac_element, emane_model.mac_config, config, emane_model.config_ignore ) - create_file(mac_element, "mac", file_path, server) + file_name = mac_file_name(iface) + create_iface_file(iface, mac_element, "mac", file_name) def create_nem_xml( - emane_model: "EmaneModel", - config: Dict[str, str], - nem_file: str, - transport_definition: str, - mac_definition: str, - phy_definition: str, - server: Optional[DistributedServer], + emane_model: "EmaneModel", iface: CoreInterface, config: Dict[str, str] ) -> None: """ Create the nem xml document. :param emane_model: emane model to create xml + :param iface: interface to create xml for :param config: all current configuration values - :param nem_file: nem file path to write - :param transport_definition: transport file definition path - :param mac_definition: mac file definition path - :param phy_definition: phy file definition path - :param server: remote server node - will run on, default is None for localhost :return: nothing """ nem_element = etree.Element("nem", name=f"{emane_model.name} NEM") if is_external(config): nem_element.set("type", "unstructured") else: - etree.SubElement(nem_element, "transport", definition=transport_definition) - etree.SubElement(nem_element, "mac", definition=mac_definition) - etree.SubElement(nem_element, "phy", definition=phy_definition) - create_file(nem_element, "nem", nem_file, server) + transport_name = transport_file_name(iface) + etree.SubElement(nem_element, "transport", definition=transport_name) + mac_name = mac_file_name(iface) + etree.SubElement(nem_element, "mac", definition=mac_name) + phy_name = phy_file_name(iface) + etree.SubElement(nem_element, "phy", definition=phy_name) + nem_name = nem_file_name(iface) + create_iface_file(iface, nem_element, "nem", nem_name) def create_event_service_xml( @@ -400,14 +344,27 @@ def create_event_service_xml( create_file(event_element, "emaneeventmsgsvc", file_path, server) -def transport_file_name(iface: CoreInterface, transport_type: TransportType) -> str: +def get_transport_type(iface: CoreInterface) -> TransportType: + """ + Get transport type for a given interface. + + :param iface: interface to get transport type for + :return: transport type + """ + transport_type = TransportType.VIRTUAL + if iface.transport_type == TransportType.RAW: + transport_type = TransportType.RAW + return transport_type + + +def transport_file_name(iface: CoreInterface) -> str: """ Create name for a transport xml file. :param iface: interface running emane - :param transport_type: transport type to generate transport file :return: transport xml file name """ + transport_type = get_transport_type(iface) return f"{iface.name}-trans-{transport_type.value}.xml" From 5f676b27bacafcd4893564160fe87c5c37b0fbd9 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 2 Jul 2020 22:15:12 -0700 Subject: [PATCH 233/929] tests: removed invalid patch due to emane refactoring --- daemon/tests/conftest.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/daemon/tests/conftest.py b/daemon/tests/conftest.py index be62fc03..665f2c1a 100644 --- a/daemon/tests/conftest.py +++ b/daemon/tests/conftest.py @@ -12,7 +12,6 @@ from mock.mock import MagicMock from core.api.grpc.client import InterfaceHelper from core.api.grpc.server import CoreGrpcServer from core.api.tlv.corehandlers import CoreHandler -from core.emane.emanemanager import EmaneManager from core.emulator.coreemu import CoreEmu from core.emulator.data import IpPrefixes from core.emulator.distributed import DistributedServer @@ -63,7 +62,6 @@ def patcher(request): patch_manager.patch_obj(CoreNode, "nodefile") patch_manager.patch_obj(Session, "write_state") patch_manager.patch_obj(Session, "write_nodes") - patch_manager.patch_obj(EmaneManager, "buildxml") yield patch_manager patch_manager.shutdown() From 2b3e26b7c2af9f724a9a060a06a35a94a7445d6c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 2 Jul 2020 23:19:40 -0700 Subject: [PATCH 234/929] daemon: cleanup emane transport service in relation to refactoring, silenced stopdaemons for rj45 nodes --- daemon/core/emane/emanemanager.py | 56 +++++++++++---------------- daemon/core/services/emaneservices.py | 46 +++++++--------------- 2 files changed, 37 insertions(+), 65 deletions(-) diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 4e2984a0..808e8020 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -134,31 +134,27 @@ class EmaneManager(ModelManager): :return: net, node, or interface model configuration """ model_name = emane_net.model.name - # use the network-wide config values or interface(NEM)-specific values? - if iface is None: - return self.get_configs(node_id=emane_net.id, config_type=model_name) - else: - # don"t use default values when interface config is the same as net - # note here that using iface.node.id as key allows for only one type - # of each model per node; - # TODO: use both node and interface as key - # Adamson change: first check for iface config keyed by "node:iface.name" - # (so that nodes w/ multiple interfaces of same conftype can have - # different configs for each separate interface) - key = 1000 * iface.node.id - if iface.node_id is not None: - key += iface.node_id - # try retrieve interface specific configuration, avoid getting defaults - config = self.get_configs(node_id=key, config_type=model_name) - # otherwise retrieve the interfaces node configuration, avoid using defaults - if not config: - config = self.get_configs(node_id=iface.node.id, config_type=model_name) - # get non interface config, when none found - if not config: - # with EMANE 0.9.2+, we need an extra NEM XML from - # model.buildnemxmlfiles(), so defaults are returned here - config = self.get_configs(node_id=emane_net.id, config_type=model_name) - return config + # don"t use default values when interface config is the same as net + # note here that using iface.node.id as key allows for only one type + # of each model per node; + # TODO: use both node and interface as key + # Adamson change: first check for iface config keyed by "node:iface.name" + # (so that nodes w/ multiple interfaces of same conftype can have + # different configs for each separate interface) + key = 1000 * iface.node.id + if iface.node_id is not None: + key += iface.node_id + # try retrieve interface specific configuration, avoid getting defaults + config = self.get_configs(node_id=key, config_type=model_name) + # otherwise retrieve the interfaces node configuration, avoid using defaults + if not config: + config = self.get_configs(node_id=iface.node.id, config_type=model_name) + # get non interface config, when none found + if not config: + # with EMANE 0.9.2+, we need an extra NEM XML from + # model.buildnemxmlfiles(), so defaults are returned here + config = self.get_configs(node_id=emane_net.id, config_type=model_name) + return config def config_reset(self, node_id: int = None) -> None: super().config_reset(node_id) @@ -587,26 +583,18 @@ class EmaneManager(ModelManager): """ Kill the appropriate EMANE daemons. """ - # TODO: we may want to improve this if we had the PIDs from the specific EMANE - # daemons that we"ve started kill_emaned = "killall -q emane" - kill_transortd = "killall -q emanetransportd" stop_emane_on_host = False for node in self.getnodes(): if isinstance(node, Rj45Node): stop_emane_on_host = True continue - if node.up: node.cmd(kill_emaned, wait=False) - # TODO: RJ45 node - if stop_emane_on_host: try: - utils.cmd(kill_emaned) - utils.cmd(kill_transortd) + utils.cmd(kill_emaned, wait=False) self.session.distributed.execute(lambda x: x.remote_cmd(kill_emaned)) - self.session.distributed.execute(lambda x: x.remote_cmd(kill_transortd)) except CoreCommandError: logging.exception("error shutting down emane daemons") diff --git a/daemon/core/services/emaneservices.py b/daemon/core/services/emaneservices.py index ef188fab..e734851d 100644 --- a/daemon/core/services/emaneservices.py +++ b/daemon/core/services/emaneservices.py @@ -1,7 +1,6 @@ from typing import Tuple from core.emane.nodes import EmaneNet -from core.errors import CoreError from core.nodes.base import CoreNode from core.services.coreservices import CoreService from core.xml import emanexml @@ -14,37 +13,22 @@ class EmaneTransportService(CoreService): dependencies: Tuple[str, ...] = () dirs: Tuple[str, ...] = () configs: Tuple[str, ...] = ("emanetransport.sh",) - startup: Tuple[str, ...] = ("sh %s" % configs[0],) - validate: Tuple[str, ...] = ("pidof %s" % executables[0],) + startup: Tuple[str, ...] = (f"sh {configs[0]}",) + validate: Tuple[str, ...] = (f"pidof {executables[0]}",) validation_timer: float = 0.5 - shutdown: Tuple[str, ...] = ("killall %s" % executables[0],) + shutdown: Tuple[str, ...] = (f"killall {executables[0]}",) @classmethod def generate_config(cls, node: CoreNode, filename: str) -> str: - if filename == cls.configs[0]: - transport_commands = [] - for iface in node.get_ifaces(): - try: - network_node = node.session.get_node(iface.net.id, EmaneNet) - config = node.session.emane.get_configs( - network_node.id, network_node.model.name - ) - if config and emanexml.is_external(config): - nem_id = network_node.getnemid(iface) - command = ( - "emanetransportd -r -l 0 -d ../transportdaemon%s.xml" - % nem_id - ) - transport_commands.append(command) - except CoreError: - pass - transport_commands = "\n".join(transport_commands) - return """ -emanegentransportxml -o ../ ../platform%s.xml -%s -""" % ( - node.id, - transport_commands, - ) - else: - raise ValueError + emane_manager = node.session.emane + cfg = "" + for iface in node.get_ifaces(): + if not isinstance(iface.net, EmaneNet): + continue + emane_net = iface.net + config = emane_manager.get_iface_config(emane_net, iface) + if emanexml.is_external(config): + nem_id = emane_net.getnemid(iface) + cfg += f"emanegentransportxml {iface.name}-platform.xml\n" + cfg += f"emanetransportd -r -l 0 -d transportdaemon{nem_id}.xml\n" + return cfg From ddcb0205f35a3b3a3ac064a4c79356f7ddf845ee Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 2 Jul 2020 23:32:59 -0700 Subject: [PATCH 235/929] daemon: cleaned up emane stopdaemons logic --- daemon/core/emane/emanemanager.py | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 808e8020..ca59ad04 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -567,36 +567,31 @@ class EmaneManager(ModelManager): log_file = os.path.join(node.nodedir, f"{iface.name}-emane.log") platform_xml = os.path.join(node.nodedir, f"{iface.name}-platform.xml") args = f"{emanecmd} -f {log_file} {platform_xml}" - output = node.cmd(args) + node.cmd(args) logging.info("node(%s) emane daemon running: %s", node.name, args) - logging.debug("node(%s) emane daemon output: %s", node.name, output) else: path = self.session.session_dir log_file = os.path.join(path, f"{iface.name}-emane.log") platform_xml = os.path.join(path, f"{iface.name}-platform.xml") emanecmd += f" -f {log_file} {platform_xml}" - utils.cmd(emanecmd, cwd=path) - self.session.distributed.execute(lambda x: x.remote_cmd(emanecmd, cwd=path)) - logging.info("host emane daemon running: %s", emanecmd) + node.host_cmd(emanecmd, cwd=path) + logging.info("node(%s) host emane daemon running: %s", node.name, emanecmd) def stopdaemons(self) -> None: """ Kill the appropriate EMANE daemons. """ kill_emaned = "killall -q emane" - stop_emane_on_host = False - for node in self.getnodes(): - if isinstance(node, Rj45Node): - stop_emane_on_host = True - continue - if node.up: - node.cmd(kill_emaned, wait=False) - if stop_emane_on_host: - try: - utils.cmd(kill_emaned, wait=False) - self.session.distributed.execute(lambda x: x.remote_cmd(kill_emaned)) - except CoreCommandError: - logging.exception("error shutting down emane daemons") + for node_id in sorted(self._emane_nets): + emane_net = self._emane_nets[node_id] + for iface in emane_net.get_ifaces(): + node = iface.node + if not node.up: + continue + if isinstance(node, Rj45Node): + node.host_cmd(kill_emaned, wait=False) + else: + node.cmd(kill_emaned, wait=False) def install_iface(self, emane_net: EmaneNet, iface: CoreInterface) -> None: config = self.get_iface_config(emane_net, iface) From ac1c27b1c8b74da40c79ed9e95765191c0e71605 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 3 Jul 2020 08:51:17 -0700 Subject: [PATCH 236/929] daemon: fixed issues when emane generated platform.xml for raw interfaces --- daemon/core/emane/emanemanager.py | 23 +++++++++++++---------- daemon/core/xml/emanexml.py | 22 ++++++++-------------- 2 files changed, 21 insertions(+), 24 deletions(-) diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index ca59ad04..476010cb 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -31,7 +31,6 @@ from core.emulator.enumerations import ( from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNode, NodeBase from core.nodes.interface import CoreInterface, TunTap -from core.nodes.physical import Rj45Node from core.xml import emanexml if TYPE_CHECKING: @@ -531,19 +530,21 @@ class EmaneManager(ModelManager): cfgloglevel = self.session.options.get_config_int("emane_log_level") realtime = self.session.options.get_config_bool("emane_realtime", default=True) if cfgloglevel: - logging.info("setting user-defined EMANE log level: %d", cfgloglevel) + logging.info("setting user-defined emane log level: %d", cfgloglevel) loglevel = str(cfgloglevel) emanecmd = f"emane -d -l {loglevel}" if realtime: emanecmd += " -r" - otagroup, _otaport = self.get_config("otamanagergroup").split(":") - otadev = self.get_config("otamanagerdevice") - otanetidx = self.session.get_control_net_index(otadev) - eventgroup, _eventport = self.get_config("eventservicegroup").split(":") - eventdev = self.get_config("eventservicedevice") - eventservicenetidx = self.session.get_control_net_index(eventdev) node = iface.node - if not isinstance(node, Rj45Node): + transport_type = emanexml.get_transport_type(iface) + if not transport_type == TransportType.RAW: + otagroup, _otaport = self.get_config("otamanagergroup").split(":") + otadev = self.get_config("otamanagerdevice") + otanetidx = self.session.get_control_net_index(otadev) + eventgroup, _eventport = self.get_config("eventservicegroup").split(":") + eventdev = self.get_config("eventservicedevice") + eventservicenetidx = self.session.get_control_net_index(eventdev) + # control network not yet started here self.session.add_remove_control_iface( node, 0, remove=False, conf_required=False @@ -559,6 +560,7 @@ class EmaneManager(ModelManager): node, eventservicenetidx, remove=False, conf_required=False ) # multicast route is needed for OTA data + logging.info("OTA GROUP(%s) OTA DEV(%s)", otagroup, otadev) node.node_net_client.create_route(otagroup, otadev) # multicast route is also needed for event data if on control network if eventservicenetidx >= 0 and eventgroup != otagroup: @@ -588,7 +590,8 @@ class EmaneManager(ModelManager): node = iface.node if not node.up: continue - if isinstance(node, Rj45Node): + transport_type = emanexml.get_transport_type(iface) + if transport_type == TransportType.RAW: node.host_cmd(kill_emaned, wait=False) else: node.cmd(kill_emaned, wait=False) diff --git a/daemon/core/xml/emanexml.py b/daemon/core/xml/emanexml.py index cb605b21..cf973f34 100644 --- a/daemon/core/xml/emanexml.py +++ b/daemon/core/xml/emanexml.py @@ -13,7 +13,6 @@ from core.emulator.enumerations import TransportType from core.errors import CoreError from core.nodes.interface import CoreInterface from core.nodes.network import CtrlNet -from core.nodes.physical import Rj45Node from core.xml import corexml if TYPE_CHECKING: @@ -93,7 +92,8 @@ def create_iface_file( :return: """ node = iface.node - if isinstance(node, Rj45Node): + transport_type = get_transport_type(iface) + if transport_type == TransportType.RAW: file_path = os.path.join(node.session.session_dir, file_name) else: file_path = os.path.join(node.nodedir, file_name) @@ -185,21 +185,15 @@ def build_platform_xml( ) add_param(transport_element, "device", iface.name) - # determine platform element to add xml to - if iface.transport_type == TransportType.RAW: - otadev = control_net.brname - eventdev = control_net.brname - else: - otadev = None - eventdev = None + transport_type = get_transport_type(iface) + transport_configs = {"otamanagerdevice", "eventservicedevice"} platform_element = etree.Element("platform") - if otadev: - emane_manager.set_config("otamanagerdevice", otadev) - if eventdev: - emane_manager.set_config("eventservicedevice", eventdev) for configuration in emane_manager.emane_config.emulator_config: name = configuration.id - value = emane_manager.get_config(name) + if transport_type == TransportType.RAW and name in transport_configs: + value = control_net.brname + else: + value = emane_manager.get_config(name) add_param(platform_element, name, value) platform_element.append(nem_element) emane_net.setnemid(iface, nem_id) From fcda1f9f14432aab23c5bf937964b3054f5eca7b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 3 Jul 2020 09:08:36 -0700 Subject: [PATCH 237/929] daemon: CoreInterface now defaults to a virtual transport type, added utility methods to check if an interface is virtual/raw, cleaned up all emane code using these types of checks --- daemon/core/emane/emanemanager.py | 9 +++------ daemon/core/nodes/interface.py | 19 +++++++++++++++++-- daemon/core/xml/emanexml.py | 31 ++++++------------------------- 3 files changed, 26 insertions(+), 33 deletions(-) diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 476010cb..15faedcc 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -26,7 +26,6 @@ from core.emulator.enumerations import ( LinkTypes, MessageFlags, RegisterTlvs, - TransportType, ) from core.errors import CoreCommandError, CoreError from core.nodes.base import CoreNode, NodeBase @@ -536,8 +535,7 @@ class EmaneManager(ModelManager): if realtime: emanecmd += " -r" node = iface.node - transport_type = emanexml.get_transport_type(iface) - if not transport_type == TransportType.RAW: + if iface.is_virtual(): otagroup, _otaport = self.get_config("otamanagergroup").split(":") otadev = self.get_config("otamanagerdevice") otanetidx = self.session.get_control_net_index(otadev) @@ -590,8 +588,7 @@ class EmaneManager(ModelManager): node = iface.node if not node.up: continue - transport_type = emanexml.get_transport_type(iface) - if transport_type == TransportType.RAW: + if iface.is_raw(): node.host_cmd(kill_emaned, wait=False) else: node.cmd(kill_emaned, wait=False) @@ -614,7 +611,7 @@ class EmaneManager(ModelManager): for key in sorted(self._emane_nets): emane_net = self._emane_nets[key] for iface in emane_net.get_ifaces(): - if iface.transport_type == TransportType.VIRTUAL: + if iface.is_virtual(): iface.shutdown() iface.poshook = None diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index e4d4d0ac..7f33973e 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -60,7 +60,7 @@ class CoreInterface: # placeholder position hook self.poshook: Callable[[CoreInterface], None] = lambda x: None # used with EMANE - self.transport_type: Optional[TransportType] = None + self.transport_type: TransportType = TransportType.VIRTUAL # id of interface for node self.node_id: Optional[int] = None # id of interface for network @@ -310,6 +310,22 @@ class CoreInterface: """ return id(self) < id(other) + def is_raw(self) -> bool: + """ + Used to determine if this interface is considered a raw interface. + + :return: True if raw interface, False otherwise + """ + return self.transport_type == TransportType.RAW + + def is_virtual(self) -> bool: + """ + Used to determine if this interface is considered a virtual interface. + + :return: True if virtual interface, False otherwise + """ + return self.transport_type == TransportType.VIRTUAL + class Veth(CoreInterface): """ @@ -404,7 +420,6 @@ class TunTap(CoreInterface): :param start: start flag """ super().__init__(session, node, name, localname, mtu, server) - self.transport_type = TransportType.VIRTUAL if start: self.startup() diff --git a/daemon/core/xml/emanexml.py b/daemon/core/xml/emanexml.py index cf973f34..0ef13a80 100644 --- a/daemon/core/xml/emanexml.py +++ b/daemon/core/xml/emanexml.py @@ -9,7 +9,6 @@ from core import utils from core.config import Configuration from core.emane.nodes import EmaneNet from core.emulator.distributed import DistributedServer -from core.emulator.enumerations import TransportType from core.errors import CoreError from core.nodes.interface import CoreInterface from core.nodes.network import CtrlNet @@ -92,8 +91,7 @@ def create_iface_file( :return: """ node = iface.node - transport_type = get_transport_type(iface) - if transport_type == TransportType.RAW: + if iface.is_raw(): file_path = os.path.join(node.session.session_dir, file_name) else: file_path = os.path.join(node.nodedir, file_name) @@ -185,12 +183,11 @@ def build_platform_xml( ) add_param(transport_element, "device", iface.name) - transport_type = get_transport_type(iface) transport_configs = {"otamanagerdevice", "eventservicedevice"} platform_element = etree.Element("platform") for configuration in emane_manager.emane_config.emulator_config: name = configuration.id - if transport_type == TransportType.RAW and name in transport_configs: + if iface.is_raw() and name in transport_configs: value = control_net.brname else: value = emane_manager.get_config(name) @@ -214,7 +211,7 @@ def create_transport_xml(iface: CoreInterface, config: Dict[str, str]) -> None: :param config: all current configuration values :return: nothing """ - transport_type = get_transport_type(iface) + transport_type = iface.transport_type transport_element = etree.Element( "transport", name=f"{transport_type.value.capitalize()} Transport", @@ -224,7 +221,7 @@ def create_transport_xml(iface: CoreInterface, config: Dict[str, str]) -> None: # get emane model cnfiguration flowcontrol = config.get("flowcontrolenable", "0") == "1" - if transport_type == TransportType.VIRTUAL: + if iface.is_virtual(): device_path = "/dev/net/tun_flowctl" if not os.path.exists(device_path): device_path = "/dev/net/tun" @@ -338,19 +335,6 @@ def create_event_service_xml( create_file(event_element, "emaneeventmsgsvc", file_path, server) -def get_transport_type(iface: CoreInterface) -> TransportType: - """ - Get transport type for a given interface. - - :param iface: interface to get transport type for - :return: transport type - """ - transport_type = TransportType.VIRTUAL - if iface.transport_type == TransportType.RAW: - transport_type = TransportType.RAW - return transport_type - - def transport_file_name(iface: CoreInterface) -> str: """ Create name for a transport xml file. @@ -358,8 +342,7 @@ def transport_file_name(iface: CoreInterface) -> str: :param iface: interface running emane :return: transport xml file name """ - transport_type = get_transport_type(iface) - return f"{iface.name}-trans-{transport_type.value}.xml" + return f"{iface.name}-trans-{iface.transport_type.value}.xml" def nem_file_name(iface: CoreInterface) -> str: @@ -369,9 +352,7 @@ def nem_file_name(iface: CoreInterface) -> str: :param iface: interface running emane :return: nem xm file name """ - append = "" - if iface and iface.transport_type == TransportType.RAW: - append = "-raw" + append = "-raw" if iface.is_raw() else "" return f"{iface.name}-nem{append}.xml" From 5cc4d92760137796e9b66f45f0705a67ebed8869 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 5 Jul 2020 21:29:03 -0700 Subject: [PATCH 238/929] daemon: removed nem map from individual emane networks, all nems are stored and generated from the emane manager --- daemon/core/api/grpc/grpcutils.py | 7 +++- daemon/core/api/grpc/server.py | 22 +++++----- daemon/core/emane/commeffect.py | 10 ++--- daemon/core/emane/emanemanager.py | 59 ++++++++++++++------------- daemon/core/emane/nodes.py | 41 ++++--------------- daemon/core/services/emaneservices.py | 2 +- daemon/core/xml/corexml.py | 4 +- daemon/core/xml/corexmldeployment.py | 7 ++-- daemon/core/xml/emanexml.py | 1 - 9 files changed, 64 insertions(+), 89 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index bd9e808d..bd3519f7 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -491,10 +491,13 @@ def iface_to_proto(node_id: int, iface: CoreInterface) -> core_pb2.Interface: ) -def get_nem_id(node: CoreNode, iface_id: int, context: ServicerContext) -> int: +def get_nem_id( + session: Session, node: CoreNode, iface_id: int, context: ServicerContext +) -> int: """ Get nem id for a given node and interface id. + :param session: session node belongs to :param node: node to get nem id for :param iface_id: id of interface on node to get nem id for :param context: request context @@ -508,7 +511,7 @@ def get_nem_id(node: CoreNode, iface_id: int, context: ServicerContext) -> int: if not isinstance(net, EmaneNet): message = f"{node.name} interface {iface_id} is not an EMANE network" context.abort(grpc.StatusCode.INVALID_ARGUMENT, message) - nem_id = net.getnemid(iface) + nem_id = session.emane.get_nem_id(iface) if nem_id is None: message = f"{node.name} interface {iface_id} nem id does not exist" context.abort(grpc.StatusCode.INVALID_ARGUMENT, message) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index aa5ec539..5bdebac6 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -1551,29 +1551,29 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): logging.debug("emane link: %s", request) session = self.get_session(request.session_id, context) nem1 = request.nem1 - emane1, iface = session.emane.nemlookup(nem1) - if not emane1 or not iface: + iface1 = session.emane.get_iface(nem1) + if not iface1: context.abort(grpc.StatusCode.NOT_FOUND, f"nem one {nem1} not found") - node1 = iface.node + node1 = iface1.node nem2 = request.nem2 - emane2, iface = session.emane.nemlookup(nem2) - if not emane2 or not iface: + iface2 = session.emane.get_iface(nem2) + if not iface2: context.abort(grpc.StatusCode.NOT_FOUND, f"nem two {nem2} not found") - node2 = iface.node + node2 = iface2.node - if emane1.id == emane2.id: + if iface1.net == iface2.net: if request.linked: flag = MessageFlags.ADD else: flag = MessageFlags.DELETE - color = session.get_link_color(emane1.id) + color = session.get_link_color(iface1.net.id) link = LinkData( message_type=flag, type=LinkTypes.WIRELESS, node1_id=node1.id, node2_id=node2.id, - network_id=emane1.id, + network_id=iface1.net.id, color=color, ) session.broadcast_link(link) @@ -1796,8 +1796,8 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): for request in request_iterator: session = self.get_session(request.session_id, context) node1 = self.get_node(session, request.node1_id, context, CoreNode) - nem1 = grpcutils.get_nem_id(node1, request.iface1_id, context) + nem1 = grpcutils.get_nem_id(session, node1, request.iface1_id, context) node2 = self.get_node(session, request.node2_id, context, CoreNode) - nem2 = grpcutils.get_nem_id(node2, request.iface2_id, context) + nem2 = grpcutils.get_nem_id(session, node2, request.iface2_id, context) session.emane.publish_pathloss(nem1, nem2, request.rx1, request.rx2) return EmanePathlossesResponse() diff --git a/daemon/core/emane/commeffect.py b/daemon/core/emane/commeffect.py index a812b66d..0fa70a92 100644 --- a/daemon/core/emane/commeffect.py +++ b/daemon/core/emane/commeffect.py @@ -10,7 +10,6 @@ from lxml import etree from core.config import ConfigGroup, Configuration from core.emane import emanemanifest, emanemodel -from core.emane.nodes import EmaneNet from core.emulator.data import LinkOptions from core.nodes.interface import CoreInterface from core.xml import emanexml @@ -124,12 +123,11 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): # TODO: batch these into multiple events per transmission # TODO: may want to split out seconds portion of delay and jitter event = CommEffectEvent() - emane_node = self.session.get_node(self.id, EmaneNet) - nemid = emane_node.getnemid(iface) - nemid2 = emane_node.getnemid(iface2) + nem1 = self.session.emane.get_nem_id(iface) + nem2 = self.session.emane.get_nem_id(iface2) logging.info("sending comm effect event") event.append( - nemid, + nem1, latency=convert_none(options.delay), jitter=convert_none(options.jitter), loss=convert_none(options.loss), @@ -137,4 +135,4 @@ class EmaneCommEffectModel(emanemodel.EmaneModel): unicast=int(convert_none(options.bandwidth)), broadcast=int(convert_none(options.bandwidth)), ) - service.publish(nemid2, event) + service.publish(nem2, event) diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 15faedcc..2a7f9844 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -90,7 +90,8 @@ class EmaneManager(ModelManager): """ super().__init__() self.session: "Session" = session - self.nems: Dict[int, CoreInterface] = {} + self.nems_to_ifaces: Dict[int, CoreInterface] = {} + self.ifaces_to_nems: Dict[CoreInterface, int] = {} self._emane_nets: Dict[int, EmaneNet] = {} self._emane_node_lock: threading.Lock = threading.Lock() # port numbers are allocated from these counters @@ -117,7 +118,7 @@ class EmaneManager(ModelManager): def next_nem_id(self) -> int: nem_id = int(self.get_config("nem_id_start")) - while nem_id in self.nems: + while nem_id in self.nems_to_ifaces: nem_id += 1 return nem_id @@ -363,7 +364,7 @@ class EmaneManager(ModelManager): 0, remove=False, conf_required=False ) nem_id = self.next_nem_id() - self.nems[nem_id] = iface + self.set_nem(nem_id, iface) self.write_nem(iface, nem_id) emanexml.build_platform_xml(self, control_net, emane_net, iface, nem_id) config = self.get_iface_config(emane_net, iface) @@ -371,6 +372,18 @@ class EmaneManager(ModelManager): self.start_daemon(iface) self.install_iface(emane_net, iface) + def set_nem(self, nem_id: int, iface: CoreInterface) -> None: + if nem_id in self.nems_to_ifaces: + raise CoreError(f"adding duplicate nem: {nem_id}") + self.nems_to_ifaces[nem_id] = iface + self.ifaces_to_nems[iface] = nem_id + + def get_iface(self, nem_id: int) -> Optional[CoreInterface]: + return self.nems_to_ifaces.get(nem_id) + + def get_nem_id(self, iface: CoreInterface) -> Optional[int]: + return self.ifaces_to_nems.get(iface) + def write_nem(self, iface: CoreInterface, nem_id: int) -> None: path = os.path.join(self.session.session_dir, "emane_nems") try: @@ -405,7 +418,8 @@ class EmaneManager(ModelManager): """ with self._emane_node_lock: self._emane_nets.clear() - self.nems.clear() + self.nems_to_ifaces.clear() + self.ifaces_to_nems.clear() def shutdown(self) -> None: """ @@ -448,42 +462,29 @@ class EmaneManager(ModelManager): model_class = self.models[model_name] emane_net.setmodel(model_class, config) - def nemlookup(self, nemid) -> Tuple[Optional[EmaneNet], Optional[CoreInterface]]: - """ - Look for the given numerical NEM ID and return the first matching - EMANE network and NEM interface. - """ - emane_node = None - iface = None - for node_id in self._emane_nets: - emane_node = self._emane_nets[node_id] - iface = emane_node.get_nem_iface(nemid) - if iface is not None: - break - else: - emane_node = None - return emane_node, iface - def get_nem_link( self, nem1: int, nem2: int, flags: MessageFlags = MessageFlags.NONE ) -> Optional[LinkData]: - emane1, iface = self.nemlookup(nem1) - if not emane1 or not iface: + iface1 = self.get_iface(nem1) + if not iface1: logging.error("invalid nem: %s", nem1) return None - node1 = iface.node - emane2, iface = self.nemlookup(nem2) - if not emane2 or not iface: + node1 = iface1.node + iface2 = self.get_iface(nem2) + if not iface2: logging.error("invalid nem: %s", nem2) return None - node2 = iface.node - color = self.session.get_link_color(emane1.id) + node2 = iface2.node + if iface1.net != iface2.net: + return None + emane_net = iface1.net + color = self.session.get_link_color(emane_net.id) return LinkData( message_type=flags, type=LinkTypes.WIRELESS, node1_id=node1.id, node2_id=node2.id, - network_id=emane1.id, + network_id=emane_net.id, color=color, ) @@ -728,7 +729,7 @@ class EmaneManager(ModelManager): Returns True if successfully parsed and a Node Message was sent. """ # convert nemid to node number - _emanenode, iface = self.nemlookup(nemid) + iface = self.get_iface(nemid) if iface is None: logging.info("location event for unknown NEM %s", nemid) return False diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index 7e8a0a4f..cfb3342e 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -52,7 +52,6 @@ class EmaneNet(CoreNetworkBase): ) -> None: super().__init__(session, _id, name, server) self.conf: str = "" - self.nemidmap: Dict[CoreInterface, int] = {} self.model: "OptionalEmaneModel" = None self.mobility: Optional[WayPointMobility] = None @@ -105,32 +104,6 @@ class EmaneNet(CoreNetworkBase): self.mobility = model(session=self.session, _id=self.id) self.mobility.update_config(config) - def setnemid(self, iface: CoreInterface, nemid: int) -> None: - """ - Record an interface to numerical ID mapping. The Emane controller - object manages and assigns these IDs for all NEMs. - """ - self.nemidmap[iface] = nemid - - def getnemid(self, iface: CoreInterface) -> Optional[int]: - """ - Given an interface, return its numerical ID. - """ - if iface not in self.nemidmap: - return None - else: - return self.nemidmap[iface] - - def get_nem_iface(self, nemid: int) -> Optional[CoreInterface]: - """ - Given a numerical NEM ID, return its interface. This returns the - first interface that matches the given NEM ID. - """ - for iface in self.nemidmap: - if self.nemidmap[iface] == nemid: - return iface - return None - def _nem_position( self, iface: CoreInterface ) -> Optional[Tuple[int, float, float, float]]: @@ -140,9 +113,9 @@ class EmaneNet(CoreNetworkBase): :param iface: interface to get nem emane position for :return: nem position tuple, None otherwise """ - nemid = self.getnemid(iface) + nem_id = self.session.emane.get_nem_id(iface) ifname = iface.localname - if nemid is None: + if nem_id is None: logging.info("nemid for %s is unknown", ifname) return node = iface.node @@ -153,7 +126,7 @@ class EmaneNet(CoreNetworkBase): node.position.set_geo(lon, lat, alt) # altitude must be an integer or warning is printed alt = int(round(alt)) - return nemid, lon, lat, alt + return nem_id, lon, lat, alt def setnemposition(self, iface: CoreInterface) -> None: """ @@ -164,7 +137,6 @@ class EmaneNet(CoreNetworkBase): if self.session.emane.service is None: logging.info("position service not available") return - position = self._nem_position(iface) if position: nemid, lon, lat, alt = position @@ -195,9 +167,12 @@ class EmaneNet(CoreNetworkBase): def links(self, flags: MessageFlags = MessageFlags.NONE) -> List[LinkData]: links = super().links(flags) - # gather current emane links - nem_ids = set(self.nemidmap.values()) emane_manager = self.session.emane + # gather current emane links + nem_ids = set() + for iface in self.get_ifaces(): + nem_id = emane_manager.get_nem_id(iface) + nem_ids.add(nem_id) emane_links = emane_manager.link_monitor.links considered = set() for link_key in emane_links: diff --git a/daemon/core/services/emaneservices.py b/daemon/core/services/emaneservices.py index e734851d..d694317a 100644 --- a/daemon/core/services/emaneservices.py +++ b/daemon/core/services/emaneservices.py @@ -28,7 +28,7 @@ class EmaneTransportService(CoreService): emane_net = iface.net config = emane_manager.get_iface_config(emane_net, iface) if emanexml.is_external(config): - nem_id = emane_net.getnemid(iface) + nem_id = emane_manager.get_nem_id(iface) cfg += f"emanegentransportxml {iface.name}-platform.xml\n" cfg += f"emanetransportd -r -l 0 -d transportdaemon{nem_id}.xml\n" return cfg diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index 340d81d0..ffd07ebd 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -501,8 +501,8 @@ class CoreXmlWriter: iface = node.get_iface(iface_data.id) # check if emane interface if isinstance(iface.net, EmaneNet): - nem = iface.net.getnemid(iface) - add_attribute(iface_element, "nem", nem) + nem_id = self.session.emane.get_nem_id(iface) + add_attribute(iface_element, "nem", nem_id) add_attribute(iface_element, "id", iface_data.id) add_attribute(iface_element, "name", iface_data.name) add_attribute(iface_element, "mac", iface_data.mac) diff --git a/daemon/core/xml/corexmldeployment.py b/daemon/core/xml/corexmldeployment.py index 51201787..c062a1d2 100644 --- a/daemon/core/xml/corexmldeployment.py +++ b/daemon/core/xml/corexmldeployment.py @@ -9,7 +9,6 @@ from core import utils from core.emane.nodes import EmaneNet from core.executables import IP from core.nodes.base import CoreNodeBase, NodeBase -from core.nodes.interface import CoreInterface if TYPE_CHECKING: from core.emulator.session import Session @@ -38,11 +37,10 @@ def add_mapping(parent_element: etree.Element, maptype: str, mapref: str) -> Non def add_emane_iface( host_element: etree.Element, - iface: CoreInterface, + nem_id: int, platform_name: str = "p1", transport_name: str = "t1", ) -> etree.Element: - nem_id = iface.net.nemidmap[iface] host_id = host_element.get("id") # platform data @@ -158,7 +156,8 @@ class CoreXmlDeployment: for iface in node.get_ifaces(): emane_element = None if isinstance(iface.net, EmaneNet): - emane_element = add_emane_iface(host_element, iface) + nem_id = self.session.emane.get_nem_id(iface) + emane_element = add_emane_iface(host_element, nem_id) parent_element = host_element if emane_element is not None: diff --git a/daemon/core/xml/emanexml.py b/daemon/core/xml/emanexml.py index 0ef13a80..88aeaa97 100644 --- a/daemon/core/xml/emanexml.py +++ b/daemon/core/xml/emanexml.py @@ -193,7 +193,6 @@ def build_platform_xml( value = emane_manager.get_config(name) add_param(platform_element, name, value) platform_element.append(nem_element) - emane_net.setnemid(iface, nem_id) mac = _MAC_PREFIX + ":00:00:" mac += f"{(nem_id >> 8) & 0xFF:02X}:{nem_id & 0xFF:02X}" iface.set_mac(mac) From b3a4b1cb10a47f8a5b7de36e1135096468cf8d61 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 5 Jul 2020 21:56:22 -0700 Subject: [PATCH 239/929] daemon: updates to support running emane on the fly for a newly connected link --- daemon/core/emane/emanemanager.py | 4 ++-- daemon/core/emane/nodes.py | 10 +++++++++- daemon/core/emulator/session.py | 4 +++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 2a7f9844..3765ba44 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -252,8 +252,8 @@ class EmaneManager(ModelManager): """ with self._emane_node_lock: if emane_net.id in self._emane_nets: - raise KeyError( - f"non-unique EMANE object id {emane_net.id} for {emane_net}" + raise CoreError( + f"duplicate emane network({emane_net.id}): {emane_net.name}" ) self._emane_nets[emane_net.id] = emane_net diff --git a/daemon/core/emane/nodes.py b/daemon/core/emane/nodes.py index cfb3342e..5791f46a 100644 --- a/daemon/core/emane/nodes.py +++ b/daemon/core/emane/nodes.py @@ -8,7 +8,13 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Type from core.emulator.data import InterfaceData, LinkData, LinkOptions from core.emulator.distributed import DistributedServer -from core.emulator.enumerations import LinkTypes, MessageFlags, NodeTypes, RegisterTlvs +from core.emulator.enumerations import ( + EventTypes, + LinkTypes, + MessageFlags, + NodeTypes, + RegisterTlvs, +) from core.errors import CoreError from core.nodes.base import CoreNetworkBase, CoreNode from core.nodes.interface import CoreInterface @@ -203,4 +209,6 @@ class EmaneNet(CoreNetworkBase): iface.set_mac(iface_data.mac) for ip in iface_data.get_ips(): iface.add_ip(ip) + if self.session.state == EventTypes.RUNTIME_STATE: + self.session.emane.start_iface(self, iface) return iface diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 9f5364b9..cad6ae3c 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -531,7 +531,7 @@ class Session: self.set_node_position(node, options) # add services to needed nodes - if isinstance(node, (CoreNode, PhysicalNode, DockerNode, LxcNode)): + if isinstance(node, (CoreNode, PhysicalNode)): node.type = options.model logging.debug("set node type: %s", node.type) self.services.add_services(node, node.type, options.services) @@ -545,6 +545,8 @@ class Session: # ensure default emane configuration if isinstance(node, EmaneNet) and options.emane: self.emane.set_model_config(_id, options.emane) + if self.state == EventTypes.RUNTIME_STATE: + self.emane.add_node(node) # set default wlan config if needed if isinstance(node, WlanNode): self.mobility.set_model_config(_id, BasicRangeModel.name) From 8dc570a98d7843337ace6d7ef22a37c7f27a309f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 6 Jul 2020 21:13:54 -0700 Subject: [PATCH 240/929] daemon: removed commented out code --- daemon/core/nodes/base.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 039008ef..7f444480 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -815,17 +815,6 @@ class CoreNode(CoreNodeBase): with self.lock: if net.has_custom_iface: return net.custom_iface(self, iface_data) - # if net.is_emane is True: - # iface_id = self.newtuntap(iface_data.id, iface_data.name) - # # TUN/TAP is not ready for addressing yet; the device may - # # take some time to appear, and installing it into a - # # namespace after it has been bound removes addressing; - # # save addresses with the interface now - # self.attachnet(iface_id, net) - # iface = self.get_iface(iface_id) - # iface.set_mac(iface_data.mac) - # for ip in ips: - # iface.add_ip(ip) else: iface_id = self.newveth(iface_data.id, iface_data.name) self.attachnet(iface_id, net) From 6f7e42d310164bbe1bef9b98de3f48f8355df8b3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 6 Jul 2020 21:32:43 -0700 Subject: [PATCH 241/929] daemon: avoid command error logging when checking for emane version as validation for checking if emane is installed --- daemon/core/emane/emanemanager.py | 33 ++++++++++++++++--------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/daemon/core/emane/emanemanager.py b/daemon/core/emane/emanemanager.py index 3765ba44..ec39137d 100644 --- a/daemon/core/emane/emanemanager.py +++ b/daemon/core/emane/emanemanager.py @@ -165,23 +165,24 @@ class EmaneManager(ModelManager): :return: nothing """ - try: - # check for emane - args = "emane --version" - emane_version = utils.cmd(args) - logging.info("using EMANE: %s", emane_version) - self.session.distributed.execute(lambda x: x.remote_cmd(args)) - - # load default emane models - self.load_models(EMANE_MODELS) - - # load custom models - custom_models_path = self.session.options.get_config("emane_models_dir") - if custom_models_path: - emane_models = utils.load_classes(custom_models_path, EmaneModel) - self.load_models(emane_models) - except CoreCommandError: + # check for emane + path = utils.which("emane", required=False) + if not path: logging.info("emane is not installed") + return + + # get version + emane_version = utils.cmd("emane --version") + logging.info("using emane: %s", emane_version) + + # load default emane models + self.load_models(EMANE_MODELS) + + # load custom models + custom_models_path = self.session.options.get_config("emane_models_dir") + if custom_models_path: + emane_models = utils.load_classes(custom_models_path, EmaneModel) + self.load_models(emane_models) def deleteeventservice(self) -> None: if self.service: From 0045c8d79c53c89f5079f64fe8b536c7fa38f43c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 6 Jul 2020 21:37:04 -0700 Subject: [PATCH 242/929] pygui: avoid trying to bring up a terminal for rj45 nodes --- daemon/core/gui/graph/node.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 6e8185b8..dfe724bd 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -193,7 +193,8 @@ class CanvasNode: def double_click(self, event: tk.Event) -> None: if self.app.core.is_runtime(): - self.canvas.core.launch_terminal(self.core_node.id) + if NodeUtils.is_container_node(self.core_node.type): + self.canvas.core.launch_terminal(self.core_node.id) else: self.show_config() From c761c55ebc5d7f89116cbfe81031953394657b85 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 6 Jul 2020 21:47:46 -0700 Subject: [PATCH 243/929] tests: patch utils.which --- daemon/tests/conftest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/daemon/tests/conftest.py b/daemon/tests/conftest.py index 665f2c1a..0e25dee9 100644 --- a/daemon/tests/conftest.py +++ b/daemon/tests/conftest.py @@ -55,6 +55,7 @@ def patcher(request): if request.config.getoption("mock"): patch_manager.patch("os.mkdir") patch_manager.patch("core.utils.cmd") + patch_manager.patch("core.utils.which") patch_manager.patch("core.nodes.netclient.get_net_client") patch_manager.patch_obj( LinuxNetClient, "get_mac", return_value="00:00:00:00:00:00" From 6648dc7825279bf59ec857725545aca9ed9809e0 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 7 Jul 2020 08:46:47 -0700 Subject: [PATCH 244/929] pygui: service and config service dialogs will now properly show services for default group selected --- daemon/core/gui/dialogs/nodeconfigservice.py | 2 +- daemon/core/gui/dialogs/nodeservice.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/daemon/core/gui/dialogs/nodeconfigservice.py b/daemon/core/gui/dialogs/nodeconfigservice.py index b5250eba..b9a9a1f5 100644 --- a/daemon/core/gui/dialogs/nodeconfigservice.py +++ b/daemon/core/gui/dialogs/nodeconfigservice.py @@ -84,7 +84,7 @@ class NodeConfigServiceDialog(Dialog): button.grid(row=0, column=3, sticky="ew") # trigger group change - self.groups.listbox.event_generate("<>") + self.handle_group_change() def handle_group_change(self, event: tk.Event = None) -> None: selection = self.groups.listbox.curselection() diff --git a/daemon/core/gui/dialogs/nodeservice.py b/daemon/core/gui/dialogs/nodeservice.py index f6f5e5cf..6fcc2912 100644 --- a/daemon/core/gui/dialogs/nodeservice.py +++ b/daemon/core/gui/dialogs/nodeservice.py @@ -82,7 +82,7 @@ class NodeServiceDialog(Dialog): button.grid(row=0, column=3, sticky="ew") # trigger group change - self.groups.listbox.event_generate("<>") + self.handle_group_change() def handle_group_change(self, event: tk.Event = None) -> None: selection = self.groups.listbox.curselection() From f1ff1a65770dc446e6cd75f113ced9eacd93d3ea Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 7 Jul 2020 14:24:43 -0700 Subject: [PATCH 245/929] pygui: only attempt to run observer commands on container nodes --- daemon/core/gui/graph/node.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index dfe724bd..f765816d 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -179,7 +179,10 @@ class CanvasNode: self.app.core.edit_node(self.core_node) def on_enter(self, event: tk.Event) -> None: - if self.app.core.is_runtime() and self.app.core.observer: + is_runtime = self.app.core.is_runtime() + has_observer = self.app.core.observer is not None + is_container = NodeUtils.is_container_node(self.core_node.type) + if is_runtime and has_observer and is_container: self.tooltip.text.set("waiting...") self.tooltip.on_enter(event) try: From bb4514b93e315c9b5682ee893679b9bb1b764a4c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 7 Jul 2020 15:16:17 -0700 Subject: [PATCH 246/929] daemon: changes to saving and restoring server used for nodes in xml --- daemon/core/xml/corexml.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index ffd07ebd..d1c43d9b 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -128,6 +128,8 @@ class NodeElement: self.element: etree.Element = etree.Element(element_name) add_attribute(self.element, "id", node.id) add_attribute(self.element, "name", node.name) + server = self.node.server.name if self.node.server else None + add_attribute(self.element, "server", server) add_attribute(self.element, "icon", node.icon) add_attribute(self.element, "canvas", node.canvas) self.add_position() @@ -801,8 +803,10 @@ class CoreXmlReader: icon = device_element.get("icon") clazz = device_element.get("class") image = device_element.get("image") - options = NodeOptions(name=name, model=model, image=image, icon=icon) - + server = device_element.get("server") + options = NodeOptions( + name=name, model=model, image=image, icon=icon, server=server + ) node_type = NodeTypes.DEFAULT if clazz == "docker": node_type = NodeTypes.DOCKER @@ -842,7 +846,8 @@ class CoreXmlReader: node_type = NodeTypes[network_element.get("type")] _class = self.session.get_node_class(node_type) icon = network_element.get("icon") - options = NodeOptions(name=name, icon=icon) + server = network_element.get("server") + options = NodeOptions(name=name, icon=icon, server=server) position_element = network_element.find("position") if position_element is not None: From fb21909dadaed688ae027313cea34bfe065772f5 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 7 Jul 2020 23:38:12 -0700 Subject: [PATCH 247/929] invoke/poetry: updated version in toml file and added invoke commands --- daemon/pyproject.toml | 2 +- tasks.py | 52 +++++++++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index 6df5f10e..609fcb08 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "core" -version = "6.4.0" +version = "6.6.0" description = "" authors = [] diff --git a/tasks.py b/tasks.py index 74e6af76..b19e8925 100644 --- a/tasks.py +++ b/tasks.py @@ -2,13 +2,51 @@ from invoke import task @task -def core(c): - c.run( - "poetry run sudo python3 scripts/core-daemon " - "-f data/core.conf -l data/logging.conf" - ) +def daemon(c): + """ + Runs core-daemon. + """ + with c.cd("daemon"): + poetry = c.run("which poetry").stdout.strip() + c.run( + f"sudo {poetry} run scripts/core-daemon " + "-f data/core.conf -l data/logging.conf" + ) @task -def core_pygui(c): - c.run("poetry run python3 scripts/core-pygui") +def gui(c): + """ + Run core-pygui. + """ + with c.cd("daemon"): + c.run("poetry run scripts/core-pygui") + + +@task +def test(c): + """ + Run core tests. + """ + with c.cd("daemon"): + poetry = c.run("which poetry").stdout.strip() + c.run(f"sudo {poetry} run pytest -v --lf -x tests", pty=True) + + +@task +def test_mock(c): + """ + Run core tests using mock to avoid running as sudo. + """ + with c.cd("daemon"): + c.run("poetry run pytest -v --mock --lf -x tests", pty=True) + + +@task +def test_emane(c): + """ + Run core emane tests. + """ + with c.cd("daemon"): + poetry = c.run("which poetry").stdout.strip() + c.run(f"sudo {poetry} run pytest -v --lf -x tests/emane", pty=True) From 43b586a1a1d65d93dd028cae3de044899d56cf76 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 8 Jul 2020 08:24:23 -0700 Subject: [PATCH 248/929] daemon: updated xml to write and read session configured distributed servers, updated pygui to send servers before session start or saving xml --- daemon/core/gui/coreclient.py | 6 ++++++ daemon/core/xml/corexml.py | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 7cf8b123..9479cbcb 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -510,6 +510,10 @@ class CoreClient: except grpc.RpcError as e: self.app.show_grpc_exception("Edit Node Error", e) + def send_servers(self) -> None: + for server in self.servers.values(): + self.client.add_session_server(self.session_id, server.name, server.address) + def start_session(self) -> StartSessionResponse: self.ifaces_manager.reset_mac() nodes = [x.core_node for x in self.canvas_nodes.values()] @@ -538,6 +542,7 @@ class CoreClient: emane_config = None response = StartSessionResponse(result=False) try: + self.send_servers() response = self.client.start_session( self.session_id, nodes, @@ -749,6 +754,7 @@ class CoreClient: """ Send to daemon all session info, but don't start the session """ + self.send_servers() self.create_nodes_and_links() for config_proto in self.get_wlan_configs_proto(): self.client.set_wlan_config( diff --git a/daemon/core/xml/corexml.py b/daemon/core/xml/corexml.py index d1c43d9b..7e3b35a2 100644 --- a/daemon/core/xml/corexml.py +++ b/daemon/core/xml/corexml.py @@ -284,6 +284,7 @@ class CoreXmlWriter: self.write_service_configs() self.write_configservice_configs() self.write_session_origin() + self.write_servers() self.write_session_hooks() self.write_session_options() self.write_session_metadata() @@ -318,6 +319,15 @@ class CoreXmlWriter: add_attribute(origin, "y", y) add_attribute(origin, "z", z) + def write_servers(self) -> None: + servers = etree.Element("servers") + for server in self.session.distributed.servers.values(): + server_element = etree.SubElement(servers, "server") + add_attribute(server_element, "name", server.name) + add_attribute(server_element, "address", server.host) + if servers.getchildren(): + self.scenario.append(servers) + def write_session_hooks(self) -> None: # hook scripts hooks = etree.Element("session_hooks") @@ -572,6 +582,7 @@ class CoreXmlReader: self.read_session_metadata() self.read_session_options() self.read_session_hooks() + self.read_servers() self.read_session_origin() self.read_service_configs() self.read_mobility_configs() @@ -635,6 +646,16 @@ class CoreXmlReader: logging.info("reading hook: state(%s) name(%s)", state, name) self.session.add_hook(state, name, data) + def read_servers(self) -> None: + servers = self.scenario.find("servers") + if servers is None: + return + for server in servers.iterchildren(): + name = server.get("name") + address = server.get("address") + logging.info("reading server: name(%s) address(%s)", name, address) + self.session.distributed.add_server(name, address) + def read_session_origin(self) -> None: session_origin = self.scenario.find("session_origin") if session_origin is None: From 7a21affbd4514b56f187a2e9cb93ec9066bec1fa Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 8 Jul 2020 08:46:30 -0700 Subject: [PATCH 249/929] pygui: update nodes to display assigned server name when not localhost --- daemon/core/gui/graph/node.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index f765816d..7b5cd2f3 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -42,10 +42,11 @@ class CanvasNode: x, y, anchor=tk.CENTER, image=self.image, tags=tags.NODE ) label_y = self._get_label_y() + label = self.get_label() self.text_id: int = self.canvas.create_text( x, label_y, - text=self.core_node.name, + text=label, tags=tags.NODE_LABEL, font=self.app.icon_text_font, fill="#0000CD", @@ -123,9 +124,16 @@ class CanvasNode: self.antennas.clear() self.antenna_images.clear() + def get_label(self) -> str: + label = self.core_node.name + if self.core_node.server: + label = f"{self.core_node.name}({self.core_node.server})" + return label + def redraw(self) -> None: self.canvas.itemconfig(self.id, image=self.image) - self.canvas.itemconfig(self.text_id, text=self.core_node.name) + label = self.get_label() + self.canvas.itemconfig(self.text_id, text=label) for edge in self.edges: edge.redraw() From 9fed90832284a716640248c90e3365f852e30ca4 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 8 Jul 2020 11:56:23 -0700 Subject: [PATCH 250/929] docs: adjustments to distributed documentation to be more complete --- docs/distributed.md | 47 +++++++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/distributed.md b/docs/distributed.md index f36efc72..ad3d61f8 100644 --- a/docs/distributed.md +++ b/docs/distributed.md @@ -12,6 +12,28 @@ run on one of the emulation servers or on a separate machine. Each machine that will act as an emulation will require the installation of a distributed CORE package and some configuration to allow SSH as root. +## CORE Configuration + +CORE configuration settings required for using distributed functionality. + +Edit **/etc/core/core.conf** or specific configuration file being used. + +```shell +# uncomment and set this to the address that remote servers +# use to get back to the main host, example below +distributed_address = 129.168.0.101 +``` + +### EMANE Specific Configurations + +EMANE needs to have controlnet configured in **core.conf** in order to startup correctly. +The names before the addresses need to match the names of distributed servers configured. + +```shell +controlnet = core1:172.16.1.0/24 core2:172.16.2.0/24 core3:172.16.3.0/24 core4:172.16.4.0/24 core5:172.16.5.0/24 +emane_event_generate = True +``` + ## Configuring SSH Distributed CORE works using the python fabric library to run commands on @@ -88,6 +110,16 @@ PermitRootLogin without-password sudo systemctl restart sshd ``` +### Fabric Config File + +Make sure the value used below is the absolute path to the file +generated above **~/.ssh/core**" + +Add/update the fabric configuration file **/etc/fabric.yml**: +```yaml +connect_kwargs: {"key_filename": "/home/user/.ssh/core"} +``` + ## Add Emulation Servers in GUI Within the core-gui navigate to menu option: @@ -152,26 +184,13 @@ to arrange the topology such that the number of tunnels is minimized. The tunnels carry data between servers to connect nodes as specified in the topology. These tunnels are created using GRE tunneling, similar to the Tunnel Tool. -### EMANE Configuration and Issues - -EMANE needs to have controlnet configured in **core.conf** in order to startup correctly. -The names before the addresses need to match the servers configured in -**~/.core/servers.conf** previously. - -```shell -controlnet = core1:172.16.1.0/24 core2:172.16.2.0/24 core3:172.16.3.0/24 core4:172.16.4.0/24 core5:172.16.5.0/24 -``` - -```shell -emane_event_generate = True -``` - ## Distributed Checklist 1. Install CORE on master server 1. Install distributed CORE package on all servers needed 1. Installed and configure public-key SSH access on all servers (if you want to use double-click shells or Widgets.) for both the GUI user (for terminals) and root for running CORE commands +1. Update CORE configuration as needed 1. Choose the servers that participate in distributed emulation. 1. Assign nodes to desired servers, empty for master server. 1. Press the **Start** button to launch the distributed emulation. From a236ea2455901140ace5d1cd809f792811e24ec7 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 9 Jul 2020 23:01:28 -0700 Subject: [PATCH 251/929] updates to poetry based installation --- configure.ac | 12 ------ docs/install2.md | 27 ++++++++++++ install2.sh | 30 +++++++++++++ tasks.py | 107 +++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 146 insertions(+), 30 deletions(-) create mode 100644 docs/install2.md create mode 100755 install2.sh diff --git a/configure.ac b/configure.ac index ae2d0c8d..02102760 100644 --- a/configure.ac +++ b/configure.ac @@ -167,18 +167,6 @@ if test "x$enable_daemon" = "xyes"; then if test "x$ovs_of_path" = "xno" ; then AC_MSG_WARN([Could not locate ovs-ofctl cannot use OVS mode]) fi - - CFLAGS_save=$CFLAGS - CPPFLAGS_save=$CPPFLAGS - if test "x$PYTHON_INCLUDE_DIR" = "x"; then - PYTHON_INCLUDE_DIR=`$PYTHON -c "import distutils.sysconfig; print(distutils.sysconfig.get_python_inc())"` - fi - CFLAGS="-I$PYTHON_INCLUDE_DIR" - CPPFLAGS="-I$PYTHON_INCLUDE_DIR" - AC_CHECK_HEADERS([Python.h], [], - AC_MSG_ERROR([Python bindings require Python development headers (try installing your 'python-devel' or 'python-dev' package)])) - CFLAGS=$CFLAGS_save - CPPFLAGS=$CPPFLAGS_save fi if [ test "x$enable_daemon" = "xyes" || test "x$enable_vnodedonly" = "xyes" ] ; then diff --git a/docs/install2.md b/docs/install2.md new file mode 100644 index 00000000..b8f7c099 --- /dev/null +++ b/docs/install2.md @@ -0,0 +1,27 @@ +# Commands Used Ubuntu + +```shell +# get pip +sudo apt install python3-pip python3-venv + +# install pipx +python3 -m pip install --user pipx +python3 -m pipx ensurepath + +# install invoke +pipx install invoke + +# install core +inv install + +# run daemon +inv daemon + +# run gui +inv gui +``` + +Commands Used CentOS + +```shell +``` diff --git a/install2.sh b/install2.sh new file mode 100755 index 00000000..a8366670 --- /dev/null +++ b/install2.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# exit on error +set -e + +# detect os/ver for install type +os="" +if [[ -f /etc/os-release ]]; then + . /etc/os-release + os=${ID} +fi + +echo "installing CORE for ${os}" +case ${os} in +"ubuntu") + sudo apt install -y python3-pip + + ;; +"centos") + sudo yum install -y python3-pip + ;; +*) + echo "unknown OS ID ${os} cannot install" + ;; +esac + +python3 -m pip install --user pipx +python3 -m pipx ensurepath +python3 -m pipx install invoke +inv install diff --git a/tasks.py b/tasks.py index b19e8925..37b2e10c 100644 --- a/tasks.py +++ b/tasks.py @@ -1,52 +1,123 @@ +import os + from invoke import task +UBUNTU = "ubuntu" +CENTOS = "centos" +DAEMON_DIR = "daemon" +VCMD_DIR = "netns" +GUI_DIR = "gui" + + +def get_python(c): + with c.cd(DAEMON_DIR): + venv = c.run("poetry env info -p", hide=True).stdout.strip() + return os.path.join(venv, "bin", "python") + + +def get_pytest(c): + with c.cd(DAEMON_DIR): + venv = c.run("poetry env info -p", hide=True).stdout.strip() + return os.path.join(venv, "bin", "pytest") + + +def get_os(): + d = {} + with open("/etc/os-release", "r") as f: + for line in f.readlines(): + line = line.strip() + key, value = line.split("=") + d[key] = value + return d["ID"] + + +@task +def install(c): + """ + install core + """ + # get os + os_name = get_os() + # install system dependencies + print("installing system dependencies...") + if os_name == UBUNTU: + c.run( + "sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 " + "ethtool tk python3-tk", hide=True + ) + else: + raise Exception(f"unsupported os: {os_name}") + # install grpcio-tools for building proto files + print("installing grpcio-tools...") + c.run("python3 -m pip install --user grpcio-tools", hide=True) + # build core + print("building core...") + c.run("./bootstrap.sh", hide=True) + c.run("./configure", hide=True) + c.run("make -j", hide=True) + # install vcmd + print("installing vcmd...") + with c.cd(VCMD_DIR): + c.run("sudo make install", hide=True) + # install vcmd + print("installing gui...") + with c.cd(GUI_DIR): + c.run("sudo make install", hide=True) + # install poetry environment + print("installing poetry...") + c.run("pipx install poetry", hide=True) + with c.cd(DAEMON_DIR): + print("installing core environment using poetry...") + c.run("poetry install", hide=True) + @task def daemon(c): """ - Runs core-daemon. + start core-daemon """ - with c.cd("daemon"): - poetry = c.run("which poetry").stdout.strip() + python = get_python(c) + with c.cd(DAEMON_DIR): c.run( - f"sudo {poetry} run scripts/core-daemon " - "-f data/core.conf -l data/logging.conf" + f"sudo {python} scripts/core-daemon " + "-f data/core.conf -l data/logging.conf", + pty=True ) @task def gui(c): """ - Run core-pygui. + start core-pygui """ - with c.cd("daemon"): - c.run("poetry run scripts/core-pygui") + with c.cd(DAEMON_DIR): + c.run("poetry run scripts/core-pygui", pty=True) @task def test(c): """ - Run core tests. + run core tests """ - with c.cd("daemon"): - poetry = c.run("which poetry").stdout.strip() - c.run(f"sudo {poetry} run pytest -v --lf -x tests", pty=True) + pytest = get_pytest(c) + with c.cd(DAEMON_DIR): + c.run(f"sudo {pytest} -v --lf -x tests", pty=True) @task def test_mock(c): """ - Run core tests using mock to avoid running as sudo. + run core tests using mock to avoid running as sudo """ - with c.cd("daemon"): + with c.cd(DAEMON_DIR): c.run("poetry run pytest -v --mock --lf -x tests", pty=True) @task def test_emane(c): """ - Run core emane tests. + run core emane tests """ - with c.cd("daemon"): - poetry = c.run("which poetry").stdout.strip() - c.run(f"sudo {poetry} run pytest -v --lf -x tests/emane", pty=True) + pytest = get_pytest(c) + with c.cd(DAEMON_DIR): + c.run(f"{pytest} -v --lf -x tests/emane", pty=True) From 139323146e437e796603cd01178f29a8af667d3d Mon Sep 17 00:00:00 2001 From: bharnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 9 Jul 2020 23:27:05 -0700 Subject: [PATCH 252/929] Update install2.sh update to account for missing python3-venv package and updating PATH in script to run newly installed commands --- install2.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/install2.sh b/install2.sh index a8366670..496906e8 100755 --- a/install2.sh +++ b/install2.sh @@ -13,8 +13,7 @@ fi echo "installing CORE for ${os}" case ${os} in "ubuntu") - sudo apt install -y python3-pip - + sudo apt install -y python3-pip python3-venv ;; "centos") sudo yum install -y python3-pip @@ -26,5 +25,6 @@ esac python3 -m pip install --user pipx python3 -m pipx ensurepath -python3 -m pipx install invoke +export PATH=$PATH:~/.local/bin +pipx install invoke inv install From d4ac9e618f1177da57fcad0c75bbb1f8761fa953 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 08:32:47 -0700 Subject: [PATCH 253/929] improvements to invoke tasks for installation --- tasks.py | 117 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 89 insertions(+), 28 deletions(-) diff --git a/tasks.py b/tasks.py index 37b2e10c..9949512c 100644 --- a/tasks.py +++ b/tasks.py @@ -1,74 +1,135 @@ import os +import sys +from enum import Enum -from invoke import task +from invoke import task, Context -UBUNTU = "ubuntu" -CENTOS = "centos" -DAEMON_DIR = "daemon" -VCMD_DIR = "netns" -GUI_DIR = "gui" +DAEMON_DIR: str = "daemon" +VCMD_DIR: str = "netns" +GUI_DIR: str = "gui" -def get_python(c): +class OsName(Enum): + UBUNTU = "ubuntu" + CENTOS = "centos" + + +class OsLike(Enum): + DEBIAN = "debian" + + +class OsInfo: + def __init__(self, name: OsName, like: OsLike, version: str) -> None: + self.name: OsName = name + self.like: OsLike = like + self.version: str = version + + +def get_python(c: Context) -> str: with c.cd(DAEMON_DIR): venv = c.run("poetry env info -p", hide=True).stdout.strip() return os.path.join(venv, "bin", "python") -def get_pytest(c): +def get_pytest(c: Context) -> str: with c.cd(DAEMON_DIR): venv = c.run("poetry env info -p", hide=True).stdout.strip() return os.path.join(venv, "bin", "pytest") -def get_os(): +def get_os() -> OsInfo: d = {} with open("/etc/os-release", "r") as f: for line in f.readlines(): line = line.strip() key, value = line.split("=") - d[key] = value - return d["ID"] + d[key] = value.strip('"') + name_value = d["ID"] + like_value = d["ID_LIKE"] + try: + name = OsName(name_value) + like = OsLike(like_value) + except ValueError: + print(f"unsupported os({name_value}) like({like_value})") + sys.exit(1) + version = d["VERSION_ID"] + return OsInfo(name, like, version) -@task -def install(c): - """ - install core - """ - # get os - os_name = get_os() - # install system dependencies +def install_system(c: Context, os_info: OsInfo) -> None: print("installing system dependencies...") - if os_name == UBUNTU: + if os_info.like == OsLike.DEBIAN: c.run( "sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 " "ethtool tk python3-tk", hide=True ) - else: - raise Exception(f"unsupported os: {os_name}") - # install grpcio-tools for building proto files + + +def install_grpcio(c: Context) -> None: print("installing grpcio-tools...") c.run("python3 -m pip install --user grpcio-tools", hide=True) - # build core + + +def build(c: Context) -> None: print("building core...") c.run("./bootstrap.sh", hide=True) c.run("./configure", hide=True) c.run("make -j", hide=True) - # install vcmd + + +def install_core(c: Context) -> None: print("installing vcmd...") with c.cd(VCMD_DIR): c.run("sudo make install", hide=True) - # install vcmd print("installing gui...") with c.cd(GUI_DIR): c.run("sudo make install", hide=True) - # install poetry environment + + +def install_poetry(c: Context, dev: bool) -> None: print("installing poetry...") c.run("pipx install poetry", hide=True) + args = "" if dev else "--no-dev" with c.cd(DAEMON_DIR): print("installing core environment using poetry...") - c.run("poetry install", hide=True) + c.run(f"poetry install {args}", hide=True) + if dev: + c.run("poetry run pre-commit install") + + +def install_ospf_mdr(c: Context, os_info: OsInfo) -> None: + if c.run("which zebra"): + print("quagga already installed, skipping ospf mdr") + return + if os_info.like == OsLike.DEBIAN: + c.run("sudo apt install -y libtool gawk libreadline-dev") + clone_dir = "/tmp/ospf-mdr" + c.run( + f"git clone https://github.com/USNavalResearchLaboratory/ospf-mdr {clone_dir}" + ) + with c.cd(clone_dir): + c.run("./bootstrap.sh") + c.run( + "./configure --disable-doc --enable-user=root --enable-group=root " + "--with-cflags=-ggdb --sysconfdir=/usr/local/etc/quagga --enable-vtysh " + "--localstatedir=/var/run/quagga" + ) + c.run("make -j") + c.run("sudo make install") + + +@task +def install(c, dev=False): + """ + install core + """ + os_info = get_os() + install_system(c, os_info) + install_grpcio(c) + build(c) + install_core(c) + install_poetry(c, dev) + install_ospf_mdr(c, os_info) @task From 8357cddbab3db1d7244ede08f1cce1cd29dd8463 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 08:51:40 -0700 Subject: [PATCH 254/929] added developer and verbose flags to poetry install --- install2.sh | 24 ++++++++++++++++++++- tasks.py | 61 ++++++++++++++++++++++++++++------------------------- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/install2.sh b/install2.sh index 496906e8..4efaee53 100755 --- a/install2.sh +++ b/install2.sh @@ -10,6 +10,28 @@ if [[ -f /etc/os-release ]]; then os=${ID} fi +# parse arguments +dev="" +verbose="" +while getopts "drv:" opt; do + case ${opt} in + d) + dev="-d" + ;; + v) + verbose="-v" + ;; + \?) + echo "script usage: $(basename $0) [-d] [-v]" >&2 + echo "" >&2 + echo "-v enable verbose install" >&2 + echo "-d enable developer install" >&2 + exit 1 + ;; + esac +done +shift $((OPTIND - 1)) + echo "installing CORE for ${os}" case ${os} in "ubuntu") @@ -27,4 +49,4 @@ python3 -m pip install --user pipx python3 -m pipx ensurepath export PATH=$PATH:~/.local/bin pipx install invoke -inv install +inv install $(dev) $(verbose) diff --git a/tasks.py b/tasks.py index 9949512c..da8fef21 100644 --- a/tasks.py +++ b/tasks.py @@ -56,80 +56,83 @@ def get_os() -> OsInfo: return OsInfo(name, like, version) -def install_system(c: Context, os_info: OsInfo) -> None: +def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: print("installing system dependencies...") if os_info.like == OsLike.DEBIAN: c.run( "sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 " - "ethtool tk python3-tk", hide=True + "ethtool tk python3-tk", hide=hide ) -def install_grpcio(c: Context) -> None: +def install_grpcio(c: Context, hide: bool) -> None: print("installing grpcio-tools...") - c.run("python3 -m pip install --user grpcio-tools", hide=True) + c.run("python3 -m pip install --user grpcio-tools", hide=hide) -def build(c: Context) -> None: +def build(c: Context, hide: bool) -> None: print("building core...") - c.run("./bootstrap.sh", hide=True) - c.run("./configure", hide=True) - c.run("make -j", hide=True) + c.run("./bootstrap.sh", hide=hide) + c.run("./configure", hide=hide) + c.run("make -j", hide=hide) -def install_core(c: Context) -> None: +def install_core(c: Context, hide: bool) -> None: print("installing vcmd...") with c.cd(VCMD_DIR): - c.run("sudo make install", hide=True) + c.run("sudo make install", hide=hide) print("installing gui...") with c.cd(GUI_DIR): - c.run("sudo make install", hide=True) + c.run("sudo make install", hide=hide) -def install_poetry(c: Context, dev: bool) -> None: +def install_poetry(c: Context, dev: bool, hide: bool) -> None: print("installing poetry...") - c.run("pipx install poetry", hide=True) + c.run("pipx install poetry", hide=hide) args = "" if dev else "--no-dev" with c.cd(DAEMON_DIR): print("installing core environment using poetry...") - c.run(f"poetry install {args}", hide=True) + c.run(f"poetry install {args}", hide=hide) if dev: c.run("poetry run pre-commit install") -def install_ospf_mdr(c: Context, os_info: OsInfo) -> None: - if c.run("which zebra"): +def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: + if c.run("which zebra", warn=True, hide=hide): print("quagga already installed, skipping ospf mdr") return if os_info.like == OsLike.DEBIAN: - c.run("sudo apt install -y libtool gawk libreadline-dev") + c.run("sudo apt install -y libtool gawk libreadline-dev", hide=hide) clone_dir = "/tmp/ospf-mdr" c.run( - f"git clone https://github.com/USNavalResearchLaboratory/ospf-mdr {clone_dir}" + f"git clone https://github.com/USNavalResearchLaboratory/ospf-mdr {clone_dir}", + hide=hide ) with c.cd(clone_dir): - c.run("./bootstrap.sh") + c.run("./bootstrap.sh", hide=hide) c.run( "./configure --disable-doc --enable-user=root --enable-group=root " "--with-cflags=-ggdb --sysconfdir=/usr/local/etc/quagga --enable-vtysh " - "--localstatedir=/var/run/quagga" + "--localstatedir=/var/run/quagga", + hide=hide ) - c.run("make -j") - c.run("sudo make install") + c.run("make -j", hide=hide) + c.run("sudo make install", hide=hide) @task -def install(c, dev=False): +def install(c, dev=False, verbose=False): """ install core """ + hide = not verbose os_info = get_os() - install_system(c, os_info) - install_grpcio(c) - build(c) - install_core(c) - install_poetry(c, dev) - install_ospf_mdr(c, os_info) + install_system(c, os_info, hide) + install_grpcio(c, hide) + build(c, hide) + install_core(c, hide) + install_poetry(c, dev, hide) + install_ospf_mdr(c, os_info, hide) @task From 41f0c8ef95bd914a92209c6c4a8d19bdc19d5eb1 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 08:56:16 -0700 Subject: [PATCH 255/929] fixed bad arguments being passed in install2.sh --- install2.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install2.sh b/install2.sh index 4efaee53..13876776 100755 --- a/install2.sh +++ b/install2.sh @@ -49,4 +49,4 @@ python3 -m pip install --user pipx python3 -m pipx ensurepath export PATH=$PATH:~/.local/bin pipx install invoke -inv install $(dev) $(verbose) +inv install ${dev} ${verbose} From 7dd2b6668016f6c625a3dc4556d0b75c73050100 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 08:58:09 -0700 Subject: [PATCH 256/929] added message for installing ospf mdr in install task --- tasks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tasks.py b/tasks.py index da8fef21..50440e4d 100644 --- a/tasks.py +++ b/tasks.py @@ -101,6 +101,7 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: if c.run("which zebra", warn=True, hide=hide): print("quagga already installed, skipping ospf mdr") return + print("installing ospf mdr...") if os_info.like == OsLike.DEBIAN: c.run("sudo apt install -y libtool gawk libreadline-dev", hide=hide) clone_dir = "/tmp/ospf-mdr" From 51200cf930ca44415a2326a9574168446d24f7d6 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 09:20:13 -0700 Subject: [PATCH 257/929] added more messages to ospf mdr invoke install --- tasks.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 50440e4d..af8abfba 100644 --- a/tasks.py +++ b/tasks.py @@ -101,15 +101,17 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: if c.run("which zebra", warn=True, hide=hide): print("quagga already installed, skipping ospf mdr") return - print("installing ospf mdr...") + print("installing ospf mdr dependencies...") if os_info.like == OsLike.DEBIAN: c.run("sudo apt install -y libtool gawk libreadline-dev", hide=hide) + print("cloning ospf mdr...") clone_dir = "/tmp/ospf-mdr" c.run( f"git clone https://github.com/USNavalResearchLaboratory/ospf-mdr {clone_dir}", hide=hide ) with c.cd(clone_dir): + print("building ospf mdr...") c.run("./bootstrap.sh", hide=hide) c.run( "./configure --disable-doc --enable-user=root --enable-group=root " @@ -118,6 +120,7 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: hide=hide ) c.run("make -j", hide=hide) + print("installing ospf mdr...") c.run("sudo make install", hide=hide) From a2a825e91df6ee5da5b29adafdbd41f2ff5ed96f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 09:55:30 -0700 Subject: [PATCH 258/929] better invoke output and removed -j from building ospf mdr --- tasks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tasks.py b/tasks.py index af8abfba..59f8b1bd 100644 --- a/tasks.py +++ b/tasks.py @@ -74,14 +74,14 @@ def build(c: Context, hide: bool) -> None: print("building core...") c.run("./bootstrap.sh", hide=hide) c.run("./configure", hide=hide) - c.run("make -j", hide=hide) + c.run("make", hide=hide) def install_core(c: Context, hide: bool) -> None: - print("installing vcmd...") + print("installing core vcmd...") with c.cd(VCMD_DIR): c.run("sudo make install", hide=hide) - print("installing gui...") + print("installing core gui...") with c.cd(GUI_DIR): c.run("sudo make install", hide=hide) @@ -119,7 +119,7 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: "--localstatedir=/var/run/quagga", hide=hide ) - c.run("make -j", hide=hide) + c.run("make", hide=hide) print("installing ospf mdr...") c.run("sudo make install", hide=hide) From 85cd31ae52a9ef203f6fdac117112f2e5cd0d131 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 10:23:45 -0700 Subject: [PATCH 259/929] fixed install2.sh argument parsing --- install2.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install2.sh b/install2.sh index 13876776..bdfd2f7b 100755 --- a/install2.sh +++ b/install2.sh @@ -13,7 +13,7 @@ fi # parse arguments dev="" verbose="" -while getopts "drv:" opt; do +while getopts "dv" opt; do case ${opt} in d) dev="-d" From 9b7dce0861beca7a4ae8ab03870026c6a1241da6 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 10:27:17 -0700 Subject: [PATCH 260/929] added example output after installation and note about getting a new terminal --- tasks.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tasks.py b/tasks.py index 59f8b1bd..8d04dd72 100644 --- a/tasks.py +++ b/tasks.py @@ -137,6 +137,11 @@ def install(c, dev=False, verbose=False): install_core(c, hide) install_poetry(c, dev, hide) install_ospf_mdr(c, os_info, hide) + print("please open a new terminal or re-login to leverage invoke for running core") + print("# run daemon") + print("inv daemon") + print("# run gui") + print("inv gui") @task From 38e68386970ad2e7579f38ecc257220457e6521c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 10:39:14 -0700 Subject: [PATCH 261/929] avoid empty lines when parsing os-release --- tasks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tasks.py b/tasks.py index 8d04dd72..05f23d5f 100644 --- a/tasks.py +++ b/tasks.py @@ -42,6 +42,8 @@ def get_os() -> OsInfo: with open("/etc/os-release", "r") as f: for line in f.readlines(): line = line.strip() + if not line: + continue key, value = line.split("=") d[key] = value.strip('"') name_value = d["ID"] From cd9ecd22570d7ec9f4076e645aa4072bb58f6e18 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 10:45:03 -0700 Subject: [PATCH 262/929] added redhat like os to invoke task --- tasks.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tasks.py b/tasks.py index 05f23d5f..272db1df 100644 --- a/tasks.py +++ b/tasks.py @@ -16,13 +16,14 @@ class OsName(Enum): class OsLike(Enum): DEBIAN = "debian" + REDHAT = "rhel fedora" class OsInfo: - def __init__(self, name: OsName, like: OsLike, version: str) -> None: + def __init__(self, name: OsName, like: OsLike, version: float) -> None: self.name: OsName = name self.like: OsLike = like - self.version: str = version + self.version: float = version def get_python(c: Context) -> str: @@ -48,13 +49,16 @@ def get_os() -> OsInfo: d[key] = value.strip('"') name_value = d["ID"] like_value = d["ID_LIKE"] + version_value = d["VERSION_ID"] try: name = OsName(name_value) like = OsLike(like_value) + version = float(version_value) except ValueError: - print(f"unsupported os({name_value}) like({like_value})") + print( + f"unsupported os({name_value}) like({like_value}) version({version_value}" + ) sys.exit(1) - version = d["VERSION_ID"] return OsInfo(name, like, version) From 7821ffb642ab9d5d302e05cb20006c427d4c34e4 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 10:51:06 -0700 Subject: [PATCH 263/929] python-devel is needed on centos --- tasks.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index 272db1df..938fb567 100644 --- a/tasks.py +++ b/tasks.py @@ -71,8 +71,10 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: ) -def install_grpcio(c: Context, hide: bool) -> None: +def install_grpcio(c: Context, os_info: OsInfo, hide: bool) -> None: print("installing grpcio-tools...") + if os_info.like == OsLike.REDHAT: + c.run("sudo yum install -y python3-devel", hide=hide) c.run("python3 -m pip install --user grpcio-tools", hide=hide) @@ -138,7 +140,7 @@ def install(c, dev=False, verbose=False): hide = not verbose os_info = get_os() install_system(c, os_info, hide) - install_grpcio(c, hide) + install_grpcio(c, os_info, hide) build(c, hide) install_core(c, hide) install_poetry(c, dev, hide) From 9bf5756a0352be784da5ac38fea3fe5fb248b6ab Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 11:20:27 -0700 Subject: [PATCH 264/929] added invoke system dependencies for redhat --- tasks.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tasks.py b/tasks.py index 938fb567..d910d5dc 100644 --- a/tasks.py +++ b/tasks.py @@ -67,14 +67,19 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: if os_info.like == OsLike.DEBIAN: c.run( "sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 " - "ethtool tk python3-tk", hide=hide + "ethtool tk python3-tk", + hide=hide + ) + elif os_info.like == OsLike.REDHAT: + c.run( + "sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel " + "iptables-ebtables iproute python3-devel python3-tkinter tk ethtool", + hide=hide ) -def install_grpcio(c: Context, os_info: OsInfo, hide: bool) -> None: +def install_grpcio(c: Context, hide: bool) -> None: print("installing grpcio-tools...") - if os_info.like == OsLike.REDHAT: - c.run("sudo yum install -y python3-devel", hide=hide) c.run("python3 -m pip install --user grpcio-tools", hide=hide) @@ -140,7 +145,7 @@ def install(c, dev=False, verbose=False): hide = not verbose os_info = get_os() install_system(c, os_info, hide) - install_grpcio(c, os_info, hide) + install_grpcio(c, hide) build(c, hide) install_core(c, hide) install_poetry(c, dev, hide) From 626b977505719d23d484a690f745f21101b34fd4 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 11:30:12 -0700 Subject: [PATCH 265/929] added ospf mdr redhat dependencies to invoke install --- tasks.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tasks.py b/tasks.py index d910d5dc..0e9f625c 100644 --- a/tasks.py +++ b/tasks.py @@ -117,6 +117,8 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: print("installing ospf mdr dependencies...") if os_info.like == OsLike.DEBIAN: c.run("sudo apt install -y libtool gawk libreadline-dev", hide=hide) + elif os_info.like == OsLike.REDHAT: + c.run("sudo yum install -y libtool gawk readline-devel", hide=hide) print("cloning ospf mdr...") clone_dir = "/tmp/ospf-mdr" c.run( From fe362a10d6b97e0d3aea5c11e2d2f9ffdefc14a9 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 12:05:11 -0700 Subject: [PATCH 266/929] poetry changes to help force installing grpcio from binary packages, causing long build times on centos --- daemon/poetry.lock | 156 ++++++++++++++++++++++++------------------ daemon/pyproject.toml | 2 +- 2 files changed, 91 insertions(+), 67 deletions(-) diff --git a/daemon/poetry.lock b/daemon/poetry.lock index c5e1ebb6..c72bc364 100644 --- a/daemon/poetry.lock +++ b/daemon/poetry.lock @@ -180,7 +180,7 @@ description = "HTTP/2-based RPC framework" name = "grpcio" optional = false python-versions = "*" -version = "1.29.0" +version = "1.27.2" [package.dependencies] six = ">=1.5.2" @@ -191,10 +191,10 @@ description = "Protobuf code generator for gRPC" name = "grpcio-tools" optional = false python-versions = "*" -version = "1.29.0" +version = "1.27.2" [package.dependencies] -grpcio = ">=1.29.0" +grpcio = ">=1.27.2" protobuf = ">=3.5.0.post1" [[package]] @@ -602,7 +602,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "ff2407f8ca447047101b8e0c8656027d07d2f15e51b3a950f2c2d789f929da6b" +content-hash = "260c6612feb7c884d03b3b98e5fb22ad4d06a58559876f239bd5c677d14a7ba1" python-versions = "^3.6" [metadata.files] @@ -725,70 +725,94 @@ flake8 = [ {file = "flake8-3.8.2.tar.gz", hash = "sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634"}, ] grpcio = [ - {file = "grpcio-1.29.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:e90f3d11185c36593186e5ff1f581acc6ddfa4190f145b0366e579de1f52803b"}, - {file = "grpcio-1.29.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:5024b26e17a1bfc9390fb3b8077bf886eee02970af780fd23072970ef08cefe8"}, - {file = "grpcio-1.29.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:23bc395a32c2465564cb242e48bdd2fdbe5a4aebf307649a800da1b971ee7f29"}, - {file = "grpcio-1.29.0-cp27-cp27m-win32.whl", hash = "sha256:886d48c32960b39e059494637eb0157a694956248d03b0de814447c188b74799"}, - {file = "grpcio-1.29.0-cp27-cp27m-win_amd64.whl", hash = "sha256:da0ca9b1089d00e39a8b83deec799a4e5c37ec1b44d804495424acde50531868"}, - {file = "grpcio-1.29.0-cp27-cp27mu-linux_armv7l.whl", hash = "sha256:ebf0ccb782027ef9e213e03b6d00bbd8dabd80959db7d468c0738e6d94b5204c"}, - {file = "grpcio-1.29.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2637ce96b7c954d2b71060f50eb4c72f81668f1b2faa6cbdc74677e405978901"}, - {file = "grpcio-1.29.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:75b2247307a7ecaf6abc9eb2bd04af8f88816c111b87bf0044d7924396e9549c"}, - {file = "grpcio-1.29.0-cp35-cp35m-linux_armv7l.whl", hash = "sha256:7bf3cb1e0f4a9c89f7b748583b994bdce183103d89d5ff486da48a7668a052c7"}, - {file = "grpcio-1.29.0-cp35-cp35m-macosx_10_7_intel.whl", hash = "sha256:a6dddb177b3cfa0cfe299fb9e07d6a3382cc79466bef48fe9c4326d5c5b1dcb8"}, - {file = "grpcio-1.29.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:b49f243936b0f6ae8eb6adf88a1e54e736f1c6724a1bff6b591d105d708263ad"}, - {file = "grpcio-1.29.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:9cfb4b71cc3c8757f137d47000f9d90d4bd818733f9ab4f78bd447e052a4cb9a"}, - {file = "grpcio-1.29.0-cp35-cp35m-win32.whl", hash = "sha256:10cdc8946a7c2284bbc8e16d346eaa2beeaae86ea598f345df86d4ef7dfedb84"}, - {file = "grpcio-1.29.0-cp35-cp35m-win_amd64.whl", hash = "sha256:806c9759f5589b3761561187408e0313a35c5c53f075c7590effab8d27d67dfe"}, - {file = "grpcio-1.29.0-cp36-cp36m-linux_armv7l.whl", hash = "sha256:57c8cc2ae8cb94c3a89671af7e1380a4cdfcd6bab7ba303f4461ec32ded250ae"}, - {file = "grpcio-1.29.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:97b72bf2242a351a89184134adbb0ae3b422e6893c6c712bc7669e2eab21501b"}, - {file = "grpcio-1.29.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:517538a54afdd67162ea2af1ac3326c0752c5d13e6ddadbc4885f6a28e91ab28"}, - {file = "grpcio-1.29.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:eede3039c3998e2cc0f6713f4ac70f235bd32967c9b958a17bf937aceebc12c3"}, - {file = "grpcio-1.29.0-cp36-cp36m-win32.whl", hash = "sha256:54e4658c09084b09cd83a5ea3a8bce78e4031ff1010bb8908c399a22a76a6f08"}, - {file = "grpcio-1.29.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7e02a7c40304eecee203f809a982732bd37fad4e798acad98fe73c66e44ff2db"}, - {file = "grpcio-1.29.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ff7931241351521b8df01d7448800ce0d59364321d8d82c49b826d455678ff08"}, - {file = "grpcio-1.29.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:5fd9ffe938e9225c654c60eb21ff011108cc27302db85200413807e0eda99a4a"}, - {file = "grpcio-1.29.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:9ef0370bcf629ece4e7e37796e4604e2514b920669be2911fc3f9c163a73a57b"}, - {file = "grpcio-1.29.0-cp37-cp37m-win32.whl", hash = "sha256:3d8c510b6eabce5192ce126003d74d7751c7218d3e2ad39fcf02400d7ec43abe"}, - {file = "grpcio-1.29.0-cp37-cp37m-win_amd64.whl", hash = "sha256:81bbf78a399e0ee516c81ddad8601f12af3fc9b30f2e4b2fbd64efd327304a4d"}, - {file = "grpcio-1.29.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:80e9f9f6265149ca7c84e1c8c31c2cf3e2869c45776fbe8880a3133a11d6d290"}, - {file = "grpcio-1.29.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:524ae8d3da61b856cf08abb3d0947df05402919e4be1f88328e0c1004031f72e"}, - {file = "grpcio-1.29.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:c3a0ef12ee86f6e72db50e01c3dba7735a76d8c30104b9b0f7fd9d65ceb9d93f"}, - {file = "grpcio-1.29.0-cp38-cp38-win32.whl", hash = "sha256:97fcbdf1f12e0079d26db73da11ee35a09adc870b1e72fbff0211f6a8003a4e8"}, - {file = "grpcio-1.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:b85f355fc24b68a6c52f2750e7141110d1fcd07dfdc9b282de0000550fe0511b"}, - {file = "grpcio-1.29.0.tar.gz", hash = "sha256:a97ea91e31863c9a3879684b5fb3c6ab4b17c5431787548fc9f52b9483ea9c25"}, + {file = "grpcio-1.27.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:dbec0a3a154dbf2eb85b38abaddf24964fa1c059ee0a4ad55d6f39211b1a4bca"}, + {file = "grpcio-1.27.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:1ef949b15a1f5f30651532a9b54edf3bd7c0b699a10931505fa2c80b2d395942"}, + {file = "grpcio-1.27.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:ed123037896a8db6709b8ad5acc0ed435453726ea0b63361d12de369624c2ab5"}, + {file = "grpcio-1.27.2-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:f9d632ce9fd485119c968ec6a7a343de698c5e014d17602ae2f110f1b05925ed"}, + {file = "grpcio-1.27.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:80c3d1ce8820dd819d1c9d6b63b6f445148480a831173b572a9174a55e7abd47"}, + {file = "grpcio-1.27.2-cp27-cp27m-win32.whl", hash = "sha256:07f82aefb4a56c7e1e52b78afb77d446847d27120a838a1a0489260182096045"}, + {file = "grpcio-1.27.2-cp27-cp27m-win_amd64.whl", hash = "sha256:28f27c64dd699b8b10f70da5f9320c1cffcaefca7dd76275b44571bd097f276c"}, + {file = "grpcio-1.27.2-cp27-cp27mu-linux_armv7l.whl", hash = "sha256:a25b84e10018875a0f294a7649d07c43e8bc3e6a821714e39e5cd607a36386d7"}, + {file = "grpcio-1.27.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:57949756a3ce1f096fa2b00f812755f5ab2effeccedb19feeb7d0deafa3d1de7"}, + {file = "grpcio-1.27.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:f3614dabd2cc8741850597b418bcf644d4f60e73615906c3acc407b78ff720b3"}, + {file = "grpcio-1.27.2-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:25c77692ea8c0929d4ad400ea9c3dcbcc4936cee84e437e0ef80da58fa73d88a"}, + {file = "grpcio-1.27.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:5dab393ab96b2ce4012823b2f2ed4ee907150424d2f02b97bd6f8dd8f17cc866"}, + {file = "grpcio-1.27.2-cp35-cp35m-linux_armv7l.whl", hash = "sha256:bb2987eb3af9bcf46019be39b82c120c3d35639a95bc4ee2d08f36ecdf469345"}, + {file = "grpcio-1.27.2-cp35-cp35m-macosx_10_7_intel.whl", hash = "sha256:6f328a3faaf81a2546a3022b3dfc137cc6d50d81082dbc0c94d1678943f05df3"}, + {file = "grpcio-1.27.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:5ebc13451246de82f130e8ee7e723e8d7ae1827f14b7b0218867667b1b12c88d"}, + {file = "grpcio-1.27.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:355bd7d7ce5ff2917d217f0e8ddac568cb7403e1ce1639b35a924db7d13a39b6"}, + {file = "grpcio-1.27.2-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:d1e5563e3b7f844dbc48d709c9e4a75647e11d0387cc1fa0c861d3e9d34bc844"}, + {file = "grpcio-1.27.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:1ec8fc865d8da6d0713e2092a27eee344cd54628b2c2065a0e77fff94df4ae00"}, + {file = "grpcio-1.27.2-cp35-cp35m-win32.whl", hash = "sha256:706e2dea3de33b0d8884c4d35ecd5911b4ff04d0697c4138096666ce983671a6"}, + {file = "grpcio-1.27.2-cp35-cp35m-win_amd64.whl", hash = "sha256:d18b4c8cacbb141979bb44355ee5813dd4d307e9d79b3a36d66eca7e0a203df8"}, + {file = "grpcio-1.27.2-cp36-cp36m-linux_armv7l.whl", hash = "sha256:02aef8ef1a5ac5f0836b543e462eb421df6048a7974211a906148053b8055ea6"}, + {file = "grpcio-1.27.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:b78af4d42985ab3143d9882d0006f48d12f1bc4ba88e78f23762777c3ee64571"}, + {file = "grpcio-1.27.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:9c0669ba9aebad540fb05a33beb7e659ea6e5ca35833fc5229c20f057db760e8"}, + {file = "grpcio-1.27.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:68a149a0482d0bc697aac702ec6efb9d380e0afebf9484db5b7e634146528371"}, + {file = "grpcio-1.27.2-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:a71138366d57901597bfcc52af7f076ab61c046f409c7b429011cd68de8f9fe6"}, + {file = "grpcio-1.27.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:9e9cfe55dc7ac2aa47e0fd3285ff829685f96803197042c9d2f0fb44e4b39b2c"}, + {file = "grpcio-1.27.2-cp36-cp36m-win32.whl", hash = "sha256:d22c897b65b1408509099f1c3334bd3704f5e4eb7c0486c57d0e212f71cb8f54"}, + {file = "grpcio-1.27.2-cp36-cp36m-win_amd64.whl", hash = "sha256:c59b9280284b791377b3524c8e39ca7b74ae2881ba1a6c51b36f4f1bb94cee49"}, + {file = "grpcio-1.27.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6e545908bcc2ae28e5b190ce3170f92d0438cf26a82b269611390114de0106eb"}, + {file = "grpcio-1.27.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:6db7ded10b82592c472eeeba34b9f12d7b0ab1e2dcad12f081b08ebdea78d7d6"}, + {file = "grpcio-1.27.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4d3b6e66f32528bf43ca2297caca768280a8e068820b1c3dca0fcf9f03c7d6f1"}, + {file = "grpcio-1.27.2-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:586d931736912865c9790c60ca2db29e8dc4eace160d5a79fec3e58df79a9386"}, + {file = "grpcio-1.27.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:c03ce53690fe492845e14f4ab7e67d5a429a06db99b226b5c7caa23081c1e2bb"}, + {file = "grpcio-1.27.2-cp37-cp37m-win32.whl", hash = "sha256:209927e65395feb449783943d62a3036982f871d7f4045fadb90b2d82b153ea8"}, + {file = "grpcio-1.27.2-cp37-cp37m-win_amd64.whl", hash = "sha256:9713578f187fb1c4d00ac554fe1edcc6b3ddd62f5d4eb578b81261115802df8e"}, + {file = "grpcio-1.27.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b4efde5524579a9ce0459ca35a57a48ca878a4973514b8bb88cb80d7c9d34c85"}, + {file = "grpcio-1.27.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:fb62996c61eeff56b59ab8abfcaa0859ec2223392c03d6085048b576b567459b"}, + {file = "grpcio-1.27.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:a22daaf30037b8e59d6968c76fe0f7ff062c976c7a026e92fbefc4c4bf3fc5a4"}, + {file = "grpcio-1.27.2-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:4a0a33ada3f6f94f855f92460896ef08c798dcc5f17d9364d1735c5adc9d7e4a"}, + {file = "grpcio-1.27.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:8111b61eee12d7af5c58f82f2c97c2664677a05df9225ef5cbc2f25398c8c454"}, + {file = "grpcio-1.27.2-cp38-cp38-win32.whl", hash = "sha256:5121fa96c79fc0ec81825091d0be5c16865f834f41b31da40b08ee60552f9961"}, + {file = "grpcio-1.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:1cff47297ee614e7ef66243dc34a776883ab6da9ca129ea114a802c5e58af5c1"}, + {file = "grpcio-1.27.2.tar.gz", hash = "sha256:5ae532b93cf9ce5a2a549b74a2c35e3b690b171ece9358519b3039c7b84c887e"}, ] grpcio-tools = [ - {file = "grpcio-tools-1.29.0.tar.gz", hash = "sha256:0f681c1ebd5472b804baa391b16dc59d92b065903999566f4776bfbd010bcec9"}, - {file = "grpcio_tools-1.29.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b504e844e6f3610f279e0fba719052a73d5acc858a82d5a1151155b3c2304478"}, - {file = "grpcio_tools-1.29.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:c52bcc2e5e9d93b805e6f292e543cbabeb9a751dc9d4d451c39d4c30ee311142"}, - {file = "grpcio_tools-1.29.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:5beffd530b496866b8e8dc811e942815a6e637669350c1341b5972bb692465cc"}, - {file = "grpcio_tools-1.29.0-cp27-cp27m-win32.whl", hash = "sha256:49dcf4c11ba2766d065c90a61eb1cefc55d5d094f93c1f66a4d98bfcbc5f740c"}, - {file = "grpcio_tools-1.29.0-cp27-cp27m-win_amd64.whl", hash = "sha256:bab2a3d627f114091a758d8a7ae48af54bff717f84bb34538fed5114982e73a5"}, - {file = "grpcio_tools-1.29.0-cp27-cp27mu-linux_armv7l.whl", hash = "sha256:2a1f27a21d09e864cdfcff22265af86d9a548ea9a775e5d6a27d7abb71c3b5aa"}, - {file = "grpcio_tools-1.29.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:56aade8ed52a6cca74a4703279aaae4aa2e2b87d0ccb5778f95d31267e74fc6b"}, - {file = "grpcio_tools-1.29.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:78075ee7459001cf5c81b1f2e3f047b63d35ed018b9e15e3abeda59b70af0a4e"}, - {file = "grpcio_tools-1.29.0-cp35-cp35m-linux_armv7l.whl", hash = "sha256:1626cd01a484f29cc9b33c3902851490149d40a550b92a382978571ca7e712cf"}, - {file = "grpcio_tools-1.29.0-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:2f1d80e3988d86477633fb39442a2310513d02fcc48881b359257a4be3cfd336"}, - {file = "grpcio_tools-1.29.0-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:8ffdcb1cbbc1bdfe249eb08c9fc6557b4f83b9f6145b5914bfd2973013d6dc1f"}, - {file = "grpcio_tools-1.29.0-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:7e52c8ed5e0157ff85493f93540e3c897c7d97be03afc73230d1022ba7b80528"}, - {file = "grpcio_tools-1.29.0-cp35-cp35m-win32.whl", hash = "sha256:f464d2efe04a46a17cf9493d67e6839aa535bb8a904cc6a2b588f1b156c9265d"}, - {file = "grpcio_tools-1.29.0-cp35-cp35m-win_amd64.whl", hash = "sha256:9de112c090ab67e90b8c36eee5876278c8d037bf7c55052848886c1e8a2dd1c2"}, - {file = "grpcio_tools-1.29.0-cp36-cp36m-linux_armv7l.whl", hash = "sha256:38ab9e8afdf34289eab85ce2343c451c36837bf2521b927b30d9a845304abf4c"}, - {file = "grpcio_tools-1.29.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:1038b3d6cfd7206caf7c0a54ed06896e2aeb0a7d213a40d9000a70595e2fca21"}, - {file = "grpcio_tools-1.29.0-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:2a681ebfde0d83b70117cac745a97a3e5dc258fd817c1c1dd2bf99579b663a28"}, - {file = "grpcio_tools-1.29.0-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:47d13ddbbc2bd0e21a6109f74e731049b1d8738b5d0124580efca3721fe77fd2"}, - {file = "grpcio_tools-1.29.0-cp36-cp36m-win32.whl", hash = "sha256:fb9c46b8a0ee1a5990f29d891d6023cb92fdab9aed408194667df04f72e9caf6"}, - {file = "grpcio_tools-1.29.0-cp36-cp36m-win_amd64.whl", hash = "sha256:f672a606a59145bacc58cf4c4bb407f107abe1289f607c09e9224c99e897ed1a"}, - {file = "grpcio_tools-1.29.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1a606f2f5b23822e2e5271bf0df98c140ceed154ea6bf5c04ea85a37a0317771"}, - {file = "grpcio_tools-1.29.0-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:d89a43d14fb3043c1876e78d7ad5018c762b0ce51c199c588fa9142442546005"}, - {file = "grpcio_tools-1.29.0-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:faf845f71fcb6cb5088429c676ae644116d56e5de41c639be4d7399bf71b9637"}, - {file = "grpcio_tools-1.29.0-cp37-cp37m-win32.whl", hash = "sha256:05f214bc904c8e4ebf0240993a868895ff96184172243c0c61b323f6f029863d"}, - {file = "grpcio_tools-1.29.0-cp37-cp37m-win_amd64.whl", hash = "sha256:afcb030067ba1b6c371a7bfd1ffd77375534144000d47d245ca77ebbd195901d"}, - {file = "grpcio_tools-1.29.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b55346fa75df4b1581627022a2c79cfeb58cdaebf719cdbf63ff8ae6d7d7704b"}, - {file = "grpcio_tools-1.29.0-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:22d91ceb853f6846bcc23f15d8a936574eeb9fc7e8941bb8a1a5f8fcf4f566b2"}, - {file = "grpcio_tools-1.29.0-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:6eddefcd10f261d2aef6c122fb0651a53fcaee86e47d407492c9acf57107c91a"}, - {file = "grpcio_tools-1.29.0-cp38-cp38-win32.whl", hash = "sha256:658e131e983f4c3bec2e096c3cc048e6420acad2b19fad82328c481088ce0d1a"}, - {file = "grpcio_tools-1.29.0-cp38-cp38-win_amd64.whl", hash = "sha256:7c52f68e864f60ed51ea59a3fd18d0989720bbf2e32d47b4096eba7b0b7f7086"}, + {file = "grpcio-tools-1.27.2.tar.gz", hash = "sha256:845a51305af9fc7f9e2078edaec9a759153195f6cf1fbb12b1fa6f077e56b260"}, + {file = "grpcio_tools-1.27.2-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:7a2d5fb558ac153a326e742ebfd7020eb781c43d3ffd920abd42b2e6c6fdfb37"}, + {file = "grpcio_tools-1.27.2-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:99961156a36aae4a402d6b14c1e7efde642794b3ddbf32c51db0cb3a199e8b11"}, + {file = "grpcio_tools-1.27.2-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:069826dd02ce1886444cf4519c4fe1b05ac9ef41491f26e97400640531db47f6"}, + {file = "grpcio_tools-1.27.2-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:fae91f30dc050a8d0b32d20dc700e6092f0bd2138d83e9570fff3f0372c1b27e"}, + {file = "grpcio_tools-1.27.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:a14dc7a36c845991d908a7179502ca47bcba5ae1817c4426ce68cf2c97b20ad9"}, + {file = "grpcio_tools-1.27.2-cp27-cp27m-win32.whl", hash = "sha256:d1a5e5fa47ba9557a7d3b31605631805adc66cdba9d95b5d10dfc52cca1fed53"}, + {file = "grpcio_tools-1.27.2-cp27-cp27m-win_amd64.whl", hash = "sha256:7b54b283ec83190680903a9037376dc915e1f03852a2d574ba4d981b7a1fd3d0"}, + {file = "grpcio_tools-1.27.2-cp27-cp27mu-linux_armv7l.whl", hash = "sha256:4698c6b6a57f73b14d91a542c69ff33a2da8729691b7060a5d7f6383624d045e"}, + {file = "grpcio_tools-1.27.2-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:87e8ca2c2d2d3e09b2a2bed5d740d7b3e64028dafb7d6be543b77eec85590736"}, + {file = "grpcio_tools-1.27.2-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bd7f59ff1252a3db8a143b13ea1c1e93d4b8cf4b852eb48b22ef1e6942f62a84"}, + {file = "grpcio_tools-1.27.2-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:a8f892378b0b02526635b806f59141abbb429d19bec56e869e04f396502c9651"}, + {file = "grpcio_tools-1.27.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:69c4a63919b9007e845d9f8980becd2f89d808a4a431ca32b9723ee37b521cb1"}, + {file = "grpcio_tools-1.27.2-cp35-cp35m-linux_armv7l.whl", hash = "sha256:dcbc06556f3713a9348c4fce02d05d91e678fc320fb2bcf0ddf8e4bb11d17867"}, + {file = "grpcio_tools-1.27.2-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:16dc3fad04fe18d50777c56af7b2d9b9984cd1cfc71184646eb431196d1645c6"}, + {file = "grpcio_tools-1.27.2-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:1de5a273eaffeb3d126a63345e9e848ea7db740762f700eb8b5d84c5e3e7687d"}, + {file = "grpcio_tools-1.27.2-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:6016c07d6566e3109a3c032cf3861902d66501ecc08a5a84c47e43027302f367"}, + {file = "grpcio_tools-1.27.2-cp35-cp35m-manylinux2010_i686.whl", hash = "sha256:915a695bc112517af48126ee0ecdb6aff05ed33f3eeef28f0d076f1f6b52ef5e"}, + {file = "grpcio_tools-1.27.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:ea4b3ad696d976d5eac74ec8df9a2c692113e455446ee38d5b3bd87f8e034fa6"}, + {file = "grpcio_tools-1.27.2-cp35-cp35m-win32.whl", hash = "sha256:a140bf853edb2b5e8692fe94869e3e34077d7599170c113d07a58286c604f4fe"}, + {file = "grpcio_tools-1.27.2-cp35-cp35m-win_amd64.whl", hash = "sha256:77e25c241e33b75612f2aa62985f746c6f6803ec4e452da508bb7f8d90a69db4"}, + {file = "grpcio_tools-1.27.2-cp36-cp36m-linux_armv7l.whl", hash = "sha256:5fd7efc2fd3370bd2c72dc58f31a407a5dff5498befa145da211b2e8c6a52c63"}, + {file = "grpcio_tools-1.27.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9ba88c2d99bcaf7b9cb720925e3290d73b2367d238c5779363fd5598b2dc98c7"}, + {file = "grpcio_tools-1.27.2-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b56caecc16307b088a431a4038c3b3bb7d0e7f9988cbd0e9fa04ac937455ea38"}, + {file = "grpcio_tools-1.27.2-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f8514453411d72cc3cf7d481f2b6057e5b7436736d0cd39ee2b2f72088bbf497"}, + {file = "grpcio_tools-1.27.2-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:c1bb8f47d58e9f7c4825abfe01e6b85eda53c8b31d2267ca4cddf3c4d0829b80"}, + {file = "grpcio_tools-1.27.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:e17b2e0936b04ced99769e26111e1e86ba81619d1b2691b1364f795e45560953"}, + {file = "grpcio_tools-1.27.2-cp36-cp36m-win32.whl", hash = "sha256:520b7dafddd0f82cb7e4f6e9c6ba1049aa804d0e207870def9fe7f94d1e14090"}, + {file = "grpcio_tools-1.27.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ee50b0cf0d28748ef9f941894eb50fc464bd61b8e96aaf80c5056bea9b80d580"}, + {file = "grpcio_tools-1.27.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:627c91923df75091d8c4d244af38d5ab7ed8d786d480751d6c2b9267fbb92fe0"}, + {file = "grpcio_tools-1.27.2-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:ef624b6134aef737b3daa4fb7e806cb8c5749efecd0b1fa9ce4f7e060c7a0221"}, + {file = "grpcio_tools-1.27.2-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e6932518db389ede8bf06b4119bbd3e17f42d4626e72dec2b8955b20ec732cb6"}, + {file = "grpcio_tools-1.27.2-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:43a1573400527a23e4174d88604fde7a9d9a69bf9473c21936b7f409858f8ebb"}, + {file = "grpcio_tools-1.27.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:57f8b9e2c7f55cd45f6dd930d6de61deb42d3eb7f9788137fbc7155cf724132a"}, + {file = "grpcio_tools-1.27.2-cp37-cp37m-win32.whl", hash = "sha256:2ca280af2cae1a014a238057bd3c0a254527569a6a9169a01c07f0590081d530"}, + {file = "grpcio_tools-1.27.2-cp37-cp37m-win_amd64.whl", hash = "sha256:59fbeb5bb9a7b94eb61642ac2cee1db5233b8094ca76fc56d4e0c6c20b5dd85f"}, + {file = "grpcio_tools-1.27.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:00c5080cfb197ed20ecf0d0ff2d07f1fc9c42c724cad21c40ff2d048de5712b1"}, + {file = "grpcio_tools-1.27.2-cp38-cp38-manylinux1_i686.whl", hash = "sha256:f5450aa904e720f9c6407b59e96a8951ed6a95463f49444b6d2594b067d39588"}, + {file = "grpcio_tools-1.27.2-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:aaa5ae26883c3d58d1a4323981f96b941fa09bb8f0f368d97c6225585280cf04"}, + {file = "grpcio_tools-1.27.2-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:1266b577abe7c720fd16a83d0a4999a192e87c4a98fc9f97e0b99b106b3e155f"}, + {file = "grpcio_tools-1.27.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:a3d2aec4b09c8e59fee8b0d1ed668d09e8c48b738f03f5d8401d7eb409111c47"}, + {file = "grpcio_tools-1.27.2-cp38-cp38-win32.whl", hash = "sha256:8e7738a4b93842bca1158cde81a3587c9b7111823e40a1ddf73292ca9d58e08b"}, + {file = "grpcio_tools-1.27.2-cp38-cp38-win_amd64.whl", hash = "sha256:84724458c86ff9b14c29b49e321f34d80445b379f4cd4d0494c694b49b1d6f88"}, ] identify = [ {file = "identify-1.4.18-py2.py3-none-any.whl", hash = "sha256:9f53e80371f2ac7c969eefda8efaabd4f77c6300f5f8fc4b634744a0db8fe5cc"}, diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index 609fcb08..0cb32e91 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -8,7 +8,7 @@ authors = [] python = "^3.6" dataclasses = { version = "*", python = "3.6" } fabric = "*" -grpcio = "*" +grpcio = "1.27.2" invoke = "*" lxml = "*" mako = "*" From 9b541d0316f9556f839acdf943d55c0cae82cbd3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 12:09:04 -0700 Subject: [PATCH 267/929] adding invoke change to support grpcio binary install --- tasks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 0e9f625c..d70df69e 100644 --- a/tasks.py +++ b/tasks.py @@ -80,7 +80,9 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: def install_grpcio(c: Context, hide: bool) -> None: print("installing grpcio-tools...") - c.run("python3 -m pip install --user grpcio-tools", hide=hide) + c.run( + "python3 -m pip install --only-binary \":all:\" --user grpcio-tools", hide=hide + ) def build(c: Context, hide: bool) -> None: From 7c3e42396ac7e97e431340a331cbff5ee665d8f2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 12:20:27 -0700 Subject: [PATCH 268/929] invoke install acount for centos prefix issues, add usage of nproc for make -j --- tasks.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tasks.py b/tasks.py index d70df69e..24e2599c 100644 --- a/tasks.py +++ b/tasks.py @@ -85,11 +85,12 @@ def install_grpcio(c: Context, hide: bool) -> None: ) -def build(c: Context, hide: bool) -> None: +def build(c: Context, os_info: OsInfo, hide: bool) -> None: print("building core...") c.run("./bootstrap.sh", hide=hide) - c.run("./configure", hide=hide) - c.run("make", hide=hide) + prefix = "--prefix=/usr" if os_info.like == OsLike.REDHAT else "" + c.run(f"./configure {prefix}", hide=hide) + c.run("make -j$(nproc)", hide=hide) def install_core(c: Context, hide: bool) -> None: @@ -109,7 +110,7 @@ def install_poetry(c: Context, dev: bool, hide: bool) -> None: print("installing core environment using poetry...") c.run(f"poetry install {args}", hide=hide) if dev: - c.run("poetry run pre-commit install") + c.run("poetry run pre-commit install", hide=hide) def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: @@ -136,7 +137,7 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: "--localstatedir=/var/run/quagga", hide=hide ) - c.run("make", hide=hide) + c.run("make -j$(nproc)", hide=hide) print("installing ospf mdr...") c.run("sudo make install", hide=hide) From 8a60a4739fe8c4ec3f9658a0be28240d948d3c7b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 12:23:26 -0700 Subject: [PATCH 269/929] fixed missing invoke install argument --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 24e2599c..5ef0f938 100644 --- a/tasks.py +++ b/tasks.py @@ -151,7 +151,7 @@ def install(c, dev=False, verbose=False): os_info = get_os() install_system(c, os_info, hide) install_grpcio(c, hide) - build(c, hide) + build(c, os_info, hide) install_core(c, hide) install_poetry(c, dev, hide) install_ospf_mdr(c, os_info, hide) From 75acbf4daef775a4a017846daa2f1296c02ee4b9 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 12:41:39 -0700 Subject: [PATCH 270/929] invoke install account for ebtables based on nf_tables --- tasks.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tasks.py b/tasks.py index 5ef0f938..e79f132f 100644 --- a/tasks.py +++ b/tasks.py @@ -76,6 +76,12 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: "iptables-ebtables iproute python3-devel python3-tkinter tk ethtool", hide=hide ) + r = c.run("ebtables -V", hide=hide) + if "nf_tables" in r.stdout: + c.run( + "sudo update-alternatives --set ebtables /usr/sbin/ebtables-legacy", + hide=hide + ) def install_grpcio(c: Context, hide: bool) -> None: From 980ab1526ddb8ecb75e210de1fd9028b295ebef2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 12:52:12 -0700 Subject: [PATCH 271/929] added invoke cleanup task --- tasks.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tasks.py b/tasks.py index e79f132f..dd37cb78 100644 --- a/tasks.py +++ b/tasks.py @@ -191,6 +191,14 @@ def gui(c): c.run("poetry run scripts/core-pygui", pty=True) +@task +def cleanup(c): + """ + run core-cleanup removing leftover core nodes, bridges, directories + """ + c.run(f"sudo daemon/scripts/core-cleanup", pty=True) + + @task def test(c): """ From ece2f1c43f286b39759128ec4a1e9b198c77da88 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 12:53:21 -0700 Subject: [PATCH 272/929] added invoke cleanup message --- tasks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tasks.py b/tasks.py index dd37cb78..e24fa415 100644 --- a/tasks.py +++ b/tasks.py @@ -196,6 +196,7 @@ def cleanup(c): """ run core-cleanup removing leftover core nodes, bridges, directories """ + print("running core-cleanup...") c.run(f"sudo daemon/scripts/core-cleanup", pty=True) From 637f7740d63c051203518a7de4924cf085144065 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 13:00:38 -0700 Subject: [PATCH 273/929] added git as invoke install dependency for ospf-mdr just in case core was a source tarball --- tasks.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index e24fa415..f5ed9c2a 100644 --- a/tasks.py +++ b/tasks.py @@ -125,9 +125,9 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: return print("installing ospf mdr dependencies...") if os_info.like == OsLike.DEBIAN: - c.run("sudo apt install -y libtool gawk libreadline-dev", hide=hide) + c.run("sudo apt install -y libtool gawk libreadline-dev git", hide=hide) elif os_info.like == OsLike.REDHAT: - c.run("sudo yum install -y libtool gawk readline-devel", hide=hide) + c.run("sudo yum install -y libtool gawk readline-devel git", hide=hide) print("cloning ospf mdr...") clone_dir = "/tmp/ospf-mdr" c.run( From a9ec21c6044704dee586c05b62e3d76150f5a223 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 14:20:13 -0700 Subject: [PATCH 274/929] add make dependency to redhat based invoke installs, since centos 8 does not have it by default --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index f5ed9c2a..4ca8e3cf 100644 --- a/tasks.py +++ b/tasks.py @@ -73,7 +73,7 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: elif os_info.like == OsLike.REDHAT: c.run( "sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel " - "iptables-ebtables iproute python3-devel python3-tkinter tk ethtool", + "iptables-ebtables iproute python3-devel python3-tkinter tk ethtool make", hide=hide ) r = c.run("ebtables -V", hide=hide) From d2fe7fcff0321149ec756f1043fdbb2abb946ce8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 16:50:37 -0700 Subject: [PATCH 275/929] invoke install account for centos 8 netem not being installed/enabled and add warning for failed ebtables legacy support --- tasks.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tasks.py b/tasks.py index 4ca8e3cf..1b6023d5 100644 --- a/tasks.py +++ b/tasks.py @@ -73,15 +73,23 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: elif os_info.like == OsLike.REDHAT: c.run( "sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel " - "iptables-ebtables iproute python3-devel python3-tkinter tk ethtool make", + "iptables-ebtables iproute python3-devel python3-tkinter tk ethtool make " + "kernel-modules-extra", hide=hide ) + # centos 8+ does not support netem by default + if os_info.name == OsName.CENTOS and os_info.version >= 8: + c.run("sudo yum install -y kernel-modules-extra", hide=hide) + c.run("sudo modprobe sch_netem", hide=hide) + # attempt to setup legacy ebtables when an nftables based version is found r = c.run("ebtables -V", hide=hide) if "nf_tables" in r.stdout: - c.run( + if not c.run( "sudo update-alternatives --set ebtables /usr/sbin/ebtables-legacy", + warn=True, hide=hide - ) + ): + print("ERROR: unable to setup required ebtables-legacy, WLAN will not work") def install_grpcio(c: Context, hide: bool) -> None: From 737dae1224526a38552b93814395082b5fb090aa Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 17:56:30 -0700 Subject: [PATCH 276/929] invoke install, added message for failed kernel netem enable in centos 8 and exit with error --- tasks.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 1b6023d5..9e4d393f 100644 --- a/tasks.py +++ b/tasks.py @@ -80,7 +80,11 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: # centos 8+ does not support netem by default if os_info.name == OsName.CENTOS and os_info.version >= 8: c.run("sudo yum install -y kernel-modules-extra", hide=hide) - c.run("sudo modprobe sch_netem", hide=hide) + if not c.run("sudo modprobe sch_netem", warn=True, hide=hide): + print("ERROR: you need to install the latest kernel") + print("run the following, restart, and try again") + print("sudo yum update") + sys.exit(1) # attempt to setup legacy ebtables when an nftables based version is found r = c.run("ebtables -V", hide=hide) if "nf_tables" in r.stdout: From 8cf89fa114e1552eb56a958b5d7be3e448c4d1d2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 17:57:10 -0700 Subject: [PATCH 277/929] invoke install, change ebtables-legacy from error to warning, since we dont exit --- tasks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 9e4d393f..0255ffd9 100644 --- a/tasks.py +++ b/tasks.py @@ -93,7 +93,9 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: warn=True, hide=hide ): - print("ERROR: unable to setup required ebtables-legacy, WLAN will not work") + print( + "WARNING: unable to setup required ebtables-legacy, WLAN will not work" + ) def install_grpcio(c: Context, hide: bool) -> None: From 80eaa274697470cf428759727681ddd32a8f6f00 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 20:02:41 -0700 Subject: [PATCH 278/929] created baseline doc to support poetry based installations --- docs/install2.md | 74 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 19 deletions(-) diff --git a/docs/install2.md b/docs/install2.md index b8f7c099..c877086c 100644 --- a/docs/install2.md +++ b/docs/install2.md @@ -1,27 +1,63 @@ -# Commands Used Ubuntu +# Installing CORE + +## Overview + +CORE provides a script to help automate installing all required software +to build and run, including a python virtual environment to run it all in. + +The following tools will be leveraged during installation: + +|Tool|Description| +|---|---| +|pip|used to install pipx| +|pipx|used to install standalone python tools (invoke, poetry)| +|invoke|used to run provided tasks (install, daemon, gui, tests, etc)| +|poetry|used to install the managed python virtual environment for running CORE| + +## Supported Linux Distributions + +Plan is to support recent Ubuntu and CentOS LTS releases. + +Verified: +* Ubuntu - 18.04, 20.04 +* CentOS - 7.8, 8.0* + +> **NOTE:** Ubuntu 20.04 requires installing legacy ebtables for WLAN +> functionality + +> **NOTE:** CentOS 8 does not provide legacy ebtables support, WLAN will not +> function properly + +## Running Installation ```shell -# get pip -sudo apt install python3-pip python3-venv +# clone CORE repo +git clone https://github.com/coreemu/core.git +cd core +git checkout enhancement/poetry-invoke -# install pipx -python3 -m pip install --user pipx -python3 -m pipx ensurepath +# run install script +./install2.sh +``` -# install invoke -pipx install invoke +## Using Invoke Tasks -# install core -inv install +The invoke tool installed by way of pipx provides conveniences for running +CORE tasks to help ensure usage of the create python virtual environment. -# run daemon +```shell +Available tasks: + + cleanup run core-cleanup removing leftover core nodes, bridges, directories + daemon start core-daemon + gui start core-pygui + install install core + test run core tests + test-emane run core emane tests + test-mock run core tests using mock to avoid running as sudo +``` + +Example running the core-daemon task from the root of the repo: +```shell inv daemon - -# run gui -inv gui -``` - -Commands Used CentOS - -```shell ``` From d0e9cee6503e9c2217558752984d5450c068e21c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 10 Jul 2020 20:29:47 -0700 Subject: [PATCH 279/929] added invoke task to help wrap core-cli --- docs/install2.md | 18 ++++++++++++++++++ tasks.py | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/docs/install2.md b/docs/install2.md index c877086c..86e3db92 100644 --- a/docs/install2.md +++ b/docs/install2.md @@ -61,3 +61,21 @@ Example running the core-daemon task from the root of the repo: ```shell inv daemon ``` + +Some tasks are wrappers around command line tools and requires running +them with a slight variation for compatibility. You can enter the +poetry shell to run the script natively. + +```shell +# running core-cli as a task requires all options to be provided +# within a string +inv cli "query session -i 1" + +# entering the poetry shell to use core-cli natively +cd $REPO/daemon +poetry shell +core-cli query session -i 1 + +# exit the shell +exit +``` diff --git a/tasks.py b/tasks.py index 0255ffd9..b180688d 100644 --- a/tasks.py +++ b/tasks.py @@ -205,6 +205,15 @@ def gui(c): c.run("poetry run scripts/core-pygui", pty=True) +@task +def cli(c, args): + """ + run core-cli used to query and modify a running session + """ + with c.cd(DAEMON_DIR): + c.run(f"poetry run scripts/core-cli {args}", pty=True) + + @task def cleanup(c): """ From 7398196dcca5473a5e1d3a2a43b51bfb7cc4d631 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 11 Jul 2020 14:06:53 -0700 Subject: [PATCH 280/929] pygui: dont show mobility player when joining sessions not in runtime --- daemon/core/gui/coreclient.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 9479cbcb..255192be 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -377,7 +377,8 @@ class CoreClient: # organize canvas self.app.canvas.organize() - self.show_mobility_players() + if self.is_runtime(): + self.show_mobility_players() # update ui to represent current state self.app.after(0, self.app.joined_session_update) From e70448352740393f02973ed784dae297752db0f9 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 11 Jul 2020 14:26:06 -0700 Subject: [PATCH 281/929] update install script to avoid issues with recent grpcio-tools and pip binary packages --- install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index a12072f1..e4ac66e8 100755 --- a/install.sh +++ b/install.sh @@ -86,7 +86,7 @@ if [ -z "${reinstall}" ]; then echo "installing core system dependencies" sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 \ python${ubuntu_py} python${ubuntu_py}-dev python3-pip python3-tk tk libtk-img ethtool autoconf - python3 -m pip install grpcio-tools + python3 -m pip install grpcio-tools==1.27.2 echo "installing ospf-mdr system dependencies" sudo apt install -y libtool gawk libreadline-dev install_ospf_mdr @@ -108,7 +108,7 @@ if [ -z "${reinstall}" ]; then echo "installing core system dependencies" sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel iptables-ebtables iproute \ python${centos_py} python${centos_py}-devel python3-pip python3-tkinter tk ethtool autoconf - sudo python3 -m pip install grpcio-tools + python3 -m pip install grpcio-tools==1.27.2 echo "installing ospf-mdr system dependencies" sudo yum install -y libtool gawk readline-devel install_ospf_mdr From 68ff7a86c8c00f31fa158041be9a71efed538781 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 11 Jul 2020 15:00:09 -0700 Subject: [PATCH 282/929] fixed install script issues with grpcio-tools and updated documentation --- docs/install.md | 4 +++- install.sh | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/install.md b/docs/install.md index 4a39218d..b7804633 100644 --- a/docs/install.md +++ b/docs/install.md @@ -232,9 +232,11 @@ git clone https://github.com/coreemu/core.git ### Install grpcio-tools Python module grpcio-tools is currently needed to generate gRPC protobuf code. +Specifically leveraging 1.27.2 to avoid compatibility issues with older versions +of pip pulling down binary files. ```shell -sudo python3 -m pip install grpcio-tools +python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 ``` ### Build and Install diff --git a/install.sh b/install.sh index e4ac66e8..3eb6694e 100755 --- a/install.sh +++ b/install.sh @@ -86,7 +86,7 @@ if [ -z "${reinstall}" ]; then echo "installing core system dependencies" sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 \ python${ubuntu_py} python${ubuntu_py}-dev python3-pip python3-tk tk libtk-img ethtool autoconf - python3 -m pip install grpcio-tools==1.27.2 + python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 echo "installing ospf-mdr system dependencies" sudo apt install -y libtool gawk libreadline-dev install_ospf_mdr @@ -108,7 +108,7 @@ if [ -z "${reinstall}" ]; then echo "installing core system dependencies" sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel iptables-ebtables iproute \ python${centos_py} python${centos_py}-devel python3-pip python3-tkinter tk ethtool autoconf - python3 -m pip install grpcio-tools==1.27.2 + python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 echo "installing ospf-mdr system dependencies" sudo yum install -y libtool gawk readline-devel install_ospf_mdr From 5a35431bcb9a278929c617ab031ff16cc1afadfb Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 11 Jul 2020 15:08:04 -0700 Subject: [PATCH 283/929] updated grpcio-tools installation to specifically specify binary requirement and updated doc --- docs/install.md | 2 +- install.sh | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/install.md b/docs/install.md index b7804633..227f877c 100644 --- a/docs/install.md +++ b/docs/install.md @@ -236,7 +236,7 @@ Specifically leveraging 1.27.2 to avoid compatibility issues with older versions of pip pulling down binary files. ```shell -python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 +python3 -m pip install --only-binary ":all:" --user grpcio-tools ``` ### Build and Install diff --git a/install.sh b/install.sh index 3eb6694e..6cc193ed 100755 --- a/install.sh +++ b/install.sh @@ -11,6 +11,10 @@ function install_python_depencencies() { sudo python3 -m pip install -r daemon/requirements.txt } +function install_grpcio_tools() { + python3 -m pip install --only-binary ":all:" --user grpcio-tools +} + function install_ospf_mdr() { rm -rf /tmp/ospf-mdr git clone https://github.com/USNavalResearchLaboratory/ospf-mdr /tmp/ospf-mdr @@ -86,7 +90,7 @@ if [ -z "${reinstall}" ]; then echo "installing core system dependencies" sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 \ python${ubuntu_py} python${ubuntu_py}-dev python3-pip python3-tk tk libtk-img ethtool autoconf - python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 + install_grpcio_tools echo "installing ospf-mdr system dependencies" sudo apt install -y libtool gawk libreadline-dev install_ospf_mdr @@ -108,7 +112,7 @@ if [ -z "${reinstall}" ]; then echo "installing core system dependencies" sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel iptables-ebtables iproute \ python${centos_py} python${centos_py}-devel python3-pip python3-tkinter tk ethtool autoconf - python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 + install_grpcio_tools echo "installing ospf-mdr system dependencies" sudo yum install -y libtool gawk readline-devel install_ospf_mdr From ec45d7198b682a9a7af67e0eb52234ad1589451b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 11 Jul 2020 16:17:50 -0700 Subject: [PATCH 284/929] ci: changes to switch to poetry --- .github/workflows/daemon-checks.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/daemon-checks.yml b/.github/workflows/daemon-checks.yml index d955ee58..9e9f7aa7 100644 --- a/.github/workflows/daemon-checks.yml +++ b/.github/workflows/daemon-checks.yml @@ -11,32 +11,32 @@ jobs: uses: actions/setup-python@v1 with: python-version: 3.6 - - name: Install pipenv + - name: install poetry run: | python -m pip install --upgrade pip - pip install pipenv + pip install poetry cd daemon cp setup.py.in setup.py cp core/constants.py.in core/constants.py sed -i 's/required=True/required=False/g' core/emulator/coreemu.py - pipenv sync --dev + poetry install - name: isort run: | cd daemon - pipenv run isort -c -df + poetry run isort -c -df - name: black run: | cd daemon - pipenv run black --check --exclude ".+_pb2.*.py|doc|build|utm\.py|setup\.py" . + poetry run black --check --exclude ".+_pb2.*.py|doc|build|utm\.py|setup\.py" . - name: flake8 run: | cd daemon - pipenv run flake8 + poetry run flake8 - name: grpc run: | cd daemon/proto - pipenv run python -m grpc_tools.protoc -I . --python_out=.. --grpc_python_out=.. core/api/grpc/*.proto + poetry run python -m grpc_tools.protoc -I . --python_out=.. --grpc_python_out=.. core/api/grpc/*.proto - name: test run: | cd daemon - pipenv run test --mock + poetry run pytest --mock tests From 5c58e99ad41d45e1a70c001d9932321623c626db Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 11 Jul 2020 22:11:23 -0700 Subject: [PATCH 285/929] updated pre-commit file to use poetry environment --- daemon/.pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/.pre-commit-config.yaml b/daemon/.pre-commit-config.yaml index 73566c9d..13a6955b 100644 --- a/daemon/.pre-commit-config.yaml +++ b/daemon/.pre-commit-config.yaml @@ -5,19 +5,19 @@ repos: name: isort stages: [commit] language: system - entry: bash -c 'cd daemon && pipenv run isort --atomic -y' + entry: bash -c 'cd daemon && poetry run isort --atomic -y' types: [python] - id: black name: black stages: [commit] language: system - entry: bash -c 'cd daemon && pipenv run black --exclude ".+_pb2.*.py|doc|build|utm\.py" .' + entry: bash -c 'cd daemon && poetry run black --exclude ".+_pb2.*.py|doc|build|utm\.py" .' types: [python] - id: flake8 name: flake8 stages: [commit] language: system - entry: bash -c 'cd daemon && pipenv run flake8' + entry: bash -c 'cd daemon && poetry run flake8' types: [python] From dcf35680984068fca2e3d35dfa9cdad66fe19082 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 13 Jul 2020 08:58:32 -0700 Subject: [PATCH 286/929] force grpcio related installations to all use the same version to avoid any version conflicts --- daemon/poetry.lock | 2 +- daemon/pyproject.toml | 2 +- docs/install.md | 5 ++--- tasks.py | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/daemon/poetry.lock b/daemon/poetry.lock index c72bc364..9de19d13 100644 --- a/daemon/poetry.lock +++ b/daemon/poetry.lock @@ -602,7 +602,7 @@ docs = ["sphinx", "jaraco.packaging (>=3.2)", "rst.linker (>=1.9)"] testing = ["jaraco.itertools", "func-timeout"] [metadata] -content-hash = "260c6612feb7c884d03b3b98e5fb22ad4d06a58559876f239bd5c677d14a7ba1" +content-hash = "94df87a12a92ccb6512e4c30965e7ba1fe54b4fa3ff75827ca55b3de8472b30e" python-versions = "^3.6" [metadata.files] diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index 0cb32e91..165fb34c 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -21,7 +21,7 @@ pyyaml = "*" [tool.poetry.dev-dependencies] black = "==19.3b0" flake8 = "*" -grpcio-tools = "*" +grpcio-tools = "1.27.2" isort = "*" mock = "*" pre-commit = "*" diff --git a/docs/install.md b/docs/install.md index 227f877c..99cee9f6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -232,11 +232,10 @@ git clone https://github.com/coreemu/core.git ### Install grpcio-tools Python module grpcio-tools is currently needed to generate gRPC protobuf code. -Specifically leveraging 1.27.2 to avoid compatibility issues with older versions -of pip pulling down binary files. +Specifically leveraging 1.27.2 as that is what will be used during runtime. ```shell -python3 -m pip install --only-binary ":all:" --user grpcio-tools +python3 -m pip install --user grpcio==12.7.2 grpcio-tools==12.7.2 ``` ### Build and Install diff --git a/tasks.py b/tasks.py index b180688d..c512d388 100644 --- a/tasks.py +++ b/tasks.py @@ -101,7 +101,7 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: def install_grpcio(c: Context, hide: bool) -> None: print("installing grpcio-tools...") c.run( - "python3 -m pip install --only-binary \":all:\" --user grpcio-tools", hide=hide + "python3 -m pip install --user grpcio==12.7.2 grpcio-tools==12.7.2", hide=hide ) From 32c7808cab79d7f12cc6fbefedb4a92feb341395 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 13 Jul 2020 09:01:55 -0700 Subject: [PATCH 287/929] fixed bad version for grpcio tools --- docs/install.md | 2 +- tasks.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/install.md b/docs/install.md index 99cee9f6..6abd0945 100644 --- a/docs/install.md +++ b/docs/install.md @@ -235,7 +235,7 @@ Python module grpcio-tools is currently needed to generate gRPC protobuf code. Specifically leveraging 1.27.2 as that is what will be used during runtime. ```shell -python3 -m pip install --user grpcio==12.7.2 grpcio-tools==12.7.2 +python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 ``` ### Build and Install diff --git a/tasks.py b/tasks.py index c512d388..cc16b943 100644 --- a/tasks.py +++ b/tasks.py @@ -101,7 +101,7 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: def install_grpcio(c: Context, hide: bool) -> None: print("installing grpcio-tools...") c.run( - "python3 -m pip install --user grpcio==12.7.2 grpcio-tools==12.7.2", hide=hide + "python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2", hide=hide ) From 63f09e02543a463b728399f0d39c51e739f41dd9 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 13 Jul 2020 13:14:13 -0700 Subject: [PATCH 288/929] added installation of modified scripts and service to invoke task --- tasks.py | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tasks.py b/tasks.py index cc16b943..c2028f35 100644 --- a/tasks.py +++ b/tasks.py @@ -1,6 +1,9 @@ +import inspect import os import sys from enum import Enum +from pathlib import Path +from tempfile import NamedTemporaryFile from invoke import task, Context @@ -162,6 +165,61 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: c.run("sudo make install", hide=hide) +def install_files(c: Context, hide: bool, prefix="/usr/local") -> None: + # install all scripts + python = get_python(c) + bin_dir = Path(prefix).joinpath("bin") + for script in Path("daemon/scripts").iterdir(): + dest = bin_dir.joinpath(script.name) + print(f"installing {script} to {dest}") + with open(script, "r") as f: + lines = f.readlines() + first = lines[0].strip() + # modify python scripts to point to virtual environment + if first == "#!/usr/bin/env python3": + lines[0] = f"#!{python}\n" + temp = NamedTemporaryFile("w", delete=False) + for line in lines: + temp.write(line) + temp.close() + c.run(f"sudo cp {temp.name} {dest}", hide=hide) + c.run(f"sudo chmod 755 {dest}", hide=hide) + os.unlink(temp.name) + # copy normal links + else: + c.run(f"sudo cp {script} {dest}", hide=hide) + + # install core configuration file + config_dir = "/etc/core" + print(f"installing core configuration files under {config_dir}") + c.run(f"sudo mkdir -p {config_dir}", hide=hide) + c.run(f"sudo cp -n daemon/data/core.conf {config_dir}", hide=hide) + c.run(f"sudo cp -n daemon/data/logging.conf {config_dir}", hide=hide) + + # install service + systemd_dir = Path("/lib/systemd/system/") + service_file = systemd_dir.joinpath("core-daemon.service") + if systemd_dir.exists(): + print(f"installing core-daemon.service for systemd to {service_file}") + service_data = inspect.cleandoc(f""" + [Unit] + Description=Common Open Research Emulator Service + After=network.target + + [Service] + Type=simple + ExecStart={bin_dir}/core-daemon + TasksMax=infinity + + [Install] + WantedBy=multi-user.target + """) + temp = NamedTemporaryFile("w", delete=False) + temp.write(service_data) + temp.close() + c.run(f"sudo cp {temp.name} {service_file}", hide=hide) + + @task def install(c, dev=False, verbose=False): """ @@ -174,6 +232,7 @@ def install(c, dev=False, verbose=False): build(c, os_info, hide) install_core(c, hide) install_poetry(c, dev, hide) + install_files(c, hide) install_ospf_mdr(c, os_info, hide) print("please open a new terminal or re-login to leverage invoke for running core") print("# run daemon") From 79058810c2b827a482880cb4fa907bf19290fedf Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 13 Jul 2020 13:48:27 -0700 Subject: [PATCH 289/929] added uninstall invoke task to uninstall core files added with the invoke install command, beyond system packages --- tasks.py | 53 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/tasks.py b/tasks.py index c2028f35..2f9bd556 100644 --- a/tasks.py +++ b/tasks.py @@ -29,10 +29,14 @@ class OsInfo: self.version: float = version -def get_python(c: Context) -> str: +def get_python(c: Context, warn: bool = False) -> str: with c.cd(DAEMON_DIR): - venv = c.run("poetry env info -p", hide=True).stdout.strip() - return os.path.join(venv, "bin", "python") + r = c.run("poetry env info -p", warn=warn, hide=True) + if r.ok: + venv = r.stdout.strip() + return os.path.join(venv, "bin", "python") + else: + return "" def get_pytest(c: Context) -> str: @@ -165,7 +169,7 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: c.run("sudo make install", hide=hide) -def install_files(c: Context, hide: bool, prefix="/usr/local") -> None: +def install_files(c: Context, hide: bool, prefix: str = "/usr/local") -> None: # install all scripts python = get_python(c) bin_dir = Path(prefix).joinpath("bin") @@ -241,6 +245,47 @@ def install(c, dev=False, verbose=False): print("inv gui") +@task +def uninstall(c, dev=False, verbose=False, prefix="/usr/local"): + """ + uninstall core + """ + hide = not verbose + print("uninstalling core-gui") + with c.cd(GUI_DIR): + c.run("sudo make uninstall", hide=hide) + print("uninstalling vcmd") + with c.cd(VCMD_DIR): + c.run("sudo make uninstall", hide=hide) + print("cleaning build directory") + c.run("make clean", hide=hide) + c.run("./bootstrap.sh clean", hide=hide) + python = get_python(c, warn=True) + if python: + with c.cd(DAEMON_DIR): + if dev: + print("uninstalling pre-commit") + c.run("poetry run pre-commit uninstall", hide=hide) + print("uninstalling poetry virtual environment") + c.run(f"poetry env remove {python}", hide=hide) + + # remove installed files + bin_dir = Path(prefix).joinpath("bin") + for script in Path("daemon/scripts").iterdir(): + dest = bin_dir.joinpath(script.name) + print(f"uninstalling {dest}") + c.run(f"sudo rm -f {dest}", hide=hide) + + # install service + systemd_dir = Path("/lib/systemd/system/") + service_name = "core-daemon.service" + service_file = systemd_dir.joinpath(service_name) + if service_file.exists(): + print(f"uninstalling service {service_file}") + c.run(f"sudo systemctl disable {service_name}", hide=hide) + c.run(f"sudo rm -f {service_file}", hide=hide) + + @task def daemon(c): """ From bd87403ae5d968473fe651db4cd0f69b46a1ec00 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 13 Jul 2020 14:13:32 -0700 Subject: [PATCH 290/929] add prefix option to install2.sh script --- install2.sh | 9 +++++++-- tasks.py | 10 ++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/install2.sh b/install2.sh index bdfd2f7b..5e5a9b11 100755 --- a/install2.sh +++ b/install2.sh @@ -13,7 +13,8 @@ fi # parse arguments dev="" verbose="" -while getopts "dv" opt; do +prefix="" +while getopts "dvp:" opt; do case ${opt} in d) dev="-d" @@ -21,11 +22,15 @@ while getopts "dv" opt; do v) verbose="-v" ;; + p) + prefix="-p ${OPTARG}" + ;; \?) echo "script usage: $(basename $0) [-d] [-v]" >&2 echo "" >&2 echo "-v enable verbose install" >&2 echo "-d enable developer install" >&2 + echo "-p install prefix, defaults to /usr/local" >&2 exit 1 ;; esac @@ -49,4 +54,4 @@ python3 -m pip install --user pipx python3 -m pipx ensurepath export PATH=$PATH:~/.local/bin pipx install invoke -inv install ${dev} ${verbose} +inv install ${dev} ${verbose} ${prefix} diff --git a/tasks.py b/tasks.py index 2f9bd556..b4f37679 100644 --- a/tasks.py +++ b/tasks.py @@ -10,6 +10,7 @@ from invoke import task, Context DAEMON_DIR: str = "daemon" VCMD_DIR: str = "netns" GUI_DIR: str = "gui" +DEFAULT_PREFIX: str = "/usr/local" class OsName(Enum): @@ -169,7 +170,7 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: c.run("sudo make install", hide=hide) -def install_files(c: Context, hide: bool, prefix: str = "/usr/local") -> None: +def install_files(c: Context, hide: bool, prefix: str = DEFAULT_PREFIX) -> None: # install all scripts python = get_python(c) bin_dir = Path(prefix).joinpath("bin") @@ -225,10 +226,11 @@ def install_files(c: Context, hide: bool, prefix: str = "/usr/local") -> None: @task -def install(c, dev=False, verbose=False): +def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): """ install core """ + print(f"installing core with prefix: {prefix}") hide = not verbose os_info = get_os() install_system(c, os_info, hide) @@ -236,7 +238,7 @@ def install(c, dev=False, verbose=False): build(c, os_info, hide) install_core(c, hide) install_poetry(c, dev, hide) - install_files(c, hide) + install_files(c, hide, prefix) install_ospf_mdr(c, os_info, hide) print("please open a new terminal or re-login to leverage invoke for running core") print("# run daemon") @@ -246,7 +248,7 @@ def install(c, dev=False, verbose=False): @task -def uninstall(c, dev=False, verbose=False, prefix="/usr/local"): +def uninstall(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): """ uninstall core """ From 125d74e7d5287b944200e9f145c0b4bb20a3bb1f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 08:34:02 -0700 Subject: [PATCH 291/929] removed pipenv specific files, wont be needed with poetry --- daemon/Pipfile | 23 -- daemon/Pipfile.lock | 732 -------------------------------------------- 2 files changed, 755 deletions(-) delete mode 100644 daemon/Pipfile delete mode 100644 daemon/Pipfile.lock diff --git a/daemon/Pipfile b/daemon/Pipfile deleted file mode 100644 index 8bf52787..00000000 --- a/daemon/Pipfile +++ /dev/null @@ -1,23 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[scripts] -core = "python scripts/core-daemon -f data/core.conf -l data/logging.conf" -core-pygui = "python scripts/core-pygui" -test = "pytest -v tests" -test-mock = "pytest -v --mock tests" -test-emane = "pytest -v tests/emane" - -[dev-packages] -grpcio-tools = "*" -isort = "*" -pre-commit = "*" -flake8 = "*" -black = "==19.3b0" -pytest = "*" -mock = "*" - -[packages] -core = {editable = true,path = "."} diff --git a/daemon/Pipfile.lock b/daemon/Pipfile.lock deleted file mode 100644 index 2fb5c3b8..00000000 --- a/daemon/Pipfile.lock +++ /dev/null @@ -1,732 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "199897f713f6f338316b33fcbbe0001e9e55fcd5e5e24b2245a89454ce13321f" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "bcrypt": { - "hashes": [ - "sha256:0258f143f3de96b7c14f762c770f5fc56ccd72f8a1857a451c1cd9a655d9ac89", - "sha256:0b0069c752ec14172c5f78208f1863d7ad6755a6fae6fe76ec2c80d13be41e42", - "sha256:19a4b72a6ae5bb467fea018b825f0a7d917789bcfe893e53f15c92805d187294", - "sha256:5432dd7b34107ae8ed6c10a71b4397f1c853bd39a4d6ffa7e35f40584cffd161", - "sha256:6305557019906466fc42dbc53b46da004e72fd7a551c044a827e572c82191752", - "sha256:69361315039878c0680be456640f8705d76cb4a3a3fe1e057e0f261b74be4b31", - "sha256:6fe49a60b25b584e2f4ef175b29d3a83ba63b3a4df1b4c0605b826668d1b6be5", - "sha256:74a015102e877d0ccd02cdeaa18b32aa7273746914a6c5d0456dd442cb65b99c", - "sha256:763669a367869786bb4c8fcf731f4175775a5b43f070f50f46f0b59da45375d0", - "sha256:8b10acde4e1919d6015e1df86d4c217d3b5b01bb7744c36113ea43d529e1c3de", - "sha256:9fe92406c857409b70a38729dbdf6578caf9228de0aef5bc44f859ffe971a39e", - "sha256:a190f2a5dbbdbff4b74e3103cef44344bc30e61255beb27310e2aec407766052", - "sha256:a595c12c618119255c90deb4b046e1ca3bcfad64667c43d1166f2b04bc72db09", - "sha256:c9457fa5c121e94a58d6505cadca8bed1c64444b83b3204928a866ca2e599105", - "sha256:cb93f6b2ab0f6853550b74e051d297c27a638719753eb9ff66d1e4072be67133", - "sha256:ce4e4f0deb51d38b1611a27f330426154f2980e66582dc5f438aad38b5f24fc1", - "sha256:d7bdc26475679dd073ba0ed2766445bb5b20ca4793ca0db32b399dccc6bc84b7", - "sha256:ff032765bb8716d9387fd5376d987a937254b0619eff0972779515b5c98820bc" - ], - "version": "==3.1.7" - }, - "cffi": { - "hashes": [ - "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff", - "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b", - "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac", - "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0", - "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384", - "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26", - "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6", - "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b", - "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e", - "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd", - "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2", - "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66", - "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc", - "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8", - "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55", - "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4", - "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5", - "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d", - "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78", - "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa", - "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793", - "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f", - "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a", - "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f", - "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30", - "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f", - "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3", - "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c" - ], - "version": "==1.14.0" - }, - "core": { - "editable": true, - "path": "." - }, - "cryptography": { - "hashes": [ - "sha256:02079a6addc7b5140ba0825f542c0869ff4df9a69c360e339ecead5baefa843c", - "sha256:1df22371fbf2004c6f64e927668734070a8953362cd8370ddd336774d6743595", - "sha256:369d2346db5934345787451504853ad9d342d7f721ae82d098083e1f49a582ad", - "sha256:3cda1f0ed8747339bbdf71b9f38ca74c7b592f24f65cdb3ab3765e4b02871651", - "sha256:44ff04138935882fef7c686878e1c8fd80a723161ad6a98da31e14b7553170c2", - "sha256:4b1030728872c59687badcca1e225a9103440e467c17d6d1730ab3d2d64bfeff", - "sha256:58363dbd966afb4f89b3b11dfb8ff200058fbc3b947507675c19ceb46104b48d", - "sha256:6ec280fb24d27e3d97aa731e16207d58bd8ae94ef6eab97249a2afe4ba643d42", - "sha256:7270a6c29199adc1297776937a05b59720e8a782531f1f122f2eb8467f9aab4d", - "sha256:73fd30c57fa2d0a1d7a49c561c40c2f79c7d6c374cc7750e9ac7c99176f6428e", - "sha256:7f09806ed4fbea8f51585231ba742b58cbcfbfe823ea197d8c89a5e433c7e912", - "sha256:90df0cc93e1f8d2fba8365fb59a858f51a11a394d64dbf3ef844f783844cc793", - "sha256:971221ed40f058f5662a604bd1ae6e4521d84e6cad0b7b170564cc34169c8f13", - "sha256:a518c153a2b5ed6b8cc03f7ae79d5ffad7315ad4569b2d5333a13c38d64bd8d7", - "sha256:b0de590a8b0979649ebeef8bb9f54394d3a41f66c5584fff4220901739b6b2f0", - "sha256:b43f53f29816ba1db8525f006fa6f49292e9b029554b3eb56a189a70f2a40879", - "sha256:d31402aad60ed889c7e57934a03477b572a03af7794fa8fb1780f21ea8f6551f", - "sha256:de96157ec73458a7f14e3d26f17f8128c959084931e8997b9e655a39c8fde9f9", - "sha256:df6b4dca2e11865e6cfbfb708e800efb18370f5a46fd601d3755bc7f85b3a8a2", - "sha256:ecadccc7ba52193963c0475ac9f6fa28ac01e01349a2ca48509667ef41ffd2cf", - "sha256:fb81c17e0ebe3358486cd8cc3ad78adbae58af12fc2bf2bc0bb84e8090fa5ce8" - ], - "version": "==2.8" - }, - "dataclasses": { - "hashes": [ - "sha256:3459118f7ede7c8bea0fe795bff7c6c2ce287d01dd226202f7c9ebc0610a7836", - "sha256:494a6dcae3b8bcf80848eea2ef64c0cc5cd307ffc263e17cdf42f3e5420808e6" - ], - "index": "pypi", - "markers": "python_version == '3.6'", - "version": "==0.7" - }, - "fabric": { - "hashes": [ - "sha256:160331934ea60036604928e792fa8e9f813266b098ef5562aa82b88527740389", - "sha256:24842d7d51556adcabd885ac3cf5e1df73fc622a1708bf3667bf5927576cdfa6" - ], - "version": "==2.5.0" - }, - "grpcio": { - "hashes": [ - "sha256:02aef8ef1a5ac5f0836b543e462eb421df6048a7974211a906148053b8055ea6", - "sha256:07f82aefb4a56c7e1e52b78afb77d446847d27120a838a1a0489260182096045", - "sha256:1cff47297ee614e7ef66243dc34a776883ab6da9ca129ea114a802c5e58af5c1", - "sha256:1ec8fc865d8da6d0713e2092a27eee344cd54628b2c2065a0e77fff94df4ae00", - "sha256:1ef949b15a1f5f30651532a9b54edf3bd7c0b699a10931505fa2c80b2d395942", - "sha256:209927e65395feb449783943d62a3036982f871d7f4045fadb90b2d82b153ea8", - "sha256:25c77692ea8c0929d4ad400ea9c3dcbcc4936cee84e437e0ef80da58fa73d88a", - "sha256:28f27c64dd699b8b10f70da5f9320c1cffcaefca7dd76275b44571bd097f276c", - "sha256:355bd7d7ce5ff2917d217f0e8ddac568cb7403e1ce1639b35a924db7d13a39b6", - "sha256:4a0a33ada3f6f94f855f92460896ef08c798dcc5f17d9364d1735c5adc9d7e4a", - "sha256:4d3b6e66f32528bf43ca2297caca768280a8e068820b1c3dca0fcf9f03c7d6f1", - "sha256:5121fa96c79fc0ec81825091d0be5c16865f834f41b31da40b08ee60552f9961", - "sha256:57949756a3ce1f096fa2b00f812755f5ab2effeccedb19feeb7d0deafa3d1de7", - "sha256:586d931736912865c9790c60ca2db29e8dc4eace160d5a79fec3e58df79a9386", - "sha256:5ae532b93cf9ce5a2a549b74a2c35e3b690b171ece9358519b3039c7b84c887e", - "sha256:5dab393ab96b2ce4012823b2f2ed4ee907150424d2f02b97bd6f8dd8f17cc866", - "sha256:5ebc13451246de82f130e8ee7e723e8d7ae1827f14b7b0218867667b1b12c88d", - "sha256:68a149a0482d0bc697aac702ec6efb9d380e0afebf9484db5b7e634146528371", - "sha256:6db7ded10b82592c472eeeba34b9f12d7b0ab1e2dcad12f081b08ebdea78d7d6", - "sha256:6e545908bcc2ae28e5b190ce3170f92d0438cf26a82b269611390114de0106eb", - "sha256:6f328a3faaf81a2546a3022b3dfc137cc6d50d81082dbc0c94d1678943f05df3", - "sha256:706e2dea3de33b0d8884c4d35ecd5911b4ff04d0697c4138096666ce983671a6", - "sha256:80c3d1ce8820dd819d1c9d6b63b6f445148480a831173b572a9174a55e7abd47", - "sha256:8111b61eee12d7af5c58f82f2c97c2664677a05df9225ef5cbc2f25398c8c454", - "sha256:9713578f187fb1c4d00ac554fe1edcc6b3ddd62f5d4eb578b81261115802df8e", - "sha256:9c0669ba9aebad540fb05a33beb7e659ea6e5ca35833fc5229c20f057db760e8", - "sha256:9e9cfe55dc7ac2aa47e0fd3285ff829685f96803197042c9d2f0fb44e4b39b2c", - "sha256:a22daaf30037b8e59d6968c76fe0f7ff062c976c7a026e92fbefc4c4bf3fc5a4", - "sha256:a25b84e10018875a0f294a7649d07c43e8bc3e6a821714e39e5cd607a36386d7", - "sha256:a71138366d57901597bfcc52af7f076ab61c046f409c7b429011cd68de8f9fe6", - "sha256:b4efde5524579a9ce0459ca35a57a48ca878a4973514b8bb88cb80d7c9d34c85", - "sha256:b78af4d42985ab3143d9882d0006f48d12f1bc4ba88e78f23762777c3ee64571", - "sha256:bb2987eb3af9bcf46019be39b82c120c3d35639a95bc4ee2d08f36ecdf469345", - "sha256:c03ce53690fe492845e14f4ab7e67d5a429a06db99b226b5c7caa23081c1e2bb", - "sha256:c59b9280284b791377b3524c8e39ca7b74ae2881ba1a6c51b36f4f1bb94cee49", - "sha256:d18b4c8cacbb141979bb44355ee5813dd4d307e9d79b3a36d66eca7e0a203df8", - "sha256:d1e5563e3b7f844dbc48d709c9e4a75647e11d0387cc1fa0c861d3e9d34bc844", - "sha256:d22c897b65b1408509099f1c3334bd3704f5e4eb7c0486c57d0e212f71cb8f54", - "sha256:dbec0a3a154dbf2eb85b38abaddf24964fa1c059ee0a4ad55d6f39211b1a4bca", - "sha256:ed123037896a8db6709b8ad5acc0ed435453726ea0b63361d12de369624c2ab5", - "sha256:f3614dabd2cc8741850597b418bcf644d4f60e73615906c3acc407b78ff720b3", - "sha256:f9d632ce9fd485119c968ec6a7a343de698c5e014d17602ae2f110f1b05925ed", - "sha256:fb62996c61eeff56b59ab8abfcaa0859ec2223392c03d6085048b576b567459b" - ], - "version": "==1.27.2" - }, - "invoke": { - "hashes": [ - "sha256:87b3ef9d72a1667e104f89b159eaf8a514dbf2f3576885b2bbdefe74c3fb2132", - "sha256:93e12876d88130c8e0d7fd6618dd5387d6b36da55ad541481dfa5e001656f134", - "sha256:de3f23bfe669e3db1085789fd859eb8ca8e0c5d9c20811e2407fa042e8a5e15d" - ], - "version": "==1.4.1" - }, - "lxml": { - "hashes": [ - "sha256:06d4e0bbb1d62e38ae6118406d7cdb4693a3fa34ee3762238bcb96c9e36a93cd", - "sha256:0701f7965903a1c3f6f09328c1278ac0eee8f56f244e66af79cb224b7ef3801c", - "sha256:1f2c4ec372bf1c4a2c7e4bb20845e8bcf8050365189d86806bad1e3ae473d081", - "sha256:4235bc124fdcf611d02047d7034164897ade13046bda967768836629bc62784f", - "sha256:5828c7f3e615f3975d48f40d4fe66e8a7b25f16b5e5705ffe1d22e43fb1f6261", - "sha256:585c0869f75577ac7a8ff38d08f7aac9033da2c41c11352ebf86a04652758b7a", - "sha256:5d467ce9c5d35b3bcc7172c06320dddb275fea6ac2037f72f0a4d7472035cea9", - "sha256:63dbc21efd7e822c11d5ddbedbbb08cd11a41e0032e382a0fd59b0b08e405a3a", - "sha256:7bc1b221e7867f2e7ff1933165c0cec7153dce93d0cdba6554b42a8beb687bdb", - "sha256:8620ce80f50d023d414183bf90cc2576c2837b88e00bea3f33ad2630133bbb60", - "sha256:8a0ebda56ebca1a83eb2d1ac266649b80af8dd4b4a3502b2c1e09ac2f88fe128", - "sha256:90ed0e36455a81b25b7034038e40880189169c308a3df360861ad74da7b68c1a", - "sha256:95e67224815ef86924fbc2b71a9dbd1f7262384bca4bc4793645794ac4200717", - "sha256:afdb34b715daf814d1abea0317b6d672476b498472f1e5aacbadc34ebbc26e89", - "sha256:b4b2c63cc7963aedd08a5f5a454c9f67251b1ac9e22fd9d72836206c42dc2a72", - "sha256:d068f55bda3c2c3fcaec24bd083d9e2eede32c583faf084d6e4b9daaea77dde8", - "sha256:d5b3c4b7edd2e770375a01139be11307f04341ec709cf724e0f26ebb1eef12c3", - "sha256:deadf4df349d1dcd7b2853a2c8796593cc346600726eff680ed8ed11812382a7", - "sha256:df533af6f88080419c5a604d0d63b2c33b1c0c4409aba7d0cb6de305147ea8c8", - "sha256:e4aa948eb15018a657702fee0b9db47e908491c64d36b4a90f59a64741516e77", - "sha256:e5d842c73e4ef6ed8c1bd77806bf84a7cb535f9c0cf9b2c74d02ebda310070e1", - "sha256:ebec08091a22c2be870890913bdadd86fcd8e9f0f22bcb398abd3af914690c15", - "sha256:edc15fcfd77395e24543be48871c251f38132bb834d9fdfdad756adb6ea37679", - "sha256:f2b74784ed7e0bc2d02bd53e48ad6ba523c9b36c194260b7a5045071abbb1012", - "sha256:fa071559f14bd1e92077b1b5f6c22cf09756c6de7139370249eb372854ce51e6", - "sha256:fd52e796fee7171c4361d441796b64df1acfceb51f29e545e812f16d023c4bbc", - "sha256:fe976a0f1ef09b3638778024ab9fb8cde3118f203364212c198f71341c0715ca" - ], - "version": "==4.5.0" - }, - "mako": { - "hashes": [ - "sha256:3139c5d64aa5d175dbafb95027057128b5fbd05a40c53999f3905ceb53366d9d", - "sha256:8e8b53c71c7e59f3de716b6832c4e401d903af574f6962edbbbf6ecc2a5fe6c9" - ], - "version": "==1.1.2" - }, - "markupsafe": { - "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", - "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" - ], - "version": "==1.1.1" - }, - "netaddr": { - "hashes": [ - "sha256:38aeec7cdd035081d3a4c306394b19d677623bf76fa0913f6695127c7753aefd", - "sha256:56b3558bd71f3f6999e4c52e349f38660e54a7a8a9943335f73dfc96883e08ca" - ], - "version": "==0.7.19" - }, - "paramiko": { - "hashes": [ - "sha256:920492895db8013f6cc0179293147f830b8c7b21fdfc839b6bad760c27459d9f", - "sha256:9c980875fa4d2cb751604664e9a2d0f69096643f5be4db1b99599fe114a97b2f" - ], - "version": "==2.7.1" - }, - "pillow": { - "hashes": [ - "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be", - "sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946", - "sha256:54ebae163e8412aff0b9df1e88adab65788f5f5b58e625dc5c7f51eaf14a6837", - "sha256:5bfef0b1cdde9f33881c913af14e43db69815c7e8df429ceda4c70a5e529210f", - "sha256:5f3546ceb08089cedb9e8ff7e3f6a7042bb5b37c2a95d392fb027c3e53a2da00", - "sha256:5f7ae9126d16194f114435ebb79cc536b5682002a4fa57fa7bb2cbcde65f2f4d", - "sha256:62a889aeb0a79e50ecf5af272e9e3c164148f4bd9636cc6bcfa182a52c8b0533", - "sha256:7406f5a9b2fd966e79e6abdaf700585a4522e98d6559ce37fc52e5c955fade0a", - "sha256:8453f914f4e5a3d828281a6628cf517832abfa13ff50679a4848926dac7c0358", - "sha256:87269cc6ce1e3dee11f23fa515e4249ae678dbbe2704598a51cee76c52e19cda", - "sha256:875358310ed7abd5320f21dd97351d62de4929b0426cdb1eaa904b64ac36b435", - "sha256:8ac6ce7ff3892e5deaab7abaec763538ffd011f74dc1801d93d3c5fc541feee2", - "sha256:91b710e3353aea6fc758cdb7136d9bbdcb26b53cefe43e2cba953ac3ee1d3313", - "sha256:9d2ba4ed13af381233e2d810ff3bab84ef9f18430a9b336ab69eaf3cd24299ff", - "sha256:a62ec5e13e227399be73303ff301f2865bf68657d15ea50b038d25fc41097317", - "sha256:ab76e5580b0ed647a8d8d2d2daee170e8e9f8aad225ede314f684e297e3643c2", - "sha256:bf4003aa538af3f4205c5fac56eacaa67a6dd81e454ffd9e9f055fff9f1bc614", - "sha256:bf598d2e37cf8edb1a2f26ed3fb255191f5232badea4003c16301cb94ac5bdd0", - "sha256:c18f70dc27cc5d236f10e7834236aff60aadc71346a5bc1f4f83a4b3abee6386", - "sha256:c5ed816632204a2fc9486d784d8e0d0ae754347aba99c811458d69fcdfd2a2f9", - "sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636", - "sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865" - ], - "version": "==7.0.0" - }, - "pycparser": { - "hashes": [ - "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", - "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" - ], - "version": "==2.20" - }, - "pynacl": { - "hashes": [ - "sha256:05c26f93964373fc0abe332676cb6735f0ecad27711035b9472751faa8521255", - "sha256:0c6100edd16fefd1557da078c7a31e7b7d7a52ce39fdca2bec29d4f7b6e7600c", - "sha256:0d0a8171a68edf51add1e73d2159c4bc19fc0718e79dec51166e940856c2f28e", - "sha256:1c780712b206317a746ace34c209b8c29dbfd841dfbc02aa27f2084dd3db77ae", - "sha256:2424c8b9f41aa65bbdbd7a64e73a7450ebb4aa9ddedc6a081e7afcc4c97f7621", - "sha256:2d23c04e8d709444220557ae48ed01f3f1086439f12dbf11976e849a4926db56", - "sha256:30f36a9c70450c7878053fa1344aca0145fd47d845270b43a7ee9192a051bf39", - "sha256:37aa336a317209f1bb099ad177fef0da45be36a2aa664507c5d72015f956c310", - "sha256:4943decfc5b905748f0756fdd99d4f9498d7064815c4cf3643820c9028b711d1", - "sha256:53126cd91356342dcae7e209f840212a58dcf1177ad52c1d938d428eebc9fee5", - "sha256:57ef38a65056e7800859e5ba9e6091053cd06e1038983016effaffe0efcd594a", - "sha256:5bd61e9b44c543016ce1f6aef48606280e45f892a928ca7068fba30021e9b786", - "sha256:6482d3017a0c0327a49dddc8bd1074cc730d45db2ccb09c3bac1f8f32d1eb61b", - "sha256:7d3ce02c0784b7cbcc771a2da6ea51f87e8716004512493a2b69016326301c3b", - "sha256:a14e499c0f5955dcc3991f785f3f8e2130ed504fa3a7f44009ff458ad6bdd17f", - "sha256:a39f54ccbcd2757d1d63b0ec00a00980c0b382c62865b61a505163943624ab20", - "sha256:aabb0c5232910a20eec8563503c153a8e78bbf5459490c49ab31f6adf3f3a415", - "sha256:bd4ecb473a96ad0f90c20acba4f0bf0df91a4e03a1f4dd6a4bdc9ca75aa3a715", - "sha256:bf459128feb543cfca16a95f8da31e2e65e4c5257d2f3dfa8c0c1031139c9c92", - "sha256:e2da3c13307eac601f3de04887624939aca8ee3c9488a0bb0eca4fb9401fc6b1", - "sha256:f67814c38162f4deb31f68d590771a29d5ae3b1bd64b75cf232308e5c74777e0" - ], - "version": "==1.3.0" - }, - "pyproj": { - "hashes": [ - "sha256:0d8196a5ac75fee2cf71c21066b3344427abfa8ad69b536d3404d5c7c9c0b886", - "sha256:12e378a0a21c73f96177f6cf64520f17e6b7aa02fc9cb27bd5c2d5b06ce170af", - "sha256:17738836128704d8f80b771572d77b8733841f0cb0ca42620549236ea62c4663", - "sha256:1a39175944710b225fd1943cb3b8ea0c8e059d3016360022ca10bbb7a6bfc9ae", - "sha256:2566bffb5395c9fbdb02077a0bc3e3ed0b2e4e3cadf65019e3139a8dfe27dd1d", - "sha256:3f43277f21ddaabed93b9885a4e494b785dca56e31fd37a935519d99b07807f0", - "sha256:424304beca6e0b0bc12aa46fc6d14a481ea47b1a4edec4854bb281656de38948", - "sha256:48128d794c8f52fcff2433a481e3aa2ccb0e0b3ccd51d3ad7cc10cc488c3f547", - "sha256:4a16b650722982cddedd45dfc36435b96e0ba83a2aebd4a4c247e5a68c852442", - "sha256:5161f1b5ece8a5263b64d97a32fbc473a4c6fdca5c95478e58e519ef1e97528e", - "sha256:6839ce14635ebfb01c67e456148f4f1fa04b03ef9645551b89d36593f2a3e57d", - "sha256:80e9f85ab81da75289308f23a62e1426a38411a07b0da738958d65ae8cc6c59c", - "sha256:881b44e94c781d02ecf1d9314fc7f44c09e6d54a8eac281869365999ac4db7a1", - "sha256:977542d2f8cf2981cf3ad72cedfebcd6ac56977c7aa830d9b49fa7888b56e83d", - "sha256:9bba6cbff7e23bb6d9062786d516602681b4414e9e423c138a7360e4d2a193e8", - "sha256:9bf64bba03ddc534ed3c6271ba8f9d31040f40cf8e9e7e458b6b1524a6f59082", - "sha256:9c712ceaa01488ebe6e357e1dfa2434c2304aad8a810e5d4c3d2abe21def6d58", - "sha256:b7da17e5a5c6039f85843e88c2f1ca8606d1a4cc13a87e7b68b9f51a54ef201a", - "sha256:bcdf81b3f13d2cc0354a4c3f7a567b71fcf6fe8098e519aaaee8e61f05c9de10", - "sha256:bebd3f987b7196e9d2ccfe55911b0c76ba9ce309bcabfb629ef205cbaaad37c5", - "sha256:c244e923073cd0bab74ba861ba31724aab90efda35b47a9676603c1a8e80b3ba", - "sha256:dacb94a9d570f4d9fc9369a22d44d7b3071cfe4d57d0ff2f57abd7ef6127fe41" - ], - "version": "==2.6.0" - }, - "pyyaml": { - "hashes": [ - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", - "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", - "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" - ], - "version": "==5.3.1" - }, - "six": { - "hashes": [ - "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", - "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" - ], - "version": "==1.14.0" - } - }, - "develop": { - "appdirs": { - "hashes": [ - "sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92", - "sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e" - ], - "version": "==1.4.3" - }, - "attrs": { - "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" - ], - "version": "==19.3.0" - }, - "black": { - "hashes": [ - "sha256:09a9dcb7c46ed496a9850b76e4e825d6049ecd38b611f1224857a79bd985a8cf", - "sha256:68950ffd4d9169716bcb8719a56c07a2f4485354fec061cdd5910aa07369731c" - ], - "index": "pypi", - "version": "==19.3b0" - }, - "cfgv": { - "hashes": [ - "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53", - "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513" - ], - "version": "==3.1.0" - }, - "click": { - "hashes": [ - "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc", - "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a" - ], - "version": "==7.1.1" - }, - "distlib": { - "hashes": [ - "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21" - ], - "version": "==0.3.0" - }, - "entrypoints": { - "hashes": [ - "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", - "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" - ], - "version": "==0.3" - }, - "filelock": { - "hashes": [ - "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59", - "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836" - ], - "version": "==3.0.12" - }, - "flake8": { - "hashes": [ - "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", - "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca" - ], - "index": "pypi", - "version": "==3.7.9" - }, - "grpcio": { - "hashes": [ - "sha256:02aef8ef1a5ac5f0836b543e462eb421df6048a7974211a906148053b8055ea6", - "sha256:07f82aefb4a56c7e1e52b78afb77d446847d27120a838a1a0489260182096045", - "sha256:1cff47297ee614e7ef66243dc34a776883ab6da9ca129ea114a802c5e58af5c1", - "sha256:1ec8fc865d8da6d0713e2092a27eee344cd54628b2c2065a0e77fff94df4ae00", - "sha256:1ef949b15a1f5f30651532a9b54edf3bd7c0b699a10931505fa2c80b2d395942", - "sha256:209927e65395feb449783943d62a3036982f871d7f4045fadb90b2d82b153ea8", - "sha256:25c77692ea8c0929d4ad400ea9c3dcbcc4936cee84e437e0ef80da58fa73d88a", - "sha256:28f27c64dd699b8b10f70da5f9320c1cffcaefca7dd76275b44571bd097f276c", - "sha256:355bd7d7ce5ff2917d217f0e8ddac568cb7403e1ce1639b35a924db7d13a39b6", - "sha256:4a0a33ada3f6f94f855f92460896ef08c798dcc5f17d9364d1735c5adc9d7e4a", - "sha256:4d3b6e66f32528bf43ca2297caca768280a8e068820b1c3dca0fcf9f03c7d6f1", - "sha256:5121fa96c79fc0ec81825091d0be5c16865f834f41b31da40b08ee60552f9961", - "sha256:57949756a3ce1f096fa2b00f812755f5ab2effeccedb19feeb7d0deafa3d1de7", - "sha256:586d931736912865c9790c60ca2db29e8dc4eace160d5a79fec3e58df79a9386", - "sha256:5ae532b93cf9ce5a2a549b74a2c35e3b690b171ece9358519b3039c7b84c887e", - "sha256:5dab393ab96b2ce4012823b2f2ed4ee907150424d2f02b97bd6f8dd8f17cc866", - "sha256:5ebc13451246de82f130e8ee7e723e8d7ae1827f14b7b0218867667b1b12c88d", - "sha256:68a149a0482d0bc697aac702ec6efb9d380e0afebf9484db5b7e634146528371", - "sha256:6db7ded10b82592c472eeeba34b9f12d7b0ab1e2dcad12f081b08ebdea78d7d6", - "sha256:6e545908bcc2ae28e5b190ce3170f92d0438cf26a82b269611390114de0106eb", - "sha256:6f328a3faaf81a2546a3022b3dfc137cc6d50d81082dbc0c94d1678943f05df3", - "sha256:706e2dea3de33b0d8884c4d35ecd5911b4ff04d0697c4138096666ce983671a6", - "sha256:80c3d1ce8820dd819d1c9d6b63b6f445148480a831173b572a9174a55e7abd47", - "sha256:8111b61eee12d7af5c58f82f2c97c2664677a05df9225ef5cbc2f25398c8c454", - "sha256:9713578f187fb1c4d00ac554fe1edcc6b3ddd62f5d4eb578b81261115802df8e", - "sha256:9c0669ba9aebad540fb05a33beb7e659ea6e5ca35833fc5229c20f057db760e8", - "sha256:9e9cfe55dc7ac2aa47e0fd3285ff829685f96803197042c9d2f0fb44e4b39b2c", - "sha256:a22daaf30037b8e59d6968c76fe0f7ff062c976c7a026e92fbefc4c4bf3fc5a4", - "sha256:a25b84e10018875a0f294a7649d07c43e8bc3e6a821714e39e5cd607a36386d7", - "sha256:a71138366d57901597bfcc52af7f076ab61c046f409c7b429011cd68de8f9fe6", - "sha256:b4efde5524579a9ce0459ca35a57a48ca878a4973514b8bb88cb80d7c9d34c85", - "sha256:b78af4d42985ab3143d9882d0006f48d12f1bc4ba88e78f23762777c3ee64571", - "sha256:bb2987eb3af9bcf46019be39b82c120c3d35639a95bc4ee2d08f36ecdf469345", - "sha256:c03ce53690fe492845e14f4ab7e67d5a429a06db99b226b5c7caa23081c1e2bb", - "sha256:c59b9280284b791377b3524c8e39ca7b74ae2881ba1a6c51b36f4f1bb94cee49", - "sha256:d18b4c8cacbb141979bb44355ee5813dd4d307e9d79b3a36d66eca7e0a203df8", - "sha256:d1e5563e3b7f844dbc48d709c9e4a75647e11d0387cc1fa0c861d3e9d34bc844", - "sha256:d22c897b65b1408509099f1c3334bd3704f5e4eb7c0486c57d0e212f71cb8f54", - "sha256:dbec0a3a154dbf2eb85b38abaddf24964fa1c059ee0a4ad55d6f39211b1a4bca", - "sha256:ed123037896a8db6709b8ad5acc0ed435453726ea0b63361d12de369624c2ab5", - "sha256:f3614dabd2cc8741850597b418bcf644d4f60e73615906c3acc407b78ff720b3", - "sha256:f9d632ce9fd485119c968ec6a7a343de698c5e014d17602ae2f110f1b05925ed", - "sha256:fb62996c61eeff56b59ab8abfcaa0859ec2223392c03d6085048b576b567459b" - ], - "version": "==1.27.2" - }, - "grpcio-tools": { - "hashes": [ - "sha256:00c5080cfb197ed20ecf0d0ff2d07f1fc9c42c724cad21c40ff2d048de5712b1", - "sha256:069826dd02ce1886444cf4519c4fe1b05ac9ef41491f26e97400640531db47f6", - "sha256:1266b577abe7c720fd16a83d0a4999a192e87c4a98fc9f97e0b99b106b3e155f", - "sha256:16dc3fad04fe18d50777c56af7b2d9b9984cd1cfc71184646eb431196d1645c6", - "sha256:1de5a273eaffeb3d126a63345e9e848ea7db740762f700eb8b5d84c5e3e7687d", - "sha256:2ca280af2cae1a014a238057bd3c0a254527569a6a9169a01c07f0590081d530", - "sha256:43a1573400527a23e4174d88604fde7a9d9a69bf9473c21936b7f409858f8ebb", - "sha256:4698c6b6a57f73b14d91a542c69ff33a2da8729691b7060a5d7f6383624d045e", - "sha256:520b7dafddd0f82cb7e4f6e9c6ba1049aa804d0e207870def9fe7f94d1e14090", - "sha256:57f8b9e2c7f55cd45f6dd930d6de61deb42d3eb7f9788137fbc7155cf724132a", - "sha256:59fbeb5bb9a7b94eb61642ac2cee1db5233b8094ca76fc56d4e0c6c20b5dd85f", - "sha256:5fd7efc2fd3370bd2c72dc58f31a407a5dff5498befa145da211b2e8c6a52c63", - "sha256:6016c07d6566e3109a3c032cf3861902d66501ecc08a5a84c47e43027302f367", - "sha256:627c91923df75091d8c4d244af38d5ab7ed8d786d480751d6c2b9267fbb92fe0", - "sha256:69c4a63919b9007e845d9f8980becd2f89d808a4a431ca32b9723ee37b521cb1", - "sha256:77e25c241e33b75612f2aa62985f746c6f6803ec4e452da508bb7f8d90a69db4", - "sha256:7a2d5fb558ac153a326e742ebfd7020eb781c43d3ffd920abd42b2e6c6fdfb37", - "sha256:7b54b283ec83190680903a9037376dc915e1f03852a2d574ba4d981b7a1fd3d0", - "sha256:845a51305af9fc7f9e2078edaec9a759153195f6cf1fbb12b1fa6f077e56b260", - "sha256:84724458c86ff9b14c29b49e321f34d80445b379f4cd4d0494c694b49b1d6f88", - "sha256:87e8ca2c2d2d3e09b2a2bed5d740d7b3e64028dafb7d6be543b77eec85590736", - "sha256:8e7738a4b93842bca1158cde81a3587c9b7111823e40a1ddf73292ca9d58e08b", - "sha256:915a695bc112517af48126ee0ecdb6aff05ed33f3eeef28f0d076f1f6b52ef5e", - "sha256:99961156a36aae4a402d6b14c1e7efde642794b3ddbf32c51db0cb3a199e8b11", - "sha256:9ba88c2d99bcaf7b9cb720925e3290d73b2367d238c5779363fd5598b2dc98c7", - "sha256:a140bf853edb2b5e8692fe94869e3e34077d7599170c113d07a58286c604f4fe", - "sha256:a14dc7a36c845991d908a7179502ca47bcba5ae1817c4426ce68cf2c97b20ad9", - "sha256:a3d2aec4b09c8e59fee8b0d1ed668d09e8c48b738f03f5d8401d7eb409111c47", - "sha256:a8f892378b0b02526635b806f59141abbb429d19bec56e869e04f396502c9651", - "sha256:aaa5ae26883c3d58d1a4323981f96b941fa09bb8f0f368d97c6225585280cf04", - "sha256:b56caecc16307b088a431a4038c3b3bb7d0e7f9988cbd0e9fa04ac937455ea38", - "sha256:bd7f59ff1252a3db8a143b13ea1c1e93d4b8cf4b852eb48b22ef1e6942f62a84", - "sha256:c1bb8f47d58e9f7c4825abfe01e6b85eda53c8b31d2267ca4cddf3c4d0829b80", - "sha256:d1a5e5fa47ba9557a7d3b31605631805adc66cdba9d95b5d10dfc52cca1fed53", - "sha256:dcbc06556f3713a9348c4fce02d05d91e678fc320fb2bcf0ddf8e4bb11d17867", - "sha256:e17b2e0936b04ced99769e26111e1e86ba81619d1b2691b1364f795e45560953", - "sha256:e6932518db389ede8bf06b4119bbd3e17f42d4626e72dec2b8955b20ec732cb6", - "sha256:ea4b3ad696d976d5eac74ec8df9a2c692113e455446ee38d5b3bd87f8e034fa6", - "sha256:ee50b0cf0d28748ef9f941894eb50fc464bd61b8e96aaf80c5056bea9b80d580", - "sha256:ef624b6134aef737b3daa4fb7e806cb8c5749efecd0b1fa9ce4f7e060c7a0221", - "sha256:f5450aa904e720f9c6407b59e96a8951ed6a95463f49444b6d2594b067d39588", - "sha256:f8514453411d72cc3cf7d481f2b6057e5b7436736d0cd39ee2b2f72088bbf497", - "sha256:fae91f30dc050a8d0b32d20dc700e6092f0bd2138d83e9570fff3f0372c1b27e" - ], - "index": "pypi", - "version": "==1.27.2" - }, - "identify": { - "hashes": [ - "sha256:a7577a1f55cee1d21953a5cf11a3c839ab87f5ef909a4cba6cf52ed72b4c6059", - "sha256:ab246293e6585a1c6361a505b68d5b501a0409310932b7de2c2ead667b564d89" - ], - "version": "==1.4.13" - }, - "importlib-metadata": { - "hashes": [ - "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f", - "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e" - ], - "markers": "python_version < '3.8'", - "version": "==1.6.0" - }, - "importlib-resources": { - "hashes": [ - "sha256:4019b6a9082d8ada9def02bece4a76b131518866790d58fdda0b5f8c603b36c2", - "sha256:dd98ceeef3f5ad2ef4cc287b8586da4ebad15877f351e9688987ad663a0a29b8" - ], - "markers": "python_version < '3.7'", - "version": "==1.4.0" - }, - "isort": { - "hashes": [ - "sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1", - "sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd" - ], - "index": "pypi", - "version": "==4.3.21" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, - "mock": { - "hashes": [ - "sha256:3f9b2c0196c60d21838f307f5825a7b86b678cedc58ab9e50a8988187b4d81e0", - "sha256:dd33eb70232b6118298d516bbcecd26704689c386594f0f3c4f13867b2c56f72" - ], - "index": "pypi", - "version": "==4.0.2" - }, - "more-itertools": { - "hashes": [ - "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c", - "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507" - ], - "version": "==8.2.0" - }, - "nodeenv": { - "hashes": [ - "sha256:5b2438f2e42af54ca968dd1b374d14a1194848955187b0e5e4be1f73813a5212" - ], - "version": "==1.3.5" - }, - "packaging": { - "hashes": [ - "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3", - "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752" - ], - "version": "==20.3" - }, - "pluggy": { - "hashes": [ - "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", - "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" - ], - "version": "==0.13.1" - }, - "pre-commit": { - "hashes": [ - "sha256:487c675916e6f99d355ec5595ad77b325689d423ef4839db1ed2f02f639c9522", - "sha256:c0aa11bce04a7b46c5544723aedf4e81a4d5f64ad1205a30a9ea12d5e81969e1" - ], - "index": "pypi", - "version": "==2.2.0" - }, - "protobuf": { - "hashes": [ - "sha256:0bae429443cc4748be2aadfdaf9633297cfaeb24a9a02d0ab15849175ce90fab", - "sha256:24e3b6ad259544d717902777b33966a1a069208c885576254c112663e6a5bb0f", - "sha256:310a7aca6e7f257510d0c750364774034272538d51796ca31d42c3925d12a52a", - "sha256:52e586072612c1eec18e1174f8e3bb19d08f075fc2e3f91d3b16c919078469d0", - "sha256:73152776dc75f335c476d11d52ec6f0f6925774802cd48d6189f4d5d7fe753f4", - "sha256:7774bbbaac81d3ba86de646c39f154afc8156717972bf0450c9dbfa1dc8dbea2", - "sha256:82d7ac987715d8d1eb4068bf997f3053468e0ce0287e2729c30601feb6602fee", - "sha256:8eb9c93798b904f141d9de36a0ba9f9b73cc382869e67c9e642c0aba53b0fc07", - "sha256:adf0e4d57b33881d0c63bb11e7f9038f98ee0c3e334c221f0858f826e8fb0151", - "sha256:c40973a0aee65422d8cb4e7d7cbded95dfeee0199caab54d5ab25b63bce8135a", - "sha256:c77c974d1dadf246d789f6dad1c24426137c9091e930dbf50e0a29c1fcf00b1f", - "sha256:dd9aa4401c36785ea1b6fff0552c674bdd1b641319cb07ed1fe2392388e9b0d7", - "sha256:e11df1ac6905e81b815ab6fd518e79be0a58b5dc427a2cf7208980f30694b956", - "sha256:e2f8a75261c26b2f5f3442b0525d50fd79a71aeca04b5ec270fc123536188306", - "sha256:e512b7f3a4dd780f59f1bf22c302740e27b10b5c97e858a6061772668cd6f961", - "sha256:ef2c2e56aaf9ee914d3dccc3408d42661aaf7d9bb78eaa8f17b2e6282f214481", - "sha256:fac513a9dc2a74b99abd2e17109b53945e364649ca03d9f7a0b96aa8d1807d0a", - "sha256:fdfb6ad138dbbf92b5dbea3576d7c8ba7463173f7d2cb0ca1bd336ec88ddbd80" - ], - "version": "==3.11.3" - }, - "py": { - "hashes": [ - "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa", - "sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0" - ], - "version": "==1.8.1" - }, - "pycodestyle": { - "hashes": [ - "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", - "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" - ], - "version": "==2.5.0" - }, - "pyflakes": { - "hashes": [ - "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", - "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" - ], - "version": "==2.1.1" - }, - "pyparsing": { - "hashes": [ - "sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f", - "sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec" - ], - "version": "==2.4.6" - }, - "pytest": { - "hashes": [ - "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172", - "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970" - ], - "index": "pypi", - "version": "==5.4.1" - }, - "pyyaml": { - "hashes": [ - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", - "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", - "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" - ], - "version": "==5.3.1" - }, - "six": { - "hashes": [ - "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a", - "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c" - ], - "version": "==1.14.0" - }, - "toml": { - "hashes": [ - "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c", - "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e" - ], - "version": "==0.10.0" - }, - "virtualenv": { - "hashes": [ - "sha256:4e399f48c6b71228bf79f5febd27e3bbb753d9d5905776a86667bc61ab628a25", - "sha256:9e81279f4a9d16d1c0654a127c2c86e5bca2073585341691882c1e66e31ef8a5" - ], - "version": "==20.0.15" - }, - "wcwidth": { - "hashes": [ - "sha256:cafe2186b3c009a04067022ce1dcd79cb38d8d65ee4f4791b8888d6599d1bbe1", - "sha256:ee73862862a156bf77ff92b09034fc4825dd3af9cf81bc5b360668d425f3c5f1" - ], - "version": "==0.1.9" - }, - "zipp": { - "hashes": [ - "sha256:aa36550ff0c0b7ef7fa639055d797116ee891440eac1a56f378e2d3179e0320b", - "sha256:c599e4d75c98f6798c509911d08a22e6c021d074469042177c8c86fb92eefd96" - ], - "markers": "python_version < '3.8'", - "version": "==3.1.0" - } - } -} From cb66ba60a6eb8195b2f8136e75aa4e8c891ef83f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 08:54:08 -0700 Subject: [PATCH 292/929] removed kernel-modules-extra, so it is only attempted in centos8 --- tasks.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index b4f37679..bdabc837 100644 --- a/tasks.py +++ b/tasks.py @@ -81,8 +81,7 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: elif os_info.like == OsLike.REDHAT: c.run( "sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel " - "iptables-ebtables iproute python3-devel python3-tkinter tk ethtool make " - "kernel-modules-extra", + "iptables-ebtables iproute python3-devel python3-tkinter tk ethtool make", hide=hide ) # centos 8+ does not support netem by default From e283c5ec7d0624d2931424fefafe1e41f842017d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 09:24:59 -0700 Subject: [PATCH 293/929] broke out invoke tasks for installing scripts and service, testing centos not needing prefix --- tasks.py | 69 ++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/tasks.py b/tasks.py index bdabc837..9168542c 100644 --- a/tasks.py +++ b/tasks.py @@ -115,7 +115,8 @@ def install_grpcio(c: Context, hide: bool) -> None: def build(c: Context, os_info: OsInfo, hide: bool) -> None: print("building core...") c.run("./bootstrap.sh", hide=hide) - prefix = "--prefix=/usr" if os_info.like == OsLike.REDHAT else "" + # prefix = "--prefix=/usr" if os_info.like == OsLike.REDHAT else "" + prefix = "" c.run(f"./configure {prefix}", hide=hide) c.run("make -j$(nproc)", hide=hide) @@ -169,7 +170,43 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: c.run("sudo make install", hide=hide) -def install_files(c: Context, hide: bool, prefix: str = DEFAULT_PREFIX) -> None: +@task +def install_service(c, hide, prefix=DEFAULT_PREFIX): + """ + install systemd core service + """ + # install service + bin_dir = Path(prefix).joinpath("bin") + systemd_dir = Path("/lib/systemd/system/") + service_file = systemd_dir.joinpath("core-daemon.service") + if systemd_dir.exists(): + print(f"installing core-daemon.service for systemd to {service_file}") + service_data = inspect.cleandoc(f""" + [Unit] + Description=Common Open Research Emulator Service + After=network.target + + [Service] + Type=simple + ExecStart={bin_dir}/core-daemon + TasksMax=infinity + + [Install] + WantedBy=multi-user.target + """) + temp = NamedTemporaryFile("w", delete=False) + temp.write(service_data) + temp.close() + c.run(f"sudo cp {temp.name} {service_file}", hide=hide) + else: + print(f"ERROR: systemd service path not found: {systemd_dir}") + + +@task +def install_scripts(c, hide, prefix=DEFAULT_PREFIX): + """ + install core script files, modified to leverage virtual environment + """ # install all scripts python = get_python(c) bin_dir = Path(prefix).joinpath("bin") @@ -200,34 +237,11 @@ def install_files(c: Context, hide: bool, prefix: str = DEFAULT_PREFIX) -> None: c.run(f"sudo cp -n daemon/data/core.conf {config_dir}", hide=hide) c.run(f"sudo cp -n daemon/data/logging.conf {config_dir}", hide=hide) - # install service - systemd_dir = Path("/lib/systemd/system/") - service_file = systemd_dir.joinpath("core-daemon.service") - if systemd_dir.exists(): - print(f"installing core-daemon.service for systemd to {service_file}") - service_data = inspect.cleandoc(f""" - [Unit] - Description=Common Open Research Emulator Service - After=network.target - - [Service] - Type=simple - ExecStart={bin_dir}/core-daemon - TasksMax=infinity - - [Install] - WantedBy=multi-user.target - """) - temp = NamedTemporaryFile("w", delete=False) - temp.write(service_data) - temp.close() - c.run(f"sudo cp {temp.name} {service_file}", hide=hide) - @task def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): """ - install core + install core, poetry, scripts, service, and ospf mdr """ print(f"installing core with prefix: {prefix}") hide = not verbose @@ -237,7 +251,8 @@ def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): build(c, os_info, hide) install_core(c, hide) install_poetry(c, dev, hide) - install_files(c, hide, prefix) + install_scripts(c, hide, prefix) + install_service(c, hide, prefix) install_ospf_mdr(c, os_info, hide) print("please open a new terminal or re-login to leverage invoke for running core") print("# run daemon") From 5d23be4a9d26e326d9c3468ac4c4cdd67199231b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 09:30:04 -0700 Subject: [PATCH 294/929] updates to install2.md to replace install docs --- docs/install2.md | 130 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 126 insertions(+), 4 deletions(-) diff --git a/docs/install2.md b/docs/install2.md index 86e3db92..921c80f0 100644 --- a/docs/install2.md +++ b/docs/install2.md @@ -1,4 +1,7 @@ -# Installing CORE +# CORE Installation + +* Table of Contents +{:toc} ## Overview @@ -14,6 +17,11 @@ The following tools will be leveraged during installation: |invoke|used to run provided tasks (install, daemon, gui, tests, etc)| |poetry|used to install the managed python virtual environment for running CORE| +## Required Hardware + +Any computer capable of running Linux should be able to run CORE. Since the physical machine will be hosting numerous +containers, as a general rule you should select a machine having as much RAM and CPU resources as possible. + ## Supported Linux Distributions Plan is to support recent Ubuntu and CentOS LTS releases. @@ -28,16 +36,130 @@ Verified: > **NOTE:** CentOS 8 does not provide legacy ebtables support, WLAN will not > function properly -## Running Installation +## Utility Requirements + +* iproute2 4.5+ is a requirement for bridge related commands +* ebtables not backed by nftables + +## Automated Installation + +> **NOTE:** installs OSPF MDR +> **NOTE:** sets up script files using the prefix provided +> **NOTE:** install a systemd service file to /lib/systemd/system/core-daemon.service ```shell # clone CORE repo git clone https://github.com/coreemu/core.git cd core -git checkout enhancement/poetry-invoke # run install script -./install2.sh +# script usage: install.sh [-d] [-v] +# +# -v enable verbose install +# -d enable developer install +# -p install prefix, defaults to /usr/local +./install.sh +``` + +## Manual Installation + +> **NOTE:** install OSPF MDR by manual instructions below + +```shell +# clone CORE repo +git clone https://github.com/coreemu/core.git +cd core + +# install python3 and venv support +# ubuntu +sudo apt install -y python3-pip python3-venv +# centos +sudo yum install -y python3-pip + +# install system dependencies +# ubuntu +sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 \ + ethtool tk python3-tk +# centos +sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel \ + iptables-ebtables iproute python3-devel python3-tkinter tk ethtool \ + make kernel-modules-extra + +# install grpcio-tools +python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 + +# build core +./bootstrap.sh +./configure +make +sudo make install + +# install pipx, may need to restart terminal after ensurepath +python3 -m pip install --user pipx +python3 -m pipx ensurepath + +# install poetry +pipx install poetry + +# install poetry virtual environment +cd daemon +poetry install --no-dev +cd .. + +# install invoke to run helper tasks +pipx install invoke + +# install core scripts leveraging poetry virtual environment +inv install-scripts + +# optionally install systemd service file +inv install-service +``` + +## Manually Install OSPF MDR (Routing Support) + +Virtual networks generally require some form of routing in order to work (e.g. to automatically populate routing +tables for routing packets from one subnet to another.) CORE builds OSPF routing protocol configurations by +default when the blue router node type is used. + +* [OSPF MANET Designated Routers](https://github.com/USNavalResearchLaboratory/ospf-mdr) (MDR) - the Quagga routing +suite with a modified version of OSPFv3, optimized for use with mobile wireless networks. The **mdr** node type +(and the MDR service) requires this variant of Quagga. + +```shell +# system dependencies +# ubuntu +sudo apt install -y libtool gawk libreadline-dev +# centos +sudo yum install -y libtool gawk readline-devel + +# build and install +git clone https://github.com/USNavalResearchLaboratory/ospf-mdr +cd ospf-mdr +./bootstrap.sh +./configure --disable-doc --enable-user=root --enable-group=root --with-cflags=-ggdb \ + --sysconfdir=/usr/local/etc/quagga --enable-vtysh \ + --localstatedir=/var/run/quagga +make +sudo make install +``` + +## Manually Install EMANE + +EMANE can be installed from deb or RPM packages or from source. See the +[EMANE GitHub](https://github.com/adjacentlink/emane) for full details. + +Here are quick instructions for installing all EMANE packages for Ubuntu 18.04: +```shell +# install dependencies +# ubuntu +sudo apt-get install libssl-dev libxml-libxml-perl libxml-simple-perl +wget https://adjacentlink.com/downloads/emane/emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz +tar xzf emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz +# install base emane packages +sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/emane*.deb +# install python3 bindings +sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/python3*.deb ``` ## Using Invoke Tasks From 6c7e760f4ea9e986a26ef785f982b9705358da54 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 09:31:05 -0700 Subject: [PATCH 295/929] tweak to install doc --- docs/install2.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/install2.md b/docs/install2.md index 921c80f0..9d8d97a4 100644 --- a/docs/install2.md +++ b/docs/install2.md @@ -44,7 +44,9 @@ Verified: ## Automated Installation > **NOTE:** installs OSPF MDR + > **NOTE:** sets up script files using the prefix provided + > **NOTE:** install a systemd service file to /lib/systemd/system/core-daemon.service ```shell From 35b6f5297a95faeb2700b1d1ea382d7df05cb14d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 09:42:14 -0700 Subject: [PATCH 296/929] update doc and install to properly set and provide options for OSs like centos who need a different prefix --- docs/install2.md | 3 +++ tasks.py | 11 +++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/install2.md b/docs/install2.md index 9d8d97a4..1eb94695 100644 --- a/docs/install2.md +++ b/docs/install2.md @@ -92,6 +92,7 @@ python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 # build core ./bootstrap.sh +# centos requires --prefix=/usr ./configure make sudo make install @@ -112,9 +113,11 @@ cd .. pipx install invoke # install core scripts leveraging poetry virtual environment +# centos requires --prefix=/usr inv install-scripts # optionally install systemd service file +# centos requires --prefix=/usr inv install-service ``` diff --git a/tasks.py b/tasks.py index 9168542c..5c335644 100644 --- a/tasks.py +++ b/tasks.py @@ -115,8 +115,7 @@ def install_grpcio(c: Context, hide: bool) -> None: def build(c: Context, os_info: OsInfo, hide: bool) -> None: print("building core...") c.run("./bootstrap.sh", hide=hide) - # prefix = "--prefix=/usr" if os_info.like == OsLike.REDHAT else "" - prefix = "" + prefix = "--prefix=/usr" if os_info.like == OsLike.REDHAT else "" c.run(f"./configure {prefix}", hide=hide) c.run("make -j$(nproc)", hide=hide) @@ -171,11 +170,11 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: @task -def install_service(c, hide, prefix=DEFAULT_PREFIX): +def install_service(c, verbose=False, prefix=DEFAULT_PREFIX): """ install systemd core service """ - # install service + hide = not verbose bin_dir = Path(prefix).joinpath("bin") systemd_dir = Path("/lib/systemd/system/") service_file = systemd_dir.joinpath("core-daemon.service") @@ -203,11 +202,11 @@ def install_service(c, hide, prefix=DEFAULT_PREFIX): @task -def install_scripts(c, hide, prefix=DEFAULT_PREFIX): +def install_scripts(c, verbose=False, prefix=DEFAULT_PREFIX): """ install core script files, modified to leverage virtual environment """ - # install all scripts + hide = not verbose python = get_python(c) bin_dir = Path(prefix).joinpath("bin") for script in Path("daemon/scripts").iterdir(): From 08105cf4b3603fd134bdfe46208c87dc0cbde5bc Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 09:44:28 -0700 Subject: [PATCH 297/929] updated list of invoke tasks in doc --- docs/install2.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/install2.md b/docs/install2.md index 1eb94695..42716fd3 100644 --- a/docs/install2.md +++ b/docs/install2.md @@ -175,13 +175,17 @@ CORE tasks to help ensure usage of the create python virtual environment. ```shell Available tasks: - cleanup run core-cleanup removing leftover core nodes, bridges, directories - daemon start core-daemon - gui start core-pygui - install install core - test run core tests - test-emane run core emane tests - test-mock run core tests using mock to avoid running as sudo + cleanup run core-cleanup removing leftover core nodes, bridges, directories + cli run core-cli used to query and modify a running session + daemon start core-daemon + gui start core-pygui + install install core, poetry, scripts, service, and ospf mdr + install-scripts install core script files, modified to leverage virtual environment + install-service install systemd core service + test run core tests + test-emane run core emane tests + test-mock run core tests using mock to avoid running as sudo + uninstall uninstall core ``` Example running the core-daemon task from the root of the repo: From 6b5aaa6b19ca09852bacc6da360d3a5df9a16859 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 10:01:57 -0700 Subject: [PATCH 298/929] adjust how invoke install prefix is used for core configure --- tasks.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tasks.py b/tasks.py index 5c335644..9da66faa 100644 --- a/tasks.py +++ b/tasks.py @@ -112,11 +112,10 @@ def install_grpcio(c: Context, hide: bool) -> None: ) -def build(c: Context, os_info: OsInfo, hide: bool) -> None: +def build(c: Context, hide: bool, prefix: str = DEFAULT_PREFIX) -> None: print("building core...") c.run("./bootstrap.sh", hide=hide) - prefix = "--prefix=/usr" if os_info.like == OsLike.REDHAT else "" - c.run(f"./configure {prefix}", hide=hide) + c.run(f"./configure --prefix={prefix}", hide=hide) c.run("make -j$(nproc)", hide=hide) @@ -247,7 +246,7 @@ def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): os_info = get_os() install_system(c, os_info, hide) install_grpcio(c, hide) - build(c, os_info, hide) + build(c, hide, prefix) install_core(c, hide) install_poetry(c, dev, hide) install_scripts(c, hide, prefix) From dfb3e0c4242c209ed35c4eb6f63f3f284696ab83 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 10:42:24 -0700 Subject: [PATCH 299/929] default install docs and script to poetry based installs --- docs/install.md | 377 ++++++++++++++++++----------------------------- docs/install2.md | 212 -------------------------- install.sh | 166 ++++----------------- install2.sh | 57 ------- 4 files changed, 175 insertions(+), 637 deletions(-) delete mode 100644 docs/install2.md delete mode 100755 install2.sh diff --git a/docs/install.md b/docs/install.md index 6abd0945..42716fd3 100644 --- a/docs/install.md +++ b/docs/install.md @@ -5,102 +5,123 @@ ## Overview -This section will describe how to install CORE from source or from a pre-built package. -CORE has been vetted on Ubuntu 18 and CentOS 7.6. Other versions and distributions -can work, assuming you can get the required packages and versions similar to those -noted below for the tested distributions. +CORE provides a script to help automate installing all required software +to build and run, including a python virtual environment to run it all in. -> **NOTE:** iproute2 4.5+ is a requirement for bridge related commands +The following tools will be leveraged during installation: + +|Tool|Description| +|---|---| +|pip|used to install pipx| +|pipx|used to install standalone python tools (invoke, poetry)| +|invoke|used to run provided tasks (install, daemon, gui, tests, etc)| +|poetry|used to install the managed python virtual environment for running CORE| ## Required Hardware Any computer capable of running Linux should be able to run CORE. Since the physical machine will be hosting numerous containers, as a general rule you should select a machine having as much RAM and CPU resources as possible. -## Operating System +## Supported Linux Distributions -CORE requires a Linux operating system because it uses namespacing provided by the kernel. It does not run on -Windows or Mac OS X operating systems (unless it is running within a virtual machine guest.) The -technology that CORE currently uses is Linux network namespaces. +Plan is to support recent Ubuntu and CentOS LTS releases. -Ubuntu and CentOS Linux are the recommended distributions for running CORE. However, these distributions are -not strictly required. CORE will likely work on other flavors of Linux as well, assuming dependencies are met. +Verified: +* Ubuntu - 18.04, 20.04 +* CentOS - 7.8, 8.0* -> **NOTE:** CORE Services determine what run on each node. You may require other software packages depending on the -services you wish to use. For example, the HTTP service will require the apache2 package. +> **NOTE:** Ubuntu 20.04 requires installing legacy ebtables for WLAN +> functionality -## Installed Files +> **NOTE:** CentOS 8 does not provide legacy ebtables support, WLAN will not +> function properly -CORE files are installed to the following directories by default, when the installation prefix is **/usr**. +## Utility Requirements -Install Path | Description --------------|------------ -/usr/bin/core-gui|GUI startup command -/usr/bin/coretk-gui|BETA Python GUI -/usr/bin/core-daemon|Daemon startup command -/usr/bin/{core-cleanup, coresendmsg, core-manage}|Misc. helper commands/scripts -/usr/lib/core|GUI files -/usr/lib/python{3.6+}/dist-packages/core|Python modules for daemon/scripts -/etc/core/|Daemon and log configuration files -~/.core/|User-specific GUI preferences and scenario files -/usr/share/core/|Example scripts and scenarios -/usr/share/man/man1/|Command man pages -/etc/init.d/core-daemon|SysV startup script for daemon -/usr/lib/systemd/system/core-daemon.service|Systemd startup script for daemon +* iproute2 4.5+ is a requirement for bridge related commands +* ebtables not backed by nftables -## Automated Install +## Automated Installation -There is a helper script in the root of the repository that can help automate -the CORE installation. Some steps require commands be ran as sudo and you -will be prompted for a password. This should work on Ubuntu/CentOS and will -install system dependencies, python dependencies, and CORE. This will target -system installations of python 3.6. +> **NOTE:** installs OSPF MDR + +> **NOTE:** sets up script files using the prefix provided + +> **NOTE:** install a systemd service file to /lib/systemd/system/core-daemon.service ```shell +# clone CORE repo git clone https://github.com/coreemu/core.git cd core + +# run install script +# script usage: install.sh [-d] [-v] +# +# -v enable verbose install +# -d enable developer install +# -p install prefix, defaults to /usr/local ./install.sh ``` -You can target newer system python versions using the **-v** flag. Assuming -these versions are actually available on your system. +## Manual Installation + +> **NOTE:** install OSPF MDR by manual instructions below ```shell -# ubuntu 3.7 -./install.sh -v 3.7 -# centos 3.7 -./install.sh -v 37 +# clone CORE repo +git clone https://github.com/coreemu/core.git +cd core + +# install python3 and venv support +# ubuntu +sudo apt install -y python3-pip python3-venv +# centos +sudo yum install -y python3-pip + +# install system dependencies +# ubuntu +sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 \ + ethtool tk python3-tk +# centos +sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel \ + iptables-ebtables iproute python3-devel python3-tkinter tk ethtool \ + make kernel-modules-extra + +# install grpcio-tools +python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 + +# build core +./bootstrap.sh +# centos requires --prefix=/usr +./configure +make +sudo make install + +# install pipx, may need to restart terminal after ensurepath +python3 -m pip install --user pipx +python3 -m pipx ensurepath + +# install poetry +pipx install poetry + +# install poetry virtual environment +cd daemon +poetry install --no-dev +cd .. + +# install invoke to run helper tasks +pipx install invoke + +# install core scripts leveraging poetry virtual environment +# centos requires --prefix=/usr +inv install-scripts + +# optionally install systemd service file +# centos requires --prefix=/usr +inv install-service ``` -## Pre-Req Installing Python - -Python 3.6 is the minimum required python version. Newer versions can be used if available. -These steps are needed, since the system packages can not provide all the -dependencies needed by CORE. - -### Ubuntu - -```shell -sudo apt install python3.6 -sudo apt install python3-pip -``` - -### CentOS - -```shell -sudo yum install python36 -sudo yum install python3-pip -``` - -### Dependencies - -Install the current python dependencies. - -```shell -sudo python3 -m pip install -r requirements.txt -``` - -## Pre-Req Installing OSPF MDR +## Manually Install OSPF MDR (Routing Support) Virtual networks generally require some form of routing in order to work (e.g. to automatically populate routing tables for routing packets from one subnet to another.) CORE builds OSPF routing protocol configurations by @@ -110,21 +131,14 @@ default when the blue router node type is used. suite with a modified version of OSPFv3, optimized for use with mobile wireless networks. The **mdr** node type (and the MDR service) requires this variant of Quagga. -### Ubuntu - ```shell -sudo apt install libtool gawk libreadline-dev -``` +# system dependencies +# ubuntu +sudo apt install -y libtool gawk libreadline-dev +# centos +sudo yum install -y libtool gawk readline-devel -### CentOS - -```shell -sudo yum install libtool gawk readline-devel -``` - -### Build and Install - -```shell +# build and install git clone https://github.com/USNavalResearchLaboratory/ospf-mdr cd ospf-mdr ./bootstrap.sh @@ -135,167 +149,64 @@ make sudo make install ``` -Note that the configuration directory */usr/local/etc/quagga* shown for Quagga above could be */etc/quagga*, -if you create a symbolic link from */etc/quagga/Quagga.conf -> /usr/local/etc/quagga/Quagga.conf* on the host. -The *quaggaboot.sh* script in a Linux network namespace will try and do this for you if needed. +## Manually Install EMANE -If you try to run quagga after installing from source and get an error such as: +EMANE can be installed from deb or RPM packages or from source. See the +[EMANE GitHub](https://github.com/adjacentlink/emane) for full details. +Here are quick instructions for installing all EMANE packages for Ubuntu 18.04: ```shell -error while loading shared libraries libzebra.so.0 +# install dependencies +# ubuntu +sudo apt-get install libssl-dev libxml-libxml-perl libxml-simple-perl +wget https://adjacentlink.com/downloads/emane/emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz +tar xzf emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz +# install base emane packages +sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/emane*.deb +# install python3 bindings +sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/python3*.deb ``` -this is usually a sign that you have to run ```sudo ldconfig```` to refresh the cache file. +## Using Invoke Tasks -## Installing from Packages - -The easiest way to install CORE is using the pre-built packages. The package managers on Ubuntu or CentOS -will help in automatically installing most dependencies, except for the python ones described previously. - -You can obtain the CORE packages from [CORE Releases](https://github.com/coreemu/core/releases). - -### Ubuntu - -Ubuntu package defaults to using systemd for running as a service. +The invoke tool installed by way of pipx provides conveniences for running +CORE tasks to help ensure usage of the create python virtual environment. ```shell -sudo apt install ./core_$VERSION_amd64.deb +Available tasks: + + cleanup run core-cleanup removing leftover core nodes, bridges, directories + cli run core-cli used to query and modify a running session + daemon start core-daemon + gui start core-pygui + install install core, poetry, scripts, service, and ospf mdr + install-scripts install core script files, modified to leverage virtual environment + install-service install systemd core service + test run core tests + test-emane run core emane tests + test-mock run core tests using mock to avoid running as sudo + uninstall uninstall core ``` -### CentOS +Example running the core-daemon task from the root of the repo: +```shell +inv daemon +``` -**NOTE: tkimg is not required for the core-gui, but if you get an error message about it you can install the package -on CentOS <= 6, or build from source otherwise** +Some tasks are wrappers around command line tools and requires running +them with a slight variation for compatibility. You can enter the +poetry shell to run the script natively. ```shell -yum install ./core_$VERSION_x86_64.rpm -``` - -Disabling SELINUX: - -```shell -# change the following in /etc/sysconfig/selinux -SELINUX=disabled - -# add the following to the kernel line in /etc/grub.conf -selinux=0 -``` - -Turn off firewalls: - -```shell -systemctl disable firewalld -systemctl disable iptables.service -systemctl disable ip6tables.service -chkconfig iptables off -chkconfig ip6tables off -``` - -You need to reboot after making these changes, or flush the firewall using - -```shell -iptables -F -ip6tables -F -``` - -## Installing from Source - -Steps for building from cloned source code. Python 3.6 is the minimum required version -a newer version can be used below if available. - -### Distro Requirements - -System packages required to build from source. - -#### Ubuntu - -```shell -sudo apt install git automake pkg-config gcc libev-dev ebtables iproute2 \ - python3.6 python3.6-dev python3-pip python3-tk tk libtk-img ethtool autoconf -``` - -#### CentOS - -```shell -sudo yum install git automake pkgconf-pkg-config gcc gcc-c++ libev-devel iptables-ebtables iproute \ - python36 python36-devel python3-pip python3-tkinter tk ethtool autoconf -``` - -### Clone Repository - -Clone the CORE repository for building from source. - -```shell -git clone https://github.com/coreemu/core.git -``` - -### Install grpcio-tools - -Python module grpcio-tools is currently needed to generate gRPC protobuf code. -Specifically leveraging 1.27.2 as that is what will be used during runtime. - -```shell -python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 -``` - -### Build and Install - -```shell -./bootstrap.sh -./configure -make -sudo make install -``` - -## Building Documentation - -Building documentation requires python-sphinx not noted above. - -```shell -sudo apt install python3-sphinx -sudo yum install python3-sphinx - -./bootstrap.sh -./configure -make doc -``` - -## Building Packages -Build package commands, DESTDIR is used to make install into and then for packaging by fpm. - -**NOTE: clean the DESTDIR if re-using the same directory** - -* Install [fpm](http://fpm.readthedocs.io/en/latest/installing.html) - -```shell -./bootstrap.sh -./configure -make -mkdir /tmp/core-build -make fpm DESTDIR=/tmp/core-build -``` - -This will produce and RPM and Deb package for the currently configured python version. - -## Running CORE - -Start the CORE daemon. - -```shell -# systemd -sudo systemctl daemon-reload -sudo systemctl start core-daemon - -# sysv -sudo service core-daemon start -``` - -Run the GUI - -```shell -# default gui -core-gui - -# new beta gui -coretk-gui +# running core-cli as a task requires all options to be provided +# within a string +inv cli "query session -i 1" + +# entering the poetry shell to use core-cli natively +cd $REPO/daemon +poetry shell +core-cli query session -i 1 + +# exit the shell +exit ``` diff --git a/docs/install2.md b/docs/install2.md deleted file mode 100644 index 42716fd3..00000000 --- a/docs/install2.md +++ /dev/null @@ -1,212 +0,0 @@ -# CORE Installation - -* Table of Contents -{:toc} - -## Overview - -CORE provides a script to help automate installing all required software -to build and run, including a python virtual environment to run it all in. - -The following tools will be leveraged during installation: - -|Tool|Description| -|---|---| -|pip|used to install pipx| -|pipx|used to install standalone python tools (invoke, poetry)| -|invoke|used to run provided tasks (install, daemon, gui, tests, etc)| -|poetry|used to install the managed python virtual environment for running CORE| - -## Required Hardware - -Any computer capable of running Linux should be able to run CORE. Since the physical machine will be hosting numerous -containers, as a general rule you should select a machine having as much RAM and CPU resources as possible. - -## Supported Linux Distributions - -Plan is to support recent Ubuntu and CentOS LTS releases. - -Verified: -* Ubuntu - 18.04, 20.04 -* CentOS - 7.8, 8.0* - -> **NOTE:** Ubuntu 20.04 requires installing legacy ebtables for WLAN -> functionality - -> **NOTE:** CentOS 8 does not provide legacy ebtables support, WLAN will not -> function properly - -## Utility Requirements - -* iproute2 4.5+ is a requirement for bridge related commands -* ebtables not backed by nftables - -## Automated Installation - -> **NOTE:** installs OSPF MDR - -> **NOTE:** sets up script files using the prefix provided - -> **NOTE:** install a systemd service file to /lib/systemd/system/core-daemon.service - -```shell -# clone CORE repo -git clone https://github.com/coreemu/core.git -cd core - -# run install script -# script usage: install.sh [-d] [-v] -# -# -v enable verbose install -# -d enable developer install -# -p install prefix, defaults to /usr/local -./install.sh -``` - -## Manual Installation - -> **NOTE:** install OSPF MDR by manual instructions below - -```shell -# clone CORE repo -git clone https://github.com/coreemu/core.git -cd core - -# install python3 and venv support -# ubuntu -sudo apt install -y python3-pip python3-venv -# centos -sudo yum install -y python3-pip - -# install system dependencies -# ubuntu -sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 \ - ethtool tk python3-tk -# centos -sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel \ - iptables-ebtables iproute python3-devel python3-tkinter tk ethtool \ - make kernel-modules-extra - -# install grpcio-tools -python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 - -# build core -./bootstrap.sh -# centos requires --prefix=/usr -./configure -make -sudo make install - -# install pipx, may need to restart terminal after ensurepath -python3 -m pip install --user pipx -python3 -m pipx ensurepath - -# install poetry -pipx install poetry - -# install poetry virtual environment -cd daemon -poetry install --no-dev -cd .. - -# install invoke to run helper tasks -pipx install invoke - -# install core scripts leveraging poetry virtual environment -# centos requires --prefix=/usr -inv install-scripts - -# optionally install systemd service file -# centos requires --prefix=/usr -inv install-service -``` - -## Manually Install OSPF MDR (Routing Support) - -Virtual networks generally require some form of routing in order to work (e.g. to automatically populate routing -tables for routing packets from one subnet to another.) CORE builds OSPF routing protocol configurations by -default when the blue router node type is used. - -* [OSPF MANET Designated Routers](https://github.com/USNavalResearchLaboratory/ospf-mdr) (MDR) - the Quagga routing -suite with a modified version of OSPFv3, optimized for use with mobile wireless networks. The **mdr** node type -(and the MDR service) requires this variant of Quagga. - -```shell -# system dependencies -# ubuntu -sudo apt install -y libtool gawk libreadline-dev -# centos -sudo yum install -y libtool gawk readline-devel - -# build and install -git clone https://github.com/USNavalResearchLaboratory/ospf-mdr -cd ospf-mdr -./bootstrap.sh -./configure --disable-doc --enable-user=root --enable-group=root --with-cflags=-ggdb \ - --sysconfdir=/usr/local/etc/quagga --enable-vtysh \ - --localstatedir=/var/run/quagga -make -sudo make install -``` - -## Manually Install EMANE - -EMANE can be installed from deb or RPM packages or from source. See the -[EMANE GitHub](https://github.com/adjacentlink/emane) for full details. - -Here are quick instructions for installing all EMANE packages for Ubuntu 18.04: -```shell -# install dependencies -# ubuntu -sudo apt-get install libssl-dev libxml-libxml-perl libxml-simple-perl -wget https://adjacentlink.com/downloads/emane/emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz -tar xzf emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz -# install base emane packages -sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/emane*.deb -# install python3 bindings -sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/python3*.deb -``` - -## Using Invoke Tasks - -The invoke tool installed by way of pipx provides conveniences for running -CORE tasks to help ensure usage of the create python virtual environment. - -```shell -Available tasks: - - cleanup run core-cleanup removing leftover core nodes, bridges, directories - cli run core-cli used to query and modify a running session - daemon start core-daemon - gui start core-pygui - install install core, poetry, scripts, service, and ospf mdr - install-scripts install core script files, modified to leverage virtual environment - install-service install systemd core service - test run core tests - test-emane run core emane tests - test-mock run core tests using mock to avoid running as sudo - uninstall uninstall core -``` - -Example running the core-daemon task from the root of the repo: -```shell -inv daemon -``` - -Some tasks are wrappers around command line tools and requires running -them with a slight variation for compatibility. You can enter the -poetry shell to run the script natively. - -```shell -# running core-cli as a task requires all options to be provided -# within a string -inv cli "query session -i 1" - -# entering the poetry shell to use core-cli natively -cd $REPO/daemon -poetry shell -core-cli query session -i 1 - -# exit the shell -exit -``` diff --git a/install.sh b/install.sh index 6cc193ed..5e5a9b11 100755 --- a/install.sh +++ b/install.sh @@ -3,57 +3,6 @@ # exit on error set -e -ubuntu_py=3.6 -centos_py=36 -reinstall= - -function install_python_depencencies() { - sudo python3 -m pip install -r daemon/requirements.txt -} - -function install_grpcio_tools() { - python3 -m pip install --only-binary ":all:" --user grpcio-tools -} - -function install_ospf_mdr() { - rm -rf /tmp/ospf-mdr - git clone https://github.com/USNavalResearchLaboratory/ospf-mdr /tmp/ospf-mdr - cd /tmp/ospf-mdr - ./bootstrap.sh - ./configure --disable-doc --enable-user=root --enable-group=root --with-cflags=-ggdb \ - --sysconfdir=/usr/local/etc/quagga --enable-vtysh \ - --localstatedir=/var/run/quagga - make -j8 - sudo make install - cd - -} - -function build_core() { - ./bootstrap.sh - ./configure $1 - make -j8 -} - -function install_core() { - sudo make install -} - -function uninstall_core() { - sudo make uninstall - make clean - ./bootstrap.sh clean -} - -function install_dev_core() { - cd gui - sudo make install - cd - - cd netns - sudo make install - cd - - cd daemon -} - # detect os/ver for install type os="" if [[ -f /etc/os-release ]]; then @@ -62,100 +11,47 @@ if [[ -f /etc/os-release ]]; then fi # parse arguments -while getopts "drv:" opt; do +dev="" +verbose="" +prefix="" +while getopts "dvp:" opt; do case ${opt} in d) - dev=1 + dev="-d" ;; v) - ubuntu_py=${OPTARG} - centos_py=${OPTARG} + verbose="-v" ;; - r) - reinstall=1 + p) + prefix="-p ${OPTARG}" ;; \?) - echo "script usage: $(basename $0) [-d] [-r] [-v python version]" >&2 + echo "script usage: $(basename $0) [-d] [-v]" >&2 + echo "" >&2 + echo "-v enable verbose install" >&2 + echo "-d enable developer install" >&2 + echo "-p install prefix, defaults to /usr/local" >&2 exit 1 ;; esac done shift $((OPTIND - 1)) -# check if we are reinstalling or installing -if [ -z "${reinstall}" ]; then - echo "installing CORE for ${os}" - case ${os} in - "ubuntu") - echo "installing core system dependencies" - sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 \ - python${ubuntu_py} python${ubuntu_py}-dev python3-pip python3-tk tk libtk-img ethtool autoconf - install_grpcio_tools - echo "installing ospf-mdr system dependencies" - sudo apt install -y libtool gawk libreadline-dev - install_ospf_mdr - if [[ -z ${dev} ]]; then - echo "normal install" - install_python_depencencies - build_core - install_core - else - echo "dev install" - python3 -m pip install pipenv - build_core - install_dev_core - python3 -m pipenv sync --dev - python3 -m pipenv run pre-commit install - fi - ;; - "centos") - echo "installing core system dependencies" - sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel iptables-ebtables iproute \ - python${centos_py} python${centos_py}-devel python3-pip python3-tkinter tk ethtool autoconf - install_grpcio_tools - echo "installing ospf-mdr system dependencies" - sudo yum install -y libtool gawk readline-devel - install_ospf_mdr - if [[ -z ${dev} ]]; then - echo "normal install" - install_python_depencencies - build_core --prefix=/usr - install_core - else - echo "dev install" - sudo python3 -m pip install pipenv - build_core --prefix=/usr - install_dev_core - sudo python3 -m pipenv sync --dev - python3 -m pipenv sync --dev - python3 -m pipenv run pre-commit install - fi - ;; - *) - echo "unknown OS ID ${os} cannot install" - ;; - esac -else - branch=$(git symbolic-ref --short HEAD) - echo "reinstalling CORE on ${os} with latest ${branch}" - echo "uninstalling CORE" - uninstall_core - echo "pulling latest code" - git pull - echo "installing python dependencies" - install_python_depencencies - echo "building CORE" - case ${os} in - "ubuntu") - build_core - ;; - "centos") - build_core --prefix=/usr - ;; - *) - echo "unknown OS ID ${os} cannot reinstall" - ;; - esac - echo "installing CORE" - install_core -fi +echo "installing CORE for ${os}" +case ${os} in +"ubuntu") + sudo apt install -y python3-pip python3-venv + ;; +"centos") + sudo yum install -y python3-pip + ;; +*) + echo "unknown OS ID ${os} cannot install" + ;; +esac + +python3 -m pip install --user pipx +python3 -m pipx ensurepath +export PATH=$PATH:~/.local/bin +pipx install invoke +inv install ${dev} ${verbose} ${prefix} diff --git a/install2.sh b/install2.sh deleted file mode 100755 index 5e5a9b11..00000000 --- a/install2.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/bash - -# exit on error -set -e - -# detect os/ver for install type -os="" -if [[ -f /etc/os-release ]]; then - . /etc/os-release - os=${ID} -fi - -# parse arguments -dev="" -verbose="" -prefix="" -while getopts "dvp:" opt; do - case ${opt} in - d) - dev="-d" - ;; - v) - verbose="-v" - ;; - p) - prefix="-p ${OPTARG}" - ;; - \?) - echo "script usage: $(basename $0) [-d] [-v]" >&2 - echo "" >&2 - echo "-v enable verbose install" >&2 - echo "-d enable developer install" >&2 - echo "-p install prefix, defaults to /usr/local" >&2 - exit 1 - ;; - esac -done -shift $((OPTIND - 1)) - -echo "installing CORE for ${os}" -case ${os} in -"ubuntu") - sudo apt install -y python3-pip python3-venv - ;; -"centos") - sudo yum install -y python3-pip - ;; -*) - echo "unknown OS ID ${os} cannot install" - ;; -esac - -python3 -m pip install --user pipx -python3 -m pipx ensurepath -export PATH=$PATH:~/.local/bin -pipx install invoke -inv install ${dev} ${verbose} ${prefix} From f00d4aef0b527886b494444b559a8d04f49744d0 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 11:02:00 -0700 Subject: [PATCH 300/929] update install doc to note centos 8 and netem --- docs/install.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/install.md b/docs/install.md index 42716fd3..2bf1a3b1 100644 --- a/docs/install.md +++ b/docs/install.md @@ -36,6 +36,16 @@ Verified: > **NOTE:** CentOS 8 does not provide legacy ebtables support, WLAN will not > function properly +> **NOTE:** CentOS 8 does not have the netem kernel mod available by default + +CentOS 8 Enabled netem: +```shell +sudo yum update +# restart into updated kernel +sudo yum install -y kernel-modules-extra +sudo modprobe sch_netem +``` + ## Utility Requirements * iproute2 4.5+ is a requirement for bridge related commands From 05830c68304a038920309fb2d46d61327659b4b3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 11:25:40 -0700 Subject: [PATCH 301/929] removed fpm packaging, as it will not be used anymore, beyond distributed packages --- Makefile.am | 58 ----------------------------------------------------- 1 file changed, 58 deletions(-) diff --git a/Makefile.am b/Makefile.am index 4db05408..fbdf573e 100644 --- a/Makefile.am +++ b/Makefile.am @@ -44,58 +44,6 @@ DISTCLEANFILES = aclocal.m4 \ MAINTAINERCLEANFILES = .version \ .version.date -define fpm-rpm = -fpm -s dir -t rpm -n core \ - -m "$(PACKAGE_MAINTAINERS)" \ - --license "BSD" \ - --description "Common Open Research Emulator" \ - --url https://github.com/coreemu/core \ - --vendor "$(PACKAGE_VENDOR)" \ - -p core_VERSION_ARCH.rpm \ - -v $(PACKAGE_VERSION) \ - --rpm-init scripts/core-daemon \ - --config-files "/etc/core" \ - -d "ethtool" \ - -d "tcl" \ - -d "tk" \ - -d "procps-ng" \ - -d "bash >= 3.0" \ - -d "ebtables" \ - -d "iproute" \ - -d "libev" \ - -d "net-tools" \ - -d "python3 >= 3.6" \ - -d "python3-tkinter" \ - -C $(DESTDIR) -endef - -define fpm-deb = -fpm -s dir -t deb -n core \ - -m "$(PACKAGE_MAINTAINERS)" \ - --license "BSD" \ - --description "Common Open Research Emulator" \ - --url https://github.com/coreemu/core \ - --vendor "$(PACKAGE_VENDOR)" \ - -p core_VERSION_ARCH.deb \ - -v $(PACKAGE_VERSION) \ - --deb-systemd scripts/core-daemon.service \ - --deb-no-default-config-files \ - --config-files "/etc/core" \ - -d "ethtool" \ - -d "tcl" \ - -d "tk" \ - -d "libtk-img" \ - -d "procps" \ - -d "libc6 >= 2.14" \ - -d "bash >= 3.0" \ - -d "ebtables" \ - -d "iproute2" \ - -d "libev4" \ - -d "python3 >= 3.6" \ - -d "python3-tk" \ - -C $(DESTDIR) -endef - define fpm-distributed-deb = fpm -s dir -t deb -n core-distributed \ -m "$(PACKAGE_MAINTAINERS)" \ @@ -138,12 +86,6 @@ fpm -s dir -t rpm -n core-distributed \ -C $(DESTDIR) endef -.PHONY: fpm -fpm: clean-local-fpm - $(MAKE) install DESTDIR=$(DESTDIR) - $(call fpm-deb) - $(call fpm-rpm) - .PHONY: fpm-distributed fpm-distributed: clean-local-fpm $(MAKE) -C netns install DESTDIR=$(DESTDIR) From 50f331d93ef9b80e7fa031830b9797ffb2c39851 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 11:40:20 -0700 Subject: [PATCH 302/929] removed references to building and dealing with service files, as that will now be limited to the invoke task --- Makefile.am | 4 +- configure.ac | 14 ----- scripts/.gitignore | 2 - scripts/Makefile.am | 31 --------- scripts/core-daemon.in | 112 --------------------------------- scripts/core-daemon.service.in | 11 ---- 6 files changed, 1 insertion(+), 173 deletions(-) delete mode 100644 scripts/.gitignore delete mode 100644 scripts/Makefile.am delete mode 100644 scripts/core-daemon.in delete mode 100644 scripts/core-daemon.service.in diff --git a/Makefile.am b/Makefile.am index fbdf573e..20191438 100644 --- a/Makefile.am +++ b/Makefile.am @@ -11,7 +11,7 @@ if WANT_GUI endif if WANT_DAEMON - DAEMON = scripts daemon + DAEMON = daemon endif if WANT_NETNS @@ -124,8 +124,6 @@ all: change-files .PHONY: change-files change-files: $(call change-files,gui/core-gui) - $(call change-files,scripts/core-daemon.service) - $(call change-files,scripts/core-daemon) $(call change-files,daemon/core/constants.py) $(call change-files,netns/setup.py) $(call change-files,daemon/setup.py) diff --git a/configure.ac b/configure.ac index 02102760..10d30c20 100644 --- a/configure.ac +++ b/configure.ac @@ -208,22 +208,12 @@ if [test "x$want_python" = "xyes" && test "x$enable_docs" = "xyes"] ; then AS_IF([$PYTHON -c "import sphinx_rtd_theme" &> /dev/null], [], [AC_MSG_ERROR([doc dependency missing, please install python3 -m pip install sphinx-rtd-theme])]) fi -AC_ARG_WITH([startup], - [AS_HELP_STRING([--with-startup=option], - [option=systemd,suse,none to install systemd/SUSE init scripts])], - [with_startup=$with_startup], - [with_startup=initd]) -AC_SUBST(with_startup) -AC_MSG_RESULT([using startup option $with_startup]) - # Variable substitutions AM_CONDITIONAL(WANT_GUI, test x$enable_gui = xyes) AM_CONDITIONAL(WANT_DAEMON, test x$enable_daemon = xyes) AM_CONDITIONAL(WANT_DOCS, test x$want_docs = xyes) AM_CONDITIONAL(WANT_PYTHON, test x$want_python = xyes) AM_CONDITIONAL(WANT_NETNS, test x$want_linux_netns = xyes) -AM_CONDITIONAL(WANT_INITD, test x$with_startup = xinitd) -AM_CONDITIONAL(WANT_SYSTEMD, test x$with_startup = xsystemd) AM_CONDITIONAL(WANT_VNODEDONLY, test x$enable_vnodedonly = xyes) if test $cross_compiling = no; then @@ -237,7 +227,6 @@ AC_CONFIG_FILES([Makefile gui/version.tcl gui/Makefile gui/icons/Makefile - scripts/Makefile man/Makefile docs/Makefile daemon/Makefile @@ -267,9 +256,6 @@ Daemon: Daemon path: ${bindir} Daemon config: ${CORE_CONF_DIR} Python: ${PYTHON} - Logs: ${CORE_STATE_DIR}/log - -Startup: ${with_startup} Features to build: Build GUI: ${enable_gui} diff --git a/scripts/.gitignore b/scripts/.gitignore deleted file mode 100644 index 86129f95..00000000 --- a/scripts/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -core-daemon -core-daemon.service diff --git a/scripts/Makefile.am b/scripts/Makefile.am deleted file mode 100644 index abdef40d..00000000 --- a/scripts/Makefile.am +++ /dev/null @@ -1,31 +0,0 @@ -# CORE -# (c)2011-2013 the Boeing Company. -# See the LICENSE file included in this distribution. -# -# author: Jeff Ahrenholz -# -# Makefile for installing scripts. -# - -CLEANFILES = core-daemon - -DISTCLEANFILES = Makefile.in core-daemon.service core-daemon - -EXTRA_DIST = core-daemon.in core-daemon.service.in - -SUBDIRS = - -# install startup scripts based on --with-startup=option configure option -# init.d (default), systemd -if WANT_INITD -startupdir = /etc/init.d -startup_SCRIPTS = core-daemon -endif -if WANT_SYSTEMD -startupdir = /usr/lib/systemd/system -startup_SCRIPTS = core-daemon.service -endif - -# remove extra scripts and their directories if they are empty -uninstall-hook: - rmdir -p $(startupdir) || true diff --git a/scripts/core-daemon.in b/scripts/core-daemon.in deleted file mode 100644 index 0a988f0f..00000000 --- a/scripts/core-daemon.in +++ /dev/null @@ -1,112 +0,0 @@ -#!/bin/sh -### BEGIN INIT INFO -# Provides: core-daemon -# Required-Start: $network $remote_fs -# Required-Stop: $network $remote_fs -# Default-Start: 2 3 4 5 -# Default-Stop: 0 1 6 -# Short-Description: Start the core-daemon CORE daemon at boot time -# Description: Starts and stops the core-daemon CORE daemon used to -# provide network emulation services for the CORE GUI -# or scripts. -### END INIT INFO -# -# chkconfig: 35 90 03 -# description: Starts and stops the CORE daemon \ -# used to provide network emulation services. -# -# config: /etc/core/ - -NAME=`basename $0` -PIDFILE="@CORE_STATE_DIR@/run/$NAME.pid" -LOG="@CORE_STATE_DIR@/log/$NAME.log" -CMD="@bindir@/$NAME" - -get_pid() { - cat "$PIDFILE" -} - -is_alive() { - [ -f "$PIDFILE" ] && ps -p `get_pid` > /dev/null 2>&1 -} - -corestart() { - if is_alive; then - echo "$NAME already started" - else - echo "starting $NAME" - $CMD 2>&1 >> "$LOG" & - fi - - echo $! > "$PIDFILE" - if ! is_alive; then - echo "unable to start $NAME, see $LOG" - exit 1 - fi -} - -corestop() { - if is_alive; then - echo -n "stopping $NAME.." - kill `get_pid` - for i in 1 2 3 4 5; do - sleep 1 - if ! is_alive; then - break - fi - echo -n "." - done - echo - - if is_alive; then - echo "not stopped; may still be shutting down" - exit 1 - else - echo "stopped" - if [ -f "$PIDFILE" ]; then - rm -f "$PIDFILE" - fi - fi - else - echo "$NAME not running" - fi -} - -corerestart() { - corestop - corestart -} - -corestatus() { - if is_alive; then - echo "$NAME is running" - else - echo "$NAME is stopped" - exit 1 - fi -} - - -case "$1" in - start) - corestart - ;; - stop) - corestop - ;; - restart) - corerestart - ;; - force-reload) - corerestart - ;; - status) - corestatus - ;; - *) - echo "Usage: $0 {start|stop|restart|status}" - exit 1 -esac - -exit $? - diff --git a/scripts/core-daemon.service.in b/scripts/core-daemon.service.in deleted file mode 100644 index cd53cfad..00000000 --- a/scripts/core-daemon.service.in +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Common Open Research Emulator Service -After=network.target - -[Service] -Type=simple -ExecStart=@bindir@/core-daemon -TasksMax=infinity - -[Install] -WantedBy=multi-user.target From df01f0444444d51f936c482d802e52a95513e397 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 12:08:05 -0700 Subject: [PATCH 303/929] removed python buid/installation from makefiles, poetry will handle --- .gitignore | 4 +--- daemon/.gitignore | 2 -- daemon/Makefile.am | 38 ++++---------------------------------- 3 files changed, 5 insertions(+), 39 deletions(-) delete mode 100644 daemon/.gitignore diff --git a/.gitignore b/.gitignore index bcfbadeb..2012df9d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ coverage.xml # python files *.egg-info +*.pyc # ignore package files *.rpm @@ -55,8 +56,5 @@ coverage.xml netns/setup.py daemon/setup.py -# ignore corefx build -corefx/target - # python __pycache__ diff --git a/daemon/.gitignore b/daemon/.gitignore deleted file mode 100644 index 27ffc2f1..00000000 --- a/daemon/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.pyc -build diff --git a/daemon/Makefile.am b/daemon/Makefile.am index a5663654..1cf4d233 100644 --- a/daemon/Makefile.am +++ b/daemon/Makefile.am @@ -7,43 +7,12 @@ # Makefile for building netns components. # -SETUPPY = setup.py -SETUPPYFLAGS = -v - if WANT_DOCS DOCS = doc endif SUBDIRS = proto $(DOCS) -SCRIPT_FILES := $(notdir $(wildcard scripts/*)) -MAN_FILES := $(notdir $(wildcard ../man/*.1)) - -# Python package build -noinst_SCRIPTS = build -build: - $(PYTHON) $(SETUPPY) $(SETUPPYFLAGS) build - -# Python package install -install-exec-hook: - $(PYTHON) $(SETUPPY) $(SETUPPYFLAGS) install \ - --root=/$(DESTDIR) \ - --prefix=$(prefix) \ - --single-version-externally-managed - -# Python package uninstall -uninstall-hook: - rm -rf $(DESTDIR)/etc/core - rm -rf $(DESTDIR)/$(datadir)/core - rm -f $(addprefix $(DESTDIR)/$(datarootdir)/man/man1/, $(MAN_FILES)) - rm -f $(addprefix $(DESTDIR)/$(bindir)/,$(SCRIPT_FILES)) - rm -rf $(DESTDIR)/$(pythondir)/core-$(PACKAGE_VERSION)-py$(PYTHON_VERSION).egg-info - rm -rf $(DESTDIR)/$(pythondir)/core - -# Python package cleanup -clean-local: - -rm -rf build - # because we include entire directories with EXTRA_DIST, we need to clean up # the source control files dist-hook: @@ -52,17 +21,18 @@ dist-hook: distclean-local: -rm -rf core.egg-info - DISTCLEANFILES = Makefile.in # files to include with distribution tarball -EXTRA_DIST = $(SETUPPY) \ +EXTRA_DIST = setup.py \ core \ data \ doc/conf.py.in \ examples \ scripts \ tests \ - test.py \ setup.cfg \ + MANIFEST.in \ + poetry.lock \ + pyproject.toml \ requirements.txt From 7b3f934e954691416b7c67700bb318eedc66c40f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 12:13:41 -0700 Subject: [PATCH 304/929] updated pyproject.toml to align with setup.py as is --- daemon/pyproject.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index 165fb34c..d36b341c 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -1,8 +1,11 @@ [tool.poetry] name = "core" version = "6.6.0" -description = "" -authors = [] +description = "CORE Common Open Research Emulator" +authors = ["Boeing Research & Technology"] +license = "BSD-2-Clause" +repository = "https://github.com/coreemu/core" +documentation = "https://coreemu.github.io/core/" [tool.poetry.dependencies] python = "^3.6" From 8c50d08121a01b30bd0b7aeeb8d02cfa146b6a3c Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 12:33:13 -0700 Subject: [PATCH 305/929] removed setup.py and requirements.txt as poetry will be where this information will live --- Makefile.am | 1 - daemon/Makefile.am | 6 ++--- daemon/pyproject.toml | 2 +- daemon/requirements.txt | 19 ------------- daemon/setup.py.in | 60 ----------------------------------------- 5 files changed, 3 insertions(+), 85 deletions(-) delete mode 100644 daemon/requirements.txt delete mode 100644 daemon/setup.py.in diff --git a/Makefile.am b/Makefile.am index 20191438..7a3799fc 100644 --- a/Makefile.am +++ b/Makefile.am @@ -126,7 +126,6 @@ change-files: $(call change-files,gui/core-gui) $(call change-files,daemon/core/constants.py) $(call change-files,netns/setup.py) - $(call change-files,daemon/setup.py) CORE_DOC_SRC = core-python-$(PACKAGE_VERSION) .PHONY: doc diff --git a/daemon/Makefile.am b/daemon/Makefile.am index 1cf4d233..04f48a92 100644 --- a/daemon/Makefile.am +++ b/daemon/Makefile.am @@ -24,8 +24,7 @@ distclean-local: DISTCLEANFILES = Makefile.in # files to include with distribution tarball -EXTRA_DIST = setup.py \ - core \ +EXTRA_DIST = core \ data \ doc/conf.py.in \ examples \ @@ -34,5 +33,4 @@ EXTRA_DIST = setup.py \ setup.cfg \ MANIFEST.in \ poetry.lock \ - pyproject.toml \ - requirements.txt + pyproject.toml diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index d36b341c..da22690b 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -2,7 +2,7 @@ name = "core" version = "6.6.0" description = "CORE Common Open Research Emulator" -authors = ["Boeing Research & Technology"] +authors = ["Boeing Research and Technology"] license = "BSD-2-Clause" repository = "https://github.com/coreemu/core" documentation = "https://coreemu.github.io/core/" diff --git a/daemon/requirements.txt b/daemon/requirements.txt deleted file mode 100644 index 19d155e5..00000000 --- a/daemon/requirements.txt +++ /dev/null @@ -1,19 +0,0 @@ -bcrypt==3.1.7 -cffi==1.14.0 -cryptography==2.8 -dataclasses==0.7; python_version == "3.6" -fabric==2.5.0 -grpcio==1.27.2 -invoke==1.4.1 -lxml==4.5.0 -Mako==1.1.1 -MarkupSafe==1.1.1 -netaddr==0.7.19 -paramiko==2.7.1 -Pillow==7.0.0 -protobuf==3.11.3 -pycparser==2.19 -PyNaCl==1.3.0 -pyproj==2.5.0 -PyYAML==5.3 -six==1.14.0 diff --git a/daemon/setup.py.in b/daemon/setup.py.in deleted file mode 100644 index e8c99e67..00000000 --- a/daemon/setup.py.in +++ /dev/null @@ -1,60 +0,0 @@ -""" -Defines how CORE will be built for installation. -""" - -import glob -import os - -from setuptools import find_packages, setup - -_CORE_DIR = "/etc/core" -_MAN_DIR = "share/man/man1" -_EXAMPLES_DIR = "share/core" - - -def recursive_files(data_path, files_path): - all_files = [] - for path, _directories, filenames in os.walk(files_path): - directory = os.path.join(data_path, path) - files = [] - for filename in filenames: - files.append(os.path.join(path, filename)) - all_files.append((directory, files)) - return all_files - - -data_files = [ - (_CORE_DIR, glob.glob("data/*")), - (_MAN_DIR, glob.glob("../man/**.1")), -] -data_files.extend(recursive_files(_EXAMPLES_DIR, "examples")) - -setup( - name="core", - version="@PACKAGE_VERSION@", - packages=find_packages(), - install_requires=[ - 'dataclasses;python_version=="3.6"', - "fabric", - "grpcio", - "invoke", - "lxml", - "mako", - "netaddr", - "pillow", - "protobuf", - "pyproj", - "pyyaml", - ], - tests_require=[ - "pytest", - ], - data_files=data_files, - scripts=glob.glob("scripts/*"), - include_package_data=True, - description="Python components of CORE", - url="https://github.com/coreemu/core", - author="Boeing Research & Technology", - license="BSD", - long_description="Python scripts and modules for building virtual emulated networks.", -) From 0cd3f6115dfb4eca5b262fecfd84b011f2e88edb Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 12:37:29 -0700 Subject: [PATCH 306/929] remove setup.py reference from github action --- .github/workflows/daemon-checks.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/daemon-checks.yml b/.github/workflows/daemon-checks.yml index 9e9f7aa7..6cb12124 100644 --- a/.github/workflows/daemon-checks.yml +++ b/.github/workflows/daemon-checks.yml @@ -16,7 +16,6 @@ jobs: python -m pip install --upgrade pip pip install poetry cd daemon - cp setup.py.in setup.py cp core/constants.py.in core/constants.py sed -i 's/required=True/required=False/g' core/emulator/coreemu.py poetry install From 873fc0e4683eb23a60a163f8bf0eff5a2f066182 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 12:49:40 -0700 Subject: [PATCH 307/929] removed daemon MANIFEST.in, poetry will provide --- daemon/MANIFEST.in | 2 -- daemon/Makefile.am | 1 - daemon/pyproject.toml | 1 + 3 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 daemon/MANIFEST.in diff --git a/daemon/MANIFEST.in b/daemon/MANIFEST.in deleted file mode 100644 index c46dc828..00000000 --- a/daemon/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -graft core/gui/data -graft core/configservices/*/templates diff --git a/daemon/Makefile.am b/daemon/Makefile.am index 04f48a92..7528dc01 100644 --- a/daemon/Makefile.am +++ b/daemon/Makefile.am @@ -31,6 +31,5 @@ EXTRA_DIST = core \ scripts \ tests \ setup.cfg \ - MANIFEST.in \ poetry.lock \ pyproject.toml diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index da22690b..d9f204b8 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -6,6 +6,7 @@ authors = ["Boeing Research and Technology"] license = "BSD-2-Clause" repository = "https://github.com/coreemu/core" documentation = "https://coreemu.github.io/core/" +include = ["core/gui/data/**/*", "core/configservices/*/templates"] [tool.poetry.dependencies] python = "^3.6" From fdd2e6f1f11f8a55f4e4c7804864b03a3114156f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 12:54:13 -0700 Subject: [PATCH 308/929] removed references for excluding utm.py as it is no longer present --- .github/workflows/daemon-checks.yml | 2 +- daemon/.pre-commit-config.yaml | 2 +- daemon/setup.cfg | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/daemon-checks.yml b/.github/workflows/daemon-checks.yml index 6cb12124..5ea8d1c2 100644 --- a/.github/workflows/daemon-checks.yml +++ b/.github/workflows/daemon-checks.yml @@ -26,7 +26,7 @@ jobs: - name: black run: | cd daemon - poetry run black --check --exclude ".+_pb2.*.py|doc|build|utm\.py|setup\.py" . + poetry run black --check --exclude ".+_pb2.*.py|doc|build" . - name: flake8 run: | cd daemon diff --git a/daemon/.pre-commit-config.yaml b/daemon/.pre-commit-config.yaml index 13a6955b..fe810f04 100644 --- a/daemon/.pre-commit-config.yaml +++ b/daemon/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: name: black stages: [commit] language: system - entry: bash -c 'cd daemon && poetry run black --exclude ".+_pb2.*.py|doc|build|utm\.py" .' + entry: bash -c 'cd daemon && poetry run black --exclude ".+_pb2.*.py|doc|build" .' types: [python] - id: flake8 diff --git a/daemon/setup.cfg b/daemon/setup.cfg index a3084b8b..f2c2a3aa 100644 --- a/daemon/setup.cfg +++ b/daemon/setup.cfg @@ -2,7 +2,7 @@ test=pytest [isort] -skip_glob=*_pb2*.py,utm.py,doc,build +skip_glob=*_pb2*.py,doc,build multi_line_output=3 include_trailing_comma=True force_grid_wrap=0 @@ -14,7 +14,7 @@ ignore=E501,W503,E203 max-line-length=88 max-complexity=26 select=B,C,E,F,W,T4 -exclude=*_pb2*.py,utm.py,doc,build +exclude=*_pb2*.py,doc,build [tool:pytest] norecursedirs=distributed emane From f8b0ab6ec3ed88359c4f3384335f8e0337541022 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 13:24:16 -0700 Subject: [PATCH 309/929] moved isort config from setup.cfg to pyproject.toml --- daemon/pyproject.toml | 8 ++++++++ daemon/setup.cfg | 11 ----------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index d9f204b8..f7a9874a 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -31,6 +31,14 @@ mock = "*" pre-commit = "*" pytest = "*" +[tool.isort] +skip_glob = "*_pb2*.py,doc,build" +multi_line_output = 3 +include_trailing_comma = "True" +force_grid_wrap = 0 +use_parentheses = "True" +line_length = 88 + [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" diff --git a/daemon/setup.cfg b/daemon/setup.cfg index f2c2a3aa..89c968b9 100644 --- a/daemon/setup.cfg +++ b/daemon/setup.cfg @@ -1,14 +1,3 @@ -[aliases] -test=pytest - -[isort] -skip_glob=*_pb2*.py,doc,build -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 - [flake8] ignore=E501,W503,E203 max-line-length=88 From 80194b3e38bc75a3e9de61264db6fc4a5787f354 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 13:33:40 -0700 Subject: [PATCH 310/929] moved python black configuration to pyproject.toml and fixed bad exclude --- .github/workflows/daemon-checks.yml | 2 +- daemon/.pre-commit-config.yaml | 2 +- daemon/core/nodes/docker.py | 6 ++---- daemon/pyproject.toml | 5 +++++ 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/daemon-checks.yml b/.github/workflows/daemon-checks.yml index 5ea8d1c2..52440467 100644 --- a/.github/workflows/daemon-checks.yml +++ b/.github/workflows/daemon-checks.yml @@ -26,7 +26,7 @@ jobs: - name: black run: | cd daemon - poetry run black --check --exclude ".+_pb2.*.py|doc|build" . + poetry run black --check . - name: flake8 run: | cd daemon diff --git a/daemon/.pre-commit-config.yaml b/daemon/.pre-commit-config.yaml index fe810f04..bc9ead08 100644 --- a/daemon/.pre-commit-config.yaml +++ b/daemon/.pre-commit-config.yaml @@ -12,7 +12,7 @@ repos: name: black stages: [commit] language: system - entry: bash -c 'cd daemon && poetry run black --exclude ".+_pb2.*.py|doc|build" .' + entry: bash -c 'cd daemon && poetry run black .' types: [python] - id: flake8 diff --git a/daemon/core/nodes/docker.py b/daemon/core/nodes/docker.py index 1ef814ee..ce34bd98 100644 --- a/daemon/core/nodes/docker.py +++ b/daemon/core/nodes/docker.py @@ -78,7 +78,7 @@ class DockerNode(CoreNode): name: str = None, nodedir: str = None, server: DistributedServer = None, - image: str = None + image: str = None, ) -> None: """ Create a DockerNode instance. @@ -209,9 +209,7 @@ class DockerNode(CoreNode): if self.server is not None: self.host_cmd(f"rm -f {temp.name}") os.unlink(temp.name) - logging.debug( - "node(%s) added file: %s; mode: 0%o", self.name, filename, mode - ) + logging.debug("node(%s) added file: %s; mode: 0%o", self.name, filename, mode) def nodefilecopy(self, filename: str, srcfilename: str, mode: int = None) -> None: """ diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index f7a9874a..3e37e4f9 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -39,6 +39,11 @@ force_grid_wrap = 0 use_parentheses = "True" line_length = 88 +[tool.black] +line_length = 88 +exclude = ".+_pb2.*.py|doc/|build/|__pycache__/" + + [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" From be2f7e1cae302ad608c55433b66308cf9f053d5d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 13:42:59 -0700 Subject: [PATCH 311/929] simplified invoke install/uninstall task, since daemon no longer formally installs --- daemon/pyproject.toml | 1 - tasks.py | 16 +++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index 3e37e4f9..1fdc9d1a 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -43,7 +43,6 @@ line_length = 88 line_length = 88 exclude = ".+_pb2.*.py|doc/|build/|__pycache__/" - [build-system] requires = ["poetry>=0.12"] build-backend = "poetry.masonry.api" diff --git a/tasks.py b/tasks.py index 9da66faa..6ed956c2 100644 --- a/tasks.py +++ b/tasks.py @@ -8,8 +8,6 @@ from tempfile import NamedTemporaryFile from invoke import task, Context DAEMON_DIR: str = "daemon" -VCMD_DIR: str = "netns" -GUI_DIR: str = "gui" DEFAULT_PREFIX: str = "/usr/local" @@ -121,11 +119,7 @@ def build(c: Context, hide: bool, prefix: str = DEFAULT_PREFIX) -> None: def install_core(c: Context, hide: bool) -> None: print("installing core vcmd...") - with c.cd(VCMD_DIR): - c.run("sudo make install", hide=hide) - print("installing core gui...") - with c.cd(GUI_DIR): - c.run("sudo make install", hide=hide) + c.run("sudo make install", hide=hide) def install_poetry(c: Context, dev: bool, hide: bool) -> None: @@ -265,12 +259,8 @@ def uninstall(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): uninstall core """ hide = not verbose - print("uninstalling core-gui") - with c.cd(GUI_DIR): - c.run("sudo make uninstall", hide=hide) - print("uninstalling vcmd") - with c.cd(VCMD_DIR): - c.run("sudo make uninstall", hide=hide) + print("uninstalling core") + c.run("sudo make uninstall", hide=hide) print("cleaning build directory") c.run("make clean", hide=hide) c.run("./bootstrap.sh clean", hide=hide) From 1cadf8362fbec42379938449c3e21a9061cf0ddf Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 22:09:00 -0700 Subject: [PATCH 312/929] added a text spinner while installing/uninstalling --- tasks.py | 177 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 109 insertions(+), 68 deletions(-) diff --git a/tasks.py b/tasks.py index 6ed956c2..0b727f58 100644 --- a/tasks.py +++ b/tasks.py @@ -1,9 +1,14 @@ import inspect +import itertools import os import sys +import threading +import time +from contextlib import contextmanager from enum import Enum from pathlib import Path from tempfile import NamedTemporaryFile +from typing import Optional from invoke import task, Context @@ -11,6 +16,40 @@ DAEMON_DIR: str = "daemon" DEFAULT_PREFIX: str = "/usr/local" +class Progress: + cycles = itertools.cycle(["-", "/", "|", "\\"]) + + def __init__(self, verbose: bool) -> None: + self.verbose: bool = verbose + self.thread: Optional[threading.Thread] = None + self.running: bool = False + + @contextmanager + def start(self, message: str) -> None: + if not self.verbose: + print(f"{message} ... ", end="") + self.running = True + self.thread = threading.Thread(target=self.run, daemon=True) + self.thread.start() + yield + self.stop() + + def run(self) -> None: + while self.running: + sys.stdout.write(next(self.cycles)) + sys.stdout.flush() + sys.stdout.write("\b") + time.sleep(0.1) + + def stop(self) -> None: + if not self.verbose: + print("done") + if self.thread: + self.running = False + self.thread.join() + self.thread = None + + class OsName(Enum): UBUNTU = "ubuntu" CENTOS = "centos" @@ -52,7 +91,7 @@ def get_os() -> OsInfo: if not line: continue key, value = line.split("=") - d[key] = value.strip('"') + d[key] = value.strip("\"") name_value = d["ID"] like_value = d["ID_LIKE"] version_value = d["VERSION_ID"] @@ -69,27 +108,28 @@ def get_os() -> OsInfo: def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: - print("installing system dependencies...") if os_info.like == OsLike.DEBIAN: c.run( - "sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 " - "ethtool tk python3-tk", + "sudo apt install -y automake pkg-config gcc libev-dev ebtables " + "iproute2 ethtool tk python3-tk", hide=hide ) elif os_info.like == OsLike.REDHAT: c.run( - "sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel " - "iptables-ebtables iproute python3-devel python3-tkinter tk ethtool make", + "sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ " + "libev-devel iptables-ebtables iproute python3-devel python3-tkinter " + "tk ethtool make", hide=hide ) # centos 8+ does not support netem by default if os_info.name == OsName.CENTOS and os_info.version >= 8: c.run("sudo yum install -y kernel-modules-extra", hide=hide) if not c.run("sudo modprobe sch_netem", warn=True, hide=hide): - print("ERROR: you need to install the latest kernel") + print("\nERROR: you need to install the latest kernel") print("run the following, restart, and try again") print("sudo yum update") sys.exit(1) + # attempt to setup legacy ebtables when an nftables based version is found r = c.run("ebtables -V", hide=hide) if "nf_tables" in r.stdout: @@ -99,35 +139,31 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: hide=hide ): print( - "WARNING: unable to setup required ebtables-legacy, WLAN will not work" + "\nWARNING: unable to setup ebtables-legacy, WLAN will not work" ) def install_grpcio(c: Context, hide: bool) -> None: - print("installing grpcio-tools...") c.run( - "python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2", hide=hide + "python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2", + hide=hide, ) -def build(c: Context, hide: bool, prefix: str = DEFAULT_PREFIX) -> None: - print("building core...") +def build_core(c: Context, hide: bool, prefix: str = DEFAULT_PREFIX) -> None: c.run("./bootstrap.sh", hide=hide) c.run(f"./configure --prefix={prefix}", hide=hide) c.run("make -j$(nproc)", hide=hide) def install_core(c: Context, hide: bool) -> None: - print("installing core vcmd...") c.run("sudo make install", hide=hide) def install_poetry(c: Context, dev: bool, hide: bool) -> None: - print("installing poetry...") c.run("pipx install poetry", hide=hide) args = "" if dev else "--no-dev" with c.cd(DAEMON_DIR): - print("installing core environment using poetry...") c.run(f"poetry install {args}", hide=hide) if dev: c.run("poetry run pre-commit install", hide=hide) @@ -135,31 +171,29 @@ def install_poetry(c: Context, dev: bool, hide: bool) -> None: def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: if c.run("which zebra", warn=True, hide=hide): - print("quagga already installed, skipping ospf mdr") + print("\nquagga already installed, skipping ospf mdr") return - print("installing ospf mdr dependencies...") - if os_info.like == OsLike.DEBIAN: - c.run("sudo apt install -y libtool gawk libreadline-dev git", hide=hide) - elif os_info.like == OsLike.REDHAT: - c.run("sudo yum install -y libtool gawk readline-devel git", hide=hide) - print("cloning ospf mdr...") - clone_dir = "/tmp/ospf-mdr" - c.run( - f"git clone https://github.com/USNavalResearchLaboratory/ospf-mdr {clone_dir}", - hide=hide - ) - with c.cd(clone_dir): - print("building ospf mdr...") - c.run("./bootstrap.sh", hide=hide) + p = Progress(not hide) + with p.start("installing ospf mdr dependencies"): + if os_info.like == OsLike.DEBIAN: + c.run("sudo apt install -y libtool gawk libreadline-dev git", hide=hide) + elif os_info.like == OsLike.REDHAT: + c.run("sudo yum install -y libtool gawk readline-devel git", hide=hide) + clone_dir = "/tmp/ospf-mdr" c.run( - "./configure --disable-doc --enable-user=root --enable-group=root " - "--with-cflags=-ggdb --sysconfdir=/usr/local/etc/quagga --enable-vtysh " - "--localstatedir=/var/run/quagga", + f"git clone https://github.com/USNavalResearchLaboratory/ospf-mdr {clone_dir}", hide=hide ) - c.run("make -j$(nproc)", hide=hide) - print("installing ospf mdr...") - c.run("sudo make install", hide=hide) + with c.cd(clone_dir): + c.run("./bootstrap.sh", hide=hide) + c.run( + "./configure --disable-doc --enable-user=root --enable-group=root " + "--with-cflags=-ggdb --sysconfdir=/usr/local/etc/quagga --enable-vtysh " + "--localstatedir=/var/run/quagga", + hide=hide + ) + c.run("make -j$(nproc)", hide=hide) + c.run("sudo make install", hide=hide) @task @@ -172,7 +206,6 @@ def install_service(c, verbose=False, prefix=DEFAULT_PREFIX): systemd_dir = Path("/lib/systemd/system/") service_file = systemd_dir.joinpath("core-daemon.service") if systemd_dir.exists(): - print(f"installing core-daemon.service for systemd to {service_file}") service_data = inspect.cleandoc(f""" [Unit] Description=Common Open Research Emulator Service @@ -204,7 +237,6 @@ def install_scripts(c, verbose=False, prefix=DEFAULT_PREFIX): bin_dir = Path(prefix).joinpath("bin") for script in Path("daemon/scripts").iterdir(): dest = bin_dir.joinpath(script.name) - print(f"installing {script} to {dest}") with open(script, "r") as f: lines = f.readlines() first = lines[0].strip() @@ -224,7 +256,6 @@ def install_scripts(c, verbose=False, prefix=DEFAULT_PREFIX): # install core configuration file config_dir = "/etc/core" - print(f"installing core configuration files under {config_dir}") c.run(f"sudo mkdir -p {config_dir}", hide=hide) c.run(f"sudo cp -n daemon/data/core.conf {config_dir}", hide=hide) c.run(f"sudo cp -n daemon/data/logging.conf {config_dir}", hide=hide) @@ -235,22 +266,28 @@ def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): """ install core, poetry, scripts, service, and ospf mdr """ + c.run("sudo -v", hide=True) print(f"installing core with prefix: {prefix}") + p = Progress(verbose) hide = not verbose os_info = get_os() - install_system(c, os_info, hide) - install_grpcio(c, hide) - build(c, hide, prefix) - install_core(c, hide) - install_poetry(c, dev, hide) - install_scripts(c, hide, prefix) - install_service(c, hide, prefix) - install_ospf_mdr(c, os_info, hide) - print("please open a new terminal or re-login to leverage invoke for running core") - print("# run daemon") - print("inv daemon") - print("# run gui") - print("inv gui") + with p.start("installing system dependencies"): + install_system(c, os_info, hide) + with p.start("installing system grpcio-tools"): + install_grpcio(c, hide) + with p.start("building core"): + build_core(c, hide, prefix) + with p.start("installing vcmd/gui"): + install_core(c, hide) + with p.start("installing poetry virtual environment"): + install_poetry(c, dev, hide) + with p.start("installing scripts and /etc/core"): + install_scripts(c, hide, prefix) + with p.start("installing systemd service"): + install_service(c, hide, prefix) + with p.start("installing ospf mdr"): + install_ospf_mdr(c, os_info, hide) + print("\nyou may need to open a new terminal to leverage invoke for running core") @task @@ -259,35 +296,39 @@ def uninstall(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): uninstall core """ hide = not verbose - print("uninstalling core") - c.run("sudo make uninstall", hide=hide) - print("cleaning build directory") - c.run("make clean", hide=hide) - c.run("./bootstrap.sh clean", hide=hide) + p = Progress(verbose) + c.run("sudo -v", hide=True) + with p.start("uninstalling core"): + c.run("sudo make uninstall", hide=hide) + + with p.start("cleaning build directory"): + c.run("make clean", hide=hide) + c.run("./bootstrap.sh clean", hide=hide) + python = get_python(c, warn=True) if python: with c.cd(DAEMON_DIR): if dev: - print("uninstalling pre-commit") - c.run("poetry run pre-commit uninstall", hide=hide) - print("uninstalling poetry virtual environment") - c.run(f"poetry env remove {python}", hide=hide) + with p.start("uninstalling pre-commit"): + c.run("poetry run pre-commit uninstall", hide=hide) + with p.start("uninstalling poetry virtual environment"): + c.run(f"poetry env remove {python}", hide=hide) # remove installed files bin_dir = Path(prefix).joinpath("bin") - for script in Path("daemon/scripts").iterdir(): - dest = bin_dir.joinpath(script.name) - print(f"uninstalling {dest}") - c.run(f"sudo rm -f {dest}", hide=hide) + with p.start("uninstalling script files"): + for script in Path("daemon/scripts").iterdir(): + dest = bin_dir.joinpath(script.name) + c.run(f"sudo rm -f {dest}", hide=hide) # install service systemd_dir = Path("/lib/systemd/system/") service_name = "core-daemon.service" service_file = systemd_dir.joinpath(service_name) if service_file.exists(): - print(f"uninstalling service {service_file}") - c.run(f"sudo systemctl disable {service_name}", hide=hide) - c.run(f"sudo rm -f {service_file}", hide=hide) + with p.start(f"uninstalling service {service_file}"): + c.run(f"sudo systemctl disable {service_name}", hide=hide) + c.run(f"sudo rm -f {service_file}", hide=hide) @task From 119a3640e4b8a443bb1b21995c277452347c83e4 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 14 Jul 2020 22:26:39 -0700 Subject: [PATCH 313/929] remove duplicated progress usage when installing ospf mdr --- tasks.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/tasks.py b/tasks.py index 0b727f58..a8e8b371 100644 --- a/tasks.py +++ b/tasks.py @@ -173,27 +173,25 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: if c.run("which zebra", warn=True, hide=hide): print("\nquagga already installed, skipping ospf mdr") return - p = Progress(not hide) - with p.start("installing ospf mdr dependencies"): - if os_info.like == OsLike.DEBIAN: - c.run("sudo apt install -y libtool gawk libreadline-dev git", hide=hide) - elif os_info.like == OsLike.REDHAT: - c.run("sudo yum install -y libtool gawk readline-devel git", hide=hide) - clone_dir = "/tmp/ospf-mdr" + if os_info.like == OsLike.DEBIAN: + c.run("sudo apt install -y libtool gawk libreadline-dev git", hide=hide) + elif os_info.like == OsLike.REDHAT: + c.run("sudo yum install -y libtool gawk readline-devel git", hide=hide) + clone_dir = "/tmp/ospf-mdr" + c.run( + f"git clone https://github.com/USNavalResearchLaboratory/ospf-mdr {clone_dir}", + hide=hide + ) + with c.cd(clone_dir): + c.run("./bootstrap.sh", hide=hide) c.run( - f"git clone https://github.com/USNavalResearchLaboratory/ospf-mdr {clone_dir}", + "./configure --disable-doc --enable-user=root --enable-group=root " + "--with-cflags=-ggdb --sysconfdir=/usr/local/etc/quagga --enable-vtysh " + "--localstatedir=/var/run/quagga", hide=hide ) - with c.cd(clone_dir): - c.run("./bootstrap.sh", hide=hide) - c.run( - "./configure --disable-doc --enable-user=root --enable-group=root " - "--with-cflags=-ggdb --sysconfdir=/usr/local/etc/quagga --enable-vtysh " - "--localstatedir=/var/run/quagga", - hide=hide - ) - c.run("make -j$(nproc)", hide=hide) - c.run("sudo make install", hide=hide) + c.run("make -j$(nproc)", hide=hide) + c.run("sudo make install", hide=hide) @task From a1ea762b8956488a1a97c5a05396ca0e67423e01 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 15 Jul 2020 00:08:22 -0700 Subject: [PATCH 314/929] updates to help provide better install related documentation --- docs/install.md | 65 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/install.md b/docs/install.md index 2bf1a3b1..5e8bddf0 100644 --- a/docs/install.md +++ b/docs/install.md @@ -53,11 +53,14 @@ sudo modprobe sch_netem ## Automated Installation -> **NOTE:** installs OSPF MDR +The automated install will install the various tools needed to help automate +the CORE installation (python3, pip, pipx, invoke, poetry). The script will +also automatically clone, build, and install the latest version of OSPF MDR. +Finally it will install CORE scripts and a systemd service, which have +been modified to use the installed poetry created virtual environment. -> **NOTE:** sets up script files using the prefix provided - -> **NOTE:** install a systemd service file to /lib/systemd/system/core-daemon.service +After installation has completed you should be able to run the various +CORE scripts for running core. ```shell # clone CORE repo @@ -75,6 +78,13 @@ cd core ## Manual Installation +Below is an example of more formal manual steps that can be taken to install +CORE. You can also just install invoke and run `inv install` alone to simulate +what is done using `install.sh`. + +The last two steps help install core scripts modified to leverage the installed +poetry virtual environment and setup a systemd based service, if desired. + > **NOTE:** install OSPF MDR by manual instructions below ```shell @@ -131,6 +141,24 @@ inv install-scripts inv install-service ``` +## Installed Scripts + +These scripts will be installed from the automated `install.sh` script or +using `inv install` manually. + +| Name | Description | +|---|---| +| core-daemon | runs the backed core server providing TLV and gRPC APIs | +| core-gui | runs the legacy tcl/tk based GUI | +| core-pygui | runs the new python/tk based GUI | +| core-cleanup | tool to help removed lingering core created containers, bridges, directories | +| core-imn-to-xml | tool to help automate converting a .imn file to .xml format | +| core-route-monitor | tool to help monitor traffic across nodes and feed that to SDT | +| core-service-update | tool to update automate modifying a legacy service to match current naming | +| coresendmsg | tool to send TLV API commands from command line | +| core-cli | tool to query, open xml files, and send commands using gRPC | +| core-manage | tool to add, remove, or check for services, models, and node types | + ## Manually Install OSPF MDR (Routing Support) Virtual networks generally require some form of routing in order to work (e.g. to automatically populate routing @@ -171,10 +199,10 @@ Here are quick instructions for installing all EMANE packages for Ubuntu 18.04: sudo apt-get install libssl-dev libxml-libxml-perl libxml-simple-perl wget https://adjacentlink.com/downloads/emane/emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz tar xzf emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz -# install base emane packages -sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/emane*.deb -# install python3 bindings -sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/python3*.deb + +# install emane python bindings into the core virtual environment +cd $REPO/daemon +poetry run pip install $EMANE_REPO/src/python ``` ## Using Invoke Tasks @@ -220,3 +248,24 @@ core-cli query session -i 1 # exit the shell exit ``` + +## Running User Scripts + +If you create your own scripts to run CORE directly in python or using gRPC/TLV +APIs you will need to make sure you are running them within context of the +poetry install virtual environment. + +> **NOTE:** the following assumes CORE has been installed successfully + +One way to do this would be to enable to environments shell. +```shell +cd $REPO/daemon +poetry shell +python run /path/to/script.py +``` + +Another way would be to run the script directly by way of poetry. +```shell +cd $REPO/daemon +poetry run python /path/to/script.py +``` From 642af4fe47ad70fff9fa67195c8cf1c6e921c26a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 15 Jul 2020 16:22:03 -0700 Subject: [PATCH 315/929] slimmed down install documentation and added links to relevant tools and files --- docs/install.md | 183 ++++++++++++++---------------------------------- 1 file changed, 53 insertions(+), 130 deletions(-) diff --git a/docs/install.md b/docs/install.md index 5e8bddf0..0bff31c9 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,4 +1,4 @@ -# CORE Installation +# Installation * Table of Contents {:toc} @@ -12,10 +12,10 @@ The following tools will be leveraged during installation: |Tool|Description| |---|---| -|pip|used to install pipx| -|pipx|used to install standalone python tools (invoke, poetry)| -|invoke|used to run provided tasks (install, daemon, gui, tests, etc)| -|poetry|used to install the managed python virtual environment for running CORE| +|[pip](https://pip.pypa.io/en/stable/)|used to install pipx| +|[pipx](https://pipxproject.github.io/pipx/)|used to install standalone python tools (invoke, poetry)| +|[invoke](http://www.pyinvoke.org/)|used to run provided tasks (install, daemon, gui, tests, etc)| +|[poetry](https://python-poetry.org/)|used to install the managed python virtual environment for running CORE| ## Required Hardware @@ -51,6 +51,27 @@ sudo modprobe sch_netem * iproute2 4.5+ is a requirement for bridge related commands * ebtables not backed by nftables +## Upgrading + +Please make sure to uninstall the previous installation of CORE cleanly +before proceeding to install. + +Previous install was built from source: +```shell +cd $REPO +sudo make uninstall +make clean +./bootstrap.sh clean +``` + +Installed from previously built packages: +```shell +# centos +sudo yum remove core +# ubuntu +sudo apt remove core +``` + ## Automated Installation The automated install will install the various tools needed to help automate @@ -62,6 +83,9 @@ been modified to use the installed poetry created virtual environment. After installation has completed you should be able to run the various CORE scripts for running core. +> **NOTE:** provide a prefix that will be found on path when running as sudo +> if the default prefix is not valid + ```shell # clone CORE repo git clone https://github.com/coreemu/core.git @@ -76,75 +100,20 @@ cd core ./install.sh ``` -## Manual Installation +### Unsupported Linux Distribution -Below is an example of more formal manual steps that can be taken to install -CORE. You can also just install invoke and run `inv install` alone to simulate -what is done using `install.sh`. +If you are on an unsupported distribution, you can look into the +[install.sh](https://github.com/coreemu/core/blob/master/install.sh) +and +[tasks.py](https://github.com/coreemu/core/blob/master/tasks.py) +files to see the various commands ran to install CORE and translate them to +your use case, assuming it is possible. -The last two steps help install core scripts modified to leverage the installed -poetry virtual environment and setup a systemd based service, if desired. - -> **NOTE:** install OSPF MDR by manual instructions below - -```shell -# clone CORE repo -git clone https://github.com/coreemu/core.git -cd core - -# install python3 and venv support -# ubuntu -sudo apt install -y python3-pip python3-venv -# centos -sudo yum install -y python3-pip - -# install system dependencies -# ubuntu -sudo apt install -y automake pkg-config gcc libev-dev ebtables iproute2 \ - ethtool tk python3-tk -# centos -sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ libev-devel \ - iptables-ebtables iproute python3-devel python3-tkinter tk ethtool \ - make kernel-modules-extra - -# install grpcio-tools -python3 -m pip install --user grpcio==1.27.2 grpcio-tools==1.27.2 - -# build core -./bootstrap.sh -# centos requires --prefix=/usr -./configure -make -sudo make install - -# install pipx, may need to restart terminal after ensurepath -python3 -m pip install --user pipx -python3 -m pipx ensurepath - -# install poetry -pipx install poetry - -# install poetry virtual environment -cd daemon -poetry install --no-dev -cd .. - -# install invoke to run helper tasks -pipx install invoke - -# install core scripts leveraging poetry virtual environment -# centos requires --prefix=/usr -inv install-scripts - -# optionally install systemd service file -# centos requires --prefix=/usr -inv install-service -``` +If you get install down entirely, feel free to contribute and help others. ## Installed Scripts -These scripts will be installed from the automated `install.sh` script or -using `inv install` manually. +After the installation complete it will have installed the following scripts. | Name | Description | |---|---| @@ -159,32 +128,25 @@ using `inv install` manually. | core-cli | tool to query, open xml files, and send commands using gRPC | | core-manage | tool to add, remove, or check for services, models, and node types | -## Manually Install OSPF MDR (Routing Support) +## Running User Scripts -Virtual networks generally require some form of routing in order to work (e.g. to automatically populate routing -tables for routing packets from one subnet to another.) CORE builds OSPF routing protocol configurations by -default when the blue router node type is used. +If you create your own python scripts to run CORE directly or using the gRPC/TLV +APIs you will need to make sure you are running them within context of the +installed virtual environment. -* [OSPF MANET Designated Routers](https://github.com/USNavalResearchLaboratory/ospf-mdr) (MDR) - the Quagga routing -suite with a modified version of OSPFv3, optimized for use with mobile wireless networks. The **mdr** node type -(and the MDR service) requires this variant of Quagga. +> **NOTE:** the following assumes CORE has been installed successfully +One way to do this would be to enable the core virtual environment shell. ```shell -# system dependencies -# ubuntu -sudo apt install -y libtool gawk libreadline-dev -# centos -sudo yum install -y libtool gawk readline-devel +cd $REPO/daemon +poetry shell +python run /path/to/script.py +``` -# build and install -git clone https://github.com/USNavalResearchLaboratory/ospf-mdr -cd ospf-mdr -./bootstrap.sh -./configure --disable-doc --enable-user=root --enable-group=root --with-cflags=-ggdb \ - --sysconfdir=/usr/local/etc/quagga --enable-vtysh \ - --localstatedir=/var/run/quagga -make -sudo make install +Another way would be to run the script directly by way of poetry. +```shell +cd $REPO/daemon +poetry run python /path/to/script.py ``` ## Manually Install EMANE @@ -217,7 +179,7 @@ Available tasks: cli run core-cli used to query and modify a running session daemon start core-daemon gui start core-pygui - install install core, poetry, scripts, service, and ospf mdr + install install core, scripts, service, and ospf mdr install-scripts install core script files, modified to leverage virtual environment install-service install systemd core service test run core tests @@ -230,42 +192,3 @@ Example running the core-daemon task from the root of the repo: ```shell inv daemon ``` - -Some tasks are wrappers around command line tools and requires running -them with a slight variation for compatibility. You can enter the -poetry shell to run the script natively. - -```shell -# running core-cli as a task requires all options to be provided -# within a string -inv cli "query session -i 1" - -# entering the poetry shell to use core-cli natively -cd $REPO/daemon -poetry shell -core-cli query session -i 1 - -# exit the shell -exit -``` - -## Running User Scripts - -If you create your own scripts to run CORE directly in python or using gRPC/TLV -APIs you will need to make sure you are running them within context of the -poetry install virtual environment. - -> **NOTE:** the following assumes CORE has been installed successfully - -One way to do this would be to enable to environments shell. -```shell -cd $REPO/daemon -poetry shell -python run /path/to/script.py -``` - -Another way would be to run the script directly by way of poetry. -```shell -cd $REPO/daemon -poetry run python /path/to/script.py -``` From 1c876819f19977ae9da5fb220a0e3547197a1b78 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 15 Jul 2020 16:50:04 -0700 Subject: [PATCH 316/929] task to automate installing emane --- tasks.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tasks.py b/tasks.py index a8e8b371..4b6f2ca6 100644 --- a/tasks.py +++ b/tasks.py @@ -288,6 +288,49 @@ def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): print("\nyou may need to open a new terminal to leverage invoke for running core") +@task +def install_emane(c, verbose=False): + """ + install emane and the python bindings + """ + c.run("sudo -v", hide=True) + p = Progress(verbose) + hide = not verbose + os_info = get_os() + emane_dir = "/tmp/emane" + with p.start("installing system dependencies"): + if os_info.like == OsLike.DEBIAN: + c.run( + "sudo apt install gcc g++ automake libtool libxml2-dev libprotobuf-dev " + "libpcap-dev libpcre3-dev uuid-dev pkg-config protobuf-compiler git " + "python3-protobuf python3-setuptools", + hide=hide, + ) + elif os_info.like == OsLike.REDHAT: + c.run( + "sudo yum install autoconf automake git libtool libxml2-devel " + "libpcap-devel pcre-devel libuuid-devel make gcc-c++ " + "python3-setuptools", + hide=hide, + ) + with p.start("cloning emane"): + c.run( + f"git clone https://github.com/adjacentlink/emane.git {emane_dir}", + hide=hide + ) + with p.start("building emane"): + with c.cd(emane_dir): + c.run("./autogen.sh", hide=hide) + c.run("PYTHON=python3 ./configure --prefix=/usr", hide=hide) + c.run("make -j$(nproc)", hide=hide) + with p.start("installing emane"): + with c.cd(emane_dir): + c.run("sudo make install", hide=hide) + with p.start("installing python binding for core"): + with c.cd(DAEMON_DIR): + c.run(f"poetry run pip install {emane_dir}/src/python", hide=hide) + + @task def uninstall(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): """ From 33d100acffb8716f396e29c069a9dbf155c7d98b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 15 Jul 2020 17:09:32 -0700 Subject: [PATCH 317/929] fix bad links in generated docs for grpc to point to latest on master --- docs/grpc.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/grpc.md b/docs/grpc.md index 69cf4aed..ca80256e 100644 --- a/docs/grpc.md +++ b/docs/grpc.md @@ -15,20 +15,23 @@ properly account for this issue or clear out your proxy when running if needed. ## Python Client A python client wrapper is provided at -[CoreGrpcClient](../daemon/core/api/grpc/client.py) to help provide some -conveniences when using the API. +[CoreGrpcClient](https://github.com/coreemu/core/blob/master/daemon/core/api/grpc/client.py) +to help provide some conveniences when using the API. ## Proto Files Proto files are used to define the API and protobuf messages that are used for interfaces with this API. -They can be found [here](../daemon/proto/core/api/grpc) to see the specifics of +They can be found +[here](https://github.com/coreemu/core/tree/master/daemon/proto/core/api/grpc) +to see the specifics of what is going on and response message values that would be returned. ## Examples -Example usage of this API can be found [here](../daemon/examples/grpc). These -examples will create a session using the gRPC API when the core-daemon is running. +Example usage of this API can be found +[here](https://github.com/coreemu/core/tree/master/daemon/examples/grpc). +These examples will create a session using the gRPC API when the core-daemon is running. You can then switch to and attach to these sessions using either of the CORE GUIs. From 897ecc6d356c07041b71f1ba35b28c9f3238cee0 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 15 Jul 2020 17:52:34 -0700 Subject: [PATCH 318/929] updated install emane task to auto answer yes to installing system packages --- tasks.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tasks.py b/tasks.py index 4b6f2ca6..6f5b7ac3 100644 --- a/tasks.py +++ b/tasks.py @@ -301,14 +301,14 @@ def install_emane(c, verbose=False): with p.start("installing system dependencies"): if os_info.like == OsLike.DEBIAN: c.run( - "sudo apt install gcc g++ automake libtool libxml2-dev libprotobuf-dev " - "libpcap-dev libpcre3-dev uuid-dev pkg-config protobuf-compiler git " - "python3-protobuf python3-setuptools", + "sudo apt install -y gcc g++ automake libtool libxml2-dev " + "libprotobuf-dev libpcap-dev libpcre3-dev uuid-dev pkg-config " + "protobuf-compiler git python3-protobuf python3-setuptools", hide=hide, ) elif os_info.like == OsLike.REDHAT: c.run( - "sudo yum install autoconf automake git libtool libxml2-devel " + "sudo yum install -y autoconf automake git libtool libxml2-devel " "libpcap-devel pcre-devel libuuid-devel make gcc-c++ " "python3-setuptools", hide=hide, From 495fbe5632731168ba02fc233fcc78dfc4536815 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 15 Jul 2020 21:50:35 -0700 Subject: [PATCH 319/929] added protobuf-compiler to install emane task --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index 6f5b7ac3..d044deb8 100644 --- a/tasks.py +++ b/tasks.py @@ -309,7 +309,7 @@ def install_emane(c, verbose=False): elif os_info.like == OsLike.REDHAT: c.run( "sudo yum install -y autoconf automake git libtool libxml2-devel " - "libpcap-devel pcre-devel libuuid-devel make gcc-c++ " + "libpcap-devel pcre-devel libuuid-devel make gcc-c++ protobuf-compiler " "python3-setuptools", hide=hide, ) From c884ee27cd08b51607e6a694efe257ab93b2ecbe Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 08:42:36 -0700 Subject: [PATCH 320/929] removed invoke tasks wrapping scripts, since they can be used directly, added invoke task help strings, add invoke task to run user scripts --- tasks.py | 75 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/tasks.py b/tasks.py index d044deb8..1dde1cea 100644 --- a/tasks.py +++ b/tasks.py @@ -194,7 +194,12 @@ def install_ospf_mdr(c: Context, os_info: OsInfo, hide: bool) -> None: c.run("sudo make install", hide=hide) -@task +@task( + help={ + "verbose": "enable verbose", + "prefix": f"prefix where scripts are installed, default is {DEFAULT_PREFIX}" + }, +) def install_service(c, verbose=False, prefix=DEFAULT_PREFIX): """ install systemd core service @@ -225,7 +230,12 @@ def install_service(c, verbose=False, prefix=DEFAULT_PREFIX): print(f"ERROR: systemd service path not found: {systemd_dir}") -@task +@task( + help={ + "verbose": "enable verbose", + "prefix": f"prefix where scripts are installed, default is {DEFAULT_PREFIX}" + }, +) def install_scripts(c, verbose=False, prefix=DEFAULT_PREFIX): """ install core script files, modified to leverage virtual environment @@ -259,7 +269,13 @@ def install_scripts(c, verbose=False, prefix=DEFAULT_PREFIX): c.run(f"sudo cp -n daemon/data/logging.conf {config_dir}", hide=hide) -@task +@task( + help={ + "dev": "install development mode", + "verbose": "enable verbose", + "prefix": f"prefix where scripts are installed, default is {DEFAULT_PREFIX}" + }, +) def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): """ install core, poetry, scripts, service, and ospf mdr @@ -288,7 +304,11 @@ def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): print("\nyou may need to open a new terminal to leverage invoke for running core") -@task +@task( + help={ + "verbose": "enable verbose", + }, +) def install_emane(c, verbose=False): """ install emane and the python bindings @@ -331,7 +351,13 @@ def install_emane(c, verbose=False): c.run(f"poetry run pip install {emane_dir}/src/python", hide=hide) -@task +@task( + help={ + "dev": "uninstall development mode", + "verbose": "enable verbose", + "prefix": f"prefix where scripts are installed, default is {DEFAULT_PREFIX}" + }, +) def uninstall(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): """ uninstall core @@ -386,31 +412,26 @@ def daemon(c): ) -@task -def gui(c): +@task( + help={ + "sudo": "run script as sudo", + "file": "script file to run in the core virtual environment" + }, +) +def run(c, file, sudo=False): """ - start core-pygui + convenience for running a core related script """ + if not file: + print("no script was provided") + return + python = get_python(c) + path = Path(file).absolute() with c.cd(DAEMON_DIR): - c.run("poetry run scripts/core-pygui", pty=True) - - -@task -def cli(c, args): - """ - run core-cli used to query and modify a running session - """ - with c.cd(DAEMON_DIR): - c.run(f"poetry run scripts/core-cli {args}", pty=True) - - -@task -def cleanup(c): - """ - run core-cleanup removing leftover core nodes, bridges, directories - """ - print("running core-cleanup...") - c.run(f"sudo daemon/scripts/core-cleanup", pty=True) + cmd = f"{python} {path}" + if sudo: + cmd = f"sudo {cmd}" + c.run(cmd, pty=True) @task From d1fd19edc6cd739a845e3722fa9e296a858c125a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 08:47:18 -0700 Subject: [PATCH 321/929] updated doc examples for invoke tasks --- docs/install.md | 30 +++++++++++++++++++++++------- tasks.py | 4 ++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/docs/install.md b/docs/install.md index 0bff31c9..6a9fb393 100644 --- a/docs/install.md +++ b/docs/install.md @@ -173,22 +173,38 @@ The invoke tool installed by way of pipx provides conveniences for running CORE tasks to help ensure usage of the create python virtual environment. ```shell +inv --list + Available tasks: - cleanup run core-cleanup removing leftover core nodes, bridges, directories - cli run core-cli used to query and modify a running session daemon start core-daemon - gui start core-pygui - install install core, scripts, service, and ospf mdr + install install core, poetry, scripts, service, and ospf mdr + install-emane install emane and the python bindings install-scripts install core script files, modified to leverage virtual environment install-service install systemd core service + run runs a user script in the core virtual environment test run core tests test-emane run core emane tests test-mock run core tests using mock to avoid running as sudo - uninstall uninstall core + uninstall uninstall core, scripts, service, virtual environment, and clean build directory ``` -Example running the core-daemon task from the root of the repo: +Print help for a given task: ```shell -inv daemon +inv -h install + +Usage: inv[oke] [--core-opts] install [--options] [other tasks here ...] + +Docstring: + install core, poetry, scripts, service, and ospf mdr + +Options: + -d, --dev install development mode + -p STRING, --prefix=STRING prefix where scripts are installed, default is /usr/local + -v, --verbose enable verbose +``` + +Example running a core user script: +```shell +inv run /path/to/core/grpc/script.py ``` diff --git a/tasks.py b/tasks.py index 1dde1cea..e1b539a4 100644 --- a/tasks.py +++ b/tasks.py @@ -360,7 +360,7 @@ def install_emane(c, verbose=False): ) def uninstall(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): """ - uninstall core + uninstall core, scripts, service, virtual environment, and clean build directory """ hide = not verbose p = Progress(verbose) @@ -420,7 +420,7 @@ def daemon(c): ) def run(c, file, sudo=False): """ - convenience for running a core related script + runs a user script in the core virtual environment """ if not file: print("no script was provided") From 1212e5ddf8dce4bf797b8fe2be3c3486020e5943 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 08:59:57 -0700 Subject: [PATCH 322/929] fix to avoid setting interface data for a mac to the string None, when not present --- daemon/core/nodes/base.py | 3 ++- daemon/core/nodes/network.py | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index 7f444480..cea1e81b 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -1050,8 +1050,9 @@ class CoreNetworkBase(NodeBase): if uni: unidirectional = 1 + mac = str(iface.mac) if iface.mac else None iface2_data = InterfaceData( - id=linked_node.get_iface_id(iface), name=iface.name, mac=str(iface.mac) + id=linked_node.get_iface_id(iface), name=iface.name, mac=mac ) ip4 = iface.get_ip4() if ip4: diff --git a/daemon/core/nodes/network.py b/daemon/core/nodes/network.py index a55de4cf..58c1e195 100644 --- a/daemon/core/nodes/network.py +++ b/daemon/core/nodes/network.py @@ -875,8 +875,9 @@ class PtpNet(CoreNetwork): if iface1.getparams() != iface2.getparams(): unidirectional = 1 + mac = str(iface1.mac) if iface1.mac else None iface1_data = InterfaceData( - id=iface1.node.get_iface_id(iface1), name=iface1.name, mac=str(iface1.mac) + id=iface1.node.get_iface_id(iface1), name=iface1.name, mac=mac ) ip4 = iface1.get_ip4() if ip4: @@ -887,8 +888,9 @@ class PtpNet(CoreNetwork): iface1_data.ip6 = str(ip6.ip) iface1_data.ip6_mask = ip6.prefixlen + mac = str(iface2.mac) if iface2.mac else None iface2_data = InterfaceData( - id=iface2.node.get_iface_id(iface2), name=iface2.name, mac=str(iface2.mac) + id=iface2.node.get_iface_id(iface2), name=iface2.name, mac=mac ) ip4 = iface2.get_ip4() if ip4: From 0be1972a29c7600ffce663348f169b0027deffe7 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 09:16:32 -0700 Subject: [PATCH 323/929] update to running user scripts in install doc --- docs/install.md | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/docs/install.md b/docs/install.md index 6a9fb393..6129c17f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -136,19 +136,28 @@ installed virtual environment. > **NOTE:** the following assumes CORE has been installed successfully -One way to do this would be to enable the core virtual environment shell. +There is an invoke task to help with this case. +```shell +cd $REPO +inv -h run +Usage: inv[oke] [--core-opts] run [--options] [other tasks here ...] + +Docstring: + runs a user script in the core virtual environment + +Options: + -f STRING, --file=STRING script file to run in the core virtual environment + -s, --sudo run script as sudo +``` + +Another way would be to enable the core virtual environment shell. Which +would allow you to run scripts in a more **normal** way. ```shell cd $REPO/daemon poetry shell python run /path/to/script.py ``` -Another way would be to run the script directly by way of poetry. -```shell -cd $REPO/daemon -poetry run python /path/to/script.py -``` - ## Manually Install EMANE EMANE can be installed from deb or RPM packages or from source. See the @@ -203,8 +212,3 @@ Options: -p STRING, --prefix=STRING prefix where scripts are installed, default is /usr/local -v, --verbose enable verbose ``` - -Example running a core user script: -```shell -inv run /path/to/core/grpc/script.py -``` From b50f05837476e1b524e8cd465bc98bb1865c8ee7 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 09:26:08 -0700 Subject: [PATCH 324/929] improved emane section in install doc --- docs/install.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docs/install.md b/docs/install.md index 6129c17f..12d47802 100644 --- a/docs/install.md +++ b/docs/install.md @@ -163,15 +163,23 @@ python run /path/to/script.py EMANE can be installed from deb or RPM packages or from source. See the [EMANE GitHub](https://github.com/adjacentlink/emane) for full details. -Here are quick instructions for installing all EMANE packages for Ubuntu 18.04: -```shell -# install dependencies -# ubuntu -sudo apt-get install libssl-dev libxml-libxml-perl libxml-simple-perl -wget https://adjacentlink.com/downloads/emane/emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz -tar xzf emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz +There is an invoke task to help with installing EMANE, but has issues, +which attempts to build EMANE from source, but has issue on systems with + older protobuf-compilers. -# install emane python bindings into the core virtual environment +```shell +cd $REPO +inv install-emane +``` + +Alternatively, you can +[build EMANE](https://github.com/adjacentlink/emane/wiki/Build) +from source and install the python +bindings into the core virtual environment. + +The following would install the EMANE python bindings after being +successfully built. +```shell cd $REPO/daemon poetry run pip install $EMANE_REPO/src/python ``` From db4ef2b42e3582b74d17f01b0f3a9f747b8c97c2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 10:02:56 -0700 Subject: [PATCH 325/929] fixed core.conf commented out example path for core-pygui to use .coregui instead of .coretk --- daemon/data/core.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/data/core.conf b/daemon/data/core.conf index 5ff0be7f..20ee5d1f 100644 --- a/daemon/data/core.conf +++ b/daemon/data/core.conf @@ -13,7 +13,7 @@ frr_sbin_search = "/usr/local/sbin /usr/sbin /usr/lib/frr" # this may be a comma-separated list, and directory names should be unique # and not named 'services' #custom_services_dir = /home/username/.core/myservices -#custom_config_services_dir = /home/username/.coretk/custom_services +#custom_config_services_dir = /home/username/.coregui/custom_services # uncomment to establish a standalone control backchannel for accessing nodes # (overriden by the session option of the same name) From 6b550618572fe175ffe9a8178ec93a6cc715c64a Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 10:09:26 -0700 Subject: [PATCH 326/929] update dev gui doc for new installation --- docs/devguide.md | 59 ++++++++++++------------------------------------ 1 file changed, 15 insertions(+), 44 deletions(-) diff --git a/docs/devguide.md b/docs/devguide.md index c10bb007..9b9d61c8 100644 --- a/docs/devguide.md +++ b/docs/devguide.md @@ -16,7 +16,6 @@ daemon. Here is a brief description of the source directories. |gui|Tcl/Tk GUI| |man|Template files for creating man pages for various CORE command line utilities| |netns|C program for creating CORE containers| -|scripts|Template files used for running CORE as a service| ## Getting started @@ -34,21 +33,11 @@ git checkout develop ## Install the Development Environment This command will automatically install system dependencies, clone and build OSPF-MDR, -build CORE, setup the CORE pipenv environment, and install pre-commit hooks. - -This script is currently compatible with Ubuntu and CentOS, tested on Ubuntu 18.04 and -CentOS 7.6. The script also currently defaults to using python3.6, but a different -version of python can be targeted if python3.6 is not available on your system. +build CORE, setup the CORE poetry environment, and install pre-commit hooks. You can +refer to the [install docs](install.md) for issues related to different distributions. ```shell -# default dev install using python3.6 -./install.sh -d - -# providing a newer python version for ubuntu -./install.sh -d -v 3.7 - -# providing a newer python version for centos -./install.sh -d -v 37 +./install -d ``` ### pre-commit @@ -57,42 +46,24 @@ pre-commit hooks help automate running tools to check modified code. Every time python utilities will be ran to check validity of code, potentially failing and backing out the commit. These changes are currently mandated as part of the current CI, so add the changes and commit again. -### Adding EMANE to Pipenv - -EMANE bindings are not available through pip, you will need to build and install from source. - -[Build EMANE](https://github.com/adjacentlink/emane/wiki/Build#general-build-instructions) - -```shell -# clone emane repo -git clone https://github.com/adjacentlink/emane.git - -# install emane build deps -sudo apt install libxml2-dev libprotobuf-dev uuid-dev libpcap-dev protobuf-compiler - -# build emane -./autogen.sh -./configure --prefix=/usr -make -j8 - -# install emane binding in pipenv -# NOTE: this will mody pipenv Pipfiles and we do not want that, use git checkout -- Pipfile*, to remove changes -python3 -m pipenv pip install $EMANEREPO/src/python -``` - ## Running CORE -Commands below can be used to run the core-daemon, the new core gui, and tests. +You can now run core as you normally would, or leverage some of the invoke tasks to +conveniently run tests, etc. ```shell -# runs for daemon -sudo python3 -m pipenv run core +# run core-daemon +sudo core-daemon -# runs coretk gui -python3 -m pipenv run core-pygui +# run python gui +core-pygui -# runs mocked unit tests -python3 -m pipenv run test-mock +# run tcl gui +core-gui + +# run mocked unit tests +cd $REPO +inv test-mock ``` ## Linux Network Namespace Commands From 1c2d7c6d12be0cf3be9b3822b88d373af933fc36 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 10:35:16 -0700 Subject: [PATCH 327/929] added reinstall invoke task, added some simple detections for old core installations in install task --- tasks.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tasks.py b/tasks.py index e1b539a4..931aaf93 100644 --- a/tasks.py +++ b/tasks.py @@ -107,6 +107,15 @@ def get_os() -> OsInfo: return OsInfo(name, like, version) +def check_existing_core(c: Context, hide: bool) -> None: + if c.run("python -c \"import core\"", warn=True, hide=hide): + raise SystemError("existing python2 core installation detected, please remove") + if c.run("python3 -c \"import core\"", warn=True, hide=hide): + raise SystemError("existing python3 core installation detected, please remove") + if c.run("which core-daemon", warn=True, hide=hide): + raise SystemError("core scripts found, please remove old installation") + + def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: if os_info.like == OsLike.DEBIAN: c.run( @@ -285,6 +294,8 @@ def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): p = Progress(verbose) hide = not verbose os_info = get_os() + with p.start("checking for old installations"): + check_existing_core(c, hide) with p.start("installing system dependencies"): install_system(c, os_info, hide) with p.start("installing system grpcio-tools"): @@ -398,6 +409,33 @@ def uninstall(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): c.run(f"sudo rm -f {service_file}", hide=hide) +@task( + help={ + "dev": "reinstall development mode", + "verbose": "enable verbose", + "prefix": f"prefix where scripts are installed, default is {DEFAULT_PREFIX}", + "branch": "branch to install latest code from, default is current branch" + }, +) +def reinstall(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX, branch=None): + """ + run the uninstall task, get latest from specified branch, and run install task + """ + uninstall(c, dev, verbose, prefix) + hide = not verbose + p = Progress(verbose) + with p.start("pulling latest code"): + current = c.run("git rev-parse --abbrev-ref HEAD", hide=hide).stdout.strip() + if branch and branch != current: + c.run(f"git checkout {branch}") + else: + branch = current + c.run("git pull", hide=hide) + if not Path("tasks.py").exists(): + raise FileNotFoundError(f"missing tasks.py on branch: {branch}") + install(c, dev, verbose, prefix) + + @task def daemon(c): """ From 6219d08416362e5aa6c2a41b53a967270408888f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 11:04:52 -0700 Subject: [PATCH 328/929] enable centos 8 check to enable powertools repo for centos 8 when installing emane --- tasks.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tasks.py b/tasks.py index 931aaf93..5f52f444 100644 --- a/tasks.py +++ b/tasks.py @@ -289,8 +289,8 @@ def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): """ install core, poetry, scripts, service, and ospf mdr """ - c.run("sudo -v", hide=True) print(f"installing core with prefix: {prefix}") + c.run("sudo -v", hide=True) p = Progress(verbose) hide = not verbose os_info = get_os() @@ -338,10 +338,12 @@ def install_emane(c, verbose=False): hide=hide, ) elif os_info.like == OsLike.REDHAT: + if os_info.name == OsName.CENTOS and os_info.version >= 8: + c.run("sudo yum config-manager --set-enabled PowerTools", hide=hide) c.run( "sudo yum install -y autoconf automake git libtool libxml2-devel " "libpcap-devel pcre-devel libuuid-devel make gcc-c++ protobuf-compiler " - "python3-setuptools", + "protobuf-devel python3-setuptools", hide=hide, ) with p.start("cloning emane"): @@ -373,6 +375,7 @@ def uninstall(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): """ uninstall core, scripts, service, virtual environment, and clean build directory """ + print(f"uninstalling core with prefix: {prefix}") hide = not verbose p = Progress(verbose) c.run("sudo -v", hide=True) From 35b4c157a097528718e731a575db483e3fb83793 Mon Sep 17 00:00:00 2001 From: Shawn Kelly O'Shea Date: Thu, 16 Jul 2020 15:22:33 -0400 Subject: [PATCH 329/929] Increase height of options dialogue in TCL gui We have emane models with a large list of options. Without this modification, a user cannot access all of the options provided by the emane model (some of the options are cutoff and cannot be scrolled-down to). --- gui/plugins.tcl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gui/plugins.tcl b/gui/plugins.tcl index 95c1a203..fdb5c454 100644 --- a/gui/plugins.tcl +++ b/gui/plugins.tcl @@ -672,11 +672,11 @@ proc popupCapabilityConfig { channel wlan model types values captions bmp possib pack $windowScroll -fill y -side right pack $windowCanvas -expand yes -fill both -side top - frame $windowCanvas.notebookFrame -width 700 -height 1200 + frame $windowCanvas.notebookFrame -width 700 -height 2400 set notebookFrame $windowCanvas.notebookFrame pack $notebookFrame -fill both -expand yes -padx 5 -pady 5 - ttk::notebook $notebookFrame.vals -width 690 -height 1200 + ttk::notebook $notebookFrame.vals -width 690 -height 2400 set configNotebook $notebookFrame.vals ttk::notebook::enableTraversal $configNotebook pack $configNotebook -fill both -expand yes From fdf00cff0e85bb5f5ce47250364849c51e456438 Mon Sep 17 00:00:00 2001 From: apwiggins Date: Thu, 16 Jul 2020 18:00:12 -0300 Subject: [PATCH 330/929] Update frr.py to add staticd to daemons list Add staticd to the list of possible daemons to be started. http://docs.frrouting.org/en/latest/setup.html#daemons-configuration-file https://github.com/coreemu/core/issues/397 --- daemon/core/services/frr.py | 1 + 1 file changed, 1 insertion(+) diff --git a/daemon/core/services/frr.py b/daemon/core/services/frr.py index 9a344339..e3675bc7 100644 --- a/daemon/core/services/frr.py +++ b/daemon/core/services/frr.py @@ -271,6 +271,7 @@ nhrpd=yes eigrpd=yes babeld=yes sharpd=yes +staticd=yes pbrd=yes bfdd=yes fabricd=yes From 36123e7aa545b9fd04cedfa2169a7ea12612e985 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 14:21:06 -0700 Subject: [PATCH 331/929] updated frr daemons template file for the config service to align with changes to normal service --- daemon/core/configservices/frrservices/templates/daemons | 1 + 1 file changed, 1 insertion(+) diff --git a/daemon/core/configservices/frrservices/templates/daemons b/daemon/core/configservices/frrservices/templates/daemons index 0f6bda53..dbd42108 100644 --- a/daemon/core/configservices/frrservices/templates/daemons +++ b/daemon/core/configservices/frrservices/templates/daemons @@ -20,6 +20,7 @@ nhrpd=yes eigrpd=yes babeld=yes sharpd=yes +staticd=yes pbrd=yes bfdd=yes fabricd=yes From 6d4434bc1274c5aa7612ff289a97b646f6b477cc Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 16 Jul 2020 22:51:26 -0700 Subject: [PATCH 332/929] grpc: added set session user call, updated mobility to look for files within new gui as well, fixed pygui issue when start session has a grpc exceptions, showing and empty error window --- daemon/core/api/grpc/client.py | 14 ++++++++++ daemon/core/api/grpc/server.py | 15 ++++++++++ daemon/core/gui/coreclient.py | 5 ++++ daemon/core/gui/data/xmls/sample1.xml | 2 +- daemon/core/gui/toolbar.py | 2 +- daemon/core/location/mobility.py | 40 +++++++++++++-------------- daemon/proto/core/api/grpc/core.proto | 11 ++++++++ 7 files changed, 66 insertions(+), 23 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 20e193eb..3e974233 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -414,6 +414,20 @@ class CoreGrpcClient: request = core_pb2.SetSessionStateRequest(session_id=session_id, state=state) return self.stub.SetSessionState(request) + def set_session_user( + self, session_id: int, user: str + ) -> core_pb2.SetSessionUserResponse: + """ + Set session user, used for helping to find files without full paths. + + :param session_id: id of session + :param user: user to set for session + :return: response with result of success or failure + :raises grpc.RpcError: when session doesn't exist + """ + request = core_pb2.SetSessionUserRequest(session_id=session_id, user=user) + return self.stub.SetSessionUser(request) + def add_session_server( self, session_id: int, name: str, host: str ) -> core_pb2.AddSessionServerResponse: diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 5bdebac6..da2d53c3 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -448,6 +448,21 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): return core_pb2.SetSessionStateResponse(result=result) + def SetSessionUser( + self, request: core_pb2.SetSessionUserRequest, context: ServicerContext + ) -> core_pb2.SetSessionUserResponse: + """ + Sets the user for a session. + + :param request: set session user request + :param context: context object + :return: set session user response + """ + logging.debug("set session user: %s", request) + session = self.get_session(request.session_id, context) + session.user = request.user + return core_pb2.SetSessionUserResponse(result=True) + def GetSessionOptions( self, request: core_pb2.GetSessionOptionsRequest, context: ServicerContext ) -> core_pb2.GetSessionOptionsResponse: diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 255192be..52023e14 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -1,6 +1,7 @@ """ Incorporate grpc into python tkinter GUI """ +import getpass import json import logging import os @@ -71,6 +72,7 @@ class CoreClient: self.default_services: Dict[NodeType, Set[str]] = {} self.emane_models: List[str] = [] self.observer: Optional[str] = None + self.user = getpass.getuser() # loaded configuration data self.servers: Dict[str, CoreServer] = {} @@ -289,6 +291,9 @@ class CoreClient: self.session_id, self.handle_events ) + # set session user + self.client.set_session_user(self.session_id, self.user) + # get session service defaults response = self.client.get_service_defaults(self.session_id) self.default_services = { diff --git a/daemon/core/gui/data/xmls/sample1.xml b/daemon/core/gui/data/xmls/sample1.xml index afec8874..5055c225 100644 --- a/daemon/core/gui/data/xmls/sample1.xml +++ b/daemon/core/gui/data/xmls/sample1.xml @@ -188,7 +188,7 @@ - + diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index c3e9067f..406a88ca 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -305,7 +305,7 @@ class Toolbar(ttk.Frame): self.set_runtime() self.app.core.set_metadata() self.app.core.show_mobility_players() - else: + elif response.exceptions: enable_buttons(self.design_frame, enabled=True) message = "\n".join(response.exceptions) self.app.show_error("Start Session Error", message) diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index f2e0f470..e982c5c1 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -5,10 +5,10 @@ mobility.py: mobility helpers for moving nodes and calculating wireless range. import heapq import logging import math -import os import threading import time from functools import total_ordering +from pathlib import Path from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple from core import utils @@ -1030,30 +1030,28 @@ class Ns2ScriptedMobility(WayPointMobility): def findfile(self, file_name: str) -> str: """ Locate a script file. If the specified file doesn't exist, look in the - same directory as the scenario file, or in the default - configs directory (~/.core/configs). This allows for sample files without - absolute path names. + same directory as the scenario file, or in gui directories. :param file_name: file name to find :return: absolute path to the file + :raises CoreError: when file is not found """ - if os.path.exists(file_name): - return file_name - - if self.session.file_name is not None: - d = os.path.dirname(self.session.file_name) - sessfn = os.path.join(d, file_name) - if os.path.exists(sessfn): - return sessfn - - if self.session.user is not None: - userfn = os.path.join( - "/home", self.session.user, ".core", "configs", file_name - ) - if os.path.exists(userfn): - return userfn - - return file_name + file_path = Path(file_name).expanduser() + if file_path.exists(): + return str(file_path) + if self.session.file_name: + file_path = Path(self.session.file_name).parent / file_name + if file_path.exists(): + return str(file_path) + if self.session.user: + user_path = Path(f"~{self.session.user}").expanduser() + file_path = user_path / ".core" / "configs" / file_name + if file_path.exists(): + return str(file_path) + file_path = user_path / ".coregui" / "mobility" / file_name + if file_path.exists(): + return str(file_path) + raise CoreError(f"invalid file: {file_name}") def parsemap(self, mapstr: str) -> None: """ diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index f01fca50..5ca4812c 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -39,6 +39,8 @@ service CoreApi { } rpc SetSessionState (SetSessionStateRequest) returns (SetSessionStateResponse) { } + rpc SetSessionUser (SetSessionUserRequest) returns (SetSessionUserResponse) { + } rpc AddSessionServer (AddSessionServerRequest) returns (AddSessionServerResponse) { } @@ -297,6 +299,15 @@ message SetSessionStateResponse { bool result = 1; } +message SetSessionUserRequest { + int32 session_id = 1; + string user = 2; +} + +message SetSessionUserResponse { + bool result = 1; +} + message AddSessionServerRequest { int32 session_id = 1; string name = 2; From 5e2ca0f5497b2b5ed43e104d29860a5a1297af1b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 18 Jul 2020 11:56:48 -0700 Subject: [PATCH 333/929] daemon: refactored how to get required commands, added usage of this func for validating distributed servers when added --- daemon/core/emulator/coreemu.py | 9 ++------- daemon/core/emulator/distributed.py | 12 +++++++++++- daemon/core/executables.py | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/daemon/core/emulator/coreemu.py b/daemon/core/emulator/coreemu.py index 016f2e5b..c07d8c95 100644 --- a/daemon/core/emulator/coreemu.py +++ b/daemon/core/emulator/coreemu.py @@ -9,7 +9,7 @@ import core.services from core import configservices, utils from core.configservice.manager import ConfigServiceManager from core.emulator.session import Session -from core.executables import COMMON_REQUIREMENTS, OVS_REQUIREMENTS, VCMD_REQUIREMENTS +from core.executables import get_requirements from core.services.coreservices import ServiceManager @@ -79,13 +79,8 @@ class CoreEmu: :return: nothing :raises core.errors.CoreError: when an executable does not exist on path """ - requirements = COMMON_REQUIREMENTS use_ovs = self.config.get("ovs") == "1" - if use_ovs: - requirements += OVS_REQUIREMENTS - else: - requirements += VCMD_REQUIREMENTS - for requirement in requirements: + for requirement in get_requirements(use_ovs): utils.which(requirement, required=True) def load_services(self) -> None: diff --git a/daemon/core/emulator/distributed.py b/daemon/core/emulator/distributed.py index 381eb019..a5e1009f 100644 --- a/daemon/core/emulator/distributed.py +++ b/daemon/core/emulator/distributed.py @@ -14,7 +14,8 @@ from fabric import Connection from invoke import UnexpectedExit from core import utils -from core.errors import CoreCommandError +from core.errors import CoreCommandError, CoreError +from core.executables import get_requirements from core.nodes.interface import GreTap from core.nodes.network import CoreNetwork, CtrlNet @@ -131,8 +132,17 @@ class DistributedController: :param name: distributed server name :param host: distributed server host address :return: nothing + :raises CoreError: when there is an error validating server """ server = DistributedServer(name, host) + for requirement in get_requirements(self.session.use_ovs()): + try: + server.remote_cmd(f"which {requirement}") + except CoreCommandError: + raise CoreError( + f"server({server.name}) failed validation for " + f"command({requirement})" + ) self.servers[name] = server cmd = f"mkdir -p {self.session.session_dir}" server.remote_cmd(cmd) diff --git a/daemon/core/executables.py b/daemon/core/executables.py index 17aecc1d..6eb0214a 100644 --- a/daemon/core/executables.py +++ b/daemon/core/executables.py @@ -14,3 +14,18 @@ OVS_VSCTL: str = "ovs-vsctl" COMMON_REQUIREMENTS: List[str] = [SYSCTL, IP, ETHTOOL, TC, EBTABLES, MOUNT, UMOUNT] VCMD_REQUIREMENTS: List[str] = [VNODED, VCMD] OVS_REQUIREMENTS: List[str] = [OVS_VSCTL] + + +def get_requirements(use_ovs: bool) -> List[str]: + """ + Retrieve executable requirements needed to run CORE. + + :param use_ovs: True if OVS is being used, False otherwise + :return: list of executable requirements + """ + requirements = COMMON_REQUIREMENTS + if use_ovs: + requirements += OVS_REQUIREMENTS + else: + requirements += VCMD_REQUIREMENTS + return requirements From d5d5da72560c866e2450640e04cb9e7488d9e9b2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 21 Jul 2020 10:08:12 -0700 Subject: [PATCH 334/929] bumped version to 7.0.0 --- configure.ac | 2 +- daemon/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/configure.ac b/configure.ac index 10d30c20..60f6709e 100644 --- a/configure.ac +++ b/configure.ac @@ -2,7 +2,7 @@ # Process this file with autoconf to produce a configure script. # this defines the CORE version number, must be static for AC_INIT -AC_INIT(core, 6.5.0) +AC_INIT(core, 7.0.0) # autoconf and automake initialization AC_CONFIG_SRCDIR([netns/version.h.in]) diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index 1fdc9d1a..b75f1ee3 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "core" -version = "6.6.0" +version = "7.0.0" description = "CORE Common Open Research Emulator" authors = ["Boeing Research and Technology"] license = "BSD-2-Clause" From 45bfa9fdadf0b95da3d4755c1a682d34be8319ce Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 21 Jul 2020 16:52:17 -0700 Subject: [PATCH 335/929] small tweaks to docs --- docs/devguide.md | 2 +- docs/install.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/devguide.md b/docs/devguide.md index 9b9d61c8..ba34a211 100644 --- a/docs/devguide.md +++ b/docs/devguide.md @@ -62,7 +62,7 @@ core-pygui core-gui # run mocked unit tests -cd $REPO +cd inv test-mock ``` diff --git a/docs/install.md b/docs/install.md index 12d47802..604ac509 100644 --- a/docs/install.md +++ b/docs/install.md @@ -58,7 +58,7 @@ before proceeding to install. Previous install was built from source: ```shell -cd $REPO +cd sudo make uninstall make clean ./bootstrap.sh clean @@ -138,7 +138,7 @@ installed virtual environment. There is an invoke task to help with this case. ```shell -cd $REPO +cd inv -h run Usage: inv[oke] [--core-opts] run [--options] [other tasks here ...] @@ -153,7 +153,7 @@ Options: Another way would be to enable the core virtual environment shell. Which would allow you to run scripts in a more **normal** way. ```shell -cd $REPO/daemon +cd /daemon poetry shell python run /path/to/script.py ``` @@ -168,7 +168,7 @@ which attempts to build EMANE from source, but has issue on systems with older protobuf-compilers. ```shell -cd $REPO +cd inv install-emane ``` @@ -180,8 +180,8 @@ bindings into the core virtual environment. The following would install the EMANE python bindings after being successfully built. ```shell -cd $REPO/daemon -poetry run pip install $EMANE_REPO/src/python +cd /daemon +poetry run pip install /src/python ``` ## Using Invoke Tasks From 165e404184e92858280b16eb2cd013651ad75abe Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 22 Jul 2020 12:49:11 -0700 Subject: [PATCH 336/929] added example dockerfile and build command to readme --- daemon/examples/docker/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/daemon/examples/docker/README.md b/daemon/examples/docker/README.md index 3c2b1372..17c6cb90 100644 --- a/daemon/examples/docker/README.md +++ b/daemon/examples/docker/README.md @@ -44,3 +44,18 @@ newgrp docker This directory provides a few small examples creating Docker nodes and linking them to themselves or with standard CORE nodes. + +Images used by nodes need to have networking tools installed for CORE to automate +setup and configuration of the container. + +Example Dockerfile: +``` +FROM ubuntu:latest +RUN apt-get update +RUN apt-get install -y iproute2 ethtool +``` + +Build image: +```shell +sudo docker build -t . +``` From e34002b851ec51fd74db8e56e6885f98aa8e41d9 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 22 Jul 2020 17:18:35 -0700 Subject: [PATCH 337/929] pygui: added option to launch core-pygui into a specific session using an id --- daemon/core/gui/app.py | 4 ++-- daemon/core/gui/coreclient.py | 24 ++++++++++++++++++------ daemon/core/gui/dialogs/error.py | 25 ++++++++++--------------- daemon/examples/grpc/switch.py | 4 ++-- daemon/scripts/core-pygui | 3 ++- 5 files changed, 34 insertions(+), 26 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index e0121d14..176b31e3 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -26,7 +26,7 @@ HEIGHT: int = 800 class Application(ttk.Frame): - def __init__(self, proxy: bool) -> None: + def __init__(self, proxy: bool, session_id: int = None) -> None: super().__init__() # load node icons NodeUtils.setup() @@ -56,7 +56,7 @@ class Application(ttk.Frame): self.core: CoreClient = CoreClient(self, proxy) self.setup_app() self.draw() - self.core.setup() + self.core.setup(session_id) def setup_scaling(self) -> None: self.fonts_size = {name: font.nametofont(name)["size"] for name in font.names()} diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 52023e14..26a5a390 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -473,7 +473,7 @@ class CoreClient: except grpc.RpcError as e: self.app.show_grpc_exception("Delete Session Error", e) - def setup(self) -> None: + def setup(self, session_id: int = None) -> None: """ Query sessions, if there exist any, prompt whether to join one """ @@ -494,14 +494,26 @@ class CoreClient: ) group_services.add(service.name) - # if there are no sessions, create a new session, else join a session + # join provided session, create new session, or show dialog to select an + # existing session response = self.client.get_sessions() sessions = response.sessions - if len(sessions) == 0: - self.create_new_session() + if session_id: + session_ids = set(x.id for x in sessions) + if session_id not in session_ids: + dialog = ErrorDialog( + self.app, "Join Session Error", f"{session_id} does not exist" + ) + dialog.show() + self.app.close() + else: + self.join_session(session_id) else: - dialog = SessionsDialog(self.app, True) - dialog.show() + if not sessions: + self.create_new_session() + else: + dialog = SessionsDialog(self.app, True) + dialog.show() except grpc.RpcError as e: logging.exception("core setup error") dialog = ErrorDialog(self.app, "Setup Error", e.details()) diff --git a/daemon/core/gui/dialogs/error.py b/daemon/core/gui/dialogs/error.py index 7fb81077..9d215e82 100644 --- a/daemon/core/gui/dialogs/error.py +++ b/daemon/core/gui/dialogs/error.py @@ -1,9 +1,10 @@ +import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING, Optional from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum, Images -from core.gui.themes import FRAME_PAD, PADX, PADY +from core.gui.themes import PADY from core.gui.widgets import CodeText if TYPE_CHECKING: @@ -21,21 +22,15 @@ class ErrorDialog(Dialog): def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(1, weight=1) - - frame = ttk.Frame(self.top, padding=FRAME_PAD) - frame.grid(pady=PADY, sticky="ew") - frame.columnconfigure(1, weight=1) - image = Images.get(ImageEnum.ERROR, 36) - label = ttk.Label(frame, image=image) + image = Images.get(ImageEnum.ERROR, 24) + label = ttk.Label( + self.top, text=self.title, image=image, compound=tk.LEFT, anchor=tk.CENTER + ) label.image = image - label.grid(row=0, column=0, padx=PADX) - label = ttk.Label(frame, text=self.title) - label.grid(row=0, column=1, sticky="ew") - + label.grid(sticky=tk.EW, pady=PADY) self.error_message = CodeText(self.top) self.error_message.text.insert("1.0", self.details) - self.error_message.text.config(state="disabled") - self.error_message.grid(sticky="nsew", pady=PADY) - + self.error_message.text.config(state=tk.DISABLED) + self.error_message.grid(sticky=tk.NSEW, pady=PADY) button = ttk.Button(self.top, text="Close", command=lambda: self.destroy()) - button.grid(sticky="ew") + button.grid(sticky=tk.EW) diff --git a/daemon/examples/grpc/switch.py b/daemon/examples/grpc/switch.py index 1ed7c684..79a4e621 100644 --- a/daemon/examples/grpc/switch.py +++ b/daemon/examples/grpc/switch.py @@ -40,14 +40,14 @@ def main(): # create node one position = Position(x=100, y=100) - node1 = Node(type=NodeType.DEFAULT, position=position) + node1 = Node(type=NodeType.DEFAULT, position=position, model="PC") response = core.add_node(session_id, node1) logging.info("created node: %s", response) node1_id = response.node_id # create node two position = Position(x=300, y=100) - node2 = Node(type=NodeType.DEFAULT, position=position) + node2 = Node(type=NodeType.DEFAULT, position=position, model="PC") response = core.add_node(session_id, node2) logging.info("created node: %s", response) node2_id = response.node_id diff --git a/daemon/scripts/core-pygui b/daemon/scripts/core-pygui index 46860ce9..888f4171 100755 --- a/daemon/scripts/core-pygui +++ b/daemon/scripts/core-pygui @@ -13,6 +13,7 @@ if __name__ == "__main__": parser.add_argument("-l", "--level", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], default="INFO", help="logging level") parser.add_argument("-p", "--proxy", action="store_true", help="enable proxy") + parser.add_argument("-s", "--session", type=int, help="session id to join") args = parser.parse_args() # check home directory exists and create if necessary @@ -28,5 +29,5 @@ if __name__ == "__main__": # start app Images.load_all() - app = Application(args.proxy) + app = Application(args.proxy, args.session) app.mainloop() From f8d862a296d995b242e86a1decb25d90587f5f6e Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 22 Jul 2020 19:19:22 -0700 Subject: [PATCH 338/929] grpc/pygui: added grpc alert api, updated pygui to better handle and display alerts --- daemon/core/api/grpc/client.py | 17 +++++++++++ daemon/core/api/grpc/server.py | 16 ++++++++++- daemon/core/gui/coreclient.py | 2 +- daemon/core/gui/dialogs/alerts.py | 13 +++++---- daemon/core/gui/statusbar.py | 25 ++++++++++++++-- daemon/core/gui/themes.py | 41 +++++++++++++-------------- daemon/proto/core/api/grpc/core.proto | 14 +++++++++ 7 files changed, 97 insertions(+), 31 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 3e974233..aacfa4f6 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -445,6 +445,23 @@ class CoreGrpcClient: ) return self.stub.AddSessionServer(request) + def alert( + self, + session_id: int, + level: core_pb2.ExceptionLevel, + source: str, + text: str, + node_id: int = None, + ) -> core_pb2.SessionAlertResponse: + request = core_pb2.SessionAlertRequest( + session_id=session_id, + level=level, + source=source, + text=text, + node_id=node_id, + ) + return self.stub.SessionAlert(request) + def events( self, session_id: int, diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index da2d53c3..4c204845 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -109,7 +109,12 @@ from core.api.grpc.wlan_pb2 import ( ) from core.emulator.coreemu import CoreEmu from core.emulator.data import InterfaceData, LinkData, LinkOptions, NodeOptions -from core.emulator.enumerations import EventTypes, LinkTypes, MessageFlags +from core.emulator.enumerations import ( + EventTypes, + ExceptionLevels, + LinkTypes, + MessageFlags, +) from core.emulator.session import NT, Session from core.errors import CoreCommandError, CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility @@ -584,6 +589,15 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): session.distributed.add_server(request.name, request.host) return core_pb2.AddSessionServerResponse(result=True) + def SessionAlert( + self, request: core_pb2.SessionAlertRequest, context: ServicerContext + ) -> core_pb2.SessionAlertResponse: + session = self.get_session(request.session_id, context) + level = ExceptionLevels(request.level) + node_id = request.node_id if request.node_id else None + session.exception(level, request.source, request.text, node_id) + return core_pb2.SessionAlertResponse(result=True) + def Events(self, request: core_pb2.EventsRequest, context: ServicerContext) -> None: session = self.get_session(request.session_id, context) event_types = set(request.events) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 26a5a390..8474b3cb 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -271,7 +271,7 @@ class CoreClient: def handle_exception_event(self, event: ExceptionEvent) -> None: logging.info("exception event: %s", event) - self.app.statusbar.core_alarms.append(event) + self.app.statusbar.add_alert(event) def join_session(self, session_id: int, query_location: bool = True) -> None: logging.info("join session(%s)", session_id) diff --git a/daemon/core/gui/dialogs/alerts.py b/daemon/core/gui/dialogs/alerts.py index 00ef1e8c..8e0aa02e 100644 --- a/daemon/core/gui/dialogs/alerts.py +++ b/daemon/core/gui/dialogs/alerts.py @@ -52,6 +52,7 @@ class AlertsDialog(Dialog): for alarm in self.app.statusbar.core_alarms: exception = alarm.exception_event level_name = ExceptionLevel.Enum.Name(exception.level) + node_id = exception.node_id if exception.node_id else "" insert_id = self.tree.insert( "", tk.END, @@ -60,7 +61,7 @@ class AlertsDialog(Dialog): exception.date, level_name, alarm.session_id, - exception.node_id, + node_id, exception.source, ), tags=(level_name,), @@ -98,15 +99,17 @@ class AlertsDialog(Dialog): button.grid(row=0, column=1, sticky="ew") def reset_alerts(self) -> None: - self.codetext.text.delete("1.0", tk.END) + self.codetext.text.config(state=tk.NORMAL) + self.codetext.text.delete(1.0, tk.END) + self.codetext.text.config(state=tk.DISABLED) for item in self.tree.get_children(): self.tree.delete(item) - self.app.statusbar.core_alarms.clear() + self.app.statusbar.clear_alerts() def click_select(self, event: tk.Event) -> None: current = self.tree.selection()[0] alarm = self.alarm_map[current] self.codetext.text.config(state=tk.NORMAL) - self.codetext.text.delete("1.0", "end") - self.codetext.text.insert("1.0", alarm.exception_event.text) + self.codetext.text.delete(1.0, tk.END) + self.codetext.text.insert(1.0, alarm.exception_event.text) self.codetext.text.config(state=tk.DISABLED) diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py index 2b597b63..67da0efa 100644 --- a/daemon/core/gui/statusbar.py +++ b/daemon/core/gui/statusbar.py @@ -5,7 +5,7 @@ import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING, List, Optional -from core.api.grpc.core_pb2 import ExceptionEvent +from core.api.grpc.core_pb2 import ExceptionEvent, ExceptionLevel from core.gui.dialogs.alerts import AlertsDialog from core.gui.themes import Styles @@ -22,6 +22,7 @@ class StatusBar(ttk.Frame): self.zoom: Optional[ttk.Label] = None self.cpu_usage: Optional[ttk.Label] = None self.alerts_button: Optional[ttk.Button] = None + self.alert_style = Styles.no_alert self.running: bool = False self.core_alarms: List[ExceptionEvent] = [] self.draw() @@ -60,10 +61,30 @@ class StatusBar(ttk.Frame): self.cpu_usage.grid(row=0, column=2, sticky="ew") self.alerts_button = ttk.Button( - self, text="Alerts", command=self.click_alerts, style=Styles.green_alert + self, text="Alerts", command=self.click_alerts, style=self.alert_style ) self.alerts_button.grid(row=0, column=3, sticky="ew") + def add_alert(self, event: ExceptionEvent) -> None: + self.core_alarms.append(event) + level = event.exception_event.level + self._set_alert_style(level) + label = f"Alerts ({len(self.core_alarms)})" + self.alerts_button.config(text=label, style=self.alert_style) + + def _set_alert_style(self, level: ExceptionLevel) -> None: + if level in [ExceptionLevel.FATAL, ExceptionLevel.ERROR]: + self.alert_style = Styles.red_alert + elif level == ExceptionLevel.WARNING and self.alert_style != Styles.red_alert: + self.alert_style = Styles.yellow_alert + elif self.alert_style == Styles.no_alert: + self.alert_style = Styles.green_alert + + def clear_alerts(self): + self.core_alarms.clear() + self.alert_style = Styles.no_alert + self.alerts_button.config(text="Alerts", style=self.alert_style) + def click_alerts(self) -> None: dialog = AlertsDialog(self.app) dialog.show() diff --git a/daemon/core/gui/themes.py b/daemon/core/gui/themes.py index 93a0a599..45b109f0 100644 --- a/daemon/core/gui/themes.py +++ b/daemon/core/gui/themes.py @@ -14,6 +14,7 @@ class Styles: tooltip_frame: str = "Tooltip.TFrame" service_checkbutton: str = "Service.TCheckbutton" picker_button: str = "Picker.TButton" + no_alert: str = "NAlert.TButton" green_alert: str = "GAlert.TButton" red_alert: str = "RAlert.TButton" yellow_alert: str = "YAlert.TButton" @@ -175,33 +176,29 @@ def style_listbox(widget: tk.Widget) -> None: ) +def _alert_style(style: ttk.Style, name: str, background: str): + style.configure( + name, + background=background, + padding=0, + relief=tk.RIDGE, + borderwidth=1, + font="TkDefaultFont", + foreground="black", + highlightbackground="white", + ) + style.map(name, background=[("!active", background), ("active", "white")]) + + def theme_change(event: tk.Event) -> None: style = ttk.Style() style.configure(Styles.picker_button, font="TkSmallCaptionFont") style.configure( - Styles.green_alert, - background="green", - padding=0, - relief=tk.RIDGE, - borderwidth=1, - font="TkDefaultFont", - ) - style.configure( - Styles.yellow_alert, - background="yellow", - padding=0, - relief=tk.RIDGE, - borderwidth=1, - font="TkDefaultFont", - ) - style.configure( - Styles.red_alert, - background="red", - padding=0, - relief=tk.RIDGE, - borderwidth=1, - font="TkDefaultFont", + Styles.no_alert, padding=0, relief=tk.RIDGE, borderwidth=1, font="TkDefaultFont" ) + _alert_style(style, Styles.green_alert, "green") + _alert_style(style, Styles.yellow_alert, "yellow") + _alert_style(style, Styles.red_alert, "red") def scale_fonts(fonts_size: Dict[str, int], scale: float) -> None: diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 5ca4812c..eb889d14 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -43,6 +43,8 @@ service CoreApi { } rpc AddSessionServer (AddSessionServerRequest) returns (AddSessionServerResponse) { } + rpc SessionAlert (SessionAlertRequest) returns (SessionAlertResponse) { + } // streams rpc Events (EventsRequest) returns (stream Event) { @@ -318,6 +320,18 @@ message AddSessionServerResponse { bool result = 1; } +message SessionAlertRequest { + int32 session_id = 1; + ExceptionLevel.Enum level = 2; + string source = 3; + string text = 4; + int32 node_id = 5; +} + +message SessionAlertResponse { + bool result = 1; +} + message EventsRequest { int32 session_id = 1; repeated EventType.Enum events = 2; From 3544d004317b5ddbc5053e8f38c09e005fd582e7 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 22 Jul 2020 21:57:05 -0700 Subject: [PATCH 339/929] pygui: implemented cpu usage monitor to status bar --- daemon/core/gui/graph/graph.py | 3 +- daemon/core/gui/statusbar.py | 64 ++++++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 9cb3b109..56a31c3f 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -590,8 +590,7 @@ class CanvasGraph(tk.Canvas): ) logging.debug("ratio: %s", self.ratio) logging.debug("offset: %s", self.offset) - zoom_label = f"{self.ratio * 100:.0f}%" - self.app.statusbar.zoom.config(text=zoom_label) + self.app.statusbar.set_zoom(self.ratio) if self.wallpaper: self.redraw_wallpaper() diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py index 67da0efa..e9fc03b2 100644 --- a/daemon/core/gui/statusbar.py +++ b/daemon/core/gui/statusbar.py @@ -1,7 +1,10 @@ """ status bar """ +import sched import tkinter as tk +from pathlib import Path +from threading import Thread from tkinter import ttk from typing import TYPE_CHECKING, List, Optional @@ -13,6 +16,41 @@ if TYPE_CHECKING: from core.gui.app import Application +class CpuUsage: + def __init__(self, statusbar: "StatusBar") -> None: + self.scheduler: sched.scheduler = sched.scheduler() + self.running: bool = False + self.thread: Optional[Thread] = None + self.prev_idle: int = 0 + self.prev_total: int = 0 + self.stat_file: Path = Path("/proc/stat") + self.statusbar: "StatusBar" = statusbar + + def start(self) -> None: + self.running = True + self.thread = Thread(target=self._start, daemon=True) + self.thread.start() + + def _start(self): + self.scheduler.enter(0, 0, self.run) + self.scheduler.run() + + def run(self) -> None: + lines = self.stat_file.read_text().splitlines()[0] + values = [int(x) for x in lines.split()[1:]] + idle = sum(values[3:5]) + non_idle = sum(values[:3] + values[5:8]) + total = idle + non_idle + total_diff = total - self.prev_total + idle_diff = idle - self.prev_idle + cpu_percent = (total_diff - idle_diff) / total_diff + self.statusbar.after(0, self.statusbar.set_cpu, cpu_percent) + self.prev_idle = idle + self.prev_total = total + if self.running: + self.scheduler.enter(3, 0, self.run) + + class StatusBar(ttk.Frame): def __init__(self, master: tk.Widget, app: "Application") -> None: super().__init__(master) @@ -20,12 +58,14 @@ class StatusBar(ttk.Frame): self.status: Optional[ttk.Label] = None self.statusvar: tk.StringVar = tk.StringVar() self.zoom: Optional[ttk.Label] = None - self.cpu_usage: Optional[ttk.Label] = None + self.cpu_label: Optional[ttk.Label] = None self.alerts_button: Optional[ttk.Button] = None self.alert_style = Styles.no_alert self.running: bool = False self.core_alarms: List[ExceptionEvent] = [] self.draw() + self.cpu_usage: CpuUsage = CpuUsage(self) + self.cpu_usage.start() def draw(self) -> None: self.columnconfigure(0, weight=7) @@ -46,25 +86,27 @@ class StatusBar(ttk.Frame): ) self.status.grid(row=0, column=0, sticky="ew") - self.zoom = ttk.Label( - self, - text="%s" % (int(self.app.canvas.ratio * 100)) + "%", - anchor=tk.CENTER, - borderwidth=1, - relief=tk.RIDGE, - ) + self.zoom = ttk.Label(self, anchor=tk.CENTER, borderwidth=1, relief=tk.RIDGE) self.zoom.grid(row=0, column=1, sticky="ew") + self.set_zoom(self.app.canvas.ratio) - self.cpu_usage = ttk.Label( - self, text="CPU TBD", anchor=tk.CENTER, borderwidth=1, relief=tk.RIDGE + self.cpu_label = ttk.Label( + self, anchor=tk.CENTER, borderwidth=1, relief=tk.RIDGE ) - self.cpu_usage.grid(row=0, column=2, sticky="ew") + self.cpu_label.grid(row=0, column=2, sticky="ew") + self.set_cpu(0.0) self.alerts_button = ttk.Button( self, text="Alerts", command=self.click_alerts, style=self.alert_style ) self.alerts_button.grid(row=0, column=3, sticky="ew") + def set_cpu(self, usage: float) -> None: + self.cpu_label.config(text=f"CPU {usage * 100:.2f}%") + + def set_zoom(self, zoom: float) -> None: + self.zoom.config(text=f"ZOOM {zoom * 100:.0f}%") + def add_alert(self, event: ExceptionEvent) -> None: self.core_alarms.append(event) level = event.exception_event.level From fff4bd796358797e3b88babc9f005055d035d949 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 23 Jul 2020 09:41:39 -0700 Subject: [PATCH 340/929] moved cpu usage to a grpc call that the gui will listen to, fixed grpc stream typing to be grpc.Future, fixed pygui issue for start callback when a start fails, but there are no exceptions --- daemon/core/api/grpc/client.py | 19 +++++++++++-- daemon/core/api/grpc/grpcutils.py | 20 ++++++++++++++ daemon/core/api/grpc/server.py | 9 ++++++ daemon/core/gui/coreclient.py | 27 ++++++++++++++++-- daemon/core/gui/statusbar.py | 40 --------------------------- daemon/core/gui/toolbar.py | 7 +++-- daemon/proto/core/api/grpc/core.proto | 10 +++++++ 7 files changed, 85 insertions(+), 47 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index aacfa4f6..0674a0eb 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -467,7 +467,7 @@ class CoreGrpcClient: session_id: int, handler: Callable[[core_pb2.Event], None], events: List[core_pb2.Event] = None, - ) -> grpc.Channel: + ) -> grpc.Future: """ Listen for session events. @@ -484,7 +484,7 @@ class CoreGrpcClient: def throughputs( self, session_id: int, handler: Callable[[core_pb2.ThroughputsEvent], None] - ) -> grpc.Channel: + ) -> grpc.Future: """ Listen for throughput events with information for interfaces and bridges. @@ -498,6 +498,21 @@ class CoreGrpcClient: start_streamer(stream, handler) return stream + def cpu_usage( + self, delay: int, handler: Callable[[core_pb2.CpuUsageEvent], None] + ) -> grpc.Future: + """ + Listen for cpu usage events with the given repeat delay. + + :param delay: delay between receiving events + :param handler: handler for every event + :return: stream processing events, can be used to cancel stream + """ + request = core_pb2.CpuUsageRequest(delay=delay) + stream = self.stub.CpuUsage(request) + start_streamer(stream, handler) + return stream + def add_node( self, session_id: int, node: core_pb2.Node, source: str = None ) -> core_pb2.AddNodeResponse: diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index bd3519f7..84b8ee6a 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -1,5 +1,6 @@ import logging import time +from pathlib import Path from typing import Any, Dict, List, Tuple, Type, Union import grpc @@ -20,6 +21,25 @@ from core.services.coreservices import CoreService WORKERS = 10 +class CpuUsage: + def __init__(self) -> None: + self.stat_file: Path = Path("/proc/stat") + self.prev_idle: int = 0 + self.prev_total: int = 0 + + def run(self) -> float: + lines = self.stat_file.read_text().splitlines()[0] + values = [int(x) for x in lines.split()[1:]] + idle = sum(values[3:5]) + non_idle = sum(values[:3] + values[5:8]) + total = idle + non_idle + total_diff = total - self.prev_total + idle_diff = idle - self.prev_idle + self.prev_idle = idle + self.prev_total = total + return (total_diff - idle_diff) / total_diff + + def add_node_data(node_proto: core_pb2.Node) -> Tuple[NodeTypes, int, NodeOptions]: """ Convert node protobuf message to data for creating a node. diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 4c204845..38100e05 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -681,6 +681,15 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): last_stats = stats time.sleep(delay) + def CpuUsage( + self, request: core_pb2.CpuUsageRequest, context: ServicerContext + ) -> None: + cpu_usage = grpcutils.CpuUsage() + while self._is_running(context): + usage = cpu_usage.run() + yield core_pb2.CpuUsageEvent(usage=usage) + time.sleep(request.delay) + def AddNode( self, request: core_pb2.AddNodeRequest, context: ServicerContext ) -> core_pb2.AddNodeResponse: diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 8474b3cb..fc0bd520 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -16,6 +16,7 @@ from core.api.grpc import client from core.api.grpc.common_pb2 import ConfigOption from core.api.grpc.configservices_pb2 import ConfigService, ConfigServiceConfig from core.api.grpc.core_pb2 import ( + CpuUsageEvent, Event, ExceptionEvent, Hook, @@ -55,6 +56,7 @@ if TYPE_CHECKING: from core.gui.app import Application GUI_SOURCE = "gui" +CPU_USAGE_DELAY = 3 class CoreClient: @@ -92,8 +94,9 @@ class CoreClient: self.hooks: Dict[str, Hook] = {} self.emane_config: Dict[str, ConfigOption] = {} self.mobility_players: Dict[int, MobilityPlayer] = {} - self.handling_throughputs: Optional[grpc.Channel] = None - self.handling_events: Optional[grpc.Channel] = None + self.handling_throughputs: Optional[grpc.Future] = None + self.handling_cpu_usage: Optional[grpc.Future] = None + self.handling_events: Optional[grpc.Future] = None self.xml_dir: Optional[str] = None self.xml_file: Optional[str] = None @@ -111,6 +114,7 @@ class CoreClient: ) if throughputs_enabled: self.enable_throughputs() + self.setup_cpu_usage() return self._client def reset(self) -> None: @@ -258,6 +262,20 @@ class CoreClient: self.handling_events.cancel() self.handling_events = None + def cancel_cpu_usage(self) -> None: + if self.handling_cpu_usage: + self.handling_cpu_usage.cancel() + self.handling_cpu_usage = None + + def setup_cpu_usage(self) -> None: + if self.handling_cpu_usage and self.handling_cpu_usage.running(): + return + if self.handling_cpu_usage: + self.handling_cpu_usage.cancel() + self.handling_cpu_usage = self._client.cpu_usage( + CPU_USAGE_DELAY, self.handle_cpu_event + ) + def handle_throughputs(self, event: ThroughputsEvent) -> None: if event.session_id != self.session_id: logging.warning( @@ -269,6 +287,9 @@ class CoreClient: logging.debug("handling throughputs event: %s", event) self.app.after(0, self.app.canvas.set_throughputs, event) + def handle_cpu_event(self, event: CpuUsageEvent) -> None: + self.app.after(0, self.app.statusbar.set_cpu, event.usage) + def handle_exception_event(self, event: ExceptionEvent) -> None: logging.info("exception event: %s", event) self.app.statusbar.add_alert(event) @@ -479,6 +500,8 @@ class CoreClient: """ try: self.client.connect() + self.setup_cpu_usage() + # get service information response = self.client.get_services() for service in response.services: diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py index e9fc03b2..6989593e 100644 --- a/daemon/core/gui/statusbar.py +++ b/daemon/core/gui/statusbar.py @@ -1,10 +1,7 @@ """ status bar """ -import sched import tkinter as tk -from pathlib import Path -from threading import Thread from tkinter import ttk from typing import TYPE_CHECKING, List, Optional @@ -16,41 +13,6 @@ if TYPE_CHECKING: from core.gui.app import Application -class CpuUsage: - def __init__(self, statusbar: "StatusBar") -> None: - self.scheduler: sched.scheduler = sched.scheduler() - self.running: bool = False - self.thread: Optional[Thread] = None - self.prev_idle: int = 0 - self.prev_total: int = 0 - self.stat_file: Path = Path("/proc/stat") - self.statusbar: "StatusBar" = statusbar - - def start(self) -> None: - self.running = True - self.thread = Thread(target=self._start, daemon=True) - self.thread.start() - - def _start(self): - self.scheduler.enter(0, 0, self.run) - self.scheduler.run() - - def run(self) -> None: - lines = self.stat_file.read_text().splitlines()[0] - values = [int(x) for x in lines.split()[1:]] - idle = sum(values[3:5]) - non_idle = sum(values[:3] + values[5:8]) - total = idle + non_idle - total_diff = total - self.prev_total - idle_diff = idle - self.prev_idle - cpu_percent = (total_diff - idle_diff) / total_diff - self.statusbar.after(0, self.statusbar.set_cpu, cpu_percent) - self.prev_idle = idle - self.prev_total = total - if self.running: - self.scheduler.enter(3, 0, self.run) - - class StatusBar(ttk.Frame): def __init__(self, master: tk.Widget, app: "Application") -> None: super().__init__(master) @@ -64,8 +26,6 @@ class StatusBar(ttk.Frame): self.running: bool = False self.core_alarms: List[ExceptionEvent] = [] self.draw() - self.cpu_usage: CpuUsage = CpuUsage(self) - self.cpu_usage.start() def draw(self) -> None: self.columnconfigure(0, weight=7) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 406a88ca..968b447d 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -305,10 +305,11 @@ class Toolbar(ttk.Frame): self.set_runtime() self.app.core.set_metadata() self.app.core.show_mobility_players() - elif response.exceptions: + else: enable_buttons(self.design_frame, enabled=True) - message = "\n".join(response.exceptions) - self.app.show_error("Start Session Error", message) + if response.exceptions: + message = "\n".join(response.exceptions) + self.app.show_error("Start Session Error", message) def set_runtime(self) -> None: enable_buttons(self.runtime_frame, enabled=True) diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index eb889d14..9214ad1b 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -51,6 +51,8 @@ service CoreApi { } rpc Throughputs (ThroughputsRequest) returns (stream ThroughputsEvent) { } + rpc CpuUsage (CpuUsageRequest) returns (stream CpuUsageEvent) { + } // node rpc rpc AddNode (AddNodeRequest) returns (AddNodeResponse) { @@ -347,6 +349,14 @@ message ThroughputsEvent { repeated InterfaceThroughput iface_throughputs = 3; } +message CpuUsageRequest { + int32 delay = 1; +} + +message CpuUsageEvent { + double usage = 1; +} + message InterfaceThroughput { int32 node_id = 1; int32 iface_id = 2; From ba3a2474957394b196b47e79432daf6fc5e77cf1 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Thu, 23 Jul 2020 21:21:43 -0700 Subject: [PATCH 341/929] updated changelog for 7.0.0 --- CHANGELOG.md | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96f7b30a..375a7607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,53 @@ +## 2020-07-23 CORE 7.0.0 + +* Breaking Changes + * core.emudata and core.data combined and cleaned up into core.data + * updates to consistently use mac instead of hwaddr/mac + * \#468 - code related to adding/editing/deleting links cleaned up + * \#469 - usages of per all changed to loss to be consistent + * \#470 - variables with numbered names now use numbers directly + * \#471 - node startup is no longer embedded within its constructor + * \#472 - code updated to refer to interfaces consistently as iface + * \#475 - code updates changing how ip addresses are stored on interfaces + * \#476 - executables to check for moved into own module core.executables + * \#486 - core will now install into its own python virtual environment managed by poetry +* core-daemon + * updates to properly save/load distributed servers to xml + * \#474 - added type hinting to all service files + * \#478 - fixed typo in config service directory + * \#479 - opening an xml file will now cycle through states like a normal session + * \#480 - ovs configuration will now save/load from xml and display in guis + * \#484 - changes to support adding emane links during runtime +* core-pygui + * fixed issue not displaying services for the default group in service dialogs + * fixed issue starting a session when the daemon is not present + * fixed issue attempting to open terminals for invalid nodes + * fixed issue syncing session location + * fixed issue joining a session with mobility, not in runtime + * added cpu usage monitor to status bar + * emane configurations can now be seen during runtime + * rj45 nodes can only have one link + * disabling throughputs will clear labels + * improvements to custom service copy + * link options will now be drawn on as a label + * updates to handle runtime link events + * \#477 - added optional details pane for a quick view of node/link details + * \#485 - pygui fixed observer widget for invalid nodes + * \#496 - improved alert handling +* core-gui + * \#493 - increased frame size to show all emane configuration options +* gRPC API + * added set session user rpc + * added cpu usage stream + * interface objects returned from get_node will now provide node_id, net_id, and net2_id data + * peer to peer nodes will not be included in get_session calls + * pathloss events will now throw an error when nem id not found + * \#481 - link rpc calls will broadcast out + * \#496 - added alert rpc call +* Services + * fixed issue reading files in security services + * \#494 - add staticd to daemons list for frr services + ## 2020-06-11 CORE 6.5.0 * Breaking Changes * CoreNode.newnetif - both parameters are required and now takes an InterfaceData object as its second parameter From 154fa8b77d873ff892937eb12994ce237e2f00c8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 24 Jul 2020 22:00:38 -0700 Subject: [PATCH 342/929] pygui: replaced hook with wrapped hook class, fixed hook dialog edit --- daemon/core/gui/coreclient.py | 11 ++- daemon/core/gui/dialogs/hooks.py | 39 ++++++---- daemon/core/gui/wrappers.py | 126 +++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 daemon/core/gui/wrappers.py diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index fc0bd520..97399556 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -19,7 +19,6 @@ from core.api.grpc.core_pb2 import ( CpuUsageEvent, Event, ExceptionEvent, - Hook, Interface, Link, LinkEvent, @@ -51,6 +50,7 @@ from core.gui.graph.shape import AnnotationData, Shape from core.gui.graph.shapeutils import ShapeType from core.gui.interface import InterfaceManager from core.gui.nodeutils import NodeDraw, NodeUtils +from core.gui.wrappers import Hook if TYPE_CHECKING: from core.gui.app import Application @@ -332,7 +332,8 @@ class CoreClient: # get hooks response = self.client.get_hooks(self.session_id) - for hook in response.hooks: + for hook_proto in response.hooks: + hook = Hook.from_proto(hook_proto) self.hooks[hook.file] = hook # get emane config @@ -570,7 +571,7 @@ class CoreClient: wlan_configs = self.get_wlan_configs_proto() mobility_configs = self.get_mobility_configs_proto() emane_model_configs = self.get_emane_model_configs_proto() - hooks = list(self.hooks.values()) + hooks = [x.to_proto() for x in self.hooks.values()] service_configs = self.get_service_configs_proto() file_configs = self.get_service_file_configs_proto() asymmetric_links = [ @@ -823,7 +824,9 @@ class CoreClient: config_proto.data, ) for hook in self.hooks.values(): - self.client.add_hook(self.session_id, hook.state, hook.file, hook.data) + self.client.add_hook( + self.session_id, hook.state.value, hook.file, hook.data + ) for config_proto in self.get_emane_model_configs_proto(): self.client.set_emane_model_config( self.session_id, diff --git a/daemon/core/gui/dialogs/hooks.py b/daemon/core/gui/dialogs/hooks.py index 08d666ba..b004dae2 100644 --- a/daemon/core/gui/dialogs/hooks.py +++ b/daemon/core/gui/dialogs/hooks.py @@ -2,10 +2,10 @@ import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING, Optional -from core.api.grpc import core_pb2 from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY from core.gui.widgets import CodeText, ListboxScroll +from core.gui.wrappers import Hook, SessionState if TYPE_CHECKING: from core.gui.app import Application @@ -16,8 +16,9 @@ class HookDialog(Dialog): super().__init__(app, "Hook", master=master) self.name: tk.StringVar = tk.StringVar() self.codetext: Optional[CodeText] = None - self.hook: core_pb2.Hook = core_pb2.Hook() + self.hook: Optional[Hook] = None self.state: tk.StringVar = tk.StringVar() + self.editing: bool = False self.draw() def draw(self) -> None: @@ -34,8 +35,8 @@ class HookDialog(Dialog): label.grid(row=0, column=0, sticky="ew", padx=PADX) entry = ttk.Entry(frame, textvariable=self.name) entry.grid(row=0, column=1, sticky="ew", padx=PADX) - values = tuple(x for x in core_pb2.SessionState.Enum.keys() if x != "NONE") - initial_state = core_pb2.SessionState.Enum.Name(core_pb2.SessionState.RUNTIME) + values = tuple(x.name for x in SessionState) + initial_state = SessionState.RUNTIME.name self.state.set(initial_state) self.name.set(f"{initial_state.lower()}_hook.sh") combobox = ttk.Combobox( @@ -67,23 +68,30 @@ class HookDialog(Dialog): button.grid(row=0, column=1, sticky="ew") def state_change(self, event: tk.Event) -> None: + if self.editing: + return state_name = self.state.get() self.name.set(f"{state_name.lower()}_hook.sh") - def set(self, hook: core_pb2.Hook) -> None: + def set(self, hook: Hook) -> None: + self.editing = True self.hook = hook self.name.set(hook.file) self.codetext.text.delete(1.0, tk.END) self.codetext.text.insert(tk.END, hook.data) - state_name = core_pb2.SessionState.Enum.Name(hook.state) + state_name = hook.state.name self.state.set(state_name) def save(self) -> None: data = self.codetext.text.get("1.0", tk.END).strip() - state_value = core_pb2.SessionState.Enum.Value(self.state.get()) - self.hook.file = self.name.get() - self.hook.data = data - self.hook.state = state_value + state = SessionState[self.state.get()] + file_name = self.name.get() + if self.editing: + self.hook.state = state + self.hook.file = file_name + self.hook.data = data + else: + self.hook = Hook(state=state, file=file_name, data=data) self.destroy() @@ -94,6 +102,7 @@ class HooksDialog(Dialog): self.edit_button: Optional[ttk.Button] = None self.delete_button: Optional[ttk.Button] = None self.selected: Optional[str] = None + self.selected_index: Optional[int] = None self.draw() def draw(self) -> None: @@ -133,10 +142,13 @@ class HooksDialog(Dialog): self.listbox.insert(tk.END, hook.file) def click_edit(self) -> None: - hook = self.app.core.hooks[self.selected] + hook = self.app.core.hooks.pop(self.selected) dialog = HookDialog(self, self.app) dialog.set(hook) dialog.show() + self.app.core.hooks[hook.file] = hook + self.listbox.delete(self.selected_index) + self.listbox.insert(self.selected_index, hook.file) def click_delete(self) -> None: del self.app.core.hooks[self.selected] @@ -146,11 +158,12 @@ class HooksDialog(Dialog): def select(self, event: tk.Event) -> None: if self.listbox.curselection(): - index = self.listbox.curselection()[0] - self.selected = self.listbox.get(index) + self.selected_index = self.listbox.curselection()[0] + self.selected = self.listbox.get(self.selected_index) self.edit_button.config(state=tk.NORMAL) self.delete_button.config(state=tk.NORMAL) else: self.selected = None + self.selected_index = None self.edit_button.config(state=tk.DISABLED) self.delete_button.config(state=tk.DISABLED) diff --git a/daemon/core/gui/wrappers.py b/daemon/core/gui/wrappers.py new file mode 100644 index 00000000..217ab321 --- /dev/null +++ b/daemon/core/gui/wrappers.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass +from enum import Enum +from typing import List + +from core.api.grpc import core_pb2 + + +class SessionState(Enum): + DEFINITION = 1 + CONFIGURATION = 2 + INSTANTIATION = 3 + RUNTIME = 4 + DATACOLLECT = 5 + SHUTDOWN = 6 + + +class NodeType(Enum): + DEFAULT = 0 + PHYSICAL = 1 + SWITCH = 4 + HUB = 5 + WIRELESS_LAN = 6 + RJ45 = 7 + TUNNEL = 8 + EMANE = 10 + TAP_BRIDGE = 11 + PEER_TO_PEER = 12 + CONTROL_NET = 13 + DOCKER = 15 + LXC = 16 + + +@dataclass +class Hook: + state: SessionState + file: str + data: str + + @classmethod + def from_proto(cls, hook: core_pb2.Hook) -> "Hook": + return Hook(state=SessionState(hook.state), file=hook.file, data=hook.data) + + def to_proto(self) -> core_pb2.Hook: + return core_pb2.Hook(state=self.state.value, file=self.file, data=self.data) + + +@dataclass +class Position: + x: float + y: float + + @classmethod + def from_proto(cls, position: core_pb2.Position) -> "Position": + return Position(x=position.x, y=position.y) + + def to_proto(self) -> core_pb2.Position: + return core_pb2.Position(x=self.x, y=self.y) + + +@dataclass +class Geo: + lat: float = None + lon: float = None + alt: float = None + + @classmethod + def from_proto(cls, geo: core_pb2.Geo) -> "Geo": + return Geo(lat=geo.lat, lon=geo.lon, alt=geo.alt) + + def to_proto(self) -> core_pb2.Geo: + return core_pb2.Geo(lat=self.lat, lon=self.lon, alt=self.alt) + + +@dataclass +class Node: + id: int + name: str + type: NodeType + model: str = None + position: Position = None + services: List[str] = None + config_services: List[str] = None + emane: str = None + icon: str = None + image: str = None + server: str = None + geo: Geo = None + dir: str = None + channel: str = None + + @classmethod + def from_proto(cls, node: core_pb2.Node) -> "Node": + return Node( + id=node.id, + name=node.name, + type=NodeType(node.type), + model=node.model, + position=Position.from_proto(node.position), + services=list(node.services), + config_services=list(node.config_services), + emane=node.emane, + icon=node.icon, + image=node.image, + server=node.server, + geo=Geo.from_proto(node.geo), + dir=node.dir, + channel=node.channel, + ) + + def to_proto(self) -> core_pb2.Node: + return core_pb2.Node( + id=self.id, + name=self.name, + type=self.type.value, + model=self.model, + position=self.position.to_proto(), + services=self.services, + config_services=self.config_services, + emane=self.emane, + icon=self.icon, + image=self.image, + server=self.server, + geo=self.geo.to_proto(), + dir=self.dir, + channel=self.channel, + ) From 77f6577bce08437f3dae737143ff8482e0501e06 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 25 Jul 2020 10:30:14 -0700 Subject: [PATCH 343/929] pygui: added wrappers for most usages of protobufs within pygui --- daemon/core/gui/coreclient.py | 126 ++++--- daemon/core/gui/dialogs/alerts.py | 23 +- .../core/gui/dialogs/configserviceconfig.py | 4 +- daemon/core/gui/dialogs/emaneconfig.py | 3 +- daemon/core/gui/dialogs/linkconfig.py | 29 +- daemon/core/gui/dialogs/mobilityconfig.py | 3 +- daemon/core/gui/dialogs/mobilityplayer.py | 3 +- daemon/core/gui/dialogs/nodeconfig.py | 2 +- daemon/core/gui/dialogs/sessionoptions.py | 4 +- daemon/core/gui/dialogs/sessions.py | 7 +- daemon/core/gui/dialogs/wlanconfig.py | 3 +- daemon/core/gui/frames/link.py | 4 +- daemon/core/gui/frames/node.py | 2 +- daemon/core/gui/graph/edges.py | 11 +- daemon/core/gui/graph/graph.py | 35 +- daemon/core/gui/graph/node.py | 3 +- daemon/core/gui/images.py | 2 +- daemon/core/gui/interface.py | 14 +- daemon/core/gui/nodeutils.py | 4 +- daemon/core/gui/statusbar.py | 4 +- daemon/core/gui/toolbar.py | 11 +- daemon/core/gui/widgets.py | 14 +- daemon/core/gui/wrappers.py | 337 +++++++++++++++++- 23 files changed, 475 insertions(+), 173 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 97399556..c41caeca 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -13,27 +13,8 @@ from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Tuple import grpc from core.api.grpc import client -from core.api.grpc.common_pb2 import ConfigOption from core.api.grpc.configservices_pb2 import ConfigService, ConfigServiceConfig -from core.api.grpc.core_pb2 import ( - CpuUsageEvent, - Event, - ExceptionEvent, - Interface, - Link, - LinkEvent, - LinkType, - MessageType, - Node, - NodeEvent, - NodeType, - Position, - SessionLocation, - SessionState, - StartSessionResponse, - StopSessionResponse, - ThroughputsEvent, -) +from core.api.grpc.core_pb2 import CpuUsageEvent, Event, ThroughputsEvent from core.api.grpc.emane_pb2 import EmaneModelConfig from core.api.grpc.mobility_pb2 import MobilityConfig from core.api.grpc.services_pb2 import NodeServiceData, ServiceConfig, ServiceFileConfig @@ -50,7 +31,22 @@ from core.gui.graph.shape import AnnotationData, Shape from core.gui.graph.shapeutils import ShapeType from core.gui.interface import InterfaceManager from core.gui.nodeutils import NodeDraw, NodeUtils -from core.gui.wrappers import Hook +from core.gui.wrappers import ( + ConfigOption, + ExceptionEvent, + Hook, + Interface, + Link, + LinkEvent, + LinkType, + MessageType, + Node, + NodeEvent, + NodeType, + Position, + SessionLocation, + SessionState, +) if TYPE_CHECKING: from core.gui.app import Application @@ -165,12 +161,13 @@ class CoreClient: return if event.HasField("link_event"): - self.app.after(0, self.handle_link_event, event.link_event) + link_event = LinkEvent.from_proto(event.link_event) + self.app.after(0, self.handle_link_event, link_event) elif event.HasField("session_event"): logging.info("session event: %s", event) session_event = event.session_event - if session_event.event <= SessionState.SHUTDOWN: - self.state = event.session_event.event + if session_event.event <= SessionState.SHUTDOWN.value: + self.state = SessionState(session_event.event) elif session_event.event in {7, 8, 9}: node_id = session_event.node_id dialog = self.mobility_players.get(node_id) @@ -184,10 +181,12 @@ class CoreClient: else: logging.warning("unknown session event: %s", session_event) elif event.HasField("node_event"): - self.app.after(0, self.handle_node_event, event.node_event) + node_event = NodeEvent.from_proto(event.node_event) + self.app.after(0, self.handle_node_event, node_event) elif event.HasField("config_event"): logging.info("config event: %s", event) elif event.HasField("exception_event"): + event = ExceptionEvent.from_proto(event.session_id, event.exception_event) self.handle_exception_event(event) else: logging.info("unhandled event: %s", event) @@ -307,7 +306,7 @@ class CoreClient: try: response = self.client.get_session(self.session_id) session = response.session - self.state = session.state + self.state = SessionState(session.state) self.handling_events = self.client.events( self.session_id, self.handle_events ) @@ -324,7 +323,7 @@ class CoreClient: # get location if query_location: response = self.client.get_session_location(self.session_id) - self.location = response.location + self.location = SessionLocation.from_proto(response.location) # get emane models response = self.client.get_emane_models(self.session_id) @@ -338,20 +337,22 @@ class CoreClient: # get emane config response = self.client.get_emane_config(self.session_id) - self.emane_config = response.config + self.emane_config = ConfigOption.from_dict(response.config) # update interface manager self.ifaces_manager.joined(session.links) # draw session - self.app.canvas.reset_and_redraw(session) + nodes = [Node.from_proto(x) for x in session.nodes] + links = [Link.from_proto(x) for x in session.links] + self.app.canvas.reset_and_redraw(nodes, links) # get mobility configs response = self.client.get_mobility_configs(self.session_id) for node_id in response.configs: config = response.configs[node_id].config canvas_node = self.canvas_nodes[node_id] - canvas_node.mobility_config = dict(config) + canvas_node.mobility_config = ConfigOption.from_dict(config) # get emane model config response = self.client.get_emane_model_configs(self.session_id) @@ -360,16 +361,16 @@ class CoreClient: if config.iface_id != -1: iface_id = config.iface_id canvas_node = self.canvas_nodes[config.node_id] - canvas_node.emane_model_configs[(config.model, iface_id)] = dict( - config.config - ) + canvas_node.emane_model_configs[ + (config.model, iface_id) + ] = ConfigOption.from_dict(config.config) # get wlan configurations response = self.client.get_wlan_configs(self.session_id) for _id in response.configs: mapped_config = response.configs[_id] canvas_node = self.canvas_nodes[_id] - canvas_node.wlan_config = dict(mapped_config.config) + canvas_node.wlan_config = ConfigOption.from_dict(mapped_config.config) # get service configurations response = self.client.get_node_service_configs(self.session_id) @@ -501,7 +502,6 @@ class CoreClient: """ try: self.client.connect() - self.setup_cpu_usage() # get service information response = self.client.get_services() @@ -546,8 +546,9 @@ class CoreClient: def edit_node(self, core_node: Node) -> None: try: + position = core_node.position.to_proto() self.client.edit_node( - self.session_id, core_node.id, core_node.position, source=GUI_SOURCE + self.session_id, core_node.id, position, source=GUI_SOURCE ) except grpc.RpcError as e: self.app.show_grpc_exception("Edit Node Error", e) @@ -556,18 +557,17 @@ class CoreClient: for server in self.servers.values(): self.client.add_session_server(self.session_id, server.name, server.address) - def start_session(self) -> StartSessionResponse: + def start_session(self) -> Tuple[bool, List[str]]: self.ifaces_manager.reset_mac() - nodes = [x.core_node for x in self.canvas_nodes.values()] + nodes = [x.core_node.to_proto() for x in self.canvas_nodes.values()] links = [] for edge in self.links.values(): - link = Link() - link.CopyFrom(edge.link) - if link.HasField("iface1") and not link.iface1.mac: + link = edge.link + if link.iface1 and not link.iface1.mac: link.iface1.mac = self.ifaces_manager.next_mac() - if link.HasField("iface2") and not link.iface2.mac: + if link.iface2 and not link.iface2.mac: link.iface2.mac = self.ifaces_manager.next_mac() - links.append(link) + links.append(link.to_proto()) wlan_configs = self.get_wlan_configs_proto() mobility_configs = self.get_mobility_configs_proto() emane_model_configs = self.get_emane_model_configs_proto() @@ -582,14 +582,15 @@ class CoreClient: emane_config = {x: self.emane_config[x].value for x in self.emane_config} else: emane_config = None - response = StartSessionResponse(result=False) + result = False + exceptions = [] try: self.send_servers() response = self.client.start_session( self.session_id, nodes, links, - self.location, + self.location.to_proto(), hooks, emane_config, emane_model_configs, @@ -605,20 +606,23 @@ class CoreClient: ) if response.result: self.set_metadata() + result = response.result + exceptions = response.exceptions except grpc.RpcError as e: self.app.show_grpc_exception("Start Session Error", e) - return response + return result, exceptions - def stop_session(self, session_id: int = None) -> StopSessionResponse: + def stop_session(self, session_id: int = None) -> bool: if not session_id: session_id = self.session_id - response = StopSessionResponse(result=False) + result = False try: response = self.client.stop_session(session_id) logging.info("stopped session(%s), result: %s", session_id, response) + result = response.result except grpc.RpcError as e: self.app.show_grpc_exception("Stop Session Error", e) - return response + return result def show_mobility_players(self) -> None: for canvas_node in self.canvas_nodes.values(): @@ -920,7 +924,7 @@ class CoreClient: ) return node - def deleted_graph_nodes(self, canvas_nodes: List[Node]) -> None: + def deleted_graph_nodes(self, canvas_nodes: List[CanvasNode]) -> None: """ remove the nodes selected by the user and anything related to that node such as link, configurations, interfaces @@ -951,13 +955,7 @@ class CoreClient: ip6=ip6, ip6_mask=ip6_mask, ) - logging.info( - "create node(%s) interface(%s) IPv4(%s) IPv6(%s)", - node.name, - iface.name, - iface.ip4, - iface.ip6, - ) + logging.info("create node(%s) interface(%s)", node.name, iface) return iface def create_link( @@ -1010,8 +1008,7 @@ class CoreClient: continue if not canvas_node.wlan_config: continue - config = canvas_node.wlan_config - config = {x: config[x].value for x in config} + config = ConfigOption.to_dict(canvas_node.wlan_config) node_id = canvas_node.core_node.id wlan_config = WlanConfig(node_id=node_id, config=config) configs.append(wlan_config) @@ -1024,8 +1021,7 @@ class CoreClient: continue if not canvas_node.mobility_config: continue - config = canvas_node.mobility_config - config = {x: config[x].value for x in config} + config = ConfigOption.to_dict(canvas_node.mobility_config) node_id = canvas_node.core_node.id mobility_config = MobilityConfig(node_id=node_id, config=config) configs.append(mobility_config) @@ -1039,7 +1035,7 @@ class CoreClient: node_id = canvas_node.core_node.id for key, config in canvas_node.emane_model_configs.items(): model, iface_id = key - config = {x: config[x].value for x in config} + config = ConfigOption.to_dict(config) if iface_id is None: iface_id = -1 config_proto = EmaneModelConfig( @@ -1116,7 +1112,7 @@ class CoreClient: node_id, config, ) - return dict(config) + return ConfigOption.from_dict(config) def get_mobility_config(self, node_id: int) -> Dict[str, ConfigOption]: response = self.client.get_mobility_config(self.session_id, node_id) @@ -1126,7 +1122,7 @@ class CoreClient: node_id, config, ) - return dict(config) + return ConfigOption.from_dict(config) def get_emane_model_config( self, node_id: int, model: str, iface_id: int = None @@ -1145,7 +1141,7 @@ class CoreClient: iface_id, config, ) - return dict(config) + return ConfigOption.from_dict(config) def execute_script(self, script) -> None: response = self.client.execute_script(script) diff --git a/daemon/core/gui/dialogs/alerts.py b/daemon/core/gui/dialogs/alerts.py index 8e0aa02e..fd6d342e 100644 --- a/daemon/core/gui/dialogs/alerts.py +++ b/daemon/core/gui/dialogs/alerts.py @@ -5,10 +5,10 @@ import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING, Dict, Optional -from core.api.grpc.core_pb2 import ExceptionEvent, ExceptionLevel from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY from core.gui.widgets import CodeText +from core.gui.wrappers import ExceptionEvent, ExceptionLevel if TYPE_CHECKING: from core.gui.app import Application @@ -49,9 +49,8 @@ class AlertsDialog(Dialog): self.tree.heading("source", text="Source") self.tree.bind("<>", self.click_select) - for alarm in self.app.statusbar.core_alarms: - exception = alarm.exception_event - level_name = ExceptionLevel.Enum.Name(exception.level) + for exception in self.app.statusbar.core_alarms: + level_name = exception.level.name node_id = exception.node_id if exception.node_id else "" insert_id = self.tree.insert( "", @@ -60,21 +59,21 @@ class AlertsDialog(Dialog): values=( exception.date, level_name, - alarm.session_id, + exception.session_id, node_id, exception.source, ), tags=(level_name,), ) - self.alarm_map[insert_id] = alarm + self.alarm_map[insert_id] = exception - error_name = ExceptionLevel.Enum.Name(ExceptionLevel.ERROR) + error_name = ExceptionLevel.ERROR.name self.tree.tag_configure(error_name, background="#ff6666") - fatal_name = ExceptionLevel.Enum.Name(ExceptionLevel.FATAL) + fatal_name = ExceptionLevel.FATAL.name self.tree.tag_configure(fatal_name, background="#d9d9d9") - warning_name = ExceptionLevel.Enum.Name(ExceptionLevel.WARNING) + warning_name = ExceptionLevel.WARNING.name self.tree.tag_configure(warning_name, background="#ffff99") - notice_name = ExceptionLevel.Enum.Name(ExceptionLevel.NOTICE) + notice_name = ExceptionLevel.NOTICE.name self.tree.tag_configure(notice_name, background="#85e085") yscrollbar = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview) @@ -108,8 +107,8 @@ class AlertsDialog(Dialog): def click_select(self, event: tk.Event) -> None: current = self.tree.selection()[0] - alarm = self.alarm_map[current] + exception = self.alarm_map[current] self.codetext.text.config(state=tk.NORMAL) self.codetext.text.delete(1.0, tk.END) - self.codetext.text.insert(1.0, alarm.exception_event.text) + self.codetext.text.insert(1.0, exception.text) self.codetext.text.config(state=tk.DISABLED) diff --git a/daemon/core/gui/dialogs/configserviceconfig.py b/daemon/core/gui/dialogs/configserviceconfig.py index c2d42ee4..5a6a89a8 100644 --- a/daemon/core/gui/dialogs/configserviceconfig.py +++ b/daemon/core/gui/dialogs/configserviceconfig.py @@ -8,11 +8,11 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Set import grpc -from core.api.grpc.common_pb2 import ConfigOption from core.api.grpc.services_pb2 import ServiceValidationMode from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CodeText, ConfigFrame, ListboxScroll +from core.gui.wrappers import ConfigOption if TYPE_CHECKING: from core.gui.app import Application @@ -99,7 +99,7 @@ class ConfigServiceConfigDialog(Dialog): service_config = self.canvas_node.config_service_configs.get( self.service_name, {} ) - self.config = response.config + self.config = ConfigOption.from_dict(response.config) self.default_config = {x.name: x.value for x in self.config.values()} custom_config = service_config.get("config") if custom_config: diff --git a/daemon/core/gui/dialogs/emaneconfig.py b/daemon/core/gui/dialogs/emaneconfig.py index bb334757..d87e935a 100644 --- a/daemon/core/gui/dialogs/emaneconfig.py +++ b/daemon/core/gui/dialogs/emaneconfig.py @@ -8,12 +8,11 @@ from typing import TYPE_CHECKING, Dict, List, Optional import grpc -from core.api.grpc.common_pb2 import ConfigOption -from core.api.grpc.core_pb2 import Node from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum, Images from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame +from core.gui.wrappers import ConfigOption, Node if TYPE_CHECKING: from core.gui.app import Application diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index 28798ec1..87f43284 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -5,11 +5,11 @@ import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING, Optional -from core.api.grpc import core_pb2 from core.gui import validation from core.gui.dialogs.colorpicker import ColorPickerDialog from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY +from core.gui.wrappers import Interface, Link, LinkOptions if TYPE_CHECKING: from core.gui.app import Application @@ -21,7 +21,7 @@ def get_int(var: tk.StringVar) -> Optional[int]: if value != "": return int(value) else: - return None + return 0 def get_float(var: tk.StringVar) -> Optional[float]: @@ -29,14 +29,15 @@ def get_float(var: tk.StringVar) -> Optional[float]: if value != "": return float(value) else: - return None + return 0.0 class LinkConfigurationDialog(Dialog): def __init__(self, app: "Application", edge: "CanvasEdge") -> None: super().__init__(app, "Link Configuration") self.edge: "CanvasEdge" = edge - self.is_symmetric: bool = edge.link.options.unidirectional is False + + self.is_symmetric: bool = edge.link.is_symmetric() if self.is_symmetric: symmetry_var = tk.StringVar(value=">>") else: @@ -223,32 +224,32 @@ class LinkConfigurationDialog(Dialog): delay = get_int(self.delay) duplicate = get_int(self.duplicate) loss = get_float(self.loss) - options = core_pb2.LinkOptions( + options = LinkOptions( bandwidth=bandwidth, jitter=jitter, delay=delay, dup=duplicate, loss=loss ) - link.options.CopyFrom(options) + link.options = options iface1_id = None - if link.HasField("iface1"): + if link.iface1: iface1_id = link.iface1.id iface2_id = None - if link.HasField("iface2"): + if link.iface2: iface2_id = link.iface2.id if not self.is_symmetric: link.options.unidirectional = True asym_iface1 = None if iface1_id: - asym_iface1 = core_pb2.Interface(id=iface1_id) + asym_iface1 = Interface(id=iface1_id) asym_iface2 = None if iface2_id: - asym_iface2 = core_pb2.Interface(id=iface2_id) + asym_iface2 = Interface(id=iface2_id) down_bandwidth = get_int(self.down_bandwidth) down_jitter = get_int(self.down_jitter) down_delay = get_int(self.down_delay) down_duplicate = get_int(self.down_duplicate) down_loss = get_float(self.down_loss) - options = core_pb2.LinkOptions( + options = LinkOptions( bandwidth=down_bandwidth, jitter=down_jitter, delay=down_delay, @@ -256,7 +257,7 @@ class LinkConfigurationDialog(Dialog): loss=down_loss, unidirectional=True, ) - self.edge.asymmetric_link = core_pb2.Link( + self.edge.asymmetric_link = Link( node1_id=link.node2_id, node2_id=link.node1_id, iface1=asym_iface1, @@ -267,7 +268,7 @@ class LinkConfigurationDialog(Dialog): link.options.unidirectional = False self.edge.asymmetric_link = None - if self.app.core.is_runtime() and link.HasField("options"): + if self.app.core.is_runtime() and link.options: session_id = self.app.core.session_id self.app.core.client.edit_link( session_id, @@ -316,7 +317,7 @@ class LinkConfigurationDialog(Dialog): color = self.app.canvas.itemcget(self.edge.id, "fill") self.color.set(color) link = self.edge.link - if link.HasField("options"): + if link.options: self.bandwidth.set(str(link.options.bandwidth)) self.jitter.set(str(link.options.jitter)) self.duplicate.set(str(link.options.dup)) diff --git a/daemon/core/gui/dialogs/mobilityconfig.py b/daemon/core/gui/dialogs/mobilityconfig.py index daaf9ea5..ca9caf43 100644 --- a/daemon/core/gui/dialogs/mobilityconfig.py +++ b/daemon/core/gui/dialogs/mobilityconfig.py @@ -6,11 +6,10 @@ from typing import TYPE_CHECKING, Dict, Optional import grpc -from core.api.grpc.common_pb2 import ConfigOption -from core.api.grpc.core_pb2 import Node from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame +from core.gui.wrappers import ConfigOption, Node if TYPE_CHECKING: from core.gui.app import Application diff --git a/daemon/core/gui/dialogs/mobilityplayer.py b/daemon/core/gui/dialogs/mobilityplayer.py index e6ef62ea..16aa8ea0 100644 --- a/daemon/core/gui/dialogs/mobilityplayer.py +++ b/daemon/core/gui/dialogs/mobilityplayer.py @@ -4,12 +4,11 @@ from typing import TYPE_CHECKING, Dict, Optional import grpc -from core.api.grpc.common_pb2 import ConfigOption -from core.api.grpc.core_pb2 import Node from core.api.grpc.mobility_pb2 import MobilityAction from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum from core.gui.themes import PADX, PADY +from core.gui.wrappers import ConfigOption, Node if TYPE_CHECKING: from core.gui.app import Application diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index 9e958283..33c8fb32 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Dict, Optional import netaddr from PIL.ImageTk import PhotoImage -from core.api.grpc.core_pb2 import Node from core.gui import nodeutils, validation from core.gui.appconfig import ICONS_PATH from core.gui.dialogs.dialog import Dialog @@ -16,6 +15,7 @@ from core.gui.images import Images from core.gui.nodeutils import NodeUtils from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import ListboxScroll, image_chooser +from core.gui.wrappers import Node if TYPE_CHECKING: from core.gui.app import Application diff --git a/daemon/core/gui/dialogs/sessionoptions.py b/daemon/core/gui/dialogs/sessionoptions.py index fd021fee..24bacb30 100644 --- a/daemon/core/gui/dialogs/sessionoptions.py +++ b/daemon/core/gui/dialogs/sessionoptions.py @@ -5,10 +5,10 @@ from typing import TYPE_CHECKING, Dict, Optional import grpc -from core.api.grpc.common_pb2 import ConfigOption from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame +from core.gui.wrappers import ConfigOption if TYPE_CHECKING: from core.gui.app import Application @@ -28,7 +28,7 @@ class SessionOptionsDialog(Dialog): try: session_id = self.app.core.session_id response = self.app.core.client.get_session_options(session_id) - return response.config + return ConfigOption.from_dict(response.config) except grpc.RpcError as e: self.app.show_grpc_exception("Get Session Options Error", e) self.has_error = True diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index a7d702eb..75b9dcf4 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -5,12 +5,11 @@ from typing import TYPE_CHECKING, List, Optional import grpc -from core.api.grpc import core_pb2 -from core.api.grpc.core_pb2 import SessionSummary from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum, Images from core.gui.task import ProgressTask from core.gui.themes import PADX, PADY +from core.gui.wrappers import SessionState, SessionSummary if TYPE_CHECKING: from core.gui.app import Application @@ -33,7 +32,7 @@ class SessionsDialog(Dialog): try: response = self.app.core.client.get_sessions() logging.info("sessions: %s", response) - return response.sessions + return [SessionSummary.from_proto(x) for x in response.sessions] except grpc.RpcError as e: self.app.show_grpc_exception("Get Sessions Error", e) self.destroy() @@ -82,7 +81,7 @@ class SessionsDialog(Dialog): self.tree.heading("nodes", text="Node Count") for index, session in enumerate(self.sessions): - state_name = core_pb2.SessionState.Enum.Name(session.state) + state_name = SessionState(session.state).name self.tree.insert( "", tk.END, diff --git a/daemon/core/gui/dialogs/wlanconfig.py b/daemon/core/gui/dialogs/wlanconfig.py index 326b3195..17f62dfb 100644 --- a/daemon/core/gui/dialogs/wlanconfig.py +++ b/daemon/core/gui/dialogs/wlanconfig.py @@ -3,11 +3,10 @@ from typing import TYPE_CHECKING, Dict, Optional import grpc -from core.api.grpc.common_pb2 import ConfigOption -from core.api.grpc.core_pb2 import Node from core.gui.dialogs.dialog import Dialog from core.gui.themes import PADX, PADY from core.gui.widgets import ConfigFrame +from core.gui.wrappers import ConfigOption, Node if TYPE_CHECKING: from core.gui.app import Application diff --git a/daemon/core/gui/frames/link.py b/daemon/core/gui/frames/link.py index 57b1bf66..cbea9982 100644 --- a/daemon/core/gui/frames/link.py +++ b/daemon/core/gui/frames/link.py @@ -1,9 +1,9 @@ import tkinter as tk from typing import TYPE_CHECKING, Optional -from core.api.grpc.core_pb2 import Interface from core.gui.frames.base import DetailsFrame, InfoFrameBase from core.gui.utils import bandwidth_text +from core.gui.wrappers import Interface if TYPE_CHECKING: from core.gui.app import Application @@ -62,7 +62,7 @@ class EdgeInfoFrame(InfoFrameBase): ip6 = f"{iface2.ip6}/{iface2.ip6_mask}" if iface2.ip6 else "" frame.add_detail("IP6", ip6) - if link.HasField("options"): + if link.options: frame.add_separator() bandwidth = bandwidth_text(options.bandwidth) frame.add_detail("Bandwidth", bandwidth) diff --git a/daemon/core/gui/frames/node.py b/daemon/core/gui/frames/node.py index 7480e056..577cc489 100644 --- a/daemon/core/gui/frames/node.py +++ b/daemon/core/gui/frames/node.py @@ -1,8 +1,8 @@ from typing import TYPE_CHECKING -from core.api.grpc.core_pb2 import NodeType from core.gui.frames.base import DetailsFrame, InfoFrameBase from core.gui.nodeutils import NodeUtils +from core.gui.wrappers import NodeType if TYPE_CHECKING: from core.gui.app import Application diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index d9085910..610b6cc0 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -3,14 +3,13 @@ import math import tkinter as tk from typing import TYPE_CHECKING, Optional, Tuple -from core.api.grpc import core_pb2 -from core.api.grpc.core_pb2 import Interface, Link from core.gui import themes from core.gui.dialogs.linkconfig import LinkConfigurationDialog from core.gui.frames.link import EdgeInfoFrame, WirelessEdgeInfoFrame from core.gui.graph import tags from core.gui.nodeutils import NodeUtils from core.gui.utils import bandwidth_text +from core.gui.wrappers import Interface, Link if TYPE_CHECKING: from core.gui.graph.graph import CanvasGraph @@ -305,7 +304,7 @@ class CanvasEdge(Edge): self.link = link self.draw_labels() - def iface_label(self, iface: core_pb2.Interface) -> str: + def iface_label(self, iface: Interface) -> str: label = "" if iface.name and self.canvas.show_iface_names.get(): label = f"{iface.name}" @@ -319,10 +318,10 @@ class CanvasEdge(Edge): def create_node_labels(self) -> Tuple[str, str]: label1 = None - if self.link.HasField("iface1"): + if self.link.iface1: label1 = self.iface_label(self.link.iface1) label2 = None - if self.link.HasField("iface2"): + if self.link.iface2: label2 = self.iface_label(self.link.iface2) return label1, label2 @@ -417,6 +416,8 @@ class CanvasEdge(Edge): dialog.show() def draw_link_options(self): + if not self.link.options: + return options = self.link.options lines = [] bandwidth = options.bandwidth diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 56a31c3f..8b053c78 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -7,14 +7,7 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple from PIL import Image from PIL.ImageTk import PhotoImage -from core.api.grpc.core_pb2 import ( - Interface, - Link, - LinkType, - Node, - Session, - ThroughputsEvent, -) +from core.api.grpc.core_pb2 import ThroughputsEvent from core.gui.dialogs.shapemod import ShapeDialog from core.gui.graph import tags from core.gui.graph.edges import ( @@ -30,6 +23,7 @@ from core.gui.graph.shape import Shape from core.gui.graph.shapeutils import ShapeType, is_draw_shape, is_marker from core.gui.images import ImageEnum, TypeToImage from core.gui.nodeutils import NodeDraw, NodeUtils +from core.gui.wrappers import Interface, Link, LinkType, Node if TYPE_CHECKING: from core.gui.app import Application @@ -134,12 +128,7 @@ class CanvasGraph(tk.Canvas): ) self.configure(scrollregion=self.bbox(tk.ALL)) - def reset_and_redraw(self, session: Session) -> None: - """ - Reset the private variables CanvasGraph object, redraw nodes given the new grpc - client. - :param session: session to draw - """ + def reset_and_redraw(self, nodes: List[Node], links: List[Link]) -> None: # reset view options to default state self.show_node_labels.set(True) self.show_link_labels.set(True) @@ -164,7 +153,7 @@ class CanvasGraph(tk.Canvas): self.wireless_edges.clear() self.wireless_network.clear() self.drawing_edge = None - self.draw_session(session) + self.draw_session(nodes, links) def setup_bindings(self) -> None: """ @@ -251,12 +240,12 @@ class CanvasGraph(tk.Canvas): dst.edges.add(edge) self.edges[edge.token] = edge self.core.links[edge.token] = edge - if link.HasField("iface1"): + if link.iface1: iface1 = link.iface1 self.core.iface_to_edge[(node1.id, iface1.id)] = token src.ifaces[iface1.id] = iface1 edge.src_iface = iface1 - if link.HasField("iface2"): + if link.iface2: iface2 = link.iface2 self.core.iface_to_edge[(node2.id, iface2.id)] = edge.token dst.ifaces[iface2.id] = iface2 @@ -337,19 +326,19 @@ class CanvasGraph(tk.Canvas): self.nodes[node.id] = node self.core.canvas_nodes[core_node.id] = node - def draw_session(self, session: Session) -> None: + def draw_session(self, nodes: List[Node], links: List[Link]) -> None: """ Draw existing session. """ # draw existing nodes - for core_node in session.nodes: + for core_node in nodes: # peer to peer node is not drawn on the GUI if NodeUtils.is_ignore_node(core_node.type): continue self.add_core_node(core_node) # draw existing links - for link in session.links: + for link in links: logging.debug("drawing link: %s", link) canvas_node1 = self.core.canvas_nodes[link.node1_id] canvas_node2 = self.core.canvas_nodes[link.node2_id] @@ -987,12 +976,12 @@ class CanvasGraph(tk.Canvas): copy_edge = self.edges[token] copy_link = copy_edge.link options = edge.link.options - copy_link.options.CopyFrom(options) + copy_link.options = deepcopy(options) iface1_id = None - if copy_link.HasField("iface1"): + if copy_link.iface1: iface1_id = copy_link.iface1.id iface2_id = None - if copy_link.HasField("iface2"): + if copy_link.iface2: iface2_id = copy_link.iface2.id if not options.unidirectional: copy_edge.asymmetric_link = None diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 7b5cd2f3..df6476c7 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -6,8 +6,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple import grpc from PIL.ImageTk import PhotoImage -from core.api.grpc.common_pb2 import ConfigOption -from core.api.grpc.core_pb2 import Interface, Node, NodeType from core.api.grpc.services_pb2 import NodeServiceData from core.gui import themes from core.gui.dialogs.emaneconfig import EmaneConfigDialog @@ -22,6 +20,7 @@ from core.gui.graph.edges import CanvasEdge, CanvasWirelessEdge from core.gui.graph.tooltip import CanvasTooltip from core.gui.images import ImageEnum from core.gui.nodeutils import ANTENNA_SIZE, NodeUtils +from core.gui.wrappers import ConfigOption, Interface, Node, NodeType if TYPE_CHECKING: from core.gui.app import Application diff --git a/daemon/core/gui/images.py b/daemon/core/gui/images.py index 22719457..0a2f4d5d 100644 --- a/daemon/core/gui/images.py +++ b/daemon/core/gui/images.py @@ -5,8 +5,8 @@ from typing import Dict, Optional, Tuple from PIL import Image from PIL.ImageTk import PhotoImage -from core.api.grpc.core_pb2 import NodeType from core.gui.appconfig import LOCAL_ICONS_PATH +from core.gui.wrappers import NodeType class Images: diff --git a/daemon/core/gui/interface.py b/daemon/core/gui/interface.py index f4f2e3cc..f5b1461e 100644 --- a/daemon/core/gui/interface.py +++ b/daemon/core/gui/interface.py @@ -4,9 +4,9 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple import netaddr from netaddr import EUI, IPNetwork -from core.api.grpc.core_pb2 import Interface, Link, Node from core.gui.graph.node import CanvasNode from core.gui.nodeutils import NodeUtils +from core.gui.wrappers import Interface, Link, Node if TYPE_CHECKING: from core.gui.app import Application @@ -89,10 +89,10 @@ class InterfaceManager: remaining_subnets = set() for edge in self.app.core.links.values(): link = edge.link - if link.HasField("iface1"): + if link.iface1: subnets = self.get_subnets(link.iface1) remaining_subnets.add(subnets) - if link.HasField("iface2"): + if link.iface2: subnets = self.get_subnets(link.iface2) remaining_subnets.add(subnets) @@ -100,9 +100,9 @@ class InterfaceManager: # or remove used indexes from subnet ifaces = [] for link in links: - if link.HasField("iface1"): + if link.iface1: ifaces.append(link.iface1) - if link.HasField("iface2"): + if link.iface2: ifaces.append(link.iface2) for iface in ifaces: subnets = self.get_subnets(iface) @@ -117,9 +117,9 @@ class InterfaceManager: def joined(self, links: List[Link]) -> None: ifaces = [] for link in links: - if link.HasField("iface1"): + if link.iface1: ifaces.append(link.iface1) - if link.HasField("iface2"): + if link.iface2: ifaces.append(link.iface2) # add to used subnets and mark used indexes diff --git a/daemon/core/gui/nodeutils.py b/daemon/core/gui/nodeutils.py index 08c8f31c..6c451303 100644 --- a/daemon/core/gui/nodeutils.py +++ b/daemon/core/gui/nodeutils.py @@ -3,9 +3,9 @@ from typing import List, Optional, Set from PIL.ImageTk import PhotoImage -from core.api.grpc.core_pb2 import Node, NodeType from core.gui.appconfig import CustomNode, GuiConfig from core.gui.images import ImageEnum, Images, TypeToImage +from core.gui.wrappers import Node, NodeType ICON_SIZE: int = 48 ANTENNA_SIZE: int = 32 @@ -17,7 +17,7 @@ class NodeDraw: self.image: Optional[PhotoImage] = None self.image_enum: Optional[ImageEnum] = None self.image_file: Optional[str] = None - self.node_type: NodeType = None + self.node_type: Optional[NodeType] = None self.model: Optional[str] = None self.services: Set[str] = set() self.label: Optional[str] = None diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py index 6989593e..d4304b6e 100644 --- a/daemon/core/gui/statusbar.py +++ b/daemon/core/gui/statusbar.py @@ -5,9 +5,9 @@ import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING, List, Optional -from core.api.grpc.core_pb2 import ExceptionEvent, ExceptionLevel from core.gui.dialogs.alerts import AlertsDialog from core.gui.themes import Styles +from core.gui.wrappers import ExceptionEvent, ExceptionLevel if TYPE_CHECKING: from core.gui.app import Application @@ -69,7 +69,7 @@ class StatusBar(ttk.Frame): def add_alert(self, event: ExceptionEvent) -> None: self.core_alarms.append(event) - level = event.exception_event.level + level = event.level self._set_alert_style(level) label = f"Alerts ({len(self.core_alarms)})" self.alerts_button.config(text=label, style=self.alert_style) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index 968b447d..b7b67338 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Callable, List, Optional from PIL.ImageTk import PhotoImage -from core.api.grpc import core_pb2 from core.gui.dialogs.colorpicker import ColorPickerDialog from core.gui.dialogs.runtool import RunToolDialog from core.gui.graph import tags @@ -300,15 +299,15 @@ class Toolbar(ttk.Frame): ) task.start() - def start_callback(self, response: core_pb2.StartSessionResponse) -> None: - if response.result: + def start_callback(self, result: bool, exceptions: List[str]) -> None: + if result: self.set_runtime() self.app.core.set_metadata() self.app.core.show_mobility_players() else: enable_buttons(self.design_frame, enabled=True) - if response.exceptions: - message = "\n".join(response.exceptions) + if exceptions: + message = "\n".join(exceptions) self.app.show_error("Start Session Error", message) def set_runtime(self) -> None: @@ -405,7 +404,7 @@ class Toolbar(ttk.Frame): ) task.start() - def stop_callback(self, response: core_pb2.StopSessionResponse) -> None: + def stop_callback(self, result: bool) -> None: self.set_design() self.app.canvas.stopped_session() diff --git a/daemon/core/gui/widgets.py b/daemon/core/gui/widgets.py index 81bad0f5..85f3da10 100644 --- a/daemon/core/gui/widgets.py +++ b/daemon/core/gui/widgets.py @@ -5,12 +5,10 @@ from pathlib import Path from tkinter import filedialog, font, ttk from typing import TYPE_CHECKING, Any, Callable, Dict, Set, Type -from core.api.grpc import core_pb2 -from core.api.grpc.common_pb2 import ConfigOption -from core.api.grpc.core_pb2 import ConfigOptionType from core.gui import themes, validation from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY +from core.gui.wrappers import ConfigOption, ConfigOptionType if TYPE_CHECKING: from core.gui.app import Application @@ -110,7 +108,7 @@ class ConfigFrame(ttk.Notebook): label = ttk.Label(tab.frame, text=option.label) label.grid(row=index, pady=PADY, padx=PADX, sticky="w") value = tk.StringVar() - if option.type == core_pb2.ConfigOptionType.BOOL: + if option.type == ConfigOptionType.BOOL: select = ("On", "Off") state = "readonly" if self.enabled else tk.DISABLED combobox = ttk.Combobox( @@ -129,7 +127,7 @@ class ConfigFrame(ttk.Notebook): tab.frame, textvariable=value, values=select, state=state ) combobox.grid(row=index, column=1, sticky="ew") - elif option.type == core_pb2.ConfigOptionType.STRING: + elif option.type == ConfigOptionType.STRING: value.set(option.value) state = tk.NORMAL if self.enabled else tk.DISABLED if "file" in option.label: @@ -153,7 +151,7 @@ class ConfigFrame(ttk.Notebook): tab.frame, textvariable=value, state=state ) entry.grid(row=index, column=1, sticky="ew") - elif option.type == core_pb2.ConfigOptionType.FLOAT: + elif option.type == ConfigOptionType.FLOAT: value.set(option.value) state = tk.NORMAL if self.enabled else tk.DISABLED entry = validation.PositiveFloatEntry( @@ -169,7 +167,7 @@ class ConfigFrame(ttk.Notebook): option = self.config[key] value = self.values[key] config_value = value.get() - if option.type == core_pb2.ConfigOptionType.BOOL: + if option.type == ConfigOptionType.BOOL: if config_value == "On": option.value = "1" else: @@ -182,7 +180,7 @@ class ConfigFrame(ttk.Notebook): for name, data in config.items(): option = self.config[name] value = self.values[name] - if option.type == core_pb2.ConfigOptionType.BOOL: + if option.type == ConfigOptionType.BOOL: if data == "1": data = "On" else: diff --git a/daemon/core/gui/wrappers.py b/daemon/core/gui/wrappers.py index 217ab321..f72cbac4 100644 --- a/daemon/core/gui/wrappers.py +++ b/daemon/core/gui/wrappers.py @@ -1,8 +1,22 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum -from typing import List +from typing import Dict, List -from core.api.grpc import core_pb2 +from core.api.grpc import common_pb2, core_pb2 + + +class ConfigOptionType(Enum): + UINT8 = 1 + UINT16 = 2 + UINT32 = 3 + UINT64 = 4 + INT8 = 5 + INT16 = 6 + INT32 = 7 + INT64 = 8 + FLOAT = 9 + STRING = 10 + BOOL = 11 class SessionState(Enum): @@ -30,6 +44,292 @@ class NodeType(Enum): LXC = 16 +class LinkType(Enum): + WIRELESS = 0 + WIRED = 1 + + +class ExceptionLevel(Enum): + DEFAULT = 0 + FATAL = 1 + ERROR = 2 + WARNING = 3 + NOTICE = 4 + + +class MessageType(Enum): + NONE = 0 + ADD = 1 + DELETE = 2 + CRI = 4 + LOCAL = 8 + STRING = 16 + TEXT = 32 + TTY = 64 + + +@dataclass +class SessionLocation: + x: float + y: float + z: float + lat: float + lon: float + alt: float + scale: float + + @classmethod + def from_proto(cls, location: core_pb2.SessionLocation) -> "SessionLocation": + return SessionLocation( + x=location.x, + y=location.y, + z=location.z, + lat=location.lat, + lon=location.lon, + alt=location.alt, + scale=location.scale, + ) + + def to_proto(self) -> core_pb2.SessionLocation: + return core_pb2.SessionLocation( + x=self.x, + y=self.y, + z=self.z, + lat=self.lat, + lon=self.lon, + alt=self.alt, + scale=self.scale, + ) + + +@dataclass +class ExceptionEvent: + session_id: int + node_id: int + level: ExceptionLevel + source: str + date: str + text: str + opaque: str + + @classmethod + def from_proto( + cls, session_id: int, event: core_pb2.ExceptionEvent + ) -> "ExceptionEvent": + return ExceptionEvent( + session_id=session_id, + node_id=event.node_id, + level=ExceptionLevel(event.level), + source=event.source, + date=event.date, + text=event.text, + opaque=event.opaque, + ) + + +@dataclass +class ConfigOption: + label: str + name: str + value: str + type: ConfigOptionType + group: str + select: List[str] = None + + @classmethod + def from_dict( + cls, config: Dict[str, common_pb2.ConfigOption] + ) -> Dict[str, "ConfigOption"]: + d = {} + for key, value in config.items(): + d[key] = ConfigOption.from_proto(value) + return d + + @classmethod + def to_dict(cls, config: Dict[str, "ConfigOption"]) -> Dict[str, str]: + return {k: v.value for k, v in config.items()} + + @classmethod + def from_proto(cls, option: common_pb2.ConfigOption) -> "ConfigOption": + return ConfigOption( + label=option.label, + name=option.name, + value=option.value, + type=ConfigOptionType(option.type), + group=option.group, + select=option.select, + ) + + +@dataclass +class Interface: + id: int + name: str = None + mac: str = None + ip4: str = None + ip4_mask: int = None + ip6: str = None + ip6_mask: int = None + net_id: int = None + flow_id: int = None + mtu: int = None + node_id: int = None + net2_id: int = None + + @classmethod + def from_proto(cls, iface: core_pb2.Interface) -> "Interface": + return Interface( + id=iface.id, + name=iface.name, + mac=iface.mac, + ip4=iface.ip4, + ip4_mask=iface.ip4_mask, + ip6=iface.ip6, + ip6_mask=iface.ip6_mask, + net_id=iface.net_id, + flow_id=iface.flow_id, + mtu=iface.mtu, + node_id=iface.node_id, + net2_id=iface.net2_id, + ) + + def to_proto(self) -> core_pb2.Interface: + return core_pb2.Interface( + id=self.id, + name=self.name, + mac=self.mac, + ip4=self.ip4, + ip4_mask=self.ip4_mask, + ip6=self.ip6, + ip6_mask=self.ip6_mask, + net_id=self.net_id, + flow_id=self.flow_id, + mtu=self.mtu, + node_id=self.node_id, + net2_id=self.net2_id, + ) + + +@dataclass +class LinkOptions: + jitter: int = 0 + key: int = 0 + mburst: int = 0 + mer: int = 0 + loss: float = 0.0 + bandwidth: int = 0 + burst: int = 0 + delay: int = 0 + dup: int = 0 + unidirectional: bool = False + + @classmethod + def from_proto(cls, options: core_pb2.LinkOptions) -> "LinkOptions": + return LinkOptions( + jitter=options.jitter, + key=options.key, + mburst=options.mburst, + mer=options.mer, + loss=options.loss, + bandwidth=options.bandwidth, + burst=options.burst, + delay=options.delay, + dup=options.dup, + unidirectional=options.unidirectional, + ) + + def to_proto(self) -> core_pb2.LinkOptions: + return core_pb2.LinkOptions( + jitter=self.jitter, + key=self.key, + mburst=self.mburst, + mer=self.mer, + loss=self.loss, + bandwidth=self.bandwidth, + burst=self.burst, + delay=self.delay, + dup=self.dup, + unidirectional=self.unidirectional, + ) + + +@dataclass +class Link: + node1_id: int + node2_id: int + type: LinkType = LinkType.WIRED + iface1: Interface = None + iface2: Interface = None + options: LinkOptions = None + network_id: int = None + label: str = None + color: str = None + + @classmethod + def from_proto(cls, link: core_pb2.Link) -> "Link": + iface1 = None + if link.HasField("iface1"): + iface1 = Interface.from_proto(link.iface1) + iface2 = None + if link.HasField("iface2"): + iface2 = Interface.from_proto(link.iface2) + options = None + if link.HasField("options"): + options = LinkOptions.from_proto(link.options) + return Link( + type=LinkType(link.type), + node1_id=link.node1_id, + node2_id=link.node2_id, + iface1=iface1, + iface2=iface2, + options=options, + network_id=link.network_id, + label=link.label, + color=link.color, + ) + + def to_proto(self) -> core_pb2.Link: + iface1 = self.iface1.to_proto() if self.iface1 else None + iface2 = self.iface2.to_proto() if self.iface2 else None + options = self.options.to_proto() if self.options else None + return core_pb2.Link( + type=self.type.value, + node1_id=self.node1_id, + node2_id=self.node2_id, + iface1=iface1, + iface2=iface2, + options=options, + network_id=self.network_id, + label=self.label, + color=self.color, + ) + + def is_symmetric(self) -> bool: + result = True + if self.options: + result = self.options.unidirectional is False + return result + + +@dataclass +class SessionSummary: + id: int + state: SessionState + nodes: int + file: str + dir: str + + @classmethod + def from_proto(cls, summary: core_pb2.SessionSummary) -> "SessionSummary": + return SessionSummary( + id=summary.id, + state=SessionState(summary.state), + nodes=summary.nodes, + file=summary.file, + dir=summary.dir, + ) + + @dataclass class Hook: state: SessionState @@ -78,8 +378,8 @@ class Node: type: NodeType model: str = None position: Position = None - services: List[str] = None - config_services: List[str] = None + services: List[str] = field(default_factory=list) + config_services: List[str] = field(default_factory=list) emane: str = None icon: str = None image: str = None @@ -120,7 +420,32 @@ class Node: icon=self.icon, image=self.image, server=self.server, - geo=self.geo.to_proto(), dir=self.dir, channel=self.channel, ) + + +@dataclass +class LinkEvent: + message_type: MessageType + link: Link + + @classmethod + def from_proto(cls, event: core_pb2.LinkEvent) -> "LinkEvent": + return LinkEvent( + message_type=MessageType(event.message_type), + link=Link.from_proto(event.link), + ) + + +@dataclass +class NodeEvent: + message_type: MessageType + node: Node + + @classmethod + def from_proto(cls, event: core_pb2.NodeEvent) -> "NodeEvent": + return NodeEvent( + message_type=MessageType(event.message_type), + node=Node.from_proto(event.node), + ) From a9a2fb8e46080770947bb5b0953c38644f54c56b Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 25 Jul 2020 19:43:24 -0700 Subject: [PATCH 344/929] pygui: added wrapper for throughput events, fixed sending nodes/links for configuration --- daemon/core/gui/coreclient.py | 17 +++++++-------- daemon/core/gui/graph/graph.py | 3 +-- daemon/core/gui/wrappers.py | 40 ++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index c41caeca..099ce043 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -12,9 +12,9 @@ from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Tuple import grpc -from core.api.grpc import client +from core.api.grpc import client, core_pb2 from core.api.grpc.configservices_pb2 import ConfigService, ConfigServiceConfig -from core.api.grpc.core_pb2 import CpuUsageEvent, Event, ThroughputsEvent +from core.api.grpc.core_pb2 import CpuUsageEvent, Event from core.api.grpc.emane_pb2 import EmaneModelConfig from core.api.grpc.mobility_pb2 import MobilityConfig from core.api.grpc.services_pb2 import NodeServiceData, ServiceConfig, ServiceFileConfig @@ -46,6 +46,7 @@ from core.gui.wrappers import ( Position, SessionLocation, SessionState, + ThroughputsEvent, ) if TYPE_CHECKING: @@ -275,7 +276,8 @@ class CoreClient: CPU_USAGE_DELAY, self.handle_cpu_event ) - def handle_throughputs(self, event: ThroughputsEvent) -> None: + def handle_throughputs(self, event: core_pb2.ThroughputsEvent) -> None: + event = ThroughputsEvent.from_proto(event) if event.session_id != self.session_id: logging.warning( "ignoring throughput event session(%s) current(%s)", @@ -776,12 +778,9 @@ class CoreClient: """ create nodes and links that have not been created yet """ - node_protos = [x.core_node for x in self.canvas_nodes.values()] - link_protos = [x.link for x in self.links.values()] - if self.state != SessionState.DEFINITION: - self.client.set_session_state(self.session_id, SessionState.DEFINITION) - - self.client.set_session_state(self.session_id, SessionState.DEFINITION) + node_protos = [x.core_node.to_proto() for x in self.canvas_nodes.values()] + link_protos = [x.link.to_proto() for x in self.links.values()] + self.client.set_session_state(self.session_id, SessionState.DEFINITION.value) for node_proto in node_protos: response = self.client.add_node(self.session_id, node_proto) logging.debug("create node: %s", response) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 8b053c78..ae0b00c0 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple from PIL import Image from PIL.ImageTk import PhotoImage -from core.api.grpc.core_pb2 import ThroughputsEvent from core.gui.dialogs.shapemod import ShapeDialog from core.gui.graph import tags from core.gui.graph.edges import ( @@ -23,7 +22,7 @@ from core.gui.graph.shape import Shape from core.gui.graph.shapeutils import ShapeType, is_draw_shape, is_marker from core.gui.images import ImageEnum, TypeToImage from core.gui.nodeutils import NodeDraw, NodeUtils -from core.gui.wrappers import Interface, Link, LinkType, Node +from core.gui.wrappers import Interface, Link, LinkType, Node, ThroughputsEvent if TYPE_CHECKING: from core.gui.app import Application diff --git a/daemon/core/gui/wrappers.py b/daemon/core/gui/wrappers.py index f72cbac4..4098a4df 100644 --- a/daemon/core/gui/wrappers.py +++ b/daemon/core/gui/wrappers.py @@ -68,6 +68,46 @@ class MessageType(Enum): TTY = 64 +@dataclass +class BridgeThroughput: + node_id: int + throughput: float + + @classmethod + def from_proto(cls, proto: core_pb2.BridgeThroughput) -> "BridgeThroughput": + return BridgeThroughput(node_id=proto.node_id, throughput=proto.throughput) + + +@dataclass +class InterfaceThroughput: + node_id: int + iface_id: int + throughput: float + + @classmethod + def from_proto(cls, proto: core_pb2.InterfaceThroughput) -> "InterfaceThroughput": + return InterfaceThroughput( + node_id=proto.node_id, iface_id=proto.iface_id, throughput=proto.throughput + ) + + +@dataclass +class ThroughputsEvent: + session_id: int + bridge_throughputs: List[BridgeThroughput] + iface_throughputs: List[InterfaceThroughput] + + @classmethod + def from_proto(cls, proto: core_pb2.ThroughputsEvent) -> "ThroughputsEvent": + bridges = [BridgeThroughput.from_proto(x) for x in proto.bridge_throughputs] + ifaces = [InterfaceThroughput.from_proto(x) for x in proto.iface_throughputs] + return ThroughputsEvent( + session_id=proto.session_id, + bridge_throughputs=bridges, + iface_throughputs=ifaces, + ) + + @dataclass class SessionLocation: x: float From 82a212d1cfc409ba0fa754e6d032ef12550d6743 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 25 Jul 2020 20:27:11 -0700 Subject: [PATCH 345/929] pygui: modified usages of protobufs within coreclient to use module namespace to make more obvious, replaced config services and services with wrappers --- daemon/core/gui/coreclient.py | 56 ++-- .../core/gui/dialogs/configserviceconfig.py | 3 +- daemon/core/gui/dialogs/mobilityplayer.py | 9 +- daemon/core/gui/dialogs/serviceconfig.py | 4 +- daemon/core/gui/graph/node.py | 3 +- daemon/core/gui/wrappers.py | 266 ++++++++++++------ 6 files changed, 213 insertions(+), 128 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 099ce043..fd1abc34 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -12,13 +12,15 @@ from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Tuple import grpc -from core.api.grpc import client, core_pb2 -from core.api.grpc.configservices_pb2 import ConfigService, ConfigServiceConfig -from core.api.grpc.core_pb2 import CpuUsageEvent, Event -from core.api.grpc.emane_pb2 import EmaneModelConfig -from core.api.grpc.mobility_pb2 import MobilityConfig -from core.api.grpc.services_pb2 import NodeServiceData, ServiceConfig, ServiceFileConfig -from core.api.grpc.wlan_pb2 import WlanConfig +from core.api.grpc import ( + client, + configservices_pb2, + core_pb2, + emane_pb2, + mobility_pb2, + services_pb2, + wlan_pb2, +) from core.gui import appconfig from core.gui.appconfig import CoreServer, Observer from core.gui.dialogs.emaneinstall import EmaneInstallDialog @@ -33,6 +35,7 @@ from core.gui.interface import InterfaceManager from core.gui.nodeutils import NodeDraw, NodeUtils from core.gui.wrappers import ( ConfigOption, + ConfigService, ExceptionEvent, Hook, Interface, @@ -42,6 +45,7 @@ from core.gui.wrappers import ( MessageType, Node, NodeEvent, + NodeServiceData, NodeType, Position, SessionLocation, @@ -150,7 +154,7 @@ class CoreClient: for observer in self.app.guiconfig.observers: self.custom_observers[observer.name] = observer - def handle_events(self, event: Event) -> None: + def handle_events(self, event: core_pb2.Event) -> None: if event.source == GUI_SOURCE: return if event.session_id != self.session_id: @@ -288,7 +292,7 @@ class CoreClient: logging.debug("handling throughputs event: %s", event) self.app.after(0, self.app.canvas.set_throughputs, event) - def handle_cpu_event(self, event: CpuUsageEvent) -> None: + def handle_cpu_event(self, event: core_pb2.CpuUsageEvent) -> None: self.app.after(0, self.app.statusbar.set_cpu, event.usage) def handle_exception_event(self, event: ExceptionEvent) -> None: @@ -514,7 +518,7 @@ class CoreClient: # get config service informations response = self.client.get_config_services() for service in response.services: - self.config_services[service.name] = service + self.config_services[service.name] = ConfigService.from_proto(service) group_services = self.config_services_groups.setdefault( service.group, set() ) @@ -708,7 +712,7 @@ class CoreClient: logging.debug( "get node(%s) %s service, response: %s", node_id, service_name, response ) - return response.service + return NodeServiceData.from_proto(response.service) def set_node_service( self, @@ -742,7 +746,7 @@ class CoreClient: response, ) response = self.client.get_node_service(self.session_id, node_id, service_name) - return response.service + return NodeServiceData.from_proto(response.service) def get_node_service_file( self, node_id: int, service_name: str, file_name: str @@ -1000,7 +1004,7 @@ class CoreClient: self.links[edge.token] = edge logging.info("Add link between %s and %s", src_node.name, dst_node.name) - def get_wlan_configs_proto(self) -> List[WlanConfig]: + def get_wlan_configs_proto(self) -> List[wlan_pb2.WlanConfig]: configs = [] for canvas_node in self.canvas_nodes.values(): if canvas_node.core_node.type != NodeType.WIRELESS_LAN: @@ -1009,11 +1013,11 @@ class CoreClient: continue config = ConfigOption.to_dict(canvas_node.wlan_config) node_id = canvas_node.core_node.id - wlan_config = WlanConfig(node_id=node_id, config=config) + wlan_config = wlan_pb2.WlanConfig(node_id=node_id, config=config) configs.append(wlan_config) return configs - def get_mobility_configs_proto(self) -> List[MobilityConfig]: + def get_mobility_configs_proto(self) -> List[mobility_pb2.MobilityConfig]: configs = [] for canvas_node in self.canvas_nodes.values(): if canvas_node.core_node.type != NodeType.WIRELESS_LAN: @@ -1022,11 +1026,13 @@ class CoreClient: continue config = ConfigOption.to_dict(canvas_node.mobility_config) node_id = canvas_node.core_node.id - mobility_config = MobilityConfig(node_id=node_id, config=config) + mobility_config = mobility_pb2.MobilityConfig( + node_id=node_id, config=config + ) configs.append(mobility_config) return configs - def get_emane_model_configs_proto(self) -> List[EmaneModelConfig]: + def get_emane_model_configs_proto(self) -> List[emane_pb2.EmaneModelConfig]: configs = [] for canvas_node in self.canvas_nodes.values(): if canvas_node.core_node.type != NodeType.EMANE: @@ -1037,13 +1043,13 @@ class CoreClient: config = ConfigOption.to_dict(config) if iface_id is None: iface_id = -1 - config_proto = EmaneModelConfig( + config_proto = emane_pb2.EmaneModelConfig( node_id=node_id, iface_id=iface_id, model=model, config=config ) configs.append(config_proto) return configs - def get_service_configs_proto(self) -> List[ServiceConfig]: + def get_service_configs_proto(self) -> List[services_pb2.ServiceConfig]: configs = [] for canvas_node in self.canvas_nodes.values(): if not NodeUtils.is_container_node(canvas_node.core_node.type): @@ -1052,7 +1058,7 @@ class CoreClient: continue node_id = canvas_node.core_node.id for name, config in canvas_node.service_configs.items(): - config_proto = ServiceConfig( + config_proto = services_pb2.ServiceConfig( node_id=node_id, service=name, directories=config.dirs, @@ -1064,7 +1070,7 @@ class CoreClient: configs.append(config_proto) return configs - def get_service_file_configs_proto(self) -> List[ServiceFileConfig]: + def get_service_file_configs_proto(self) -> List[services_pb2.ServiceFileConfig]: configs = [] for canvas_node in self.canvas_nodes.values(): if not NodeUtils.is_container_node(canvas_node.core_node.type): @@ -1074,13 +1080,15 @@ class CoreClient: node_id = canvas_node.core_node.id for service, file_configs in canvas_node.service_file_configs.items(): for file, data in file_configs.items(): - config_proto = ServiceFileConfig( + config_proto = services_pb2.ServiceFileConfig( node_id=node_id, service=service, file=file, data=data ) configs.append(config_proto) return configs - def get_config_service_configs_proto(self) -> List[ConfigServiceConfig]: + def get_config_service_configs_proto( + self + ) -> List[configservices_pb2.ConfigServiceConfig]: config_service_protos = [] for canvas_node in self.canvas_nodes.values(): if not NodeUtils.is_container_node(canvas_node.core_node.type): @@ -1090,7 +1098,7 @@ class CoreClient: node_id = canvas_node.core_node.id for name, service_config in canvas_node.config_service_configs.items(): config = service_config.get("config", {}) - config_proto = ConfigServiceConfig( + config_proto = configservices_pb2.ConfigServiceConfig( node_id=node_id, name=name, templates=service_config["templates"], diff --git a/daemon/core/gui/dialogs/configserviceconfig.py b/daemon/core/gui/dialogs/configserviceconfig.py index 5a6a89a8..5463d88e 100644 --- a/daemon/core/gui/dialogs/configserviceconfig.py +++ b/daemon/core/gui/dialogs/configserviceconfig.py @@ -8,11 +8,10 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Set import grpc -from core.api.grpc.services_pb2 import ServiceValidationMode from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CodeText, ConfigFrame, ListboxScroll -from core.gui.wrappers import ConfigOption +from core.gui.wrappers import ConfigOption, ServiceValidationMode if TYPE_CHECKING: from core.gui.app import Application diff --git a/daemon/core/gui/dialogs/mobilityplayer.py b/daemon/core/gui/dialogs/mobilityplayer.py index 16aa8ea0..66833aff 100644 --- a/daemon/core/gui/dialogs/mobilityplayer.py +++ b/daemon/core/gui/dialogs/mobilityplayer.py @@ -4,11 +4,10 @@ from typing import TYPE_CHECKING, Dict, Optional import grpc -from core.api.grpc.mobility_pb2 import MobilityAction from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum from core.gui.themes import PADX, PADY -from core.gui.wrappers import ConfigOption, Node +from core.gui.wrappers import ConfigOption, MobilityAction, Node if TYPE_CHECKING: from core.gui.app import Application @@ -150,7 +149,7 @@ class MobilityPlayerDialog(Dialog): session_id = self.app.core.session_id try: self.app.core.client.mobility_action( - session_id, self.node.id, MobilityAction.START + session_id, self.node.id, MobilityAction.START.value ) except grpc.RpcError as e: self.app.show_grpc_exception("Mobility Error", e) @@ -160,7 +159,7 @@ class MobilityPlayerDialog(Dialog): session_id = self.app.core.session_id try: self.app.core.client.mobility_action( - session_id, self.node.id, MobilityAction.PAUSE + session_id, self.node.id, MobilityAction.PAUSE.value ) except grpc.RpcError as e: self.app.show_grpc_exception("Mobility Error", e) @@ -170,7 +169,7 @@ class MobilityPlayerDialog(Dialog): session_id = self.app.core.session_id try: self.app.core.client.mobility_action( - session_id, self.node.id, MobilityAction.STOP + session_id, self.node.id, MobilityAction.STOP.value ) except grpc.RpcError as e: self.app.show_grpc_exception("Mobility Error", e) diff --git a/daemon/core/gui/dialogs/serviceconfig.py b/daemon/core/gui/dialogs/serviceconfig.py index 4e615db0..c033cfdc 100644 --- a/daemon/core/gui/dialogs/serviceconfig.py +++ b/daemon/core/gui/dialogs/serviceconfig.py @@ -7,12 +7,12 @@ from typing import TYPE_CHECKING, Dict, List, Optional, Set, Tuple import grpc from PIL.ImageTk import PhotoImage -from core.api.grpc.services_pb2 import NodeServiceData, ServiceValidationMode from core.gui.dialogs.copyserviceconfig import CopyServiceConfigDialog from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum, Images from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CodeText, ListboxScroll +from core.gui.wrappers import NodeServiceData, ServiceValidationMode if TYPE_CHECKING: from core.gui.app import Application @@ -72,7 +72,7 @@ class ServiceConfigDialog(Dialog): self.service_file_data: Optional[CodeText] = None self.validation_period_entry: Optional[ttk.Entry] = None self.original_service_files: Dict[str, str] = {} - self.default_config: NodeServiceData = None + self.default_config: Optional[NodeServiceData] = None self.temp_service_files: Dict[str, str] = {} self.modified_files: Set[str] = set() self.has_error: bool = False diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index df6476c7..217389c0 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple import grpc from PIL.ImageTk import PhotoImage -from core.api.grpc.services_pb2 import NodeServiceData from core.gui import themes from core.gui.dialogs.emaneconfig import EmaneConfigDialog from core.gui.dialogs.mobilityconfig import MobilityConfigDialog @@ -20,7 +19,7 @@ from core.gui.graph.edges import CanvasEdge, CanvasWirelessEdge from core.gui.graph.tooltip import CanvasTooltip from core.gui.images import ImageEnum from core.gui.nodeutils import ANTENNA_SIZE, NodeUtils -from core.gui.wrappers import ConfigOption, Interface, Node, NodeType +from core.gui.wrappers import ConfigOption, Interface, Node, NodeServiceData, NodeType if TYPE_CHECKING: from core.gui.app import Application diff --git a/daemon/core/gui/wrappers.py b/daemon/core/gui/wrappers.py index 4098a4df..5fb12837 100644 --- a/daemon/core/gui/wrappers.py +++ b/daemon/core/gui/wrappers.py @@ -2,7 +2,25 @@ from dataclasses import dataclass, field from enum import Enum from typing import Dict, List -from core.api.grpc import common_pb2, core_pb2 +from core.api.grpc import common_pb2, configservices_pb2, core_pb2, services_pb2 + + +class ConfigServiceValidationMode(Enum): + BLOCKING = 0 + NON_BLOCKING = 1 + TIMER = 2 + + +class ServiceValidationMode(Enum): + BLOCKING = 0 + NON_BLOCKING = 1 + TIMER = 2 + + +class MobilityAction(Enum): + START = 0 + PAUSE = 1 + STOP = 2 class ConfigOptionType(Enum): @@ -68,6 +86,68 @@ class MessageType(Enum): TTY = 64 +@dataclass +class ConfigService: + group: str + name: str + executables: List[str] + dependencies: List[str] + directories: List[str] + files: List[str] + startup: List[str] + validate: List[str] + shutdown: List[str] + validation_mode: ConfigServiceValidationMode + validation_timer: int + validation_period: float + + @classmethod + def from_proto(cls, proto: configservices_pb2.ConfigService) -> "ConfigService": + return ConfigService( + group=proto.group, + name=proto.name, + executables=proto.executables, + dependencies=proto.dependencies, + directories=proto.directories, + files=proto.files, + startup=proto.startup, + validate=proto.validate, + shutdown=proto.shutdown, + validation_mode=ConfigServiceValidationMode(proto.validation_mode), + validation_timer=proto.validation_timer, + validation_period=proto.validation_period, + ) + + +@dataclass +class NodeServiceData: + executables: List[str] + dependencies: List[str] + dirs: List[str] + configs: List[str] + startup: List[str] + validate: List[str] + validation_mode: ServiceValidationMode + validation_timer: int + shutdown: List[str] + meta: str + + @classmethod + def from_proto(cls, proto: services_pb2.NodeServiceData) -> "NodeServiceData": + return NodeServiceData( + executables=proto.executables, + dependencies=proto.dependencies, + dirs=proto.dirs, + configs=proto.configs, + startup=proto.startup, + validate=proto.validate, + validation_mode=proto.validation_mode, + validation_timer=proto.validation_timer, + shutdown=proto.shutdown, + meta=proto.meta, + ) + + @dataclass class BridgeThroughput: node_id: int @@ -119,15 +199,15 @@ class SessionLocation: scale: float @classmethod - def from_proto(cls, location: core_pb2.SessionLocation) -> "SessionLocation": + def from_proto(cls, proto: core_pb2.SessionLocation) -> "SessionLocation": return SessionLocation( - x=location.x, - y=location.y, - z=location.z, - lat=location.lat, - lon=location.lon, - alt=location.alt, - scale=location.scale, + x=proto.x, + y=proto.y, + z=proto.z, + lat=proto.lat, + lon=proto.lon, + alt=proto.alt, + scale=proto.scale, ) def to_proto(self) -> core_pb2.SessionLocation: @@ -154,16 +234,16 @@ class ExceptionEvent: @classmethod def from_proto( - cls, session_id: int, event: core_pb2.ExceptionEvent + cls, session_id: int, proto: core_pb2.ExceptionEvent ) -> "ExceptionEvent": return ExceptionEvent( session_id=session_id, - node_id=event.node_id, - level=ExceptionLevel(event.level), - source=event.source, - date=event.date, - text=event.text, - opaque=event.opaque, + node_id=proto.node_id, + level=ExceptionLevel(proto.level), + source=proto.source, + date=proto.date, + text=proto.text, + opaque=proto.opaque, ) @@ -190,14 +270,14 @@ class ConfigOption: return {k: v.value for k, v in config.items()} @classmethod - def from_proto(cls, option: common_pb2.ConfigOption) -> "ConfigOption": + def from_proto(cls, proto: common_pb2.ConfigOption) -> "ConfigOption": return ConfigOption( - label=option.label, - name=option.name, - value=option.value, - type=ConfigOptionType(option.type), - group=option.group, - select=option.select, + label=proto.label, + name=proto.name, + value=proto.value, + type=ConfigOptionType(proto.type), + group=proto.group, + select=proto.select, ) @@ -217,20 +297,20 @@ class Interface: net2_id: int = None @classmethod - def from_proto(cls, iface: core_pb2.Interface) -> "Interface": + def from_proto(cls, proto: core_pb2.Interface) -> "Interface": return Interface( - id=iface.id, - name=iface.name, - mac=iface.mac, - ip4=iface.ip4, - ip4_mask=iface.ip4_mask, - ip6=iface.ip6, - ip6_mask=iface.ip6_mask, - net_id=iface.net_id, - flow_id=iface.flow_id, - mtu=iface.mtu, - node_id=iface.node_id, - net2_id=iface.net2_id, + id=proto.id, + name=proto.name, + mac=proto.mac, + ip4=proto.ip4, + ip4_mask=proto.ip4_mask, + ip6=proto.ip6, + ip6_mask=proto.ip6_mask, + net_id=proto.net_id, + flow_id=proto.flow_id, + mtu=proto.mtu, + node_id=proto.node_id, + net2_id=proto.net2_id, ) def to_proto(self) -> core_pb2.Interface: @@ -264,18 +344,18 @@ class LinkOptions: unidirectional: bool = False @classmethod - def from_proto(cls, options: core_pb2.LinkOptions) -> "LinkOptions": + def from_proto(cls, proto: core_pb2.LinkOptions) -> "LinkOptions": return LinkOptions( - jitter=options.jitter, - key=options.key, - mburst=options.mburst, - mer=options.mer, - loss=options.loss, - bandwidth=options.bandwidth, - burst=options.burst, - delay=options.delay, - dup=options.dup, - unidirectional=options.unidirectional, + jitter=proto.jitter, + key=proto.key, + mburst=proto.mburst, + mer=proto.mer, + loss=proto.loss, + bandwidth=proto.bandwidth, + burst=proto.burst, + delay=proto.delay, + dup=proto.dup, + unidirectional=proto.unidirectional, ) def to_proto(self) -> core_pb2.LinkOptions: @@ -306,26 +386,26 @@ class Link: color: str = None @classmethod - def from_proto(cls, link: core_pb2.Link) -> "Link": + def from_proto(cls, proto: core_pb2.Link) -> "Link": iface1 = None - if link.HasField("iface1"): - iface1 = Interface.from_proto(link.iface1) + if proto.HasField("iface1"): + iface1 = Interface.from_proto(proto.iface1) iface2 = None - if link.HasField("iface2"): - iface2 = Interface.from_proto(link.iface2) + if proto.HasField("iface2"): + iface2 = Interface.from_proto(proto.iface2) options = None - if link.HasField("options"): - options = LinkOptions.from_proto(link.options) + if proto.HasField("options"): + options = LinkOptions.from_proto(proto.options) return Link( - type=LinkType(link.type), - node1_id=link.node1_id, - node2_id=link.node2_id, + type=LinkType(proto.type), + node1_id=proto.node1_id, + node2_id=proto.node2_id, iface1=iface1, iface2=iface2, options=options, - network_id=link.network_id, - label=link.label, - color=link.color, + network_id=proto.network_id, + label=proto.label, + color=proto.color, ) def to_proto(self) -> core_pb2.Link: @@ -360,13 +440,13 @@ class SessionSummary: dir: str @classmethod - def from_proto(cls, summary: core_pb2.SessionSummary) -> "SessionSummary": + def from_proto(cls, proto: core_pb2.SessionSummary) -> "SessionSummary": return SessionSummary( - id=summary.id, - state=SessionState(summary.state), - nodes=summary.nodes, - file=summary.file, - dir=summary.dir, + id=proto.id, + state=SessionState(proto.state), + nodes=proto.nodes, + file=proto.file, + dir=proto.dir, ) @@ -377,8 +457,8 @@ class Hook: data: str @classmethod - def from_proto(cls, hook: core_pb2.Hook) -> "Hook": - return Hook(state=SessionState(hook.state), file=hook.file, data=hook.data) + def from_proto(cls, proto: core_pb2.Hook) -> "Hook": + return Hook(state=SessionState(proto.state), file=proto.file, data=proto.data) def to_proto(self) -> core_pb2.Hook: return core_pb2.Hook(state=self.state.value, file=self.file, data=self.data) @@ -390,8 +470,8 @@ class Position: y: float @classmethod - def from_proto(cls, position: core_pb2.Position) -> "Position": - return Position(x=position.x, y=position.y) + def from_proto(cls, proto: core_pb2.Position) -> "Position": + return Position(x=proto.x, y=proto.y) def to_proto(self) -> core_pb2.Position: return core_pb2.Position(x=self.x, y=self.y) @@ -404,8 +484,8 @@ class Geo: alt: float = None @classmethod - def from_proto(cls, geo: core_pb2.Geo) -> "Geo": - return Geo(lat=geo.lat, lon=geo.lon, alt=geo.alt) + def from_proto(cls, proto: core_pb2.Geo) -> "Geo": + return Geo(lat=proto.lat, lon=proto.lon, alt=proto.alt) def to_proto(self) -> core_pb2.Geo: return core_pb2.Geo(lat=self.lat, lon=self.lon, alt=self.alt) @@ -429,22 +509,22 @@ class Node: channel: str = None @classmethod - def from_proto(cls, node: core_pb2.Node) -> "Node": + def from_proto(cls, proto: core_pb2.Node) -> "Node": return Node( - id=node.id, - name=node.name, - type=NodeType(node.type), - model=node.model, - position=Position.from_proto(node.position), - services=list(node.services), - config_services=list(node.config_services), - emane=node.emane, - icon=node.icon, - image=node.image, - server=node.server, - geo=Geo.from_proto(node.geo), - dir=node.dir, - channel=node.channel, + id=proto.id, + name=proto.name, + type=NodeType(proto.type), + model=proto.model, + position=Position.from_proto(proto.position), + services=list(proto.services), + config_services=list(proto.config_services), + emane=proto.emane, + icon=proto.icon, + image=proto.image, + server=proto.server, + geo=Geo.from_proto(proto.geo), + dir=proto.dir, + channel=proto.channel, ) def to_proto(self) -> core_pb2.Node: @@ -471,10 +551,10 @@ class LinkEvent: link: Link @classmethod - def from_proto(cls, event: core_pb2.LinkEvent) -> "LinkEvent": + def from_proto(cls, proto: core_pb2.LinkEvent) -> "LinkEvent": return LinkEvent( - message_type=MessageType(event.message_type), - link=Link.from_proto(event.link), + message_type=MessageType(proto.message_type), + link=Link.from_proto(proto.link), ) @@ -484,8 +564,8 @@ class NodeEvent: node: Node @classmethod - def from_proto(cls, event: core_pb2.NodeEvent) -> "NodeEvent": + def from_proto(cls, proto: core_pb2.NodeEvent) -> "NodeEvent": return NodeEvent( - message_type=MessageType(event.message_type), - node=Node.from_proto(event.node), + message_type=MessageType(proto.message_type), + node=Node.from_proto(proto.node), ) From 41a3c5fd7fa2d2de040b39f93b721cb80b3da9e2 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 26 Jul 2020 11:45:40 -0700 Subject: [PATCH 346/929] pygui: added wrapper class for sessions returned by grpc GetSession --- daemon/core/gui/coreclient.py | 9 ++++----- daemon/core/gui/graph/graph.py | 13 +++++++------ daemon/core/gui/wrappers.py | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index fd1abc34..5ddc28d7 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -48,6 +48,7 @@ from core.gui.wrappers import ( NodeServiceData, NodeType, Position, + Session, SessionLocation, SessionState, ThroughputsEvent, @@ -311,8 +312,8 @@ class CoreClient: # get session data try: response = self.client.get_session(self.session_id) - session = response.session - self.state = SessionState(session.state) + session = Session.from_proto(response.session) + self.state = session.state self.handling_events = self.client.events( self.session_id, self.handle_events ) @@ -349,9 +350,7 @@ class CoreClient: self.ifaces_manager.joined(session.links) # draw session - nodes = [Node.from_proto(x) for x in session.nodes] - links = [Link.from_proto(x) for x in session.links] - self.app.canvas.reset_and_redraw(nodes, links) + self.app.canvas.reset_and_redraw(session) # get mobility configs response = self.client.get_mobility_configs(self.session_id) diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index ae0b00c0..81e0d1c6 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -22,7 +22,7 @@ from core.gui.graph.shape import Shape from core.gui.graph.shapeutils import ShapeType, is_draw_shape, is_marker from core.gui.images import ImageEnum, TypeToImage from core.gui.nodeutils import NodeDraw, NodeUtils -from core.gui.wrappers import Interface, Link, LinkType, Node, ThroughputsEvent +from core.gui.wrappers import Interface, Link, LinkType, Node, Session, ThroughputsEvent if TYPE_CHECKING: from core.gui.app import Application @@ -127,7 +127,7 @@ class CanvasGraph(tk.Canvas): ) self.configure(scrollregion=self.bbox(tk.ALL)) - def reset_and_redraw(self, nodes: List[Node], links: List[Link]) -> None: + def reset_and_redraw(self, session: Session) -> None: # reset view options to default state self.show_node_labels.set(True) self.show_link_labels.set(True) @@ -152,7 +152,7 @@ class CanvasGraph(tk.Canvas): self.wireless_edges.clear() self.wireless_network.clear() self.drawing_edge = None - self.draw_session(nodes, links) + self.draw_session(session) def setup_bindings(self) -> None: """ @@ -325,19 +325,20 @@ class CanvasGraph(tk.Canvas): self.nodes[node.id] = node self.core.canvas_nodes[core_node.id] = node - def draw_session(self, nodes: List[Node], links: List[Link]) -> None: + def draw_session(self, session: Session) -> None: """ Draw existing session. """ # draw existing nodes - for core_node in nodes: + for core_node in session.nodes: + logging.debug("drawing node: %s", core_node) # peer to peer node is not drawn on the GUI if NodeUtils.is_ignore_node(core_node.type): continue self.add_core_node(core_node) # draw existing links - for link in links: + for link in session.links: logging.debug("drawing link: %s", link) canvas_node1 = self.core.canvas_nodes[link.node1_id] canvas_node2 = self.core.canvas_nodes[link.node2_id] diff --git a/daemon/core/gui/wrappers.py b/daemon/core/gui/wrappers.py index 5fb12837..612c7646 100644 --- a/daemon/core/gui/wrappers.py +++ b/daemon/core/gui/wrappers.py @@ -545,6 +545,27 @@ class Node: ) +@dataclass +class Session: + id: int + state: SessionState + nodes: List[Node] + links: List[Link] + dir: str + + @classmethod + def from_proto(cls, proto: core_pb2.Session) -> "Session": + nodes = [Node.from_proto(x) for x in proto.nodes] + links = [Link.from_proto(x) for x in proto.links] + return Session( + id=proto.id, + state=SessionState(proto.state), + nodes=nodes, + links=links, + dir=proto.dir, + ) + + @dataclass class LinkEvent: message_type: MessageType From 3bdd6292cdc0161d75be89c07180f0e79d71774f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 27 Jul 2020 18:19:51 -0700 Subject: [PATCH 347/929] grpc: update GetSession to return all session related information, rather than needing 8 different calls, pygui: updated session protobuf wrapper to handle all new data --- daemon/core/api/grpc/grpcutils.py | 129 +++++++++++++++++++- daemon/core/api/grpc/server.py | 137 +++++++--------------- daemon/core/emulator/session.py | 2 +- daemon/core/gui/graph/graph.py | 2 +- daemon/core/gui/wrappers.py | 70 ++++++++++- daemon/proto/core/api/grpc/core.proto | 12 ++ daemon/proto/core/api/grpc/emane.proto | 8 +- daemon/proto/core/api/grpc/services.proto | 15 +-- 8 files changed, 260 insertions(+), 115 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 84b8ee6a..a024c064 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -8,13 +8,22 @@ from grpc import ServicerContext from core import utils from core.api.grpc import common_pb2, core_pb2 -from core.api.grpc.services_pb2 import NodeServiceData, ServiceConfig +from core.api.grpc.common_pb2 import MappedConfig +from core.api.grpc.configservices_pb2 import ConfigServiceConfig +from core.api.grpc.emane_pb2 import EmaneModelConfig +from core.api.grpc.services_pb2 import ( + NodeServiceConfig, + NodeServiceData, + ServiceConfig, + ServiceDefaults, +) from core.config import ConfigurableOptions from core.emane.nodes import EmaneNet from core.emulator.data import InterfaceData, LinkData, LinkOptions, NodeOptions from core.emulator.enumerations import LinkTypes, NodeTypes from core.emulator.session import Session -from core.nodes.base import CoreNode, NodeBase +from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility +from core.nodes.base import CoreNode, CoreNodeBase, NodeBase from core.nodes.interface import CoreInterface from core.services.coreservices import CoreService @@ -536,3 +545,119 @@ def get_nem_id( message = f"{node.name} interface {iface_id} nem id does not exist" context.abort(grpc.StatusCode.INVALID_ARGUMENT, message) return nem_id + + +def get_emane_model_configs(session: Session) -> List[EmaneModelConfig]: + configs = [] + for _id in session.emane.node_configurations: + if _id == -1: + continue + model_configs = session.emane.node_configurations[_id] + for model_name in model_configs: + model = session.emane.models[model_name] + current_config = session.emane.get_model_config(_id, model_name) + config = get_config_options(current_config, model) + node_id, iface_id = parse_emane_model_id(_id) + model_config = EmaneModelConfig( + node_id=node_id, model=model_name, iface_id=iface_id, config=config + ) + configs.append(model_config) + return configs + + +def get_wlan_configs(session: Session) -> Dict[int, MappedConfig]: + configs = {} + for node_id in session.mobility.node_configurations: + model_config = session.mobility.node_configurations[node_id] + if node_id == -1: + continue + for model_name in model_config: + if model_name != BasicRangeModel.name: + continue + current_config = session.mobility.get_model_config(node_id, model_name) + config = get_config_options(current_config, BasicRangeModel) + mapped_config = MappedConfig(config=config) + configs[node_id] = mapped_config + return configs + + +def get_mobility_configs(session: Session) -> Dict[int, MappedConfig]: + configs = {} + for node_id in session.mobility.node_configurations: + model_config = session.mobility.node_configurations[node_id] + if node_id == -1: + continue + for model_name in model_config: + if model_name != Ns2ScriptedMobility.name: + continue + current_config = session.mobility.get_model_config(node_id, model_name) + config = get_config_options(current_config, Ns2ScriptedMobility) + mapped_config = MappedConfig(config=config) + configs[node_id] = mapped_config + return configs + + +def get_hooks(session: Session) -> List[core_pb2.Hook]: + hooks = [] + for state in session.hooks: + state_hooks = session.hooks[state] + for file_name, file_data in state_hooks: + hook = core_pb2.Hook(state=state.value, file=file_name, data=file_data) + hooks.append(hook) + return hooks + + +def get_emane_models(session: Session) -> List[str]: + emane_models = [] + for model in session.emane.models.keys(): + if len(model.split("_")) != 2: + continue + emane_models.append(model) + return emane_models + + +def get_default_services(session: Session) -> List[ServiceDefaults]: + default_services = [] + for name, services in session.services.default_services.items(): + default_service = ServiceDefaults(node_type=name, services=services) + default_services.append(default_service) + return default_services + + +def get_node_service_configs(session: Session) -> List[NodeServiceConfig]: + configs = [] + for node_id, service_configs in session.services.custom_services.items(): + for name in service_configs: + service = session.services.get_service(node_id, name) + service_proto = get_service_configuration(service) + config = NodeServiceConfig( + node_id=node_id, + service=name, + data=service_proto, + files=service.config_data, + ) + configs.append(config) + return configs + + +def get_node_config_service_configs(session: Session) -> List[ConfigServiceConfig]: + configs = [] + for node in session.nodes.values(): + if not isinstance(node, CoreNodeBase): + continue + for name, service in node.config_services.items(): + if not service.custom_templates and not service.custom_config: + continue + config_proto = ConfigServiceConfig( + node_id=node.id, + name=name, + templates=service.custom_templates, + config=service.custom_config, + ) + configs.append(config_proto) + return configs + + +def get_emane_config(session: Session) -> Dict[str, common_pb2.ConfigOption]: + current_config = session.emane.get_configs() + return get_config_options(current_config, session.emane.emane_config) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 38100e05..65029e0a 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -19,7 +19,6 @@ from core.api.grpc import ( core_pb2_grpc, grpcutils, ) -from core.api.grpc.common_pb2 import MappedConfig from core.api.grpc.configservices_pb2 import ( ConfigService, GetConfigServiceDefaultsRequest, @@ -89,7 +88,6 @@ from core.api.grpc.services_pb2 import ( ServiceAction, ServiceActionRequest, ServiceActionResponse, - ServiceDefaults, SetNodeServiceFileRequest, SetNodeServiceFileResponse, SetNodeServiceRequest, @@ -118,7 +116,7 @@ from core.emulator.enumerations import ( from core.emulator.session import NT, Session from core.errors import CoreCommandError, CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility -from core.nodes.base import CoreNode, CoreNodeBase, NodeBase +from core.nodes.base import CoreNode, NodeBase from core.nodes.network import PtpNet, WlanNode from core.services.coreservices import ServiceManager @@ -558,7 +556,6 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get session: %s", request) session = self.get_session(request.session_id, context) - links = [] nodes = [] for _id in session.nodes: @@ -568,9 +565,37 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): nodes.append(node_proto) node_links = get_links(node) links.extend(node_links) - + default_services = grpcutils.get_default_services(session) + x, y, z = session.location.refxyz + lat, lon, alt = session.location.refgeo + location = core_pb2.SessionLocation( + x=x, y=y, z=z, lat=lat, lon=lon, alt=alt, scale=session.location.refscale + ) + hooks = grpcutils.get_hooks(session) + emane_models = grpcutils.get_emane_models(session) + emane_config = grpcutils.get_emane_config(session) + emane_model_configs = grpcutils.get_emane_model_configs(session) + wlan_configs = grpcutils.get_wlan_configs(session) + mobility_configs = grpcutils.get_mobility_configs(session) + service_configs = grpcutils.get_node_service_configs(session) + config_service_configs = grpcutils.get_node_config_service_configs(session) session_proto = core_pb2.Session( - state=session.state.value, nodes=nodes, links=links, dir=session.session_dir + state=session.state.value, + nodes=nodes, + links=links, + dir=session.session_dir, + user=session.user, + default_services=default_services, + location=location, + hooks=hooks, + emane_models=emane_models, + emane_config=emane_config, + emane_model_configs=emane_model_configs, + wlan_configs=wlan_configs, + service_configs=service_configs, + config_service_configs=config_service_configs, + mobility_configs=mobility_configs, + metadata=session.metadata, ) return core_pb2.GetSessionResponse(session=session_proto) @@ -1012,12 +1037,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get hooks: %s", request) session = self.get_session(request.session_id, context) - hooks = [] - for state in session.hooks: - state_hooks = session.hooks[state] - for file_name, file_data in state_hooks: - hook = core_pb2.Hook(state=state.value, file=file_name, data=file_data) - hooks.append(hook) + hooks = grpcutils.get_hooks(session) return core_pb2.GetHooksResponse(hooks=hooks) def AddHook( @@ -1050,19 +1070,8 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get mobility configs: %s", request) session = self.get_session(request.session_id, context) - response = GetMobilityConfigsResponse() - for node_id in session.mobility.node_configurations: - model_config = session.mobility.node_configurations[node_id] - if node_id == -1: - continue - for model_name in model_config: - if model_name != Ns2ScriptedMobility.name: - continue - current_config = session.mobility.get_model_config(node_id, model_name) - config = get_config_options(current_config, Ns2ScriptedMobility) - mapped_config = MappedConfig(config=config) - response.configs[node_id].CopyFrom(mapped_config) - return response + configs = grpcutils.get_mobility_configs(session) + return GetMobilityConfigsResponse(configs=configs) def GetMobilityConfig( self, request: GetMobilityConfigRequest, context: ServicerContext @@ -1157,12 +1166,8 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get service defaults: %s", request) session = self.get_session(request.session_id, context) - all_service_defaults = [] - for node_type in session.services.default_services: - services = session.services.default_services[node_type] - service_defaults = ServiceDefaults(node_type=node_type, services=services) - all_service_defaults.append(service_defaults) - return GetServiceDefaultsResponse(defaults=all_service_defaults) + defaults = grpcutils.get_default_services(session) + return GetServiceDefaultsResponse(defaults=defaults) def SetServiceDefaults( self, request: SetServiceDefaultsRequest, context: ServicerContext @@ -1196,18 +1201,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get node service configs: %s", request) session = self.get_session(request.session_id, context) - configs = [] - for node_id, service_configs in session.services.custom_services.items(): - for name in service_configs: - service = session.services.get_service(node_id, name) - service_proto = grpcutils.get_service_configuration(service) - config = GetNodeServiceConfigsResponse.ServiceConfig( - node_id=node_id, - service=name, - data=service_proto, - files=service.config_data, - ) - configs.append(config) + configs = grpcutils.get_node_service_configs(session) return GetNodeServiceConfigsResponse(configs=configs) def GetNodeService( @@ -1337,19 +1331,8 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get wlan configs: %s", request) session = self.get_session(request.session_id, context) - response = GetWlanConfigsResponse() - for node_id in session.mobility.node_configurations: - model_config = session.mobility.node_configurations[node_id] - if node_id == -1: - continue - for model_name in model_config: - if model_name != BasicRangeModel.name: - continue - current_config = session.mobility.get_model_config(node_id, model_name) - config = get_config_options(current_config, BasicRangeModel) - mapped_config = MappedConfig(config=config) - response.configs[node_id].CopyFrom(mapped_config) - return response + configs = grpcutils.get_wlan_configs(session) + return GetWlanConfigsResponse(configs=configs) def GetWlanConfig( self, request: GetWlanConfigRequest, context: ServicerContext @@ -1401,8 +1384,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get emane config: %s", request) session = self.get_session(request.session_id, context) - current_config = session.emane.get_configs() - config = get_config_options(current_config, session.emane.emane_config) + config = grpcutils.get_emane_config(session) return GetEmaneConfigResponse(config=config) def SetEmaneConfig( @@ -1433,11 +1415,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get emane models: %s", request) session = self.get_session(request.session_id, context) - models = [] - for model in session.emane.models.keys(): - if len(model.split("_")) != 2: - continue - models.append(model) + models = grpcutils.get_emane_models(session) return GetEmaneModelsResponse(models=models) def GetEmaneModelConfig( @@ -1491,22 +1469,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("get emane model configs: %s", request) session = self.get_session(request.session_id, context) - - configs = [] - for _id in session.emane.node_configurations: - if _id == -1: - continue - - model_configs = session.emane.node_configurations[_id] - for model_name in model_configs: - model = session.emane.models[model_name] - current_config = session.emane.get_model_config(_id, model_name) - config = get_config_options(current_config, model) - node_id, iface_id = grpcutils.parse_emane_model_id(_id) - model_config = GetEmaneModelConfigsResponse.ModelConfig( - node_id=node_id, model=model_name, iface_id=iface_id, config=config - ) - configs.append(model_config) + configs = grpcutils.get_emane_model_configs(session) return GetEmaneModelConfigsResponse(configs=configs) def SaveXml( @@ -1713,21 +1676,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): :return: get node config service configs response """ session = self.get_session(request.session_id, context) - configs = [] - for node in session.nodes.values(): - if not isinstance(node, CoreNodeBase): - continue - - for name, service in node.config_services.items(): - if not service.custom_templates and not service.custom_config: - continue - config_proto = configservices_pb2.ConfigServiceConfig( - node_id=node.id, - name=name, - templates=service.custom_templates, - config=service.custom_config, - ) - configs.append(config_proto) + configs = grpcutils.get_node_config_service_configs(session) return GetNodeConfigServiceConfigsResponse(configs=configs) def GetNodeConfigServices( diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index cad6ae3c..4127b141 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -119,7 +119,7 @@ class Session: # states and hooks handlers self.state: EventTypes = EventTypes.DEFINITION_STATE self.state_time: float = time.monotonic() - self.hooks: Dict[EventTypes, Tuple[str, str]] = {} + self.hooks: Dict[EventTypes, List[Tuple[str, str]]] = {} self.state_hooks: Dict[EventTypes, List[Callable[[EventTypes], None]]] = {} self.add_state_hook( state=EventTypes.RUNTIME_STATE, hook=self.runtime_state_hook diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 81e0d1c6..f2a27444 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -330,7 +330,7 @@ class CanvasGraph(tk.Canvas): Draw existing session. """ # draw existing nodes - for core_node in session.nodes: + for core_node in session.nodes.values(): logging.debug("drawing node: %s", core_node) # peer to peer node is not drawn on the GUI if NodeUtils.is_ignore_node(core_node.type): diff --git a/daemon/core/gui/wrappers.py b/daemon/core/gui/wrappers.py index 612c7646..835a9d17 100644 --- a/daemon/core/gui/wrappers.py +++ b/daemon/core/gui/wrappers.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Dict, List +from typing import Dict, List, Optional, Tuple from core.api.grpc import common_pb2, configservices_pb2, core_pb2, services_pb2 @@ -119,6 +119,12 @@ class ConfigService: ) +@dataclass +class ConfigServiceData: + templates: Dict[str, str] + config: Dict[str, str] + + @dataclass class NodeServiceData: executables: List[str] @@ -508,6 +514,22 @@ class Node: dir: str = None channel: str = None + # configurations + emane_model_configs: Dict[ + Tuple[str, Optional[int]], Dict[str, ConfigOption] + ] = field(default_factory=dict, repr=False) + wlan_config: Dict[str, ConfigOption] = field(default_factory=dict, repr=False) + mobility_config: Dict[str, ConfigOption] = field(default_factory=dict, repr=False) + service_configs: Dict[str, NodeServiceData] = field( + default_factory=dict, repr=False + ) + service_file_configs: Dict[str, Dict[str, str]] = field( + default_factory=dict, repr=False + ) + config_service_configs: Dict[str, ConfigServiceData] = field( + default_factory=dict, repr=False + ) + @classmethod def from_proto(cls, proto: core_pb2.Node) -> "Node": return Node( @@ -549,20 +571,62 @@ class Node: class Session: id: int state: SessionState - nodes: List[Node] + nodes: Dict[int, Node] links: List[Link] dir: str + user: str + default_services: Dict[str, List[str]] + location: SessionLocation + hooks: List[Hook] + emane_models: List[str] + emane_config: Dict[str, ConfigOption] + metadata: Dict[str, str] @classmethod def from_proto(cls, proto: core_pb2.Session) -> "Session": - nodes = [Node.from_proto(x) for x in proto.nodes] + nodes: Dict[int, Node] = {x.id: Node.from_proto(x) for x in proto.nodes} links = [Link.from_proto(x) for x in proto.links] + default_services = {x.node_type: x.services for x in proto.default_services} + hooks = [Hook.from_proto(x) for x in proto.hooks] + # update nodes with their current configurations + for model in proto.emane_model_configs: + iface_id = None + if model.iface_id != -1: + iface_id = model.iface_id + node = nodes[model.node_id] + key = (model.model, iface_id) + node.emane_model_configs[key] = ConfigOption.from_dict(model.config) + for node_id, mapped_config in proto.wlan_configs.items(): + node = nodes[node_id] + node.wlan_config = ConfigOption.from_dict(mapped_config.config) + for config in proto.service_configs: + service = config.service + node = nodes[config.node_id] + node.service_configs[service] = NodeServiceData.from_proto(config.data) + for file, data in config.files.items(): + files = node.service_file_configs.setdefault(service, {}) + files[file] = data + for config in proto.config_service_configs: + node = nodes[config.node_id] + node.config_service_configs[config.name] = ConfigServiceData( + templates=dict(config.templates), config=dict(config.config) + ) + for node_id, mapped_config in proto.mobility_configs.items(): + node = nodes[node_id] + node.mobility_config = ConfigOption.from_dict(mapped_config.config) return Session( id=proto.id, state=SessionState(proto.state), nodes=nodes, links=links, dir=proto.dir, + user=proto.user, + default_services=default_services, + location=SessionLocation.from_proto(proto.location), + hooks=hooks, + emane_models=list(proto.emane_models), + emane_config=ConfigOption.from_dict(proto.emane_config), + metadata=dict(proto.metadata), ) diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 9214ad1b..1b20257c 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -714,6 +714,18 @@ message Session { repeated Node nodes = 3; repeated Link links = 4; string dir = 5; + string user = 6; + repeated services.ServiceDefaults default_services = 7; + SessionLocation location = 8; + repeated Hook hooks = 9; + repeated string emane_models = 10; + map emane_config = 11; + repeated emane.EmaneModelConfig emane_model_configs = 12; + map wlan_configs = 13; + repeated services.NodeServiceConfig service_configs = 14; + repeated configservices.ConfigServiceConfig config_service_configs = 15; + map mobility_configs = 16; + map metadata = 17; } message SessionSummary { diff --git a/daemon/proto/core/api/grpc/emane.proto b/daemon/proto/core/api/grpc/emane.proto index ac5456fd..ce9a4297 100644 --- a/daemon/proto/core/api/grpc/emane.proto +++ b/daemon/proto/core/api/grpc/emane.proto @@ -54,13 +54,7 @@ message GetEmaneModelConfigsRequest { } message GetEmaneModelConfigsResponse { - message ModelConfig { - int32 node_id = 1; - string model = 2; - int32 iface_id = 3; - map config = 4; - } - repeated ModelConfig configs = 1; + repeated EmaneModelConfig configs = 1; } message GetEmaneEventChannelRequest { diff --git a/daemon/proto/core/api/grpc/services.proto b/daemon/proto/core/api/grpc/services.proto index 7e8498a7..cf6d9cbf 100644 --- a/daemon/proto/core/api/grpc/services.proto +++ b/daemon/proto/core/api/grpc/services.proto @@ -59,6 +59,13 @@ message NodeServiceData { string meta = 10; } +message NodeServiceConfig { + int32 node_id = 1; + string service = 2; + NodeServiceData data = 3; + map files = 4; +} + message GetServicesRequest { } @@ -89,13 +96,7 @@ message GetNodeServiceConfigsRequest { } message GetNodeServiceConfigsResponse { - message ServiceConfig { - int32 node_id = 1; - string service = 2; - NodeServiceData data = 3; - map files = 4; - } - repeated ServiceConfig configs = 1; + repeated NodeServiceConfig configs = 1; } message GetNodeServiceRequest { From 588afaad13c8996c790f91bd743bf21cc06b1de6 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 28 Jul 2020 00:03:15 -0700 Subject: [PATCH 348/929] pygui: changes to make use of wrapped session object and wrapped nodes to maintain and retrieving configurations information --- daemon/core/api/grpc/server.py | 1 + daemon/core/gui/coreclient.py | 379 ++++++------------ daemon/core/gui/dialogs/canvassizeandscale.py | 4 +- .../core/gui/dialogs/configserviceconfig.py | 59 ++- daemon/core/gui/dialogs/copyserviceconfig.py | 21 +- daemon/core/gui/dialogs/emaneconfig.py | 30 +- daemon/core/gui/dialogs/hooks.py | 15 +- daemon/core/gui/dialogs/linkconfig.py | 2 +- daemon/core/gui/dialogs/mobilityconfig.py | 12 +- daemon/core/gui/dialogs/mobilityplayer.py | 45 +-- daemon/core/gui/dialogs/nodeconfigservice.py | 24 +- daemon/core/gui/dialogs/nodeservice.py | 23 +- daemon/core/gui/dialogs/runtool.py | 2 +- daemon/core/gui/dialogs/serviceconfig.py | 42 +- daemon/core/gui/dialogs/sessionoptions.py | 4 +- daemon/core/gui/dialogs/sessions.py | 2 +- daemon/core/gui/dialogs/wlanconfig.py | 6 +- daemon/core/gui/graph/graph.py | 21 +- daemon/core/gui/graph/node.py | 21 +- daemon/core/gui/task.py | 2 - daemon/core/gui/wrappers.py | 24 +- 21 files changed, 284 insertions(+), 455 deletions(-) diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 65029e0a..cd9cf714 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -580,6 +580,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): service_configs = grpcutils.get_node_service_configs(session) config_service_configs = grpcutils.get_node_config_service_configs(session) session_proto = core_pb2.Session( + id=session.id, state=session.state.value, nodes=nodes, links=links, diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 5ddc28d7..36adf189 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -37,7 +37,6 @@ from core.gui.wrappers import ( ConfigOption, ConfigService, ExceptionEvent, - Hook, Interface, Link, LinkEvent, @@ -61,6 +60,10 @@ GUI_SOURCE = "gui" CPU_USAGE_DELAY = 3 +def to_dict(config: Dict[str, ConfigOption]) -> Dict[str, str]: + return {x: y.value for x, y in config.items()} + + class CoreClient: def __init__(self, app: "Application", proxy: bool) -> None: """ @@ -69,14 +72,13 @@ class CoreClient: self.app: "Application" = app self.master: tk.Tk = app.master self._client: client.CoreGrpcClient = client.CoreGrpcClient(proxy=proxy) - self.session_id: Optional[int] = None + self.session: Optional[Session] = None + self.user = getpass.getuser() + + # global service settings self.services: Dict[str, Set[str]] = {} self.config_services_groups: Dict[str, Set[str]] = {} self.config_services: Dict[str, ConfigService] = {} - self.default_services: Dict[NodeType, Set[str]] = {} - self.emane_models: List[str] = [] - self.observer: Optional[str] = None - self.user = getpass.getuser() # loaded configuration data self.servers: Dict[str, CoreServer] = {} @@ -87,15 +89,12 @@ class CoreClient: # helpers self.iface_to_edge: Dict[Tuple[int, ...], Tuple[int, ...]] = {} self.ifaces_manager: InterfaceManager = InterfaceManager(self.app) + self.observer: Optional[str] = None # session data - self.state: Optional[SessionState] = None - self.canvas_nodes: Dict[int, CanvasNode] = {} - self.location: Optional[SessionLocation] = None - self.links: Dict[Tuple[int, int], CanvasEdge] = {} - self.hooks: Dict[str, Hook] = {} - self.emane_config: Dict[str, ConfigOption] = {} self.mobility_players: Dict[int, MobilityPlayer] = {} + self.canvas_nodes: Dict[int, CanvasNode] = {} + self.links: Dict[Tuple[int, int], CanvasEdge] = {} self.handling_throughputs: Optional[grpc.Future] = None self.handling_cpu_usage: Optional[grpc.Future] = None self.handling_events: Optional[grpc.Future] = None @@ -104,15 +103,15 @@ class CoreClient: @property def client(self) -> client.CoreGrpcClient: - if self.session_id: - response = self._client.check_session(self.session_id) + if self.session: + response = self._client.check_session(self.session.id) if not response.result: throughputs_enabled = self.handling_throughputs is not None self.cancel_throughputs() self.cancel_events() - self._client.create_session(self.session_id) + self._client.create_session(self.session.id) self.handling_events = self._client.events( - self.session_id, self.handle_events + self.session.id, self.handle_events ) if throughputs_enabled: self.enable_throughputs() @@ -126,8 +125,6 @@ class CoreClient: # session data self.canvas_nodes.clear() self.links.clear() - self.hooks.clear() - self.emane_config = None self.close_mobility_players() self.mobility_players.clear() # clear streams @@ -145,12 +142,10 @@ class CoreClient: # read distributed servers for server in self.app.guiconfig.servers: self.servers[server.name] = server - # read custom nodes for custom_node in self.app.guiconfig.nodes: node_draw = NodeDraw.from_custom(custom_node) self.custom_nodes[custom_node.name] = node_draw - # read observers for observer in self.app.guiconfig.observers: self.custom_observers[observer.name] = observer @@ -158,11 +153,11 @@ class CoreClient: def handle_events(self, event: core_pb2.Event) -> None: if event.source == GUI_SOURCE: return - if event.session_id != self.session_id: + if event.session_id != self.session.id: logging.warning( "ignoring event session(%s) current(%s)", event.session_id, - self.session_id, + self.session.id, ) return @@ -173,7 +168,7 @@ class CoreClient: logging.info("session event: %s", event) session_event = event.session_event if session_event.event <= SessionState.SHUTDOWN.value: - self.state = SessionState(session_event.event) + self.session.state = SessionState(session_event.event) elif session_event.event in {7, 8, 9}: node_id = session_event.node_id dialog = self.mobility_players.get(node_id) @@ -253,7 +248,7 @@ class CoreClient: def enable_throughputs(self) -> None: self.handling_throughputs = self.client.throughputs( - self.session_id, self.handle_throughputs + self.session.id, self.handle_throughputs ) def cancel_throughputs(self) -> None: @@ -283,11 +278,11 @@ class CoreClient: def handle_throughputs(self, event: core_pb2.ThroughputsEvent) -> None: event = ThroughputsEvent.from_proto(event) - if event.session_id != self.session_id: + if event.session_id != self.session.id: logging.warning( "ignoring throughput event session(%s) current(%s)", event.session_id, - self.session_id, + self.session.id, ) return logging.debug("handling throughputs event: %s", event) @@ -300,126 +295,33 @@ class CoreClient: logging.info("exception event: %s", event) self.app.statusbar.add_alert(event) - def join_session(self, session_id: int, query_location: bool = True) -> None: - logging.info("join session(%s)", session_id) - # update session and title - self.session_id = session_id - self.master.title(f"CORE Session({self.session_id})") - - # clear session data + def join_session(self, session_id: int) -> None: + logging.info("joining session(%s)", session_id) self.reset() - - # get session data try: - response = self.client.get_session(self.session_id) - session = Session.from_proto(response.session) - self.state = session.state + response = self.client.get_session(session_id) + self.session = Session.from_proto(response.session) + self.client.set_session_user(self.session.id, self.user) + self.master.title(f"CORE Session({self.session.id})") self.handling_events = self.client.events( - self.session_id, self.handle_events + self.session.id, self.handle_events ) - - # set session user - self.client.set_session_user(self.session_id, self.user) - - # get session service defaults - response = self.client.get_service_defaults(self.session_id) - self.default_services = { - x.node_type: set(x.services) for x in response.defaults - } - - # get location - if query_location: - response = self.client.get_session_location(self.session_id) - self.location = SessionLocation.from_proto(response.location) - - # get emane models - response = self.client.get_emane_models(self.session_id) - self.emane_models = response.models - - # get hooks - response = self.client.get_hooks(self.session_id) - for hook_proto in response.hooks: - hook = Hook.from_proto(hook_proto) - self.hooks[hook.file] = hook - - # get emane config - response = self.client.get_emane_config(self.session_id) - self.emane_config = ConfigOption.from_dict(response.config) - - # update interface manager - self.ifaces_manager.joined(session.links) - - # draw session - self.app.canvas.reset_and_redraw(session) - - # get mobility configs - response = self.client.get_mobility_configs(self.session_id) - for node_id in response.configs: - config = response.configs[node_id].config - canvas_node = self.canvas_nodes[node_id] - canvas_node.mobility_config = ConfigOption.from_dict(config) - - # get emane model config - response = self.client.get_emane_model_configs(self.session_id) - for config in response.configs: - iface_id = None - if config.iface_id != -1: - iface_id = config.iface_id - canvas_node = self.canvas_nodes[config.node_id] - canvas_node.emane_model_configs[ - (config.model, iface_id) - ] = ConfigOption.from_dict(config.config) - - # get wlan configurations - response = self.client.get_wlan_configs(self.session_id) - for _id in response.configs: - mapped_config = response.configs[_id] - canvas_node = self.canvas_nodes[_id] - canvas_node.wlan_config = ConfigOption.from_dict(mapped_config.config) - - # get service configurations - response = self.client.get_node_service_configs(self.session_id) - for config in response.configs: - canvas_node = self.canvas_nodes[config.node_id] - canvas_node.service_configs[config.service] = config.data - logging.debug("service file configs: %s", config.files) - for file_name in config.files: - data = config.files[file_name] - files = canvas_node.service_file_configs.setdefault( - config.service, {} - ) - files[file_name] = data - - # get config service configurations - response = self.client.get_node_config_service_configs(self.session_id) - for config in response.configs: - canvas_node = self.canvas_nodes[config.node_id] - service_config = canvas_node.config_service_configs.setdefault( - config.name, {} - ) - if config.templates: - service_config["templates"] = config.templates - if config.config: - service_config["config"] = config.config - - # get metadata - response = self.client.get_session_metadata(self.session_id) - self.parse_metadata(response.config) + self.ifaces_manager.joined(self.session.links) + self.app.canvas.reset_and_redraw(self.session) + self.parse_metadata() + self.app.canvas.organize() + if self.is_runtime(): + self.show_mobility_players() + self.app.after(0, self.app.joined_session_update) except grpc.RpcError as e: self.app.show_grpc_exception("Join Session Error", e) - # organize canvas - self.app.canvas.organize() - if self.is_runtime(): - self.show_mobility_players() - # update ui to represent current state - self.app.after(0, self.app.joined_session_update) - def is_runtime(self) -> bool: - return self.state == SessionState.RUNTIME + return self.session and self.session.state == SessionState.RUNTIME - def parse_metadata(self, config: Dict[str, str]) -> None: + def parse_metadata(self) -> None: # canvas setting + config = self.session.metadata canvas_config = config.get("canvas") logging.debug("canvas metadata: %s", canvas_config) if canvas_config: @@ -447,7 +349,7 @@ class CoreClient: if shapes_config: shapes_config = json.loads(shapes_config) for shape_config in shapes_config: - logging.info("loading shape: %s", shape_config) + logging.debug("loading shape: %s", shape_config) shape_type = shape_config["type"] try: shape_type = ShapeType(shape_type) @@ -478,8 +380,9 @@ class CoreClient: try: response = self.client.create_session() logging.info("created session: %s", response) + self.join_session(response.session_id) location_config = self.app.guiconfig.location - self.location = SessionLocation( + self.session.location = SessionLocation( x=location_config.x, y=location_config.y, z=location_config.z, @@ -488,13 +391,12 @@ class CoreClient: alt=location_config.alt, scale=location_config.scale, ) - self.join_session(response.session_id, query_location=False) except grpc.RpcError as e: self.app.show_grpc_exception("New Session Error", e) def delete_session(self, session_id: int = None) -> None: if session_id is None: - session_id = self.session_id + session_id = self.session.id try: response = self.client.delete_session(session_id) logging.info("deleted session(%s), Result: %s", session_id, response) @@ -507,13 +409,11 @@ class CoreClient: """ try: self.client.connect() - - # get service information + # get all available services response = self.client.get_services() for service in response.services: group_services = self.services.setdefault(service.group, set()) group_services.add(service.name) - # get config service informations response = self.client.get_config_services() for service in response.services: @@ -522,7 +422,6 @@ class CoreClient: service.group, set() ) group_services.add(service.name) - # join provided session, create new session, or show dialog to select an # existing session response = self.client.get_sessions() @@ -553,14 +452,14 @@ class CoreClient: try: position = core_node.position.to_proto() self.client.edit_node( - self.session_id, core_node.id, position, source=GUI_SOURCE + self.session.id, core_node.id, position, source=GUI_SOURCE ) except grpc.RpcError as e: self.app.show_grpc_exception("Edit Node Error", e) def send_servers(self) -> None: for server in self.servers.values(): - self.client.add_session_server(self.session_id, server.name, server.address) + self.client.add_session_server(self.session.id, server.name, server.address) def start_session(self) -> Tuple[bool, List[str]]: self.ifaces_manager.reset_mac() @@ -576,26 +475,23 @@ class CoreClient: wlan_configs = self.get_wlan_configs_proto() mobility_configs = self.get_mobility_configs_proto() emane_model_configs = self.get_emane_model_configs_proto() - hooks = [x.to_proto() for x in self.hooks.values()] + hooks = [x.to_proto() for x in self.session.hooks.values()] service_configs = self.get_service_configs_proto() file_configs = self.get_service_file_configs_proto() asymmetric_links = [ x.asymmetric_link for x in self.links.values() if x.asymmetric_link ] config_service_configs = self.get_config_service_configs_proto() - if self.emane_config: - emane_config = {x: self.emane_config[x].value for x in self.emane_config} - else: - emane_config = None + emane_config = to_dict(self.session.emane_config) result = False exceptions = [] try: self.send_servers() response = self.client.start_session( - self.session_id, + self.session.id, nodes, links, - self.location.to_proto(), + self.session.location.to_proto(), hooks, emane_config, emane_model_configs, @@ -607,7 +503,7 @@ class CoreClient: config_service_configs, ) logging.info( - "start session(%s), result: %s", self.session_id, response.result + "start session(%s), result: %s", self.session.id, response.result ) if response.result: self.set_metadata() @@ -619,7 +515,7 @@ class CoreClient: def stop_session(self, session_id: int = None) -> bool: if not session_id: - session_id = self.session_id + session_id = self.session.id result = False try: response = self.client.stop_session(session_id) @@ -630,15 +526,12 @@ class CoreClient: return result def show_mobility_players(self) -> None: - for canvas_node in self.canvas_nodes.values(): - if canvas_node.core_node.type != NodeType.WIRELESS_LAN: + for node in self.session.nodes.values(): + if node.type != NodeType.WIRELESS_LAN: continue - if canvas_node.mobility_config: - mobility_player = MobilityPlayer( - self.app, canvas_node, canvas_node.mobility_config - ) - node_id = canvas_node.core_node.id - self.mobility_players[node_id] = mobility_player + if node.mobility_config: + mobility_player = MobilityPlayer(self.app, node) + self.mobility_players[node.id] = mobility_player mobility_player.show() def set_metadata(self) -> None: @@ -662,8 +555,8 @@ class CoreClient: shapes = json.dumps(shapes) metadata = {"canvas": canvas_config, "shapes": shapes} - response = self.client.set_session_metadata(self.session_id, metadata) - logging.info("set session metadata %s, result: %s", metadata, response) + response = self.client.set_session_metadata(self.session.id, metadata) + logging.debug("set session metadata %s, result: %s", metadata, response) def launch_terminal(self, node_id: int) -> None: try: @@ -675,7 +568,7 @@ class CoreClient: parent=self.app, ) return - response = self.client.get_node_terminal(self.session_id, node_id) + response = self.client.get_node_terminal(self.session.id, node_id) cmd = f"{terminal} {response.terminal} &" logging.info("launching terminal %s", cmd) os.system(cmd) @@ -687,10 +580,10 @@ class CoreClient: Save core session as to an xml file """ try: - if self.state != SessionState.RUNTIME: + if not self.is_runtime(): logging.debug("Send session data to the daemon") self.send_data() - response = self.client.save_xml(self.session_id, file_path) + response = self.client.save_xml(self.session.id, file_path) logging.info("saved xml file %s, result: %s", file_path, response) except grpc.RpcError as e: self.app.show_grpc_exception("Save XML Error", e) @@ -707,7 +600,7 @@ class CoreClient: self.app.show_grpc_exception("Open XML Error", e) def get_node_service(self, node_id: int, service_name: str) -> NodeServiceData: - response = self.client.get_node_service(self.session_id, node_id, service_name) + response = self.client.get_node_service(self.session.id, node_id, service_name) logging.debug( "get node(%s) %s service, response: %s", node_id, service_name, response ) @@ -724,7 +617,7 @@ class CoreClient: shutdowns: List[str], ) -> NodeServiceData: response = self.client.set_node_service( - self.session_id, + self.session.id, node_id, service_name, directories=dirs, @@ -744,14 +637,14 @@ class CoreClient: shutdowns, response, ) - response = self.client.get_node_service(self.session_id, node_id, service_name) + response = self.client.get_node_service(self.session.id, node_id, service_name) return NodeServiceData.from_proto(response.service) def get_node_service_file( self, node_id: int, service_name: str, file_name: str ) -> str: response = self.client.get_node_service_file( - self.session_id, node_id, service_name, file_name + self.session.id, node_id, service_name, file_name ) logging.debug( "get service file for node(%s), service: %s, file: %s, result: %s", @@ -766,7 +659,7 @@ class CoreClient: self, node_id: int, service_name: str, file_name: str, data: str ) -> None: response = self.client.set_node_service_file( - self.session_id, node_id, service_name, file_name, data + self.session.id, node_id, service_name, file_name, data ) logging.info( "set node(%s) service file, service: %s, file: %s, data: %s, result: %s", @@ -783,13 +676,13 @@ class CoreClient: """ node_protos = [x.core_node.to_proto() for x in self.canvas_nodes.values()] link_protos = [x.link.to_proto() for x in self.links.values()] - self.client.set_session_state(self.session_id, SessionState.DEFINITION.value) + self.client.set_session_state(self.session.id, SessionState.DEFINITION.value) for node_proto in node_protos: - response = self.client.add_node(self.session_id, node_proto) + response = self.client.add_node(self.session.id, node_proto) logging.debug("create node: %s", response) for link_proto in link_protos: response = self.client.add_link( - self.session_id, + self.session.id, link_proto.node1_id, link_proto.node2_id, link_proto.iface1, @@ -806,15 +699,15 @@ class CoreClient: self.create_nodes_and_links() for config_proto in self.get_wlan_configs_proto(): self.client.set_wlan_config( - self.session_id, config_proto.node_id, config_proto.config + self.session.id, config_proto.node_id, config_proto.config ) for config_proto in self.get_mobility_configs_proto(): self.client.set_mobility_config( - self.session_id, config_proto.node_id, config_proto.config + self.session.id, config_proto.node_id, config_proto.config ) for config_proto in self.get_service_configs_proto(): self.client.set_node_service( - self.session_id, + self.session.id, config_proto.node_id, config_proto.service, startup=config_proto.startup, @@ -823,38 +716,37 @@ class CoreClient: ) for config_proto in self.get_service_file_configs_proto(): self.client.set_node_service_file( - self.session_id, + self.session.id, config_proto.node_id, config_proto.service, config_proto.file, config_proto.data, ) - for hook in self.hooks.values(): + for hook in self.session.hooks.values(): self.client.add_hook( - self.session_id, hook.state.value, hook.file, hook.data + self.session.id, hook.state.value, hook.file, hook.data ) for config_proto in self.get_emane_model_configs_proto(): self.client.set_emane_model_config( - self.session_id, + self.session.id, config_proto.node_id, config_proto.model, config_proto.config, config_proto.iface_id, ) - if self.emane_config: - config = {x: self.emane_config[x].value for x in self.emane_config} - self.client.set_emane_config(self.session_id, config) - if self.location: - self.client.set_session_location( - self.session_id, - self.location.x, - self.location.y, - self.location.z, - self.location.lat, - self.location.lon, - self.location.alt, - self.location.scale, - ) + config = to_dict(self.session.emane_config) + self.client.set_emane_config(self.session.id, config) + location = self.session.location + self.client.set_session_location( + self.session.id, + location.x, + location.y, + location.z, + location.lat, + location.lon, + location.alt, + location.scale, + ) self.set_metadata() def close(self) -> None: @@ -888,16 +780,16 @@ class CoreClient: image = "ubuntu:latest" emane = None if node_type == NodeType.EMANE: - if not self.emane_models: + if not self.session.emane_models: dialog = EmaneInstallDialog(self.app) dialog.show() return - emane = self.emane_models[0] - name = f"EMANE{node_id}" + emane = self.session.emane_models[0] + name = f"emane{node_id}" elif node_type == NodeType.WIRELESS_LAN: - name = f"WLAN{node_id}" + name = f"wlan{node_id}" elif node_type in [NodeType.RJ45, NodeType.TUNNEL]: - name = "UNASSIGNED" + name = "unassigned" else: name = f"n{node_id}" node = Node( @@ -914,13 +806,13 @@ class CoreClient: node.services[:] = services # assign default services to CORE node else: - services = self.default_services.get(model) + services = self.session.default_services.get(model) if services: - node.services[:] = services + node.services = services.copy() logging.info( "add node(%s) to session(%s), coordinates(%s, %s)", node.name, - self.session_id, + self.session.id, x, y, ) @@ -1005,60 +897,56 @@ class CoreClient: def get_wlan_configs_proto(self) -> List[wlan_pb2.WlanConfig]: configs = [] - for canvas_node in self.canvas_nodes.values(): - if canvas_node.core_node.type != NodeType.WIRELESS_LAN: + for node in self.session.nodes.values(): + if node.type != NodeType.WIRELESS_LAN: continue - if not canvas_node.wlan_config: + if not node.wlan_config: continue - config = ConfigOption.to_dict(canvas_node.wlan_config) - node_id = canvas_node.core_node.id - wlan_config = wlan_pb2.WlanConfig(node_id=node_id, config=config) + config = ConfigOption.to_dict(node.wlan_config) + wlan_config = wlan_pb2.WlanConfig(node_id=node.id, config=config) configs.append(wlan_config) return configs def get_mobility_configs_proto(self) -> List[mobility_pb2.MobilityConfig]: configs = [] - for canvas_node in self.canvas_nodes.values(): - if canvas_node.core_node.type != NodeType.WIRELESS_LAN: + for node in self.session.nodes.values(): + if node.type != NodeType.WIRELESS_LAN: continue - if not canvas_node.mobility_config: + if not node.mobility_config: continue - config = ConfigOption.to_dict(canvas_node.mobility_config) - node_id = canvas_node.core_node.id + config = ConfigOption.to_dict(node.mobility_config) mobility_config = mobility_pb2.MobilityConfig( - node_id=node_id, config=config + node_id=node.id, config=config ) configs.append(mobility_config) return configs def get_emane_model_configs_proto(self) -> List[emane_pb2.EmaneModelConfig]: configs = [] - for canvas_node in self.canvas_nodes.values(): - if canvas_node.core_node.type != NodeType.EMANE: + for node in self.session.nodes.values(): + if node.type != NodeType.EMANE: continue - node_id = canvas_node.core_node.id - for key, config in canvas_node.emane_model_configs.items(): + for key, config in node.emane_model_configs.items(): model, iface_id = key config = ConfigOption.to_dict(config) if iface_id is None: iface_id = -1 config_proto = emane_pb2.EmaneModelConfig( - node_id=node_id, iface_id=iface_id, model=model, config=config + node_id=node.id, iface_id=iface_id, model=model, config=config ) configs.append(config_proto) return configs def get_service_configs_proto(self) -> List[services_pb2.ServiceConfig]: configs = [] - for canvas_node in self.canvas_nodes.values(): - if not NodeUtils.is_container_node(canvas_node.core_node.type): + for node in self.session.nodes.values(): + if not NodeUtils.is_container_node(node.type): continue - if not canvas_node.service_configs: + if not node.service_configs: continue - node_id = canvas_node.core_node.id - for name, config in canvas_node.service_configs.items(): + for name, config in node.service_configs.items(): config_proto = services_pb2.ServiceConfig( - node_id=node_id, + node_id=node.id, service=name, directories=config.dirs, files=config.configs, @@ -1071,16 +959,15 @@ class CoreClient: def get_service_file_configs_proto(self) -> List[services_pb2.ServiceFileConfig]: configs = [] - for canvas_node in self.canvas_nodes.values(): - if not NodeUtils.is_container_node(canvas_node.core_node.type): + for node in self.session.nodes.values(): + if not NodeUtils.is_container_node(node.type): continue - if not canvas_node.service_file_configs: + if not node.service_file_configs: continue - node_id = canvas_node.core_node.id - for service, file_configs in canvas_node.service_file_configs.items(): + for service, file_configs in node.service_file_configs.items(): for file, data in file_configs.items(): config_proto = services_pb2.ServiceFileConfig( - node_id=node_id, service=service, file=file, data=data + node_id=node.id, service=service, file=file, data=data ) configs.append(config_proto) return configs @@ -1089,29 +976,27 @@ class CoreClient: self ) -> List[configservices_pb2.ConfigServiceConfig]: config_service_protos = [] - for canvas_node in self.canvas_nodes.values(): - if not NodeUtils.is_container_node(canvas_node.core_node.type): + for node in self.session.nodes.values(): + if not NodeUtils.is_container_node(node.type): continue - if not canvas_node.config_service_configs: + if not node.config_service_configs: continue - node_id = canvas_node.core_node.id - for name, service_config in canvas_node.config_service_configs.items(): - config = service_config.get("config", {}) + for name, service_config in node.config_service_configs.items(): config_proto = configservices_pb2.ConfigServiceConfig( - node_id=node_id, + node_id=node.id, name=name, - templates=service_config["templates"], - config=config, + templates=service_config.templates, + config=service_config.config, ) config_service_protos.append(config_proto) return config_service_protos def run(self, node_id: int) -> str: logging.info("running node(%s) cmd: %s", node_id, self.observer) - return self.client.node_command(self.session_id, node_id, self.observer).output + return self.client.node_command(self.session.id, node_id, self.observer).output def get_wlan_config(self, node_id: int) -> Dict[str, ConfigOption]: - response = self.client.get_wlan_config(self.session_id, node_id) + response = self.client.get_wlan_config(self.session.id, node_id) config = response.config logging.debug( "get wlan configuration from node %s, result configuration: %s", @@ -1121,7 +1006,7 @@ class CoreClient: return ConfigOption.from_dict(config) def get_mobility_config(self, node_id: int) -> Dict[str, ConfigOption]: - response = self.client.get_mobility_config(self.session_id, node_id) + response = self.client.get_mobility_config(self.session.id, node_id) config = response.config logging.debug( "get mobility config from node %s, result configuration: %s", @@ -1136,7 +1021,7 @@ class CoreClient: if iface_id is None: iface_id = -1 response = self.client.get_emane_model_config( - self.session_id, node_id, model, iface_id + self.session.id, node_id, model, iface_id ) config = response.config logging.debug( diff --git a/daemon/core/gui/dialogs/canvassizeandscale.py b/daemon/core/gui/dialogs/canvassizeandscale.py index 38cecc83..e8ad6693 100644 --- a/daemon/core/gui/dialogs/canvassizeandscale.py +++ b/daemon/core/gui/dialogs/canvassizeandscale.py @@ -27,7 +27,7 @@ class SizeAndScaleDialog(Dialog): width, height = self.canvas.current_dimensions self.pixel_width: tk.IntVar = tk.IntVar(value=width) self.pixel_height: tk.IntVar = tk.IntVar(value=height) - location = self.app.core.location + location = self.app.core.session.location self.x: tk.DoubleVar = tk.DoubleVar(value=location.x) self.y: tk.DoubleVar = tk.DoubleVar(value=location.y) self.lat: tk.DoubleVar = tk.DoubleVar(value=location.lat) @@ -192,7 +192,7 @@ class SizeAndScaleDialog(Dialog): self.canvas.redraw_canvas((width, height)) if self.canvas.wallpaper: self.canvas.redraw_wallpaper() - location = self.app.core.location + location = self.app.core.session.location location.x = self.x.get() location.y = self.y.get() location.lat = self.lat.get() diff --git a/daemon/core/gui/dialogs/configserviceconfig.py b/daemon/core/gui/dialogs/configserviceconfig.py index 5463d88e..f778cf15 100644 --- a/daemon/core/gui/dialogs/configserviceconfig.py +++ b/daemon/core/gui/dialogs/configserviceconfig.py @@ -11,28 +11,26 @@ import grpc from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CodeText, ConfigFrame, ListboxScroll -from core.gui.wrappers import ConfigOption, ServiceValidationMode +from core.gui.wrappers import ( + ConfigOption, + ConfigServiceData, + Node, + ServiceValidationMode, +) if TYPE_CHECKING: from core.gui.app import Application - from core.gui.graph.node import CanvasNode from core.gui.coreclient import CoreClient class ConfigServiceConfigDialog(Dialog): def __init__( - self, - master: tk.BaseWidget, - app: "Application", - service_name: str, - canvas_node: "CanvasNode", - node_id: int, + self, master: tk.BaseWidget, app: "Application", service_name: str, node: Node ) -> None: title = f"{service_name} Config Service" super().__init__(app, title, master=master) self.core: "CoreClient" = app.core - self.canvas_node: "CanvasNode" = canvas_node - self.node_id: int = node_id + self.node: Node = node self.service_name: str = service_name self.radiovar: tk.IntVar = tk.IntVar() self.radiovar.set(2) @@ -50,7 +48,7 @@ class ConfigServiceConfigDialog(Dialog): self.validation_time: Optional[int] = None self.validation_period: tk.StringVar = tk.StringVar() self.modes: List[str] = [] - self.mode_configs: Dict[str, str] = {} + self.mode_configs: Dict[str, Dict[str, str]] = {} self.notebook: Optional[ttk.Notebook] = None self.templates_combobox: Optional[ttk.Combobox] = None @@ -91,25 +89,18 @@ class ConfigServiceConfigDialog(Dialog): response = self.core.client.get_config_service_defaults(self.service_name) self.original_service_files = response.templates self.temp_service_files = dict(self.original_service_files) - self.modes = sorted(x.name for x in response.modes) self.mode_configs = {x.name: x.config for x in response.modes} - - service_config = self.canvas_node.config_service_configs.get( - self.service_name, {} - ) self.config = ConfigOption.from_dict(response.config) self.default_config = {x.name: x.value for x in self.config.values()} - custom_config = service_config.get("config") - if custom_config: - for key, value in custom_config.items(): + service_config = self.node.config_service_configs.get(self.service_name) + if service_config: + for key, value in service_config.config.items(): self.config[key].value = value - logging.info("default config: %s", self.default_config) - - custom_templates = service_config.get("templates", {}) - for file, data in custom_templates.items(): - self.modified_files.add(file) - self.temp_service_files[file] = data + logging.info("default config: %s", self.default_config) + for file, data in service_config.templates.items(): + self.modified_files.add(file) + self.temp_service_files[file] = data except grpc.RpcError as e: self.app.show_grpc_exception("Get Config Service Error", e) self.has_error = True @@ -313,20 +304,18 @@ class ConfigServiceConfigDialog(Dialog): def click_apply(self) -> None: current_listbox = self.master.current.listbox if not self.is_custom(): - self.canvas_node.config_service_configs.pop(self.service_name, None) + self.node.config_service_configs.pop(self.service_name, None) current_listbox.itemconfig(current_listbox.curselection()[0], bg="") self.destroy() return - - service_config = self.canvas_node.config_service_configs.setdefault( - self.service_name, {} - ) + service_config = self.node.config_service_configs.get(self.service_name) + if not service_config: + service_config = ConfigServiceData() if self.config_frame: self.config_frame.parse_config() - service_config["config"] = {x.name: x.value for x in self.config.values()} - templates_config = service_config.setdefault("templates", {}) + service_config.config = {x.name: x.value for x in self.config.values()} for file in self.modified_files: - templates_config[file] = self.temp_service_files[file] + service_config.templates[file] = self.temp_service_files[file] all_current = current_listbox.get(0, tk.END) current_listbox.itemconfig(all_current.index(self.service_name), bg="green") self.destroy() @@ -360,9 +349,9 @@ class ConfigServiceConfigDialog(Dialog): return has_custom_templates or has_custom_config def click_defaults(self) -> None: - self.canvas_node.config_service_configs.pop(self.service_name, None) + self.node.config_service_configs.pop(self.service_name, None) logging.info( - "cleared config service config: %s", self.canvas_node.config_service_configs + "cleared config service config: %s", self.node.config_service_configs ) self.temp_service_files = dict(self.original_service_files) filename = self.templates_combobox.get() diff --git a/daemon/core/gui/dialogs/copyserviceconfig.py b/daemon/core/gui/dialogs/copyserviceconfig.py index 2a01249d..b60d5a0d 100644 --- a/daemon/core/gui/dialogs/copyserviceconfig.py +++ b/daemon/core/gui/dialogs/copyserviceconfig.py @@ -43,16 +43,15 @@ class CopyServiceConfigDialog(Dialog): listbox_scroll = ListboxScroll(self.top) listbox_scroll.grid(sticky="nsew", pady=PADY) self.listbox = listbox_scroll.listbox - for canvas_node in self.app.canvas.nodes.values(): - file_configs = canvas_node.service_file_configs.get(self.service) + for node in self.app.core.session.nodes.values(): + file_configs = node.service_file_configs.get(self.service) if not file_configs: continue data = file_configs.get(self.file_name) if not data: continue - name = canvas_node.core_node.name - self.nodes[name] = canvas_node.id - self.listbox.insert(tk.END, name) + self.nodes[node.name] = node.id + self.listbox.insert(tk.END, node.name) frame = ttk.Frame(self.top) frame.grid(sticky="ew") @@ -70,9 +69,9 @@ class CopyServiceConfigDialog(Dialog): if not selection: return name = self.listbox.get(selection) - canvas_node_id = self.nodes[name] - canvas_node = self.app.canvas.nodes[canvas_node_id] - data = canvas_node.service_file_configs[self.service][self.file_name] + node_id = self.nodes[name] + node = self.app.core.session.nodes[node_id] + data = node.service_file_configs[self.service][self.file_name] self.dialog.temp_service_files[self.file_name] = data self.dialog.modified_files.add(self.file_name) self.dialog.service_file_data.text.delete(1.0, tk.END) @@ -84,9 +83,9 @@ class CopyServiceConfigDialog(Dialog): if not selection: return name = self.listbox.get(selection) - canvas_node_id = self.nodes[name] - canvas_node = self.app.canvas.nodes[canvas_node_id] - data = canvas_node.service_file_configs[self.service][self.file_name] + node_id = self.nodes[name] + node = self.app.core.session.nodes[node_id] + data = node.service_file_configs[self.service][self.file_name] dialog = ViewConfigDialog( self.app, self, name, self.service, self.file_name, data ) diff --git a/daemon/core/gui/dialogs/emaneconfig.py b/daemon/core/gui/dialogs/emaneconfig.py index d87e935a..019eeaa9 100644 --- a/daemon/core/gui/dialogs/emaneconfig.py +++ b/daemon/core/gui/dialogs/emaneconfig.py @@ -16,7 +16,6 @@ from core.gui.wrappers import ConfigOption, Node if TYPE_CHECKING: from core.gui.app import Application - from core.gui.graph.node import CanvasNode class GlobalEmaneDialog(Dialog): @@ -29,8 +28,9 @@ class GlobalEmaneDialog(Dialog): def draw(self) -> None: self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) + session = self.app.core.session self.config_frame = ConfigFrame( - self.top, self.app, self.app.core.emane_config, self.enabled + self.top, self.app, session.emane_config, self.enabled ) self.config_frame.draw_config() self.config_frame.grid(sticky="nsew", pady=PADY) @@ -58,24 +58,19 @@ class EmaneModelDialog(Dialog): self, master: tk.BaseWidget, app: "Application", - canvas_node: "CanvasNode", + node: Node, model: str, iface_id: int = None, ) -> None: - super().__init__( - app, f"{canvas_node.core_node.name} {model} Configuration", master=master - ) - self.canvas_node: "CanvasNode" = canvas_node - self.node: Node = canvas_node.core_node + super().__init__(app, f"{node.name} {model} Configuration", master=master) + self.node: Node = node self.model: str = f"emane_{model}" self.iface_id: int = iface_id self.config_frame: Optional[ConfigFrame] = None self.enabled: bool = not self.app.core.is_runtime() self.has_error: bool = False try: - config = self.canvas_node.emane_model_configs.get( - (self.model, self.iface_id) - ) + config = self.node.emane_model_configs.get((self.model, self.iface_id)) if not config: config = self.app.core.get_emane_model_config( self.node.id, self.model, self.iface_id @@ -110,19 +105,18 @@ class EmaneModelDialog(Dialog): def click_apply(self) -> None: self.config_frame.parse_config() key = (self.model, self.iface_id) - self.canvas_node.emane_model_configs[key] = self.config + self.node.emane_model_configs[key] = self.config self.destroy() class EmaneConfigDialog(Dialog): - def __init__(self, app: "Application", canvas_node: "CanvasNode") -> None: - super().__init__(app, f"{canvas_node.core_node.name} EMANE Configuration") - self.canvas_node: "CanvasNode" = canvas_node - self.node: Node = canvas_node.core_node + def __init__(self, app: "Application", node: Node) -> None: + super().__init__(app, f"{node.name} EMANE Configuration") + self.node: Node = node self.radiovar: tk.IntVar = tk.IntVar() self.radiovar.set(1) self.emane_models: List[str] = [ - x.split("_")[1] for x in self.app.core.emane_models + x.split("_")[1] for x in self.app.core.session.emane_models ] model = self.node.emane.split("_")[1] self.emane_model: tk.StringVar = tk.StringVar(value=model) @@ -231,7 +225,7 @@ class EmaneConfigDialog(Dialog): draw emane model configuration """ model_name = self.emane_model.get() - dialog = EmaneModelDialog(self, self.app, self.canvas_node, model_name) + dialog = EmaneModelDialog(self, self.app, self.node, model_name) if not dialog.has_error: dialog.show() diff --git a/daemon/core/gui/dialogs/hooks.py b/daemon/core/gui/dialogs/hooks.py index b004dae2..31ef3e15 100644 --- a/daemon/core/gui/dialogs/hooks.py +++ b/daemon/core/gui/dialogs/hooks.py @@ -113,8 +113,9 @@ class HooksDialog(Dialog): listbox_scroll.grid(sticky="nsew", pady=PADY) self.listbox = listbox_scroll.listbox self.listbox.bind("<>", self.select) - for hook_file in self.app.core.hooks: - self.listbox.insert(tk.END, hook_file) + session = self.app.core.session + for file in session.hooks: + self.listbox.insert(tk.END, file) frame = ttk.Frame(self.top) frame.grid(sticky="ew") @@ -138,20 +139,22 @@ class HooksDialog(Dialog): dialog.show() hook = dialog.hook if hook: - self.app.core.hooks[hook.file] = hook + self.app.core.session.hooks[hook.file] = hook self.listbox.insert(tk.END, hook.file) def click_edit(self) -> None: - hook = self.app.core.hooks.pop(self.selected) + session = self.app.core.session + hook = session.hooks.pop(self.selected) dialog = HookDialog(self, self.app) dialog.set(hook) dialog.show() - self.app.core.hooks[hook.file] = hook + session.hooks[hook.file] = hook self.listbox.delete(self.selected_index) self.listbox.insert(self.selected_index, hook.file) def click_delete(self) -> None: - del self.app.core.hooks[self.selected] + session = self.app.core.session + del session.hooks[self.selected] self.listbox.delete(tk.ANCHOR) self.edit_button.config(state=tk.DISABLED) self.delete_button.config(state=tk.DISABLED) diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index 87f43284..2a91da30 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -269,7 +269,7 @@ class LinkConfigurationDialog(Dialog): self.edge.asymmetric_link = None if self.app.core.is_runtime() and link.options: - session_id = self.app.core.session_id + session_id = self.app.core.session.id self.app.core.client.edit_link( session_id, link.node1_id, diff --git a/daemon/core/gui/dialogs/mobilityconfig.py b/daemon/core/gui/dialogs/mobilityconfig.py index ca9caf43..857167be 100644 --- a/daemon/core/gui/dialogs/mobilityconfig.py +++ b/daemon/core/gui/dialogs/mobilityconfig.py @@ -13,18 +13,16 @@ from core.gui.wrappers import ConfigOption, Node if TYPE_CHECKING: from core.gui.app import Application - from core.gui.graph.node import CanvasNode class MobilityConfigDialog(Dialog): - def __init__(self, app: "Application", canvas_node: "CanvasNode") -> None: - super().__init__(app, f"{canvas_node.core_node.name} Mobility Configuration") - self.canvas_node: "CanvasNode" = canvas_node - self.node: Node = canvas_node.core_node + def __init__(self, app: "Application", node: Node) -> None: + super().__init__(app, f"{node.name} Mobility Configuration") + self.node: Node = node self.config_frame: Optional[ConfigFrame] = None self.has_error: bool = False try: - config = self.canvas_node.mobility_config + config = self.node.mobility_config if not config: config = self.app.core.get_mobility_config(self.node.id) self.config: Dict[str, ConfigOption] = config @@ -56,5 +54,5 @@ class MobilityConfigDialog(Dialog): def click_apply(self) -> None: self.config_frame.parse_config() - self.canvas_node.mobility_config = self.config + self.node.mobility_config = self.config self.destroy() diff --git a/daemon/core/gui/dialogs/mobilityplayer.py b/daemon/core/gui/dialogs/mobilityplayer.py index 66833aff..1bee97d2 100644 --- a/daemon/core/gui/dialogs/mobilityplayer.py +++ b/daemon/core/gui/dialogs/mobilityplayer.py @@ -1,38 +1,31 @@ import tkinter as tk from tkinter import ttk -from typing import TYPE_CHECKING, Dict, Optional +from typing import TYPE_CHECKING, Optional import grpc from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum from core.gui.themes import PADX, PADY -from core.gui.wrappers import ConfigOption, MobilityAction, Node +from core.gui.wrappers import MobilityAction, Node if TYPE_CHECKING: from core.gui.app import Application - from core.gui.graph.node import CanvasNode ICON_SIZE: int = 16 class MobilityPlayer: - def __init__( - self, - app: "Application", - canvas_node: "CanvasNode", - config: Dict[str, ConfigOption], - ) -> None: + def __init__(self, app: "Application", node: Node) -> None: self.app: "Application" = app - self.canvas_node: "CanvasNode" = canvas_node - self.config: Dict[str, ConfigOption] = config + self.node: Node = node self.dialog: Optional[MobilityPlayerDialog] = None self.state: Optional[MobilityAction] = None def show(self) -> None: if self.dialog: self.dialog.destroy() - self.dialog = MobilityPlayerDialog(self.app, self.canvas_node, self.config) + self.dialog = MobilityPlayerDialog(self.app, self.node) self.dialog.protocol("WM_DELETE_WINDOW", self.close) if self.state == MobilityAction.START: self.set_play() @@ -64,20 +57,11 @@ class MobilityPlayer: class MobilityPlayerDialog(Dialog): - def __init__( - self, - app: "Application", - canvas_node: "CanvasNode", - config: Dict[str, ConfigOption], - ) -> None: - super().__init__( - app, f"{canvas_node.core_node.name} Mobility Player", modal=False - ) + def __init__(self, app: "Application", node: Node) -> None: + super().__init__(app, f"{node.name} Mobility Player", modal=False) self.resizable(False, False) self.geometry("") - self.canvas_node: "CanvasNode" = canvas_node - self.node: Node = canvas_node.core_node - self.config: Dict[str, ConfigOption] = config + self.node: Node = node self.play_button: Optional[ttk.Button] = None self.pause_button: Optional[ttk.Button] = None self.stop_button: Optional[ttk.Button] = None @@ -85,9 +69,10 @@ class MobilityPlayerDialog(Dialog): self.draw() def draw(self) -> None: + config = self.node.mobility_config self.top.columnconfigure(0, weight=1) - file_name = self.config["file"].value + file_name = config["file"].value label = ttk.Label(self.top, text=file_name) label.grid(sticky="ew", pady=PADY) @@ -114,13 +99,13 @@ class MobilityPlayerDialog(Dialog): self.stop_button.image = image self.stop_button.grid(row=0, column=2, sticky="ew", padx=PADX) - loop = tk.IntVar(value=int(self.config["loop"].value == "1")) + loop = tk.IntVar(value=int(config["loop"].value == "1")) checkbutton = ttk.Checkbutton( frame, text="Loop?", variable=loop, state=tk.DISABLED ) checkbutton.grid(row=0, column=3, padx=PADX) - rate = self.config["refresh_ms"].value + rate = config["refresh_ms"].value label = ttk.Label(frame, text=f"rate {rate} ms") label.grid(row=0, column=4) @@ -146,7 +131,7 @@ class MobilityPlayerDialog(Dialog): def click_play(self) -> None: self.set_play() - session_id = self.app.core.session_id + session_id = self.app.core.session.id try: self.app.core.client.mobility_action( session_id, self.node.id, MobilityAction.START.value @@ -156,7 +141,7 @@ class MobilityPlayerDialog(Dialog): def click_pause(self) -> None: self.set_pause() - session_id = self.app.core.session_id + session_id = self.app.core.session.id try: self.app.core.client.mobility_action( session_id, self.node.id, MobilityAction.PAUSE.value @@ -166,7 +151,7 @@ class MobilityPlayerDialog(Dialog): def click_stop(self) -> None: self.set_stop() - session_id = self.app.core.session_id + session_id = self.app.core.session.id try: self.app.core.client.mobility_action( session_id, self.node.id, MobilityAction.STOP.value diff --git a/daemon/core/gui/dialogs/nodeconfigservice.py b/daemon/core/gui/dialogs/nodeconfigservice.py index b9a9a1f5..dee34f71 100644 --- a/daemon/core/gui/dialogs/nodeconfigservice.py +++ b/daemon/core/gui/dialogs/nodeconfigservice.py @@ -10,25 +10,24 @@ from core.gui.dialogs.configserviceconfig import ConfigServiceConfigDialog from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CheckboxList, ListboxScroll +from core.gui.wrappers import Node if TYPE_CHECKING: from core.gui.app import Application - from core.gui.graph.node import CanvasNode class NodeConfigServiceDialog(Dialog): def __init__( - self, app: "Application", canvas_node: "CanvasNode", services: Set[str] = None + self, app: "Application", node: Node, services: Set[str] = None ) -> None: - title = f"{canvas_node.core_node.name} Config Services" + title = f"{node.name} Config Services" super().__init__(app, title) - self.canvas_node: "CanvasNode" = canvas_node - self.node_id: int = canvas_node.core_node.id + self.node: Node = node self.groups: Optional[ListboxScroll] = None self.services: Optional[CheckboxList] = None self.current: Optional[ListboxScroll] = None if services is None: - services = set(canvas_node.core_node.config_services) + services = set(node.config_services) self.current_services: Set[str] = services self.draw() @@ -102,7 +101,7 @@ class NodeConfigServiceDialog(Dialog): elif not var.get() and name in self.current_services: self.current_services.remove(name) self.draw_current_services() - self.canvas_node.core_node.config_services[:] = self.current_services + self.node.config_services[:] = self.current_services def click_configure(self) -> None: current_selection = self.current.listbox.curselection() @@ -111,8 +110,7 @@ class NodeConfigServiceDialog(Dialog): self, self.app, self.current.listbox.get(current_selection[0]), - self.canvas_node, - self.node_id, + self.node, ) if not dialog.has_error: dialog.show() @@ -132,10 +130,8 @@ class NodeConfigServiceDialog(Dialog): self.current.listbox.itemconfig(tk.END, bg="green") def click_save(self) -> None: - self.canvas_node.core_node.config_services[:] = self.current_services - logging.info( - "saved node config services: %s", self.canvas_node.core_node.config_services - ) + self.node.config_services[:] = self.current_services + logging.info("saved node config services: %s", self.node.config_services) self.destroy() def click_cancel(self) -> None: @@ -154,4 +150,4 @@ class NodeConfigServiceDialog(Dialog): return def is_custom_service(self, service: str) -> bool: - return service in self.canvas_node.config_service_configs + return service in self.node.config_service_configs diff --git a/daemon/core/gui/dialogs/nodeservice.py b/daemon/core/gui/dialogs/nodeservice.py index 6fcc2912..a56736d5 100644 --- a/daemon/core/gui/dialogs/nodeservice.py +++ b/daemon/core/gui/dialogs/nodeservice.py @@ -9,22 +9,21 @@ from core.gui.dialogs.dialog import Dialog from core.gui.dialogs.serviceconfig import ServiceConfigDialog from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CheckboxList, ListboxScroll +from core.gui.wrappers import Node if TYPE_CHECKING: from core.gui.app import Application - from core.gui.graph.node import CanvasNode class NodeServiceDialog(Dialog): - def __init__(self, app: "Application", canvas_node: "CanvasNode") -> None: - title = f"{canvas_node.core_node.name} Services" + def __init__(self, app: "Application", node: Node) -> None: + title = f"{node.name} Services" super().__init__(app, title) - self.canvas_node: "CanvasNode" = canvas_node - self.node_id: int = canvas_node.core_node.id + self.node: Node = node self.groups: Optional[ListboxScroll] = None self.services: Optional[CheckboxList] = None self.current: Optional[ListboxScroll] = None - services = set(canvas_node.core_node.services) + services = set(node.services) self.current_services: Set[str] = services self.draw() @@ -104,7 +103,7 @@ class NodeServiceDialog(Dialog): self.current.listbox.insert(tk.END, name) if self.is_custom_service(name): self.current.listbox.itemconfig(tk.END, bg="green") - self.canvas_node.core_node.services[:] = self.current_services + self.node.services = self.current_services.copy() def click_configure(self) -> None: current_selection = self.current.listbox.curselection() @@ -113,8 +112,7 @@ class NodeServiceDialog(Dialog): self, self.app, self.current.listbox.get(current_selection[0]), - self.canvas_node, - self.node_id, + self.node, ) # if error occurred when creating ServiceConfigDialog, don't show the dialog @@ -128,8 +126,7 @@ class NodeServiceDialog(Dialog): ) def click_save(self) -> None: - core_node = self.canvas_node.core_node - core_node.services[:] = self.current_services + self.node.services[:] = self.current_services self.destroy() def click_remove(self) -> None: @@ -144,6 +141,6 @@ class NodeServiceDialog(Dialog): return def is_custom_service(self, service: str) -> bool: - has_service_config = service in self.canvas_node.service_configs - has_file_config = service in self.canvas_node.service_file_configs + has_service_config = service in self.node.service_configs + has_file_config = service in self.node.service_file_configs return has_service_config or has_file_config diff --git a/daemon/core/gui/dialogs/runtool.py b/daemon/core/gui/dialogs/runtool.py index c66fea8f..a1517593 100644 --- a/daemon/core/gui/dialogs/runtool.py +++ b/daemon/core/gui/dialogs/runtool.py @@ -107,7 +107,7 @@ class RunToolDialog(Dialog): node_name = self.node_list.listbox.get(selection) node_id = self.executable_nodes[node_name] response = self.app.core.client.node_command( - self.app.core.session_id, node_id, command + self.app.core.session.id, node_id, command ) self.result.text.insert( tk.END, f"> {node_name} > {command}:\n{response.output}\n" diff --git a/daemon/core/gui/dialogs/serviceconfig.py b/daemon/core/gui/dialogs/serviceconfig.py index c033cfdc..13be0bcd 100644 --- a/daemon/core/gui/dialogs/serviceconfig.py +++ b/daemon/core/gui/dialogs/serviceconfig.py @@ -12,11 +12,10 @@ from core.gui.dialogs.dialog import Dialog from core.gui.images import ImageEnum, Images from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.widgets import CodeText, ListboxScroll -from core.gui.wrappers import NodeServiceData, ServiceValidationMode +from core.gui.wrappers import Node, NodeServiceData, ServiceValidationMode if TYPE_CHECKING: from core.gui.app import Application - from core.gui.graph.node import CanvasNode from core.gui.coreclient import CoreClient ICON_SIZE: int = 16 @@ -24,18 +23,12 @@ ICON_SIZE: int = 16 class ServiceConfigDialog(Dialog): def __init__( - self, - master: tk.BaseWidget, - app: "Application", - service_name: str, - canvas_node: "CanvasNode", - node_id: int, + self, master: tk.BaseWidget, app: "Application", service_name: str, node: Node ) -> None: title = f"{service_name} Service" super().__init__(app, title, master=master) self.core: "CoreClient" = app.core - self.canvas_node: "CanvasNode" = canvas_node - self.node_id: int = node_id + self.node: Node = node self.service_name: str = service_name self.radiovar: tk.IntVar = tk.IntVar(value=2) self.metadata: str = "" @@ -84,15 +77,13 @@ class ServiceConfigDialog(Dialog): try: self.app.core.create_nodes_and_links() default_config = self.app.core.get_node_service( - self.node_id, self.service_name + self.node.id, self.service_name ) self.default_startup = default_config.startup[:] self.default_validate = default_config.validate[:] self.default_shutdown = default_config.shutdown[:] self.default_directories = default_config.dirs[:] - custom_service_config = self.canvas_node.service_configs.get( - self.service_name - ) + custom_service_config = self.node.service_configs.get(self.service_name) self.default_config = default_config service_config = ( custom_service_config if custom_service_config else default_config @@ -109,15 +100,13 @@ class ServiceConfigDialog(Dialog): self.temp_directories = service_config.dirs[:] self.original_service_files = { x: self.app.core.get_node_service_file( - self.node_id, self.service_name, x + self.node.id, self.service_name, x ) for x in default_config.configs } self.temp_service_files = dict(self.original_service_files) - file_configs = self.canvas_node.service_file_configs.get( - self.service_name, {} - ) + file_configs = self.node.service_file_configs.get(self.service_name, {}) for file, data in file_configs.items(): self.temp_service_files[file] = data except grpc.RpcError as e: @@ -453,7 +442,7 @@ class ServiceConfigDialog(Dialog): and not self.has_new_files() and not self.is_custom_directory() ): - self.canvas_node.service_configs.pop(self.service_name, None) + self.node.service_configs.pop(self.service_name, None) self.current_service_color("") self.destroy() return @@ -466,7 +455,7 @@ class ServiceConfigDialog(Dialog): ): startup, validate, shutdown = self.get_commands() config = self.core.set_node_service( - self.node_id, + self.node.id, self.service_name, dirs=self.temp_directories, files=list(self.filename_combobox["values"]), @@ -474,15 +463,15 @@ class ServiceConfigDialog(Dialog): validations=validate, shutdowns=shutdown, ) - self.canvas_node.service_configs[self.service_name] = config + self.node.service_configs[self.service_name] = config for file in self.modified_files: - file_configs = self.canvas_node.service_file_configs.setdefault( + file_configs = self.node.service_file_configs.setdefault( self.service_name, {} ) file_configs[file] = self.temp_service_files[file] # TODO: check if this is really needed self.app.core.set_node_service_file( - self.node_id, self.service_name, file, self.temp_service_files[file] + self.node.id, self.service_name, file, self.temp_service_files[file] ) self.current_service_color("green") except grpc.RpcError as e: @@ -526,8 +515,8 @@ class ServiceConfigDialog(Dialog): clears out any custom configuration permanently """ # clear coreclient data - self.canvas_node.service_configs.pop(self.service_name, None) - file_configs = self.canvas_node.service_file_configs.pop(self.service_name, {}) + self.node.service_configs.pop(self.service_name, None) + file_configs = self.node.service_file_configs.pop(self.service_name, {}) file_configs.pop(self.service_name, None) self.temp_service_files = dict(self.original_service_files) self.modified_files.clear() @@ -564,9 +553,8 @@ class ServiceConfigDialog(Dialog): def click_copy(self) -> None: file_name = self.filename_combobox.get() - name = self.canvas_node.core_node.name dialog = CopyServiceConfigDialog( - self.app, self, name, self.service_name, file_name + self.app, self, self.node.name, self.service_name, file_name ) dialog.show() diff --git a/daemon/core/gui/dialogs/sessionoptions.py b/daemon/core/gui/dialogs/sessionoptions.py index 24bacb30..570bfbde 100644 --- a/daemon/core/gui/dialogs/sessionoptions.py +++ b/daemon/core/gui/dialogs/sessionoptions.py @@ -26,7 +26,7 @@ class SessionOptionsDialog(Dialog): def get_config(self) -> Dict[str, ConfigOption]: try: - session_id = self.app.core.session_id + session_id = self.app.core.session.id response = self.app.core.client.get_session_options(session_id) return ConfigOption.from_dict(response.config) except grpc.RpcError as e: @@ -54,7 +54,7 @@ class SessionOptionsDialog(Dialog): def save(self) -> None: config = self.config_frame.parse_config() try: - session_id = self.app.core.session_id + session_id = self.app.core.session.id response = self.app.core.client.set_session_options(session_id, config) logging.info("saved session config: %s", response) except grpc.RpcError as e: diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index 75b9dcf4..d41e2052 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -201,7 +201,7 @@ class SessionsDialog(Dialog): logging.debug("delete session: %s", self.selected_session) self.tree.delete(self.selected_id) self.app.core.delete_session(self.selected_session) - if self.selected_session == self.app.core.session_id: + if self.selected_session == self.app.core.session.id: self.click_new() self.destroy() self.click_select() diff --git a/daemon/core/gui/dialogs/wlanconfig.py b/daemon/core/gui/dialogs/wlanconfig.py index 17f62dfb..d4595556 100644 --- a/daemon/core/gui/dialogs/wlanconfig.py +++ b/daemon/core/gui/dialogs/wlanconfig.py @@ -29,7 +29,7 @@ class WlanConfigDialog(Dialog): self.ranges: Dict[int, int] = {} self.positive_int: int = self.app.master.register(self.validate_and_update) try: - config = self.canvas_node.wlan_config + config = self.node.wlan_config if not config: config = self.app.core.get_wlan_config(self.node.id) self.config: Dict[str, ConfigOption] = config @@ -83,9 +83,9 @@ class WlanConfigDialog(Dialog): retrieve user's wlan configuration and store the new configuration values """ config = self.config_frame.parse_config() - self.canvas_node.wlan_config = self.config + self.node.wlan_config = self.config if self.app.core.is_runtime(): - session_id = self.app.core.session_id + session_id = self.app.core.session.id self.app.core.client.set_wlan_config(session_id, self.node.id, config) self.remove_ranges() self.destroy() diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index f2a27444..54d2cae1 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -940,16 +940,19 @@ class CanvasGraph(tk.Canvas): if not copy: continue node = CanvasNode(self.app, scaled_x, scaled_y, copy, canvas_node.image) - # copy configurations and services - node.core_node.services[:] = canvas_node.core_node.services - node.core_node.config_services[:] = canvas_node.core_node.config_services - node.emane_model_configs = deepcopy(canvas_node.emane_model_configs) - node.wlan_config = deepcopy(canvas_node.wlan_config) - node.mobility_config = deepcopy(canvas_node.mobility_config) - node.service_configs = deepcopy(canvas_node.service_configs) - node.service_file_configs = deepcopy(canvas_node.service_file_configs) - node.config_service_configs = deepcopy(canvas_node.config_service_configs) + node.core_node.services = core_node.services.copy() + node.core_node.config_services = core_node.config_services.copy() + node.core_node.emane_model_configs = deepcopy(core_node.emane_model_configs) + node.core_node.wlan_config = deepcopy(core_node.wlan_config) + node.core_node.mobility_config = deepcopy(core_node.mobility_config) + node.core_node.service_configs = deepcopy(core_node.service_configs) + node.core_node.service_file_configs = deepcopy( + core_node.service_file_configs + ) + node.core_node.config_service_configs = deepcopy( + core_node.config_service_configs + ) copy_map[canvas_node.id] = node.id self.core.canvas_nodes[copy.id] = node diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 217389c0..ffc72fbf 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -1,7 +1,7 @@ import functools import logging import tkinter as tk -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple +from typing import TYPE_CHECKING, Dict, List, Set import grpc from PIL.ImageTk import PhotoImage @@ -19,7 +19,7 @@ from core.gui.graph.edges import CanvasEdge, CanvasWirelessEdge from core.gui.graph.tooltip import CanvasTooltip from core.gui.images import ImageEnum from core.gui.nodeutils import ANTENNA_SIZE, NodeUtils -from core.gui.wrappers import ConfigOption, Interface, Node, NodeServiceData, NodeType +from core.gui.wrappers import Interface, Node, NodeType if TYPE_CHECKING: from core.gui.app import Application @@ -56,15 +56,6 @@ class CanvasNode: self.wireless_edges: Set[CanvasWirelessEdge] = set() self.antennas: List[int] = [] self.antenna_images: Dict[int, PhotoImage] = {} - # possible configurations - self.emane_model_configs: Dict[ - Tuple[str, Optional[int]], Dict[str, ConfigOption] - ] = {} - self.wlan_config: Dict[str, ConfigOption] = {} - self.mobility_config: Dict[str, ConfigOption] = {} - self.service_configs: Dict[str, NodeServiceData] = {} - self.service_file_configs: Dict[str, Dict[str, str]] = {} - self.config_service_configs: Dict[str, Any] = {} self.setup_bindings() self.context: tk.Menu = tk.Menu(self.canvas) themes.style_menu(self.context) @@ -299,7 +290,7 @@ class CanvasNode: dialog.show() def show_mobility_config(self) -> None: - dialog = MobilityConfigDialog(self.app, self) + dialog = MobilityConfigDialog(self.app, self.core_node) if not dialog.has_error: dialog.show() @@ -308,15 +299,15 @@ class CanvasNode: mobility_player.show() def show_emane_config(self) -> None: - dialog = EmaneConfigDialog(self.app, self) + dialog = EmaneConfigDialog(self.app, self.core_node) dialog.show() def show_services(self) -> None: - dialog = NodeServiceDialog(self.app, self) + dialog = NodeServiceDialog(self.app, self.core_node) dialog.show() def show_config_services(self) -> None: - dialog = NodeConfigServiceDialog(self.app, self) + dialog = NodeConfigServiceDialog(self.app, self.core_node) dialog.show() def has_emane_link(self, iface_id: int) -> Node: diff --git a/daemon/core/gui/task.py b/daemon/core/gui/task.py index c60350f9..f56fd54b 100644 --- a/daemon/core/gui/task.py +++ b/daemon/core/gui/task.py @@ -33,7 +33,6 @@ class ProgressTask: thread.start() def run(self) -> None: - logging.info("running task") try: values = self.task(*self.args) if values is None: @@ -41,7 +40,6 @@ class ProgressTask: elif values and not isinstance(values, tuple): values = (values,) if self.callback: - logging.info("calling callback") self.app.after(0, self.callback, *values) except Exception as e: logging.exception("progress task exception") diff --git a/daemon/core/gui/wrappers.py b/daemon/core/gui/wrappers.py index 835a9d17..d86e20dd 100644 --- a/daemon/core/gui/wrappers.py +++ b/daemon/core/gui/wrappers.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Set, Tuple from core.api.grpc import common_pb2, configservices_pb2, core_pb2, services_pb2 @@ -121,8 +121,8 @@ class ConfigService: @dataclass class ConfigServiceData: - templates: Dict[str, str] - config: Dict[str, str] + templates: Dict[str, str] = field(default_factory=dict) + config: Dict[str, str] = field(default_factory=dict) @dataclass @@ -504,8 +504,8 @@ class Node: type: NodeType model: str = None position: Position = None - services: List[str] = field(default_factory=list) - config_services: List[str] = field(default_factory=list) + services: Set[str] = field(default_factory=set) + config_services: Set[str] = field(default_factory=set) emane: str = None icon: str = None image: str = None @@ -538,8 +538,8 @@ class Node: type=NodeType(proto.type), model=proto.model, position=Position.from_proto(proto.position), - services=list(proto.services), - config_services=list(proto.config_services), + services=set(proto.services), + config_services=set(proto.config_services), emane=proto.emane, icon=proto.icon, image=proto.image, @@ -575,9 +575,9 @@ class Session: links: List[Link] dir: str user: str - default_services: Dict[str, List[str]] + default_services: Dict[str, Set[str]] location: SessionLocation - hooks: List[Hook] + hooks: Dict[str, Hook] emane_models: List[str] emane_config: Dict[str, ConfigOption] metadata: Dict[str, str] @@ -586,8 +586,10 @@ class Session: def from_proto(cls, proto: core_pb2.Session) -> "Session": nodes: Dict[int, Node] = {x.id: Node.from_proto(x) for x in proto.nodes} links = [Link.from_proto(x) for x in proto.links] - default_services = {x.node_type: x.services for x in proto.default_services} - hooks = [Hook.from_proto(x) for x in proto.hooks] + default_services = { + x.node_type: set(x.services) for x in proto.default_services + } + hooks = {x.file: Hook.from_proto(x) for x in proto.hooks} # update nodes with their current configurations for model in proto.emane_model_configs: iface_id = None From 27495cbda10341fe0a802742cb0aed28d1524316 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 28 Jul 2020 10:24:01 -0700 Subject: [PATCH 349/929] pygui: changes around using session.nodes instead of canvas_nodes when possible --- daemon/core/gui/coreclient.py | 68 +++++++++++++++--------------- daemon/core/gui/dialogs/find.py | 17 +++----- daemon/core/gui/dialogs/runtool.py | 6 +-- daemon/core/gui/frames/link.py | 10 ++--- daemon/core/gui/graph/graph.py | 24 +++++------ 5 files changed, 58 insertions(+), 67 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 36adf189..6129031a 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -118,6 +118,12 @@ class CoreClient: self.setup_cpu_usage() return self._client + def set_canvas_node(self, node: Node, canvas_node: CanvasNode) -> None: + self.canvas_nodes[node.id] = canvas_node + + def get_canvas_node(self, node_id: int) -> CanvasNode: + return self.canvas_nodes[node_id] + def reset(self) -> None: # helpers self.ifaces_manager.reset() @@ -231,18 +237,21 @@ class CoreClient: def handle_node_event(self, event: NodeEvent) -> None: logging.debug("node event: %s", event) + node = event.node if event.message_type == MessageType.NONE: - canvas_node = self.canvas_nodes[event.node.id] - x = event.node.position.x - y = event.node.position.y + canvas_node = self.canvas_nodes[node.id] + x = node.position.x + y = node.position.y canvas_node.move(x, y) elif event.message_type == MessageType.DELETE: - canvas_node = self.canvas_nodes[event.node.id] + canvas_node = self.canvas_nodes[node.id] self.app.canvas.clear_selection() self.app.canvas.select_object(canvas_node.id) self.app.canvas.delete_selected_objects() elif event.message_type == MessageType.ADD: - self.app.canvas.add_core_node(event.node) + if node.id in self.session.nodes: + logging.error("core node already exists: %s", node) + self.app.canvas.add_core_node(node) else: logging.warning("unknown node event: %s", event) @@ -463,10 +472,9 @@ class CoreClient: def start_session(self) -> Tuple[bool, List[str]]: self.ifaces_manager.reset_mac() - nodes = [x.core_node.to_proto() for x in self.canvas_nodes.values()] + nodes = [x.to_proto() for x in self.session.nodes.values()] links = [] - for edge in self.links.values(): - link = edge.link + for link in self.session.links: if link.iface1 and not link.iface1.mac: link.iface1.mac = self.ifaces_manager.next_mac() if link.iface2 and not link.iface2.mac: @@ -674,13 +682,12 @@ class CoreClient: """ create nodes and links that have not been created yet """ - node_protos = [x.core_node.to_proto() for x in self.canvas_nodes.values()] - link_protos = [x.link.to_proto() for x in self.links.values()] self.client.set_session_state(self.session.id, SessionState.DEFINITION.value) - for node_proto in node_protos: - response = self.client.add_node(self.session.id, node_proto) - logging.debug("create node: %s", response) - for link_proto in link_protos: + for node in self.session.nodes.values(): + response = self.client.add_node(self.session.id, node.to_proto()) + logging.debug("created node: %s", response) + for link in self.session.links: + link_proto = link.to_proto() response = self.client.add_link( self.session.id, link_proto.node1_id, @@ -689,7 +696,7 @@ class CoreClient: link_proto.iface2, link_proto.options, ) - logging.debug("create link: %s", response) + logging.debug("created link: %s", response) def send_data(self) -> None: """ @@ -762,7 +769,7 @@ class CoreClient: """ i = 1 while True: - if i not in self.canvas_nodes: + if i not in self.session.nodes: break i += 1 return i @@ -816,18 +823,20 @@ class CoreClient: x, y, ) + self.session.nodes[node.id] = node return node - def deleted_graph_nodes(self, canvas_nodes: List[CanvasNode]) -> None: + def deleted_canvas_nodes(self, canvas_nodes: List[CanvasNode]) -> None: """ remove the nodes selected by the user and anything related to that node such as link, configurations, interfaces """ for canvas_node in canvas_nodes: - node_id = canvas_node.core_node.id - del self.canvas_nodes[node_id] + node = canvas_node.core_node + del self.canvas_nodes[node.id] + del self.session.nodes[node.id] - def deleted_graph_edges(self, edges: Iterable[CanvasEdge]) -> None: + def deleted_canvas_edges(self, edges: Iterable[CanvasEdge]) -> None: links = [] for edge in edges: del self.links[edge.token] @@ -861,20 +870,19 @@ class CoreClient: """ src_node = canvas_src_node.core_node dst_node = canvas_dst_node.core_node - - # determine subnet self.ifaces_manager.determine_subnets(canvas_src_node, canvas_dst_node) - src_iface = None if NodeUtils.is_container_node(src_node.type): src_iface = self.create_iface(canvas_src_node) self.iface_to_edge[(src_node.id, src_iface.id)] = edge.token - + edge.src_iface = src_iface + canvas_src_node.ifaces[src_iface.id] = src_iface dst_iface = None if NodeUtils.is_container_node(dst_node.type): dst_iface = self.create_iface(canvas_dst_node) self.iface_to_edge[(dst_node.id, dst_iface.id)] = edge.token - + edge.dst_iface = dst_iface + canvas_dst_node.ifaces[dst_iface.id] = dst_iface link = Link( type=LinkType.WIRED, node1_id=src_node.id, @@ -882,17 +890,9 @@ class CoreClient: iface1=src_iface, iface2=dst_iface, ) - # assign after creating link proto, since interfaces are copied - if src_iface: - iface1 = link.iface1 - edge.src_iface = iface1 - canvas_src_node.ifaces[iface1.id] = iface1 - if dst_iface: - iface2 = link.iface2 - edge.dst_iface = iface2 - canvas_dst_node.ifaces[iface2.id] = iface2 edge.set_link(link) self.links[edge.token] = edge + self.session.links.append(link) logging.info("Add link between %s and %s", src_node.name, dst_node.name) def get_wlan_configs_proto(self) -> List[wlan_pb2.WlanConfig]: diff --git a/daemon/core/gui/dialogs/find.py b/daemon/core/gui/dialogs/find.py index 328f673e..a4600847 100644 --- a/daemon/core/gui/dialogs/find.py +++ b/daemon/core/gui/dialogs/find.py @@ -87,22 +87,19 @@ class FindDialog(Dialog): """ node_name = self.find_text.get().strip() self.clear_treeview_items() - for node_id, node in sorted( - self.app.core.canvas_nodes.items(), key=lambda x: x[0] - ): - name = node.core_node.name + for node in self.app.core.session.nodes.values(): + name = node.name if not node_name or node_name == name: - pos_x = round(node.core_node.position.x, 1) - pos_y = round(node.core_node.position.y, 1) + pos_x = round(node.position.x, 1) + pos_y = round(node.position.y, 1) # TODO: I am not sure what to insert for Detail column # leaving it blank for now self.tree.insert( "", tk.END, - text=str(node_id), - values=(node_id, name, f"<{pos_x}, {pos_y}>", ""), + text=str(node.id), + values=(node.id, name, f"<{pos_x}, {pos_y}>", ""), ) - results = self.tree.get_children("") if results: self.tree.selection_set(results[0]) @@ -121,7 +118,7 @@ class FindDialog(Dialog): if item: self.app.canvas.delete("find") node_id = int(self.tree.item(item, "text")) - canvas_node = self.app.core.canvas_nodes[node_id] + canvas_node = self.app.core.get_canvas_node(node_id) x0, y0, x1, y1 = self.app.canvas.bbox(canvas_node.id) dist = 5 * self.app.guiconfig.scale diff --git a/daemon/core/gui/dialogs/runtool.py b/daemon/core/gui/dialogs/runtool.py index a1517593..e36c4c9a 100644 --- a/daemon/core/gui/dialogs/runtool.py +++ b/daemon/core/gui/dialogs/runtool.py @@ -25,9 +25,9 @@ class RunToolDialog(Dialog): """ store all CORE nodes (nodes that execute commands) from all existing nodes """ - for nid, node in self.app.core.canvas_nodes.items(): - if NodeUtils.is_container_node(node.core_node.type): - self.executable_nodes[node.core_node.name] = nid + for node in self.app.core.session.nodes.values(): + if NodeUtils.is_container_node(node.type): + self.executable_nodes[node.name] = node.id def draw(self) -> None: self.top.rowconfigure(0, weight=1) diff --git a/daemon/core/gui/frames/link.py b/daemon/core/gui/frames/link.py index cbea9982..093f39eb 100644 --- a/daemon/core/gui/frames/link.py +++ b/daemon/core/gui/frames/link.py @@ -34,10 +34,8 @@ class EdgeInfoFrame(InfoFrameBase): self.columnconfigure(0, weight=1) link = self.edge.link options = link.options - src_canvas_node = self.app.core.canvas_nodes[link.node1_id] - src_node = src_canvas_node.core_node - dst_canvas_node = self.app.core.canvas_nodes[link.node2_id] - dst_node = dst_canvas_node.core_node + src_node = self.app.core.session.nodes[link.node1_id] + dst_node = self.app.core.session.nodes[link.node2_id] frame = DetailsFrame(self) frame.grid(sticky="ew") @@ -81,9 +79,9 @@ class WirelessEdgeInfoFrame(InfoFrameBase): def draw(self) -> None: link = self.edge.link - src_canvas_node = self.app.core.canvas_nodes[link.node1_id] + src_canvas_node = self.app.canvas.nodes[self.edge.src] src_node = src_canvas_node.core_node - dst_canvas_node = self.app.core.canvas_nodes[link.node2_id] + dst_canvas_node = self.app.canvas.nodes[self.edge.dst] dst_node = dst_canvas_node.core_node # find interface for each node connected to network diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 54d2cae1..69ae87cc 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -311,10 +311,7 @@ class CanvasGraph(tk.Canvas): edge.middle_label_text(link.label) def add_core_node(self, core_node: Node) -> None: - if core_node.id in self.core.canvas_nodes: - logging.error("core node already exists: %s", core_node) - return - logging.debug("adding node %s", core_node) + logging.debug("adding node: %s", core_node) # if the gui can't find node's image, default to the "edit-node" image image = NodeUtils.node_image(core_node, self.app.guiconfig, self.app.app_scale) if not image: @@ -323,7 +320,7 @@ class CanvasGraph(tk.Canvas): y = core_node.position.y node = CanvasNode(self.app, x, y, core_node, image) self.nodes[node.id] = node - self.core.canvas_nodes[core_node.id] = node + self.core.set_canvas_node(core_node, node) def draw_session(self, session: Session) -> None: """ @@ -336,12 +333,11 @@ class CanvasGraph(tk.Canvas): if NodeUtils.is_ignore_node(core_node.type): continue self.add_core_node(core_node) - - # draw existing links + # draw existing links for link in session.links: logging.debug("drawing link: %s", link) - canvas_node1 = self.core.canvas_nodes[link.node1_id] - canvas_node2 = self.core.canvas_nodes[link.node2_id] + canvas_node1 = self.core.get_canvas_node(link.node1_id) + canvas_node2 = self.core.get_canvas_node(link.node2_id) if link.type == LinkType.WIRELESS: self.add_wireless_edge(canvas_node1, canvas_node2, link) else: @@ -544,8 +540,8 @@ class CanvasGraph(tk.Canvas): shape.delete() self.selection.clear() - self.core.deleted_graph_nodes(nodes) - self.core.deleted_graph_edges(edges) + self.core.deleted_canvas_nodes(nodes) + self.core.deleted_canvas_edges(edges) def delete_edge(self, edge: CanvasEdge) -> None: edge.delete() @@ -564,7 +560,7 @@ class CanvasGraph(tk.Canvas): dst_wireless = NodeUtils.is_wireless_node(dst_node.core_node.type) if dst_wireless: src_node.delete_antenna() - self.core.deleted_graph_edges([edge]) + self.core.deleted_canvas_edges([edge]) def zoom(self, event: tk.Event, factor: float = None) -> None: if not factor: @@ -750,8 +746,8 @@ class CanvasGraph(tk.Canvas): image_file = self.node_draw.image_file self.node_draw.image = self.app.get_custom_icon(image_file, ICON_SIZE) node = CanvasNode(self.app, x, y, core_node, self.node_draw.image) - self.core.canvas_nodes[core_node.id] = node self.nodes[node.id] = node + self.core.set_canvas_node(core_node, node) def width_and_height(self) -> Tuple[int, int]: """ @@ -955,8 +951,8 @@ class CanvasGraph(tk.Canvas): ) copy_map[canvas_node.id] = node.id - self.core.canvas_nodes[copy.id] = node self.nodes[node.id] = node + self.core.set_canvas_node(copy, node) for edge in canvas_node.edges: if edge.src not in self.to_copy or edge.dst not in self.to_copy: if canvas_node.id == edge.src: From 0d2dd70727ab6c8ab5b0055539d9587c146dce21 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 28 Jul 2020 16:13:37 -0700 Subject: [PATCH 350/929] daemon: changes usage of running scripts using /bin/sh to bash to help provide consistency in what could be ran, added bash as a dependency in installation scripts, added bash as an executable check during startup --- .../configservices/frrservices/services.py | 2 +- .../configservices/nrlservices/services.py | 16 ++++++++-------- .../configservices/quaggaservices/services.py | 2 +- .../securityservices/services.py | 10 +++++----- .../configservices/utilservices/services.py | 18 +++++++++--------- daemon/core/executables.py | 12 +++++++++++- daemon/core/location/mobility.py | 3 ++- daemon/core/nodes/base.py | 2 +- daemon/core/nodes/client.py | 8 +++++--- daemon/core/nodes/interface.py | 4 ++-- daemon/core/nodes/netclient.py | 6 +----- daemon/core/services/emaneservices.py | 2 +- daemon/core/services/frr.py | 2 +- daemon/core/services/nrl.py | 6 +++--- daemon/core/services/quagga.py | 2 +- daemon/core/services/sdn.py | 4 ++-- daemon/core/services/security.py | 10 +++++----- daemon/core/services/ucarp.py | 2 +- daemon/core/services/utility.py | 18 +++++++++--------- tasks.py | 4 ++-- 20 files changed, 71 insertions(+), 62 deletions(-) diff --git a/daemon/core/configservices/frrservices/services.py b/daemon/core/configservices/frrservices/services.py index 72050077..fa6f599a 100644 --- a/daemon/core/configservices/frrservices/services.py +++ b/daemon/core/configservices/frrservices/services.py @@ -65,7 +65,7 @@ class FRRZebra(ConfigService): ] executables: List[str] = ["zebra"] dependencies: List[str] = [] - startup: List[str] = ["sh frrboot.sh zebra"] + startup: List[str] = ["bash frrboot.sh zebra"] validate: List[str] = ["pidof zebra"] shutdown: List[str] = ["killall zebra"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING diff --git a/daemon/core/configservices/nrlservices/services.py b/daemon/core/configservices/nrlservices/services.py index cf9b4c88..3f911aef 100644 --- a/daemon/core/configservices/nrlservices/services.py +++ b/daemon/core/configservices/nrlservices/services.py @@ -14,7 +14,7 @@ class MgenSinkService(ConfigService): files: List[str] = ["mgensink.sh", "sink.mgen"] executables: List[str] = ["mgen"] dependencies: List[str] = [] - startup: List[str] = ["sh mgensink.sh"] + startup: List[str] = ["bash mgensink.sh"] validate: List[str] = ["pidof mgen"] shutdown: List[str] = ["killall mgen"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -36,7 +36,7 @@ class NrlNhdp(ConfigService): files: List[str] = ["nrlnhdp.sh"] executables: List[str] = ["nrlnhdp"] dependencies: List[str] = [] - startup: List[str] = ["sh nrlnhdp.sh"] + startup: List[str] = ["bash nrlnhdp.sh"] validate: List[str] = ["pidof nrlnhdp"] shutdown: List[str] = ["killall nrlnhdp"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -58,7 +58,7 @@ class NrlSmf(ConfigService): files: List[str] = ["startsmf.sh"] executables: List[str] = ["nrlsmf", "killall"] dependencies: List[str] = [] - startup: List[str] = ["sh startsmf.sh"] + startup: List[str] = ["bash startsmf.sh"] validate: List[str] = ["pidof nrlsmf"] shutdown: List[str] = ["killall nrlsmf"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -93,7 +93,7 @@ class NrlOlsr(ConfigService): files: List[str] = ["nrlolsrd.sh"] executables: List[str] = ["nrlolsrd"] dependencies: List[str] = [] - startup: List[str] = ["sh nrlolsrd.sh"] + startup: List[str] = ["bash nrlolsrd.sh"] validate: List[str] = ["pidof nrlolsrd"] shutdown: List[str] = ["killall nrlolsrd"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -117,7 +117,7 @@ class NrlOlsrv2(ConfigService): files: List[str] = ["nrlolsrv2.sh"] executables: List[str] = ["nrlolsrv2"] dependencies: List[str] = [] - startup: List[str] = ["sh nrlolsrv2.sh"] + startup: List[str] = ["bash nrlolsrv2.sh"] validate: List[str] = ["pidof nrlolsrv2"] shutdown: List[str] = ["killall nrlolsrv2"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -139,7 +139,7 @@ class OlsrOrg(ConfigService): files: List[str] = ["olsrd.sh", "/etc/olsrd/olsrd.conf"] executables: List[str] = ["olsrd"] dependencies: List[str] = [] - startup: List[str] = ["sh olsrd.sh"] + startup: List[str] = ["bash olsrd.sh"] validate: List[str] = ["pidof olsrd"] shutdown: List[str] = ["killall olsrd"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -161,7 +161,7 @@ class MgenActor(ConfigService): files: List[str] = ["start_mgen_actor.sh"] executables: List[str] = ["mgen"] dependencies: List[str] = [] - startup: List[str] = ["sh start_mgen_actor.sh"] + startup: List[str] = ["bash start_mgen_actor.sh"] validate: List[str] = ["pidof mgen"] shutdown: List[str] = ["killall mgen"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -176,7 +176,7 @@ class Arouted(ConfigService): files: List[str] = ["startarouted.sh"] executables: List[str] = ["arouted"] dependencies: List[str] = [] - startup: List[str] = ["sh startarouted.sh"] + startup: List[str] = ["bash startarouted.sh"] validate: List[str] = ["pidof arouted"] shutdown: List[str] = ["pkill arouted"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING diff --git a/daemon/core/configservices/quaggaservices/services.py b/daemon/core/configservices/quaggaservices/services.py index 19430664..bf23e00c 100644 --- a/daemon/core/configservices/quaggaservices/services.py +++ b/daemon/core/configservices/quaggaservices/services.py @@ -65,7 +65,7 @@ class Zebra(ConfigService): ] executables: List[str] = ["zebra"] dependencies: List[str] = [] - startup: List[str] = ["sh quaggaboot.sh zebra"] + startup: List[str] = ["bash quaggaboot.sh zebra"] validate: List[str] = ["pidof zebra"] shutdown: List[str] = ["killall zebra"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING diff --git a/daemon/core/configservices/securityservices/services.py b/daemon/core/configservices/securityservices/services.py index 4a58fd8c..c656f5ca 100644 --- a/daemon/core/configservices/securityservices/services.py +++ b/daemon/core/configservices/securityservices/services.py @@ -14,7 +14,7 @@ class VpnClient(ConfigService): files: List[str] = ["vpnclient.sh"] executables: List[str] = ["openvpn", "ip", "killall"] dependencies: List[str] = [] - startup: List[str] = ["sh vpnclient.sh"] + startup: List[str] = ["bash vpnclient.sh"] validate: List[str] = ["pidof openvpn"] shutdown: List[str] = ["killall openvpn"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -48,7 +48,7 @@ class VpnServer(ConfigService): files: List[str] = ["vpnserver.sh"] executables: List[str] = ["openvpn", "ip", "killall"] dependencies: List[str] = [] - startup: List[str] = ["sh vpnserver.sh"] + startup: List[str] = ["bash vpnserver.sh"] validate: List[str] = ["pidof openvpn"] shutdown: List[str] = ["killall openvpn"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -91,7 +91,7 @@ class IPsec(ConfigService): files: List[str] = ["ipsec.sh"] executables: List[str] = ["racoon", "ip", "setkey", "killall"] dependencies: List[str] = [] - startup: List[str] = ["sh ipsec.sh"] + startup: List[str] = ["bash ipsec.sh"] validate: List[str] = ["pidof racoon"] shutdown: List[str] = ["killall racoon"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -106,7 +106,7 @@ class Firewall(ConfigService): files: List[str] = ["firewall.sh"] executables: List[str] = ["iptables"] dependencies: List[str] = [] - startup: List[str] = ["sh firewall.sh"] + startup: List[str] = ["bash firewall.sh"] validate: List[str] = [] shutdown: List[str] = [] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -121,7 +121,7 @@ class Nat(ConfigService): files: List[str] = ["nat.sh"] executables: List[str] = ["iptables"] dependencies: List[str] = [] - startup: List[str] = ["sh nat.sh"] + startup: List[str] = ["bash nat.sh"] validate: List[str] = [] shutdown: List[str] = [] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING diff --git a/daemon/core/configservices/utilservices/services.py b/daemon/core/configservices/utilservices/services.py index b6bc0eb5..9b3369db 100644 --- a/daemon/core/configservices/utilservices/services.py +++ b/daemon/core/configservices/utilservices/services.py @@ -16,7 +16,7 @@ class DefaultRouteService(ConfigService): files: List[str] = ["defaultroute.sh"] executables: List[str] = ["ip"] dependencies: List[str] = [] - startup: List[str] = ["sh defaultroute.sh"] + startup: List[str] = ["bash defaultroute.sh"] validate: List[str] = [] shutdown: List[str] = [] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -44,7 +44,7 @@ class DefaultMulticastRouteService(ConfigService): files: List[str] = ["defaultmroute.sh"] executables: List[str] = [] dependencies: List[str] = [] - startup: List[str] = ["sh defaultmroute.sh"] + startup: List[str] = ["bash defaultmroute.sh"] validate: List[str] = [] shutdown: List[str] = [] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -66,7 +66,7 @@ class StaticRouteService(ConfigService): files: List[str] = ["staticroute.sh"] executables: List[str] = [] dependencies: List[str] = [] - startup: List[str] = ["sh staticroute.sh"] + startup: List[str] = ["bash staticroute.sh"] validate: List[str] = [] shutdown: List[str] = [] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -94,7 +94,7 @@ class IpForwardService(ConfigService): files: List[str] = ["ipforward.sh"] executables: List[str] = ["sysctl"] dependencies: List[str] = [] - startup: List[str] = ["sh ipforward.sh"] + startup: List[str] = ["bash ipforward.sh"] validate: List[str] = [] shutdown: List[str] = [] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -116,7 +116,7 @@ class SshService(ConfigService): files: List[str] = ["startsshd.sh", "/etc/ssh/sshd_config"] executables: List[str] = ["sshd"] dependencies: List[str] = [] - startup: List[str] = ["sh startsshd.sh"] + startup: List[str] = ["bash startsshd.sh"] validate: List[str] = [] shutdown: List[str] = ["killall sshd"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -164,7 +164,7 @@ class DhcpClientService(ConfigService): files: List[str] = ["startdhcpclient.sh"] executables: List[str] = ["dhclient"] dependencies: List[str] = [] - startup: List[str] = ["sh startdhcpclient.sh"] + startup: List[str] = ["bash startdhcpclient.sh"] validate: List[str] = ["pidof dhclient"] shutdown: List[str] = ["killall dhclient"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING @@ -200,9 +200,9 @@ class PcapService(ConfigService): files: List[str] = ["pcap.sh"] executables: List[str] = ["tcpdump"] dependencies: List[str] = [] - startup: List[str] = ["sh pcap.sh start"] + startup: List[str] = ["bash pcap.sh start"] validate: List[str] = ["pidof tcpdump"] - shutdown: List[str] = ["sh pcap.sh stop"] + shutdown: List[str] = ["bash pcap.sh stop"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING default_configs: List[Configuration] = [] modes: Dict[str, Dict[str, str]] = {} @@ -249,7 +249,7 @@ class AtdService(ConfigService): files: List[str] = ["startatd.sh"] executables: List[str] = ["atd"] dependencies: List[str] = [] - startup: List[str] = ["sh startatd.sh"] + startup: List[str] = ["bash startatd.sh"] validate: List[str] = ["pidof atd"] shutdown: List[str] = ["pkill atd"] validation_mode: ConfigServiceMode = ConfigServiceMode.BLOCKING diff --git a/daemon/core/executables.py b/daemon/core/executables.py index 6eb0214a..7b7f80b7 100644 --- a/daemon/core/executables.py +++ b/daemon/core/executables.py @@ -1,5 +1,6 @@ from typing import List +BASH: str = "bash" VNODED: str = "vnoded" VCMD: str = "vcmd" SYSCTL: str = "sysctl" @@ -11,7 +12,16 @@ MOUNT: str = "mount" UMOUNT: str = "umount" OVS_VSCTL: str = "ovs-vsctl" -COMMON_REQUIREMENTS: List[str] = [SYSCTL, IP, ETHTOOL, TC, EBTABLES, MOUNT, UMOUNT] +COMMON_REQUIREMENTS: List[str] = [ + BASH, + EBTABLES, + ETHTOOL, + IP, + MOUNT, + SYSCTL, + TC, + UMOUNT, +] VCMD_REQUIREMENTS: List[str] = [VNODED, VCMD] OVS_REQUIREMENTS: List[str] = [OVS_VSCTL] diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index e982c5c1..0e9b2e32 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -22,6 +22,7 @@ from core.emulator.enumerations import ( RegisterTlvs, ) from core.errors import CoreError +from core.executables import BASH from core.nodes.base import CoreNode from core.nodes.interface import CoreInterface from core.nodes.network import WlanNode @@ -1167,7 +1168,7 @@ class Ns2ScriptedMobility(WayPointMobility): if filename is None or filename == "": return filename = self.findfile(filename) - args = f"/bin/sh {filename} {typestr}" + args = f"{BASH} {filename} {typestr}" utils.cmd( args, cwd=self.session.session_dir, env=self.session.get_environment() ) diff --git a/daemon/core/nodes/base.py b/daemon/core/nodes/base.py index cea1e81b..4cf6ea8d 100644 --- a/daemon/core/nodes/base.py +++ b/daemon/core/nodes/base.py @@ -599,7 +599,7 @@ class CoreNode(CoreNodeBase): if self.server is None: return self.client.check_cmd(args, wait=wait, shell=shell) else: - args = self.client.create_cmd(args) + args = self.client.create_cmd(args, shell) return self.server.remote_cmd(args, wait=wait) def termcmdstring(self, sh: str = "/bin/sh") -> str: diff --git a/daemon/core/nodes/client.py b/daemon/core/nodes/client.py index 93e099cf..710724b1 100644 --- a/daemon/core/nodes/client.py +++ b/daemon/core/nodes/client.py @@ -5,7 +5,7 @@ The control channel can be accessed via calls using the vcmd shell. """ from core import utils -from core.executables import VCMD +from core.executables import BASH, VCMD class VnodeClient: @@ -49,7 +49,9 @@ class VnodeClient: """ pass - def create_cmd(self, args: str) -> str: + def create_cmd(self, args: str, shell: bool = False) -> str: + if shell: + args = f'{BASH} -c "{args}"' return f"{VCMD} -c {self.ctrlchnlname} -- {args}" def check_cmd(self, args: str, wait: bool = True, shell: bool = False) -> str: @@ -63,5 +65,5 @@ class VnodeClient: :raises core.CoreCommandError: when there is a non-zero exit status """ self._verify_connection() - args = self.create_cmd(args) + args = self.create_cmd(args, shell) return utils.cmd(args, wait=wait, shell=shell) diff --git a/daemon/core/nodes/interface.py b/daemon/core/nodes/interface.py index 7f33973e..20dc8fd3 100644 --- a/daemon/core/nodes/interface.py +++ b/daemon/core/nodes/interface.py @@ -383,12 +383,12 @@ class Veth(CoreInterface): try: self.node.node_net_client.device_flush(self.name) except CoreCommandError: - logging.exception("error shutting down interface") + pass if self.localname: try: self.net_client.delete_device(self.localname) except CoreCommandError: - logging.info("link already removed: %s", self.localname) + pass self.up = False diff --git a/daemon/core/nodes/netclient.py b/daemon/core/nodes/netclient.py index 96a1f4be..68fbef98 100644 --- a/daemon/core/nodes/netclient.py +++ b/daemon/core/nodes/netclient.py @@ -121,11 +121,7 @@ class LinuxNetClient: :param device: device to flush :return: nothing """ - self.run( - f"[ -e /sys/class/net/{device} ] && " - f"{IP} address flush dev {device} || true", - shell=True, - ) + self.run(f"{IP} address flush dev {device}") def device_mac(self, device: str, mac: str) -> None: """ diff --git a/daemon/core/services/emaneservices.py b/daemon/core/services/emaneservices.py index d694317a..4fd78ec1 100644 --- a/daemon/core/services/emaneservices.py +++ b/daemon/core/services/emaneservices.py @@ -13,7 +13,7 @@ class EmaneTransportService(CoreService): dependencies: Tuple[str, ...] = () dirs: Tuple[str, ...] = () configs: Tuple[str, ...] = ("emanetransport.sh",) - startup: Tuple[str, ...] = (f"sh {configs[0]}",) + startup: Tuple[str, ...] = (f"bash {configs[0]}",) validate: Tuple[str, ...] = (f"pidof {executables[0]}",) validation_timer: float = 0.5 shutdown: Tuple[str, ...] = (f"killall {executables[0]}",) diff --git a/daemon/core/services/frr.py b/daemon/core/services/frr.py index b130fd8c..cec9d860 100644 --- a/daemon/core/services/frr.py +++ b/daemon/core/services/frr.py @@ -26,7 +26,7 @@ class FRRZebra(CoreService): "/usr/local/etc/frr/vtysh.conf", "/usr/local/etc/frr/daemons", ) - startup: Tuple[str, ...] = ("sh frrboot.sh zebra",) + startup: Tuple[str, ...] = ("bash frrboot.sh zebra",) shutdown: Tuple[str, ...] = ("killall zebra",) validate: Tuple[str, ...] = ("pidof zebra",) diff --git a/daemon/core/services/nrl.py b/daemon/core/services/nrl.py index 697f4eee..91e053b2 100644 --- a/daemon/core/services/nrl.py +++ b/daemon/core/services/nrl.py @@ -97,7 +97,7 @@ class NrlSmf(NrlService): name: str = "SMF" executables: Tuple[str, ...] = ("nrlsmf",) - startup: Tuple[str, ...] = ("sh startsmf.sh",) + startup: Tuple[str, ...] = ("bash startsmf.sh",) shutdown: Tuple[str, ...] = ("killall nrlsmf",) validate: Tuple[str, ...] = ("pidof nrlsmf",) configs: Tuple[str, ...] = ("startsmf.sh",) @@ -566,7 +566,7 @@ class MgenActor(NrlService): group: str = "ProtoSvc" executables: Tuple[str, ...] = ("mgen",) configs: Tuple[str, ...] = ("start_mgen_actor.sh",) - startup: Tuple[str, ...] = ("sh start_mgen_actor.sh",) + startup: Tuple[str, ...] = ("bash start_mgen_actor.sh",) validate: Tuple[str, ...] = ("pidof mgen",) shutdown: Tuple[str, ...] = ("killall mgen",) @@ -596,7 +596,7 @@ class Arouted(NrlService): name: str = "arouted" executables: Tuple[str, ...] = ("arouted",) configs: Tuple[str, ...] = ("startarouted.sh",) - startup: Tuple[str, ...] = ("sh startarouted.sh",) + startup: Tuple[str, ...] = ("bash startarouted.sh",) shutdown: Tuple[str, ...] = ("pkill arouted",) validate: Tuple[str, ...] = ("pidof arouted",) diff --git a/daemon/core/services/quagga.py b/daemon/core/services/quagga.py index 9e2c7cc0..8c474fd8 100644 --- a/daemon/core/services/quagga.py +++ b/daemon/core/services/quagga.py @@ -25,7 +25,7 @@ class Zebra(CoreService): "quaggaboot.sh", "/usr/local/etc/quagga/vtysh.conf", ) - startup: Tuple[str, ...] = ("sh quaggaboot.sh zebra",) + startup: Tuple[str, ...] = ("bash quaggaboot.sh zebra",) shutdown: Tuple[str, ...] = ("killall zebra",) validate: Tuple[str, ...] = ("pidof zebra",) diff --git a/daemon/core/services/sdn.py b/daemon/core/services/sdn.py index ef077662..e72b5138 100644 --- a/daemon/core/services/sdn.py +++ b/daemon/core/services/sdn.py @@ -31,7 +31,7 @@ class OvsService(SdnService): "/var/log/openvswitch", ) configs: Tuple[str, ...] = ("OvsService.sh",) - startup: Tuple[str, ...] = ("sh OvsService.sh",) + startup: Tuple[str, ...] = ("bash OvsService.sh",) shutdown: Tuple[str, ...] = ("killall ovs-vswitchd", "killall ovsdb-server") @classmethod @@ -119,7 +119,7 @@ class RyuService(SdnService): group: str = "SDN" executables: Tuple[str, ...] = ("ryu-manager",) configs: Tuple[str, ...] = ("ryuService.sh",) - startup: Tuple[str, ...] = ("sh ryuService.sh",) + startup: Tuple[str, ...] = ("bash ryuService.sh",) shutdown: Tuple[str, ...] = ("killall ryu-manager",) @classmethod diff --git a/daemon/core/services/security.py b/daemon/core/services/security.py index b813579e..788988c9 100644 --- a/daemon/core/services/security.py +++ b/daemon/core/services/security.py @@ -16,7 +16,7 @@ class VPNClient(CoreService): name: str = "VPNClient" group: str = "Security" configs: Tuple[str, ...] = ("vpnclient.sh",) - startup: Tuple[str, ...] = ("sh vpnclient.sh",) + startup: Tuple[str, ...] = ("bash vpnclient.sh",) shutdown: Tuple[str, ...] = ("killall openvpn",) validate: Tuple[str, ...] = ("pidof openvpn",) custom_needed: bool = True @@ -43,7 +43,7 @@ class VPNServer(CoreService): name: str = "VPNServer" group: str = "Security" configs: Tuple[str, ...] = ("vpnserver.sh",) - startup: Tuple[str, ...] = ("sh vpnserver.sh",) + startup: Tuple[str, ...] = ("bash vpnserver.sh",) shutdown: Tuple[str, ...] = ("killall openvpn",) validate: Tuple[str, ...] = ("pidof openvpn",) custom_needed: bool = True @@ -71,7 +71,7 @@ class IPsec(CoreService): name: str = "IPsec" group: str = "Security" configs: Tuple[str, ...] = ("ipsec.sh",) - startup: Tuple[str, ...] = ("sh ipsec.sh",) + startup: Tuple[str, ...] = ("bash ipsec.sh",) shutdown: Tuple[str, ...] = ("killall racoon",) custom_needed: bool = True @@ -97,7 +97,7 @@ class Firewall(CoreService): name: str = "Firewall" group: str = "Security" configs: Tuple[str, ...] = ("firewall.sh",) - startup: Tuple[str, ...] = ("sh firewall.sh",) + startup: Tuple[str, ...] = ("bash firewall.sh",) custom_needed: bool = True @classmethod @@ -127,7 +127,7 @@ class Nat(CoreService): group: str = "Security" executables: Tuple[str, ...] = ("iptables",) configs: Tuple[str, ...] = ("nat.sh",) - startup: Tuple[str, ...] = ("sh nat.sh",) + startup: Tuple[str, ...] = ("bash nat.sh",) custom_needed: bool = False @classmethod diff --git a/daemon/core/services/ucarp.py b/daemon/core/services/ucarp.py index 8ac92dd3..522eeaf6 100644 --- a/daemon/core/services/ucarp.py +++ b/daemon/core/services/ucarp.py @@ -19,7 +19,7 @@ class Ucarp(CoreService): UCARP_ETC + "/default-down.sh", "ucarpboot.sh", ) - startup: Tuple[str, ...] = ("sh ucarpboot.sh",) + startup: Tuple[str, ...] = ("bash ucarpboot.sh",) shutdown: Tuple[str, ...] = ("killall ucarp",) validate: Tuple[str, ...] = ("pidof ucarp",) diff --git a/daemon/core/services/utility.py b/daemon/core/services/utility.py index 774c4104..a30d1f62 100644 --- a/daemon/core/services/utility.py +++ b/daemon/core/services/utility.py @@ -28,7 +28,7 @@ class UtilService(CoreService): class IPForwardService(UtilService): name: str = "IPForward" configs: Tuple[str, ...] = ("ipforward.sh",) - startup: Tuple[str, ...] = ("sh ipforward.sh",) + startup: Tuple[str, ...] = ("bash ipforward.sh",) @classmethod def generate_config(cls, node: CoreNode, filename: str) -> str: @@ -61,7 +61,7 @@ class IPForwardService(UtilService): class DefaultRouteService(UtilService): name: str = "DefaultRoute" configs: Tuple[str, ...] = ("defaultroute.sh",) - startup: Tuple[str, ...] = ("sh defaultroute.sh",) + startup: Tuple[str, ...] = ("bash defaultroute.sh",) @classmethod def generate_config(cls, node: CoreNode, filename: str) -> str: @@ -84,7 +84,7 @@ class DefaultRouteService(UtilService): class DefaultMulticastRouteService(UtilService): name: str = "DefaultMulticastRoute" configs: Tuple[str, ...] = ("defaultmroute.sh",) - startup: Tuple[str, ...] = ("sh defaultmroute.sh",) + startup: Tuple[str, ...] = ("bash defaultmroute.sh",) @classmethod def generate_config(cls, node: CoreNode, filename: str) -> str: @@ -103,7 +103,7 @@ class DefaultMulticastRouteService(UtilService): class StaticRouteService(UtilService): name: str = "StaticRoute" configs: Tuple[str, ...] = ("staticroute.sh",) - startup: Tuple[str, ...] = ("sh staticroute.sh",) + startup: Tuple[str, ...] = ("bash staticroute.sh",) custom_needed: bool = True @classmethod @@ -135,7 +135,7 @@ class SshService(UtilService): name: str = "SSH" configs: Tuple[str, ...] = ("startsshd.sh", "/etc/ssh/sshd_config") dirs: Tuple[str, ...] = ("/etc/ssh", "/var/run/sshd") - startup: Tuple[str, ...] = ("sh startsshd.sh",) + startup: Tuple[str, ...] = ("bash startsshd.sh",) shutdown: Tuple[str, ...] = ("killall sshd",) validation_mode: ServiceMode = ServiceMode.BLOCKING @@ -278,7 +278,7 @@ class DhcpClientService(UtilService): name: str = "DHCPClient" configs: Tuple[str, ...] = ("startdhcpclient.sh",) - startup: Tuple[str, ...] = ("sh startdhcpclient.sh",) + startup: Tuple[str, ...] = ("bash startdhcpclient.sh",) shutdown: Tuple[str, ...] = ("killall dhclient",) validate: Tuple[str, ...] = ("pidof dhclient",) @@ -561,8 +561,8 @@ class PcapService(UtilService): name: str = "pcap" configs: Tuple[str, ...] = ("pcap.sh",) - startup: Tuple[str, ...] = ("sh pcap.sh start",) - shutdown: Tuple[str, ...] = ("sh pcap.sh stop",) + startup: Tuple[str, ...] = ("bash pcap.sh start",) + shutdown: Tuple[str, ...] = ("bash pcap.sh stop",) validate: Tuple[str, ...] = ("pidof tcpdump",) meta: str = "logs network traffic to pcap packet capture files" @@ -671,7 +671,7 @@ class AtdService(UtilService): name: str = "atd" configs: Tuple[str, ...] = ("startatd.sh",) dirs: Tuple[str, ...] = ("/var/spool/cron/atjobs", "/var/spool/cron/atspool") - startup: Tuple[str, ...] = ("sh startatd.sh",) + startup: Tuple[str, ...] = ("bash startatd.sh",) shutdown: Tuple[str, ...] = ("pkill atd",) @classmethod diff --git a/tasks.py b/tasks.py index 5f52f444..c3e6d2bb 100644 --- a/tasks.py +++ b/tasks.py @@ -120,14 +120,14 @@ def install_system(c: Context, os_info: OsInfo, hide: bool) -> None: if os_info.like == OsLike.DEBIAN: c.run( "sudo apt install -y automake pkg-config gcc libev-dev ebtables " - "iproute2 ethtool tk python3-tk", + "iproute2 ethtool tk python3-tk bash", hide=hide ) elif os_info.like == OsLike.REDHAT: c.run( "sudo yum install -y automake pkgconf-pkg-config gcc gcc-c++ " "libev-devel iptables-ebtables iproute python3-devel python3-tkinter " - "tk ethtool make", + "tk ethtool make bash", hide=hide ) # centos 8+ does not support netem by default From 9e3e0e0326345b8efb07622f5f383728153ce7bd Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 28 Jul 2020 16:34:05 -0700 Subject: [PATCH 351/929] install: fixed issue identifying python versions to install dataclasses for, using ~ should account for any version up to 3.7 properly --- daemon/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index b75f1ee3..7ce3a125 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -10,7 +10,7 @@ include = ["core/gui/data/**/*", "core/configservices/*/templates"] [tool.poetry.dependencies] python = "^3.6" -dataclasses = { version = "*", python = "3.6" } +dataclasses = { version = "*", python = "~3.6" } fabric = "*" grpcio = "1.27.2" invoke = "*" From 511a3037a8d0cc09b36e7d07849dbe08ea53fd18 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 28 Jul 2020 16:35:46 -0700 Subject: [PATCH 352/929] bumped versions for release --- configure.ac | 2 +- daemon/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/configure.ac b/configure.ac index 60f6709e..6ed18b69 100644 --- a/configure.ac +++ b/configure.ac @@ -2,7 +2,7 @@ # Process this file with autoconf to produce a configure script. # this defines the CORE version number, must be static for AC_INIT -AC_INIT(core, 7.0.0) +AC_INIT(core, 7.0.1) # autoconf and automake initialization AC_CONFIG_SRCDIR([netns/version.h.in]) diff --git a/daemon/pyproject.toml b/daemon/pyproject.toml index 7ce3a125..dec01670 100644 --- a/daemon/pyproject.toml +++ b/daemon/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "core" -version = "7.0.0" +version = "7.0.1" description = "CORE Common Open Research Emulator" authors = ["Boeing Research and Technology"] license = "BSD-2-Clause" From afe434f25cbf2e01a89fb75f4c914c514675a55e Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 28 Jul 2020 16:39:16 -0700 Subject: [PATCH 353/929] updated changelog for bugfix release 7.0.1 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 375a7607..21eb0a57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2020-07-28 CORE 7.0.1 + +* Bugfixes + * \#500 - fixed issue running node commands with shell=True + * fixed issue for poetry based install not properly vetting requirements for dataclasses dependency + ## 2020-07-23 CORE 7.0.0 * Breaking Changes From 858e771efd8f040a18c7e222e4dd971536afe145 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 28 Jul 2020 21:49:34 -0700 Subject: [PATCH 354/929] pygui: fixes for copying links/asymmetric links, fixes for configuring asymmetric links, fixed issues adding nodes/links and editing links from gui due to not being able to identify same source changes --- daemon/core/gui/coreclient.py | 56 +++++++++++++++++++------ daemon/core/gui/dialogs/linkconfig.py | 37 ++++------------- daemon/core/gui/graph/graph.py | 59 ++++++++++++++++++--------- 3 files changed, 91 insertions(+), 61 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 6129031a..9fe8130a 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -474,21 +474,22 @@ class CoreClient: self.ifaces_manager.reset_mac() nodes = [x.to_proto() for x in self.session.nodes.values()] links = [] - for link in self.session.links: + asymmetric_links = [] + for edge in self.links.values(): + link = edge.link if link.iface1 and not link.iface1.mac: link.iface1.mac = self.ifaces_manager.next_mac() if link.iface2 and not link.iface2.mac: link.iface2.mac = self.ifaces_manager.next_mac() links.append(link.to_proto()) + if edge.asymmetric_link: + asymmetric_links.append(edge.asymmetric_link.to_proto()) wlan_configs = self.get_wlan_configs_proto() mobility_configs = self.get_mobility_configs_proto() emane_model_configs = self.get_emane_model_configs_proto() hooks = [x.to_proto() for x in self.session.hooks.values()] service_configs = self.get_service_configs_proto() file_configs = self.get_service_file_configs_proto() - asymmetric_links = [ - x.asymmetric_link for x in self.links.values() if x.asymmetric_link - ] config_service_configs = self.get_config_service_configs_proto() emane_config = to_dict(self.session.emane_config) result = False @@ -686,17 +687,32 @@ class CoreClient: for node in self.session.nodes.values(): response = self.client.add_node(self.session.id, node.to_proto()) logging.debug("created node: %s", response) - for link in self.session.links: - link_proto = link.to_proto() + asymmetric_links = [] + for edge in self.links.values(): + link = edge.link response = self.client.add_link( self.session.id, - link_proto.node1_id, - link_proto.node2_id, - link_proto.iface1, - link_proto.iface2, - link_proto.options, + link.node1_id, + link.node2_id, + link.iface1, + link.iface2, + link.options, + source=GUI_SOURCE, ) logging.debug("created link: %s", response) + if edge.asymmetric_link: + asymmetric_links.append(edge.asymmetric_link) + for link in asymmetric_links: + response = self.client.add_link( + self.session.id, + link.node1_id, + link.node2_id, + link.iface1, + link.iface2, + link.options, + source=GUI_SOURCE, + ) + logging.debug("created asymmetric link: %s", response) def send_data(self) -> None: """ @@ -892,8 +908,7 @@ class CoreClient: ) edge.set_link(link) self.links[edge.token] = edge - self.session.links.append(link) - logging.info("Add link between %s and %s", src_node.name, dst_node.name) + logging.info("added link between %s and %s", src_node.name, dst_node.name) def get_wlan_configs_proto(self) -> List[wlan_pb2.WlanConfig]: configs = [] @@ -1039,3 +1054,18 @@ class CoreClient: logging.info("execute python script %s", response) if response.session_id != -1: self.join_session(response.session_id) + + def edit_link(self, link: Link) -> None: + iface1_id = link.iface1.id if link.iface1 else None + iface2_id = link.iface2.id if link.iface2 else None + response = self.client.edit_link( + self.session.id, + link.node1_id, + link.node2_id, + link.options.to_proto(), + iface1_id, + iface2_id, + source=GUI_SOURCE, + ) + if not response.result: + logging.error("error editing link: %s", link) diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index 2a91da30..914bad1e 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -228,21 +228,15 @@ class LinkConfigurationDialog(Dialog): bandwidth=bandwidth, jitter=jitter, delay=delay, dup=duplicate, loss=loss ) link.options = options - - iface1_id = None - if link.iface1: - iface1_id = link.iface1.id - iface2_id = None - if link.iface2: - iface2_id = link.iface2.id - + iface1_id = link.iface1.id if link.iface1 else None + iface2_id = link.iface2.id if link.iface2 else None if not self.is_symmetric: link.options.unidirectional = True asym_iface1 = None - if iface1_id: + if iface1_id is not None: asym_iface1 = Interface(id=iface1_id) asym_iface2 = None - if iface2_id: + if iface2_id is not None: asym_iface2 = Interface(id=iface2_id) down_bandwidth = get_int(self.down_bandwidth) down_jitter = get_int(self.down_jitter) @@ -260,8 +254,8 @@ class LinkConfigurationDialog(Dialog): self.edge.asymmetric_link = Link( node1_id=link.node2_id, node2_id=link.node1_id, - iface1=asym_iface1, - iface2=asym_iface2, + iface1=asym_iface2, + iface2=asym_iface1, options=options, ) else: @@ -269,24 +263,9 @@ class LinkConfigurationDialog(Dialog): self.edge.asymmetric_link = None if self.app.core.is_runtime() and link.options: - session_id = self.app.core.session.id - self.app.core.client.edit_link( - session_id, - link.node1_id, - link.node2_id, - link.options, - iface1_id, - iface2_id, - ) + self.app.core.edit_link(link) if self.edge.asymmetric_link: - self.app.core.client.edit_link( - session_id, - link.node2_id, - link.node1_id, - self.edge.asymmetric_link.options, - iface1_id, - iface2_id, - ) + self.app.core.edit_link(self.edge.asymmetric_link) # update edge label self.edge.draw_link_options() diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index 69ae87cc..bb762bb8 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -262,7 +262,7 @@ class CanvasGraph(tk.Canvas): edge = self.edges.get(token) if not edge: return - edge.link.options.CopyFrom(link.options) + edge.link.options = deepcopy(link.options) def add_wireless_edge(self, src: CanvasNode, dst: CanvasNode, link: Link) -> None: network_id = link.network_id if link.network_id else None @@ -924,7 +924,8 @@ class CanvasGraph(tk.Canvas): # maps original node canvas id to copy node canvas id copy_map = {} # the edges that will be copy over - to_copy_edges = [] + to_copy_edges = set() + to_copy_ids = {x.id for x in self.to_copy} for canvas_node in self.to_copy: core_node = canvas_node.core_node actual_x = core_node.position.x + 50 @@ -954,15 +955,39 @@ class CanvasGraph(tk.Canvas): self.nodes[node.id] = node self.core.set_canvas_node(copy, node) for edge in canvas_node.edges: - if edge.src not in self.to_copy or edge.dst not in self.to_copy: + if edge.src not in to_copy_ids or edge.dst not in to_copy_ids: if canvas_node.id == edge.src: dst_node = self.nodes[edge.dst] self.create_edge(node, dst_node) + token = create_edge_token(node.id, dst_node.id) elif canvas_node.id == edge.dst: src_node = self.nodes[edge.src] self.create_edge(src_node, node) + token = create_edge_token(src_node.id, node.id) + copy_edge = self.edges[token] + copy_link = copy_edge.link + iface1_id = copy_link.iface1.id if copy_link.iface1 else None + iface2_id = copy_link.iface2.id if copy_link.iface2 else None + options = edge.link.options + if options: + copy_edge.link.options = deepcopy(options) + if options and options.unidirectional: + asym_iface1 = None + if iface1_id is not None: + asym_iface1 = Interface(id=iface1_id) + asym_iface2 = None + if iface2_id is not None: + asym_iface2 = Interface(id=iface2_id) + copy_edge.asymmetric_link = Link( + node1_id=copy_link.node2_id, + node2_id=copy_link.node1_id, + iface1=asym_iface2, + iface2=asym_iface1, + options=deepcopy(edge.asymmetric_link.options), + ) + copy_edge.redraw() else: - to_copy_edges.append(edge) + to_copy_edges.add(edge) # copy link and link config for edge in to_copy_edges: @@ -974,30 +999,26 @@ class CanvasGraph(tk.Canvas): token = create_edge_token(src_node_copy.id, dst_node_copy.id) copy_edge = self.edges[token] copy_link = copy_edge.link + iface1_id = copy_link.iface1.id if copy_link.iface1 else None + iface2_id = copy_link.iface2.id if copy_link.iface2 else None options = edge.link.options - copy_link.options = deepcopy(options) - iface1_id = None - if copy_link.iface1: - iface1_id = copy_link.iface1.id - iface2_id = None - if copy_link.iface2: - iface2_id = copy_link.iface2.id - if not options.unidirectional: - copy_edge.asymmetric_link = None - else: + if options: + copy_link.options = deepcopy(options) + if options and options.unidirectional: asym_iface1 = None - if iface1_id: + if iface1_id is not None: asym_iface1 = Interface(id=iface1_id) asym_iface2 = None - if iface2_id: + if iface2_id is not None: asym_iface2 = Interface(id=iface2_id) copy_edge.asymmetric_link = Link( node1_id=copy_link.node2_id, node2_id=copy_link.node1_id, - iface1=asym_iface1, - iface2=asym_iface2, - options=edge.asymmetric_link.options, + iface1=asym_iface2, + iface2=asym_iface1, + options=deepcopy(edge.asymmetric_link.options), ) + copy_edge.redraw() self.itemconfig( copy_edge.id, width=self.itemcget(edge.id, "width"), From fe36d28522f31559aa27f913db4d4bb23d867fc6 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 28 Jul 2020 22:45:42 -0700 Subject: [PATCH 355/929] pygui: fixed issue with changes to protobuf files for getting emane model configs on a session --- daemon/core/api/grpc/grpcutils.py | 6 +++--- daemon/core/gui/coreclient.py | 2 ++ daemon/proto/core/api/grpc/core.proto | 2 +- daemon/proto/core/api/grpc/emane.proto | 9 ++++++++- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index a024c064..51be85fe 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -10,7 +10,7 @@ from core import utils from core.api.grpc import common_pb2, core_pb2 from core.api.grpc.common_pb2 import MappedConfig from core.api.grpc.configservices_pb2 import ConfigServiceConfig -from core.api.grpc.emane_pb2 import EmaneModelConfig +from core.api.grpc.emane_pb2 import GetEmaneModelConfig from core.api.grpc.services_pb2 import ( NodeServiceConfig, NodeServiceData, @@ -547,7 +547,7 @@ def get_nem_id( return nem_id -def get_emane_model_configs(session: Session) -> List[EmaneModelConfig]: +def get_emane_model_configs(session: Session) -> List[GetEmaneModelConfig]: configs = [] for _id in session.emane.node_configurations: if _id == -1: @@ -558,7 +558,7 @@ def get_emane_model_configs(session: Session) -> List[EmaneModelConfig]: current_config = session.emane.get_model_config(_id, model_name) config = get_config_options(current_config, model) node_id, iface_id = parse_emane_model_id(_id) - model_config = EmaneModelConfig( + model_config = GetEmaneModelConfig( node_id=node_id, model=model_name, iface_id=iface_id, config=config ) configs.append(model_config) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 9fe8130a..b30a265b 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -404,6 +404,8 @@ class CoreClient: self.app.show_grpc_exception("New Session Error", e) def delete_session(self, session_id: int = None) -> None: + if session_id is None and not self.session: + return if session_id is None: session_id = self.session.id try: diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index 1b20257c..d5ffda59 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -720,7 +720,7 @@ message Session { repeated Hook hooks = 9; repeated string emane_models = 10; map emane_config = 11; - repeated emane.EmaneModelConfig emane_model_configs = 12; + repeated emane.GetEmaneModelConfig emane_model_configs = 12; map wlan_configs = 13; repeated services.NodeServiceConfig service_configs = 14; repeated configservices.ConfigServiceConfig config_service_configs = 15; diff --git a/daemon/proto/core/api/grpc/emane.proto b/daemon/proto/core/api/grpc/emane.proto index ce9a4297..ad6a22ca 100644 --- a/daemon/proto/core/api/grpc/emane.proto +++ b/daemon/proto/core/api/grpc/emane.proto @@ -53,8 +53,15 @@ message GetEmaneModelConfigsRequest { int32 session_id = 1; } +message GetEmaneModelConfig { + int32 node_id = 1; + string model = 2; + int32 iface_id = 3; + map config = 4; +} + message GetEmaneModelConfigsResponse { - repeated EmaneModelConfig configs = 1; + repeated GetEmaneModelConfig configs = 1; } message GetEmaneEventChannelRequest { From d30778b2382ec9eb480c5c0552c0ecbbabfd26a6 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 29 Jul 2020 16:55:42 -0700 Subject: [PATCH 356/929] daemon: fixed mobility checks to allow both wlan/emane, pygui: enabled emane nodes to configure mobility --- daemon/core/api/grpc/grpcutils.py | 14 ++++++ daemon/core/api/grpc/server.py | 6 ++- daemon/core/gui/coreclient.py | 4 +- daemon/core/gui/graph/node.py | 4 +- daemon/core/gui/nodeutils.py | 5 +++ daemon/core/location/mobility.py | 74 +++++++++++++++---------------- 6 files changed, 65 insertions(+), 42 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 51be85fe..8f666508 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -22,9 +22,11 @@ from core.emane.nodes import EmaneNet from core.emulator.data import InterfaceData, LinkData, LinkOptions, NodeOptions from core.emulator.enumerations import LinkTypes, NodeTypes from core.emulator.session import Session +from core.errors import CoreError from core.location.mobility import BasicRangeModel, Ns2ScriptedMobility from core.nodes.base import CoreNode, CoreNodeBase, NodeBase from core.nodes.interface import CoreInterface +from core.nodes.network import WlanNode from core.services.coreservices import CoreService WORKERS = 10 @@ -661,3 +663,15 @@ def get_node_config_service_configs(session: Session) -> List[ConfigServiceConfi def get_emane_config(session: Session) -> Dict[str, common_pb2.ConfigOption]: current_config = session.emane.get_configs() return get_config_options(current_config, session.emane.emane_config) + + +def get_mobility_node( + session: Session, node_id: int, context: ServicerContext +) -> Union[WlanNode, EmaneNet]: + try: + return session.get_node(node_id, WlanNode) + except CoreError: + try: + return session.get_node(node_id, EmaneNet) + except CoreError: + context.abort(grpc.StatusCode.NOT_FOUND, "node id is not for wlan or emane") diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index cd9cf714..81f4335e 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -1125,7 +1125,11 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): """ logging.debug("mobility action: %s", request) session = self.get_session(request.session_id, context) - node = self.get_node(session, request.node_id, context, WlanNode) + node = grpcutils.get_mobility_node(session, request.node_id, context) + if not node.mobility: + context.abort( + grpc.StatusCode.NOT_FOUND, f"node({node.name}) does not have mobility" + ) result = True if request.action == MobilityAction.START: node.mobility.start() diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index b30a265b..bb6c4f95 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -538,7 +538,7 @@ class CoreClient: def show_mobility_players(self) -> None: for node in self.session.nodes.values(): - if node.type != NodeType.WIRELESS_LAN: + if not NodeUtils.is_mobility(node): continue if node.mobility_config: mobility_player = MobilityPlayer(self.app, node) @@ -927,7 +927,7 @@ class CoreClient: def get_mobility_configs_proto(self) -> List[mobility_pb2.MobilityConfig]: configs = [] for node in self.session.nodes.values(): - if node.type != NodeType.WIRELESS_LAN: + if not NodeUtils.is_mobility(node): continue if not node.mobility_config: continue diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index ffc72fbf..100404ef 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -206,6 +206,7 @@ class CanvasNode: self.context.delete(0, tk.END) is_wlan = self.core_node.type == NodeType.WIRELESS_LAN is_emane = self.core_node.type == NodeType.EMANE + is_mobility = is_wlan or is_emane if self.app.core.is_runtime(): self.context.add_command(label="Configure", command=self.show_config) if is_emane: @@ -216,7 +217,7 @@ class CanvasNode: self.context.add_command( label="WLAN Config", command=self.show_wlan_config ) - if is_wlan and self.core_node.id in self.app.core.mobility_players: + if is_mobility and self.core_node.id in self.app.core.mobility_players: self.context.add_command( label="Mobility Player", command=self.show_mobility_player ) @@ -235,6 +236,7 @@ class CanvasNode: self.context.add_command( label="WLAN Config", command=self.show_wlan_config ) + if is_mobility: self.context.add_command( label="Mobility Config", command=self.show_mobility_config ) diff --git a/daemon/core/gui/nodeutils.py b/daemon/core/gui/nodeutils.py index 6c451303..8cba5bf0 100644 --- a/daemon/core/gui/nodeutils.py +++ b/daemon/core/gui/nodeutils.py @@ -63,10 +63,15 @@ class NodeUtils: WIRELESS_NODES: Set[NodeType] = {NodeType.WIRELESS_LAN, NodeType.EMANE} RJ45_NODES: Set[NodeType] = {NodeType.RJ45} IGNORE_NODES: Set[NodeType] = {NodeType.CONTROL_NET} + MOBILITY_NODES: Set[NodeType] = {NodeType.WIRELESS_LAN, NodeType.EMANE} NODE_MODELS: Set[str] = {"router", "host", "PC", "mdr", "prouter"} ROUTER_NODES: Set[str] = {"router", "mdr"} ANTENNA_ICON: PhotoImage = None + @classmethod + def is_mobility(cls, node: Node) -> bool: + return node.type in cls.MOBILITY_NODES + @classmethod def is_router_node(cls, node: Node) -> bool: return cls.is_model_node(node.type) and node.model in cls.ROUTER_NODES diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index 0e9b2e32..ce422277 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -9,10 +9,11 @@ import threading import time from functools import total_ordering from pathlib import Path -from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Union from core import utils from core.config import ConfigGroup, ConfigurableOptions, Configuration, ModelManager +from core.emane.nodes import EmaneNet from core.emulator.data import EventData, LinkData, LinkOptions from core.emulator.enumerations import ( ConfigDataTypes, @@ -31,6 +32,13 @@ if TYPE_CHECKING: from core.emulator.session import Session +def get_mobility_node(session: "Session", node_id: int) -> Union[WlanNode, EmaneNet]: + try: + return session.get_node(node_id, WlanNode) + except CoreError: + return session.get_node(node_id, EmaneNet) + + class MobilityManager(ModelManager): """ Member of session class for handling configuration data for mobility and @@ -69,30 +77,25 @@ class MobilityManager(ModelManager): """ if node_ids is None: node_ids = self.nodes() - for node_id in node_ids: - logging.debug("checking mobility startup for node: %s", node_id) logging.debug( - "node mobility configurations: %s", self.get_all_configs(node_id) + "node(%s) mobility startup: %s", node_id, self.get_all_configs(node_id) ) - try: - node = self.session.get_node(node_id, WlanNode) + node = get_mobility_node(self.session, node_id) + # TODO: may be an issue if there are multiple mobility models + for model in self.models.values(): + config = self.get_configs(node_id, model.name) + if not config: + continue + self.set_model(node, model, config) + if node.mobility: + self.session.event_loop.add_event(0.0, node.mobility.startup) except CoreError: + logging.exception("mobility startup error") logging.warning( "skipping mobility configuration for unknown node: %s", node_id ) - continue - - for model_name in self.models: - config = self.get_configs(node_id, model_name) - if not config: - continue - model_class = self.models[model_name] - self.set_model(node, model_class, config) - - if node.mobility: - self.session.event_loop.add_event(0.0, node.mobility.startup) def handleevent(self, event_data: EventData) -> None: """ @@ -106,40 +109,35 @@ class MobilityManager(ModelManager): node_id = event_data.node name = event_data.name try: - node = self.session.get_node(node_id, WlanNode) + node = get_mobility_node(self.session, node_id) except CoreError: logging.exception( - "Ignoring event for model '%s', unknown node '%s'", name, node_id + "ignoring event for model(%s), unknown node(%s)", name, node_id ) return # name is e.g. "mobility:ns2script" models = name[9:].split(",") for model in models: - try: - cls = self.models[model] - except KeyError: - logging.warning("Ignoring event for unknown model '%s'", model) + cls = self.models.get(model) + if not cls: + logging.warning("ignoring event for unknown model '%s'", model) continue - if cls.config_type in [RegisterTlvs.WIRELESS, RegisterTlvs.MOBILITY]: model = node.mobility else: continue - if model is None: - logging.warning("Ignoring event, %s has no model", node.name) + logging.warning("ignoring event, %s has no model", node.name) continue - if cls.name != model.name: logging.warning( - "Ignoring event for %s wrong model %s,%s", + "ignoring event for %s wrong model %s,%s", node.name, cls.name, model.name, ) continue - if event_type in [EventTypes.STOP, EventTypes.RESTART]: model.stop(move_initial=True) if event_type in [EventTypes.START, EventTypes.RESTART]: @@ -162,11 +160,9 @@ class MobilityManager(ModelManager): event_type = EventTypes.START elif model.state == model.STATE_PAUSED: event_type = EventTypes.PAUSE - start_time = int(model.lasttime - model.timezero) end_time = int(model.endtime) data = f"start={start_time} end={end_time}" - event_data = EventData( node=model.id, event_type=event_type, @@ -174,7 +170,6 @@ class MobilityManager(ModelManager): data=data, time=str(time.monotonic()), ) - self.session.broadcast_event(event_data) def updatewlans( @@ -593,7 +588,7 @@ class WayPointMobility(WirelessModel): self.lasttime: Optional[float] = None self.endtime: Optional[int] = None self.timezero: float = 0.0 - self.wlan: WlanNode = session.get_node(_id, WlanNode) + self.net: Union[WlanNode, EmaneNet] = get_mobility_node(self.session, self.id) # these are really set in child class via confmatrix self.loop: bool = False self.refresh_ms: int = 50 @@ -601,6 +596,9 @@ class WayPointMobility(WirelessModel): # (ns-3 sets this to False as new waypoints may be added from trace) self.empty_queue_stop: bool = True + def startup(self): + raise NotImplementedError + def runround(self) -> None: """ Advance script time and move nodes. @@ -643,7 +641,7 @@ class WayPointMobility(WirelessModel): # only move interfaces attached to self.wlan, or all nodenum in script? moved = [] moved_ifaces = [] - for iface in self.wlan.get_ifaces(): + for iface in self.net.get_ifaces(): node = iface.node if self.movenode(node, dt): moved.append(node) @@ -723,7 +721,7 @@ class WayPointMobility(WirelessModel): """ moved = [] moved_ifaces = [] - for iface in self.wlan.get_ifaces(): + for iface in self.net.get_ifaces(): node = iface.node if node.id not in self.initial: continue @@ -1094,7 +1092,7 @@ class Ns2ScriptedMobility(WayPointMobility): :return: nothing """ if self.autostart == "": - logging.info("not auto-starting ns-2 script for %s", self.wlan.name) + logging.info("not auto-starting ns-2 script for %s", self.net.name) return try: t = float(self.autostart) @@ -1102,11 +1100,11 @@ class Ns2ScriptedMobility(WayPointMobility): logging.exception( "Invalid auto-start seconds specified '%s' for %s", self.autostart, - self.wlan.name, + self.net.name, ) return self.movenodesinitial() - logging.info("scheduling ns-2 script for %s autostart at %s", self.wlan.name, t) + logging.info("scheduling ns-2 script for %s autostart at %s", self.net.name, t) self.state = self.STATE_RUNNING self.session.event_loop.add_event(t, self.run) From 46f896925c67e5a4bc261270d35c7b82bab389a8 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 29 Jul 2020 17:08:20 -0700 Subject: [PATCH 357/929] daemon: fixed mobility manager updates to support emane/wlan --- daemon/core/emane/emanemodel.py | 4 ++-- daemon/core/location/mobility.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/daemon/core/emane/emanemodel.py b/daemon/core/emane/emanemodel.py index 8672163d..0ee9aa40 100644 --- a/daemon/core/emane/emanemodel.py +++ b/daemon/core/emane/emanemodel.py @@ -130,8 +130,8 @@ class EmaneModel(WirelessModel): :return: nothing """ try: - wlan = self.session.get_node(self.id, EmaneNet) - wlan.setnempositions(moved_ifaces) + emane_net = self.session.get_node(self.id, EmaneNet) + emane_net.setnempositions(moved_ifaces) except CoreError: logging.exception("error during update") diff --git a/daemon/core/location/mobility.py b/daemon/core/location/mobility.py index ce422277..a548433c 100644 --- a/daemon/core/location/mobility.py +++ b/daemon/core/location/mobility.py @@ -100,7 +100,7 @@ class MobilityManager(ModelManager): def handleevent(self, event_data: EventData) -> None: """ Handle an Event Message used to start, stop, or pause - mobility scripts for a given WlanNode. + mobility scripts for a given mobility network. :param event_data: event data to handle :return: nothing @@ -172,12 +172,12 @@ class MobilityManager(ModelManager): ) self.session.broadcast_event(event_data) - def updatewlans( + def update_nets( self, moved: List[CoreNode], moved_ifaces: List[CoreInterface] ) -> None: """ A mobility script has caused nodes in the 'moved' list to move. - Update every WlanNode. This saves range calculations if the model + Update every mobility network. This saves range calculations if the model were to recalculate for each individual node movement. :param moved: moved nodes @@ -186,11 +186,11 @@ class MobilityManager(ModelManager): """ for node_id in self.nodes(): try: - node = self.session.get_node(node_id, WlanNode) + node = get_mobility_node(self.session, node_id) + if node.model: + node.model.update(moved, moved_ifaces) except CoreError: - continue - if node.model: - node.model.update(moved, moved_ifaces) + logging.exception("error updating mobility node") class WirelessModel(ConfigurableOptions): @@ -648,7 +648,7 @@ class WayPointMobility(WirelessModel): moved_ifaces.append(iface) # calculate all ranges after moving nodes; this saves calculations - self.session.mobility.updatewlans(moved, moved_ifaces) + self.session.mobility.update_nets(moved, moved_ifaces) # TODO: check session state self.session.event_loop.add_event(0.001 * self.refresh_ms, self.runround) @@ -729,7 +729,7 @@ class WayPointMobility(WirelessModel): self.setnodeposition(node, x, y, z) moved.append(node) moved_ifaces.append(iface) - self.session.mobility.updatewlans(moved, moved_ifaces) + self.session.mobility.update_nets(moved, moved_ifaces) def addwaypoint( self, From 63103ab25086ffd43e89fb56459f5ab4d429c40f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 31 Jul 2020 23:09:26 -0700 Subject: [PATCH 358/929] pygui: removed unused unlimited button from linkconfig dialog --- daemon/core/gui/dialogs/linkconfig.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index 914bad1e..1aa2d7f8 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -77,10 +77,7 @@ class LinkConfigurationDialog(Dialog): frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) - frame.columnconfigure(1, weight=1) frame.grid(row=1, column=0, sticky="ew", pady=PADY) - button = ttk.Button(frame, text="Unlimited") - button.grid(row=0, column=0, sticky="ew", padx=PADX) if self.is_symmetric: button = ttk.Button( frame, textvariable=self.symmetry_var, command=self.change_symmetry @@ -89,7 +86,7 @@ class LinkConfigurationDialog(Dialog): button = ttk.Button( frame, textvariable=self.symmetry_var, command=self.change_symmetry ) - button.grid(row=0, column=1, sticky="ew") + button.grid(sticky="ew") if self.is_symmetric: self.symmetric_frame = self.get_frame() From eb422f5bab665fcd0b592be6072a5c573766bdba Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 31 Jul 2020 23:13:07 -0700 Subject: [PATCH 359/929] pygui: mac editing disabled for nodes during runtime --- daemon/core/gui/dialogs/nodeconfig.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index 33c8fb32..604e933a 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -232,8 +232,10 @@ class NodeConfigDialog(Dialog): label = ttk.Label(tab, text="MAC") label.grid(row=row, column=0, padx=PADX, pady=PADY) auto_set = not iface.mac - mac_state = tk.DISABLED if auto_set else tk.NORMAL is_auto = tk.BooleanVar(value=auto_set) + mac_state = tk.DISABLED if auto_set else tk.NORMAL + if state == tk.DISABLED: + mac_state = tk.DISABLED checkbutton = ttk.Checkbutton( tab, text="Auto?", variable=is_auto, state=state ) From e7a93e7fd6ef885cc6c47c61ccc76975eec11449 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 31 Jul 2020 23:18:11 -0700 Subject: [PATCH 360/929] pygui: config dialogs that allow selecting a file default to ~/.coregui --- daemon/core/gui/widgets.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/daemon/core/gui/widgets.py b/daemon/core/gui/widgets.py index 85f3da10..0d5bff22 100644 --- a/daemon/core/gui/widgets.py +++ b/daemon/core/gui/widgets.py @@ -5,7 +5,7 @@ from pathlib import Path from tkinter import filedialog, font, ttk from typing import TYPE_CHECKING, Any, Callable, Dict, Set, Type -from core.gui import themes, validation +from core.gui import appconfig, themes, validation from core.gui.dialogs.dialog import Dialog from core.gui.themes import FRAME_PAD, PADX, PADY from core.gui.wrappers import ConfigOption, ConfigOptionType @@ -26,7 +26,9 @@ INT_TYPES: Set[ConfigOptionType] = { def file_button_click(value: tk.StringVar, parent: tk.Widget) -> None: - file_path = filedialog.askopenfilename(title="Select File", parent=parent) + file_path = filedialog.askopenfilename( + title="Select File", initialdir=str(appconfig.HOME_PATH), parent=parent + ) if file_path: value.set(file_path) From 04f7bc561b7a59130c2cf080997f56583b0517bf Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 31 Jul 2020 23:23:18 -0700 Subject: [PATCH 361/929] pygui: fixed exception from bad check when double clicking in sessions dialog --- daemon/core/gui/dialogs/sessions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index d41e2052..3f9f3c9b 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -190,7 +190,7 @@ class SessionsDialog(Dialog): def double_click_join(self, _event: tk.Event) -> None: item = self.tree.selection() - if item is None: + if not item: return session_id = int(self.tree.item(item, "text")) self.join_session(session_id) From fc44ad6fe84bb52ee35ea1c2fafc95fe6de90501 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 1 Aug 2020 11:00:26 -0700 Subject: [PATCH 362/929] pygui: update title to show xml file when one is opened, fixed issue creating nodes/links when not runtime due to refactoring, removed xml_file from coreclient and depend on the grpc GetSession wrapped data, grpc: added opened file information to GetSession call --- daemon/core/api/grpc/grpcutils.py | 2 +- daemon/core/api/grpc/server.py | 1 + daemon/core/gui/coreclient.py | 61 +++++++++++++++------------ daemon/core/gui/dialogs/sessions.py | 2 - daemon/core/gui/menubar.py | 24 +++-------- daemon/core/gui/wrappers.py | 4 ++ daemon/proto/core/api/grpc/core.proto | 1 + 7 files changed, 48 insertions(+), 47 deletions(-) diff --git a/daemon/core/api/grpc/grpcutils.py b/daemon/core/api/grpc/grpcutils.py index 8f666508..eaec2359 100644 --- a/daemon/core/api/grpc/grpcutils.py +++ b/daemon/core/api/grpc/grpcutils.py @@ -229,7 +229,7 @@ def get_config_options( """ results = {} for configuration in configurable_options.configurations(): - value = config[configuration.id] + value = config.get(configuration.id, configuration.default) config_option = common_pb2.ConfigOption( label=configuration.label, name=configuration.id, diff --git a/daemon/core/api/grpc/server.py b/daemon/core/api/grpc/server.py index 81f4335e..55bdc802 100644 --- a/daemon/core/api/grpc/server.py +++ b/daemon/core/api/grpc/server.py @@ -597,6 +597,7 @@ class CoreGrpcServer(core_pb2_grpc.CoreApiServicer): config_service_configs=config_service_configs, mobility_configs=mobility_configs, metadata=session.metadata, + file=session.file_name, ) return core_pb2.GetSessionResponse(session=session_proto) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index bb6c4f95..8a881945 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -22,7 +22,7 @@ from core.api.grpc import ( wlan_pb2, ) from core.gui import appconfig -from core.gui.appconfig import CoreServer, Observer +from core.gui.appconfig import XMLS_PATH, CoreServer, Observer from core.gui.dialogs.emaneinstall import EmaneInstallDialog from core.gui.dialogs.error import ErrorDialog from core.gui.dialogs.mobilityplayer import MobilityPlayer @@ -98,8 +98,6 @@ class CoreClient: self.handling_throughputs: Optional[grpc.Future] = None self.handling_cpu_usage: Optional[grpc.Future] = None self.handling_events: Optional[grpc.Future] = None - self.xml_dir: Optional[str] = None - self.xml_file: Optional[str] = None @property def client(self) -> client.CoreGrpcClient: @@ -311,7 +309,8 @@ class CoreClient: response = self.client.get_session(session_id) self.session = Session.from_proto(response.session) self.client.set_session_user(self.session.id, self.user) - self.master.title(f"CORE Session({self.session.id})") + title_file = self.session.file.name if self.session.file else "" + self.master.title(f"CORE Session({self.session.id}) {title_file}") self.handling_events = self.client.events( self.session.id, self.handle_events ) @@ -586,10 +585,18 @@ class CoreClient: except grpc.RpcError as e: self.app.show_grpc_exception("Node Terminal Error", e) - def save_xml(self, file_path: str) -> None: + def get_xml_dir(self) -> str: + return str(self.session.file.parent) if self.session.file else str(XMLS_PATH) + + def save_xml(self, file_path: str = None) -> None: """ Save core session as to an xml file """ + if not file_path and not self.session.file: + logging.error("trying to save xml for session with no file") + return + if not file_path: + file_path = str(self.session.file) try: if not self.is_runtime(): logging.debug("Send session data to the daemon") @@ -687,34 +694,17 @@ class CoreClient: """ self.client.set_session_state(self.session.id, SessionState.DEFINITION.value) for node in self.session.nodes.values(): - response = self.client.add_node(self.session.id, node.to_proto()) + response = self.client.add_node( + self.session.id, node.to_proto(), source=GUI_SOURCE + ) logging.debug("created node: %s", response) asymmetric_links = [] for edge in self.links.values(): - link = edge.link - response = self.client.add_link( - self.session.id, - link.node1_id, - link.node2_id, - link.iface1, - link.iface2, - link.options, - source=GUI_SOURCE, - ) - logging.debug("created link: %s", response) + self.add_link(edge.link) if edge.asymmetric_link: asymmetric_links.append(edge.asymmetric_link) for link in asymmetric_links: - response = self.client.add_link( - self.session.id, - link.node1_id, - link.node2_id, - link.iface1, - link.iface2, - link.options, - source=GUI_SOURCE, - ) - logging.debug("created asymmetric link: %s", response) + self.add_link(link) def send_data(self) -> None: """ @@ -1057,6 +1047,23 @@ class CoreClient: if response.session_id != -1: self.join_session(response.session_id) + def add_link(self, link: Link) -> None: + iface1 = link.iface1.to_proto() if link.iface1 else None + iface2 = link.iface2.to_proto() if link.iface2 else None + options = link.options.to_proto() if link.options else None + response = self.client.add_link( + self.session.id, + link.node1_id, + link.node2_id, + iface1, + iface2, + options, + source=GUI_SOURCE, + ) + logging.debug("added link: %s", response) + if not response.result: + logging.error("error adding link: %s", link) + def edit_link(self, link: Link) -> None: iface1_id = link.iface1.id if link.iface1 else None iface2_id = link.iface2.id if link.iface2 else None diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index 3f9f3c9b..83e4001a 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -181,8 +181,6 @@ class SessionsDialog(Dialog): def join_session(self, session_id: int) -> None: self.destroy() - if self.app.core.xml_file: - self.app.core.xml_file = None task = ProgressTask( self.app, "Join", self.app.core.join_session, args=(session_id,) ) diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index 3b85ac6f..fd1413b6 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -6,7 +6,6 @@ from functools import partial from tkinter import filedialog, messagebox from typing import TYPE_CHECKING, Optional -from core.gui.appconfig import XMLS_PATH from core.gui.coreclient import CoreClient from core.gui.dialogs.about import AboutDialog from core.gui.dialogs.canvassizeandscale import SizeAndScaleDialog @@ -265,16 +264,13 @@ class Menubar(tk.Menu): ) def click_save(self, _event=None) -> None: - xml_file = self.core.xml_file - if xml_file: - self.core.save_xml(xml_file) + if self.core.session.file: + self.core.save_xml() else: self.click_save_xml() def click_save_xml(self, _event: tk.Event = None) -> None: - init_dir = self.core.xml_dir - if not init_dir: - init_dir = str(XMLS_PATH) + init_dir = self.core.get_xml_dir() file_path = filedialog.asksaveasfilename( initialdir=init_dir, title="Save As", @@ -284,12 +280,9 @@ class Menubar(tk.Menu): if file_path: self.add_recent_file_to_gui_config(file_path) self.core.save_xml(file_path) - self.core.xml_file = file_path def click_open_xml(self, _event: tk.Event = None) -> None: - init_dir = self.core.xml_dir - if not init_dir: - init_dir = str(XMLS_PATH) + init_dir = self.core.get_xml_dir() file_path = filedialog.askopenfilename( initialdir=init_dir, title="Open", @@ -298,12 +291,10 @@ class Menubar(tk.Menu): if file_path: self.open_xml_task(file_path) - def open_xml_task(self, filename: str) -> None: - self.add_recent_file_to_gui_config(filename) - self.core.xml_file = filename - self.core.xml_dir = str(os.path.dirname(filename)) + def open_xml_task(self, file_path: str) -> None: + self.add_recent_file_to_gui_config(file_path) self.prompt_save_running_session() - task = ProgressTask(self.app, "Open XML", self.core.open_xml, args=(filename,)) + task = ProgressTask(self.app, "Open XML", self.core.open_xml, args=(file_path,)) task.start() def execute_python(self) -> None: @@ -357,7 +348,6 @@ class Menubar(tk.Menu): def click_new(self) -> None: self.prompt_save_running_session() self.core.create_new_session() - self.core.xml_file = None def click_find(self, _event: tk.Event = None) -> None: dialog = FindDialog(self.app) diff --git a/daemon/core/gui/wrappers.py b/daemon/core/gui/wrappers.py index d86e20dd..52384fe2 100644 --- a/daemon/core/gui/wrappers.py +++ b/daemon/core/gui/wrappers.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field from enum import Enum +from pathlib import Path from typing import Dict, List, Optional, Set, Tuple from core.api.grpc import common_pb2, configservices_pb2, core_pb2, services_pb2 @@ -581,6 +582,7 @@ class Session: emane_models: List[str] emane_config: Dict[str, ConfigOption] metadata: Dict[str, str] + file: Path @classmethod def from_proto(cls, proto: core_pb2.Session) -> "Session": @@ -616,6 +618,7 @@ class Session: for node_id, mapped_config in proto.mobility_configs.items(): node = nodes[node_id] node.mobility_config = ConfigOption.from_dict(mapped_config.config) + file_path = Path(proto.file) if proto.file else None return Session( id=proto.id, state=SessionState(proto.state), @@ -629,6 +632,7 @@ class Session: emane_models=list(proto.emane_models), emane_config=ConfigOption.from_dict(proto.emane_config), metadata=dict(proto.metadata), + file=file_path, ) diff --git a/daemon/proto/core/api/grpc/core.proto b/daemon/proto/core/api/grpc/core.proto index d5ffda59..4727bbef 100644 --- a/daemon/proto/core/api/grpc/core.proto +++ b/daemon/proto/core/api/grpc/core.proto @@ -726,6 +726,7 @@ message Session { repeated configservices.ConfigServiceConfig config_service_configs = 15; map mobility_configs = 16; map metadata = 17; + string file = 18; } message SessionSummary { From 06563d59539cefc9da2bcb082816a8374cda5f74 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sat, 1 Aug 2020 11:07:11 -0700 Subject: [PATCH 363/929] pygui: fixed issue editing hook with a new name --- daemon/core/gui/dialogs/hooks.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/daemon/core/gui/dialogs/hooks.py b/daemon/core/gui/dialogs/hooks.py index 31ef3e15..ce7caf29 100644 --- a/daemon/core/gui/dialogs/hooks.py +++ b/daemon/core/gui/dialogs/hooks.py @@ -149,13 +149,15 @@ class HooksDialog(Dialog): dialog.set(hook) dialog.show() session.hooks[hook.file] = hook + self.selected = hook.file self.listbox.delete(self.selected_index) self.listbox.insert(self.selected_index, hook.file) + self.listbox.select_set(self.selected_index) def click_delete(self) -> None: session = self.app.core.session del session.hooks[self.selected] - self.listbox.delete(tk.ANCHOR) + self.listbox.delete(self.selected_index) self.edit_button.config(state=tk.DISABLED) self.delete_button.config(state=tk.DISABLED) From 2aeb119b0455b3fee5b41f134db1cca5da481f5d Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 2 Aug 2020 10:03:21 -0700 Subject: [PATCH 364/929] pygui: changes to display both link and asym link options on edges in canvas --- daemon/core/gui/graph/edges.py | 46 ++++++++++++++++++++++------------ daemon/core/gui/utils.py | 12 +++++++++ 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index 610b6cc0..b313957d 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -8,7 +8,7 @@ from core.gui.dialogs.linkconfig import LinkConfigurationDialog from core.gui.frames.link import EdgeInfoFrame, WirelessEdgeInfoFrame from core.gui.graph import tags from core.gui.nodeutils import NodeUtils -from core.gui.utils import bandwidth_text +from core.gui.utils import bandwidth_text, delay_jitter_text from core.gui.wrappers import Interface, Link if TYPE_CHECKING: @@ -419,21 +419,35 @@ class CanvasEdge(Edge): if not self.link.options: return options = self.link.options + asym_options = None + if self.asymmetric_link and self.asymmetric_link.options: + asym_options = self.asymmetric_link.options lines = [] - bandwidth = options.bandwidth - if bandwidth > 0: - lines.append(bandwidth_text(bandwidth)) - delay = options.delay - jitter = options.jitter - if delay > 0 and jitter > 0: - lines.append(f"{delay} us (\u00B1{jitter} us)") - elif jitter > 0: - lines.append(f"0 us (\u00B1{jitter} us)") - loss = options.loss - if loss > 0: - lines.append(f"loss={loss}%") - dup = options.dup - if dup > 0: - lines.append(f"dup={dup}%") + # bandwidth + if options.bandwidth > 0: + bandwidth_line = bandwidth_text(options.bandwidth) + if asym_options and asym_options.bandwidth > 0: + bandwidth_line += f" / {bandwidth_text(asym_options.bandwidth)}" + lines.append(bandwidth_line) + # delay/jitter + dj_line = delay_jitter_text(options.delay, options.jitter) + if dj_line and asym_options: + asym_dj_line = delay_jitter_text(asym_options.delay, asym_options.jitter) + if asym_dj_line: + dj_line += f" / {asym_dj_line}" + if dj_line: + lines.append(dj_line) + # loss + if options.loss > 0: + loss_line = f"loss={options.loss}%" + if asym_options and asym_options.loss > 0: + loss_line += f" / loss={asym_options.loss}%" + lines.append(loss_line) + # duplicate + if options.dup > 0: + dup_line = f"dup={options.dup}%" + if asym_options and asym_options.dup > 0: + dup_line += f" / dup={asym_options.dup}%" + lines.append(dup_line) label = "\n".join(lines) self.middle_label_text(label) diff --git a/daemon/core/gui/utils.py b/daemon/core/gui/utils.py index ee5ad8cb..59171ae9 100644 --- a/daemon/core/gui/utils.py +++ b/daemon/core/gui/utils.py @@ -1,3 +1,6 @@ +from typing import Optional + + def bandwidth_text(bandwidth: int) -> str: size = {0: "bps", 1: "Kbps", 2: "Mbps", 3: "Gbps"} unit = 1000 @@ -8,3 +11,12 @@ def bandwidth_text(bandwidth: int) -> str: if i == 3: break return f"{bandwidth} {size[i]}" + + +def delay_jitter_text(delay: int, jitter: int) -> Optional[str]: + line = None + if delay > 0 and jitter > 0: + line = f"{delay} us (\u00B1{jitter} us)" + elif jitter > 0: + line = f"0 us (\u00B1{jitter} us)" + return line From f0bc3bbc998b6437fc077529de03181663b0c917 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 2 Aug 2020 10:36:14 -0700 Subject: [PATCH 365/929] pygui: updates to leverage tk provided constants for sticky configuration, instead of duplicate strings everywhere --- daemon/core/gui/app.py | 20 ++-- daemon/core/gui/dialogs/about.py | 4 +- daemon/core/gui/dialogs/alerts.py | 16 ++-- daemon/core/gui/dialogs/canvassizeandscale.py | 72 +++++++------- daemon/core/gui/dialogs/canvaswallpaper.py | 28 +++--- daemon/core/gui/dialogs/colorpicker.py | 36 +++---- .../core/gui/dialogs/configserviceconfig.py | 66 ++++++------- daemon/core/gui/dialogs/copyserviceconfig.py | 16 ++-- daemon/core/gui/dialogs/customnodes.py | 46 ++++----- daemon/core/gui/dialogs/dialog.py | 4 +- daemon/core/gui/dialogs/emaneconfig.py | 36 +++---- daemon/core/gui/dialogs/emaneinstall.py | 7 +- daemon/core/gui/dialogs/executepython.py | 16 ++-- daemon/core/gui/dialogs/find.py | 18 ++-- daemon/core/gui/dialogs/hooks.py | 28 +++--- daemon/core/gui/dialogs/ipdialog.py | 26 ++--- daemon/core/gui/dialogs/linkconfig.py | 56 +++++------ daemon/core/gui/dialogs/macdialog.py | 10 +- daemon/core/gui/dialogs/mobilityconfig.py | 9 +- daemon/core/gui/dialogs/mobilityplayer.py | 12 +-- daemon/core/gui/dialogs/nodeconfig.py | 44 ++++----- daemon/core/gui/dialogs/nodeconfigservice.py | 20 ++-- daemon/core/gui/dialogs/nodeservice.py | 20 ++-- daemon/core/gui/dialogs/observers.py | 28 +++--- daemon/core/gui/dialogs/preferences.py | 30 +++--- daemon/core/gui/dialogs/runtool.py | 26 ++--- daemon/core/gui/dialogs/servers.py | 28 +++--- daemon/core/gui/dialogs/serviceconfig.py | 94 +++++++++---------- daemon/core/gui/dialogs/sessionoptions.py | 8 +- daemon/core/gui/dialogs/sessions.py | 18 ++-- daemon/core/gui/dialogs/shapemod.py | 48 +++++----- daemon/core/gui/dialogs/throughput.py | 32 +++---- daemon/core/gui/dialogs/wlanconfig.py | 9 +- daemon/core/gui/frames/link.py | 4 +- daemon/core/gui/frames/node.py | 3 +- daemon/core/gui/statusbar.py | 10 +- daemon/core/gui/task.py | 3 +- daemon/core/gui/toolbar.py | 14 +-- daemon/core/gui/tooltip.py | 2 +- daemon/core/gui/widgets.py | 30 +++--- 40 files changed, 501 insertions(+), 496 deletions(-) diff --git a/daemon/core/gui/app.py b/daemon/core/gui/app.py index 176b31e3..be744bb4 100644 --- a/daemon/core/gui/app.py +++ b/daemon/core/gui/app.py @@ -111,13 +111,13 @@ class Application(ttk.Frame): self.master.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) self.columnconfigure(1, weight=1) - self.grid(sticky="nsew") + self.grid(sticky=tk.NSEW) self.toolbar = Toolbar(self) - self.toolbar.grid(sticky="ns") + self.toolbar.grid(sticky=tk.NS) self.right_frame = ttk.Frame(self) self.right_frame.columnconfigure(0, weight=1) self.right_frame.rowconfigure(0, weight=1) - self.right_frame.grid(row=0, column=1, sticky="nsew") + self.right_frame.grid(row=0, column=1, sticky=tk.NSEW) self.draw_canvas() self.draw_infobar() self.draw_status() @@ -139,21 +139,21 @@ class Application(ttk.Frame): canvas_frame = ttk.Frame(self.right_frame) canvas_frame.rowconfigure(0, weight=1) canvas_frame.columnconfigure(0, weight=1) - canvas_frame.grid(row=0, column=0, sticky="nsew", pady=1) + canvas_frame.grid(row=0, column=0, sticky=tk.NSEW, pady=1) self.canvas = CanvasGraph(canvas_frame, self, self.core) - self.canvas.grid(sticky="nsew") + self.canvas.grid(sticky=tk.NSEW) scroll_y = ttk.Scrollbar(canvas_frame, command=self.canvas.yview) - scroll_y.grid(row=0, column=1, sticky="ns") + scroll_y.grid(row=0, column=1, sticky=tk.NS) scroll_x = ttk.Scrollbar( canvas_frame, orient=tk.HORIZONTAL, command=self.canvas.xview ) - scroll_x.grid(row=1, column=0, sticky="ew") + scroll_x.grid(row=1, column=0, sticky=tk.EW) self.canvas.configure(xscrollcommand=scroll_x.set) self.canvas.configure(yscrollcommand=scroll_y.set) def draw_status(self) -> None: self.statusbar = StatusBar(self.right_frame, self) - self.statusbar.grid(sticky="ew", columnspan=2) + self.statusbar.grid(sticky=tk.EW, columnspan=2) def display_info(self, frame_class: Type[InfoFrameBase], **kwargs: Any) -> None: if not self.show_infobar.get(): @@ -161,7 +161,7 @@ class Application(ttk.Frame): self.clear_info() self.info_frame = frame_class(self.infobar, **kwargs) self.info_frame.draw() - self.info_frame.grid(sticky="nsew") + self.info_frame.grid(sticky=tk.NSEW) def clear_info(self) -> None: if self.info_frame: @@ -174,7 +174,7 @@ class Application(ttk.Frame): def show_info(self) -> None: self.default_info() - self.infobar.grid(row=0, column=1, sticky="nsew") + self.infobar.grid(row=0, column=1, sticky=tk.NSEW) def hide_info(self) -> None: self.infobar.grid_forget() diff --git a/daemon/core/gui/dialogs/about.py b/daemon/core/gui/dialogs/about.py index fa96e218..c932807d 100644 --- a/daemon/core/gui/dialogs/about.py +++ b/daemon/core/gui/dialogs/about.py @@ -46,9 +46,9 @@ class AboutDialog(Dialog): codetext = CodeText(self.top) codetext.text.insert("1.0", LICENSE) codetext.text.config(state=tk.DISABLED) - codetext.grid(sticky="nsew") + codetext.grid(sticky=tk.NSEW) label = ttk.Label( self.top, text="Icons from https://icons8.com", anchor=tk.CENTER ) - label.grid(sticky="ew") + label.grid(sticky=tk.EW) diff --git a/daemon/core/gui/dialogs/alerts.py b/daemon/core/gui/dialogs/alerts.py index fd6d342e..a0193727 100644 --- a/daemon/core/gui/dialogs/alerts.py +++ b/daemon/core/gui/dialogs/alerts.py @@ -30,13 +30,13 @@ class AlertsDialog(Dialog): frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) frame.rowconfigure(0, weight=1) - frame.grid(sticky="nsew", pady=PADY) + frame.grid(sticky=tk.NSEW, pady=PADY) self.tree = ttk.Treeview( frame, columns=("time", "level", "session_id", "node", "source"), show="headings", ) - self.tree.grid(row=0, column=0, sticky="nsew") + self.tree.grid(row=0, column=0, sticky=tk.NSEW) self.tree.column("time", stretch=tk.YES) self.tree.heading("time", text="Time") self.tree.column("level", stretch=tk.YES, width=100) @@ -77,25 +77,25 @@ class AlertsDialog(Dialog): self.tree.tag_configure(notice_name, background="#85e085") yscrollbar = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview) - yscrollbar.grid(row=0, column=1, sticky="ns") + yscrollbar.grid(row=0, column=1, sticky=tk.NS) self.tree.configure(yscrollcommand=yscrollbar.set) xscrollbar = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview) - xscrollbar.grid(row=1, sticky="ew") + xscrollbar.grid(row=1, sticky=tk.EW) self.tree.configure(xscrollcommand=xscrollbar.set) self.codetext = CodeText(self.top) self.codetext.text.config(state=tk.DISABLED, height=11) - self.codetext.grid(sticky="nsew", pady=PADY) + self.codetext.grid(sticky=tk.NSEW, pady=PADY) frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) button = ttk.Button(frame, text="Reset", command=self.reset_alerts) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Close", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def reset_alerts(self) -> None: self.codetext.text.config(state=tk.NORMAL) diff --git a/daemon/core/gui/dialogs/canvassizeandscale.py b/daemon/core/gui/dialogs/canvassizeandscale.py index e8ad6693..e50bf986 100644 --- a/daemon/core/gui/dialogs/canvassizeandscale.py +++ b/daemon/core/gui/dialogs/canvassizeandscale.py @@ -23,7 +23,7 @@ class SizeAndScaleDialog(Dialog): """ super().__init__(app, "Canvas Size and Scale") self.canvas: CanvasGraph = self.app.canvas - self.section_font: font.Font = font.Font(weight="bold") + self.section_font: font.Font = font.Font(weight=font.BOLD) width, height = self.canvas.current_dimensions self.pixel_width: tk.IntVar = tk.IntVar(value=width) self.pixel_height: tk.IntVar = tk.IntVar(value=height) @@ -54,68 +54,68 @@ class SizeAndScaleDialog(Dialog): def draw_size(self) -> None: label_frame = ttk.Labelframe(self.top, text="Size", padding=FRAME_PAD) - label_frame.grid(sticky="ew") + label_frame.grid(sticky=tk.EW) label_frame.columnconfigure(0, weight=1) # draw size row 1 frame = ttk.Frame(label_frame) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) frame.columnconfigure(3, weight=1) label = ttk.Label(frame, text="Width") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) entry = validation.PositiveIntEntry(frame, textvariable=self.pixel_width) - entry.grid(row=0, column=1, sticky="ew", padx=PADX) + entry.grid(row=0, column=1, sticky=tk.EW, padx=PADX) entry.bind("", self.size_scale_keyup) label = ttk.Label(frame, text="x Height") - label.grid(row=0, column=2, sticky="w", padx=PADX) + label.grid(row=0, column=2, sticky=tk.W, padx=PADX) entry = validation.PositiveIntEntry(frame, textvariable=self.pixel_height) - entry.grid(row=0, column=3, sticky="ew", padx=PADX) + entry.grid(row=0, column=3, sticky=tk.EW, padx=PADX) entry.bind("", self.size_scale_keyup) label = ttk.Label(frame, text="Pixels") - label.grid(row=0, column=4, sticky="w") + label.grid(row=0, column=4, sticky=tk.W) # draw size row 2 frame = ttk.Frame(label_frame) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) frame.columnconfigure(3, weight=1) label = ttk.Label(frame, text="Width") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) entry = validation.PositiveFloatEntry( frame, textvariable=self.meters_width, state=tk.DISABLED ) - entry.grid(row=0, column=1, sticky="ew", padx=PADX) + entry.grid(row=0, column=1, sticky=tk.EW, padx=PADX) label = ttk.Label(frame, text="x Height") - label.grid(row=0, column=2, sticky="w", padx=PADX) + label.grid(row=0, column=2, sticky=tk.W, padx=PADX) entry = validation.PositiveFloatEntry( frame, textvariable=self.meters_height, state=tk.DISABLED ) - entry.grid(row=0, column=3, sticky="ew", padx=PADX) + entry.grid(row=0, column=3, sticky=tk.EW, padx=PADX) label = ttk.Label(frame, text="Meters") - label.grid(row=0, column=4, sticky="w") + label.grid(row=0, column=4, sticky=tk.W) def draw_scale(self) -> None: label_frame = ttk.Labelframe(self.top, text="Scale", padding=FRAME_PAD) - label_frame.grid(sticky="ew") + label_frame.grid(sticky=tk.EW) label_frame.columnconfigure(0, weight=1) frame = ttk.Frame(label_frame) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text=f"{PIXEL_SCALE} Pixels =") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) entry = validation.PositiveFloatEntry(frame, textvariable=self.scale) - entry.grid(row=0, column=1, sticky="ew", padx=PADX) + entry.grid(row=0, column=1, sticky=tk.EW, padx=PADX) entry.bind("", self.size_scale_keyup) label = ttk.Label(frame, text="Meters") - label.grid(row=0, column=2, sticky="w") + label.grid(row=0, column=2, sticky=tk.W) def draw_reference_point(self) -> None: label_frame = ttk.Labelframe( self.top, text="Reference Point", padding=FRAME_PAD ) - label_frame.grid(sticky="ew") + label_frame.grid(sticky=tk.EW) label_frame.columnconfigure(0, weight=1) label = ttk.Label( @@ -124,61 +124,61 @@ class SizeAndScaleDialog(Dialog): label.grid() frame = ttk.Frame(label_frame) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) frame.columnconfigure(3, weight=1) label = ttk.Label(frame, text="X") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) entry = validation.PositiveFloatEntry(frame, textvariable=self.x) - entry.grid(row=0, column=1, sticky="ew", padx=PADX) + entry.grid(row=0, column=1, sticky=tk.EW, padx=PADX) label = ttk.Label(frame, text="Y") - label.grid(row=0, column=2, sticky="w", padx=PADX) + label.grid(row=0, column=2, sticky=tk.W, padx=PADX) entry = validation.PositiveFloatEntry(frame, textvariable=self.y) - entry.grid(row=0, column=3, sticky="ew", padx=PADX) + entry.grid(row=0, column=3, sticky=tk.EW, padx=PADX) label = ttk.Label(label_frame, text="Translates To") label.grid() frame = ttk.Frame(label_frame) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) frame.columnconfigure(3, weight=1) frame.columnconfigure(5, weight=1) label = ttk.Label(frame, text="Lat") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) entry = validation.FloatEntry(frame, textvariable=self.lat) - entry.grid(row=0, column=1, sticky="ew", padx=PADX) + entry.grid(row=0, column=1, sticky=tk.EW, padx=PADX) label = ttk.Label(frame, text="Lon") - label.grid(row=0, column=2, sticky="w", padx=PADX) + label.grid(row=0, column=2, sticky=tk.W, padx=PADX) entry = validation.FloatEntry(frame, textvariable=self.lon) - entry.grid(row=0, column=3, sticky="ew", padx=PADX) + entry.grid(row=0, column=3, sticky=tk.EW, padx=PADX) label = ttk.Label(frame, text="Alt") - label.grid(row=0, column=4, sticky="w", padx=PADX) + label.grid(row=0, column=4, sticky=tk.W, padx=PADX) entry = validation.FloatEntry(frame, textvariable=self.alt) - entry.grid(row=0, column=5, sticky="ew") + entry.grid(row=0, column=5, sticky=tk.EW) def draw_save_as_default(self) -> None: button = ttk.Checkbutton( self.top, text="Save as default?", variable=self.save_default ) - button.grid(sticky="w", pady=PADY) + button.grid(sticky=tk.W, pady=PADY) def draw_buttons(self) -> None: frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) button = ttk.Button(frame, text="Apply", command=self.click_apply) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def size_scale_keyup(self, _event: tk.Event) -> None: scale = self.scale.get() diff --git a/daemon/core/gui/dialogs/canvaswallpaper.py b/daemon/core/gui/dialogs/canvaswallpaper.py index 8a1e71d8..629f9f36 100644 --- a/daemon/core/gui/dialogs/canvaswallpaper.py +++ b/daemon/core/gui/dialogs/canvaswallpaper.py @@ -51,7 +51,7 @@ class CanvasWallpaperDialog(Dialog): def draw_image_label(self) -> None: label = ttk.Label(self.top, text="Image filename: ") - label.grid(sticky="ew") + label.grid(sticky=tk.EW) if self.filename.get(): self.draw_preview() @@ -60,17 +60,17 @@ class CanvasWallpaperDialog(Dialog): frame.columnconfigure(0, weight=2) frame.columnconfigure(1, weight=1) frame.columnconfigure(2, weight=1) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW, pady=PADY) entry = ttk.Entry(frame, textvariable=self.filename) entry.focus() - entry.grid(row=0, column=0, sticky="ew", padx=PADX) + entry.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="...", command=self.click_open_image) - button.grid(row=0, column=1, sticky="ew", padx=PADX) + button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Clear", command=self.click_clear) - button.grid(row=0, column=2, sticky="ew") + button.grid(row=0, column=2, sticky=tk.EW) def draw_options(self) -> None: frame = ttk.Frame(self.top) @@ -78,30 +78,30 @@ class CanvasWallpaperDialog(Dialog): frame.columnconfigure(1, weight=1) frame.columnconfigure(2, weight=1) frame.columnconfigure(3, weight=1) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW, pady=PADY) button = ttk.Radiobutton( frame, text="upper-left", value=1, variable=self.scale_option ) - button.grid(row=0, column=0, sticky="ew") + button.grid(row=0, column=0, sticky=tk.EW) self.options.append(button) button = ttk.Radiobutton( frame, text="centered", value=2, variable=self.scale_option ) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) self.options.append(button) button = ttk.Radiobutton( frame, text="scaled", value=3, variable=self.scale_option ) - button.grid(row=0, column=2, sticky="ew") + button.grid(row=0, column=2, sticky=tk.EW) self.options.append(button) button = ttk.Radiobutton( frame, text="titled", value=4, variable=self.scale_option ) - button.grid(row=0, column=3, sticky="ew") + button.grid(row=0, column=3, sticky=tk.EW) self.options.append(button) def draw_additional_options(self) -> None: @@ -111,19 +111,19 @@ class CanvasWallpaperDialog(Dialog): variable=self.adjust_to_dim, command=self.click_adjust_canvas, ) - checkbutton.grid(sticky="ew", padx=PADX) + checkbutton.grid(sticky=tk.EW, padx=PADX, pady=PADY) def draw_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(pady=PADY, sticky="ew") + frame.grid(sticky=tk.EW) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) button = ttk.Button(frame, text="Apply", command=self.click_apply) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_open_image(self) -> None: filename = image_chooser(self, BACKGROUNDS_PATH) diff --git a/daemon/core/gui/dialogs/colorpicker.py b/daemon/core/gui/dialogs/colorpicker.py index 908b8acb..a2f131d4 100644 --- a/daemon/core/gui/dialogs/colorpicker.py +++ b/daemon/core/gui/dialogs/colorpicker.py @@ -48,13 +48,13 @@ class ColorPickerDialog(Dialog): # rgb frames frame = ttk.Frame(self.top) - frame.grid(row=0, column=0, sticky="ew", pady=PADY) + frame.grid(row=0, column=0, sticky=tk.EW, pady=PADY) frame.columnconfigure(2, weight=3) frame.columnconfigure(3, weight=1) label = ttk.Label(frame, text="R") label.grid(row=0, column=0, padx=PADX) self.red_entry = validation.RgbEntry(frame, width=3, textvariable=self.red) - self.red_entry.grid(row=0, column=1, sticky="ew", padx=PADX) + self.red_entry.grid(row=0, column=1, sticky=tk.EW, padx=PADX) scale = ttk.Scale( frame, from_=0, @@ -64,20 +64,20 @@ class ColorPickerDialog(Dialog): variable=self.red_scale, command=lambda x: self.scale_callback(self.red_scale, self.red), ) - scale.grid(row=0, column=2, sticky="ew", padx=PADX) + scale.grid(row=0, column=2, sticky=tk.EW, padx=PADX) self.red_label = ttk.Label( frame, background="#%02x%02x%02x" % (self.red.get(), 0, 0), width=5 ) - self.red_label.grid(row=0, column=3, sticky="ew") + self.red_label.grid(row=0, column=3, sticky=tk.EW) frame = ttk.Frame(self.top) - frame.grid(row=1, column=0, sticky="ew", pady=PADY) + frame.grid(row=1, column=0, sticky=tk.EW, pady=PADY) frame.columnconfigure(2, weight=3) frame.columnconfigure(3, weight=1) label = ttk.Label(frame, text="G") label.grid(row=0, column=0, padx=PADX) self.green_entry = validation.RgbEntry(frame, width=3, textvariable=self.green) - self.green_entry.grid(row=0, column=1, sticky="ew", padx=PADX) + self.green_entry.grid(row=0, column=1, sticky=tk.EW, padx=PADX) scale = ttk.Scale( frame, from_=0, @@ -87,20 +87,20 @@ class ColorPickerDialog(Dialog): variable=self.green_scale, command=lambda x: self.scale_callback(self.green_scale, self.green), ) - scale.grid(row=0, column=2, sticky="ew", padx=PADX) + scale.grid(row=0, column=2, sticky=tk.EW, padx=PADX) self.green_label = ttk.Label( frame, background="#%02x%02x%02x" % (0, self.green.get(), 0), width=5 ) - self.green_label.grid(row=0, column=3, sticky="ew") + self.green_label.grid(row=0, column=3, sticky=tk.EW) frame = ttk.Frame(self.top) - frame.grid(row=2, column=0, sticky="ew", pady=PADY) + frame.grid(row=2, column=0, sticky=tk.EW, pady=PADY) frame.columnconfigure(2, weight=3) frame.columnconfigure(3, weight=1) label = ttk.Label(frame, text="B") label.grid(row=0, column=0, padx=PADX) self.blue_entry = validation.RgbEntry(frame, width=3, textvariable=self.blue) - self.blue_entry.grid(row=0, column=1, sticky="ew", padx=PADX) + self.blue_entry.grid(row=0, column=1, sticky=tk.EW, padx=PADX) scale = ttk.Scale( frame, from_=0, @@ -110,31 +110,31 @@ class ColorPickerDialog(Dialog): variable=self.blue_scale, command=lambda x: self.scale_callback(self.blue_scale, self.blue), ) - scale.grid(row=0, column=2, sticky="ew", padx=PADX) + scale.grid(row=0, column=2, sticky=tk.EW, padx=PADX) self.blue_label = ttk.Label( frame, background="#%02x%02x%02x" % (0, 0, self.blue.get()), width=5 ) - self.blue_label.grid(row=0, column=3, sticky="ew") + self.blue_label.grid(row=0, column=3, sticky=tk.EW) # hex code and color display frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) frame.rowconfigure(1, weight=1) self.hex_entry = validation.HexEntry(frame, textvariable=self.hex) - self.hex_entry.grid(sticky="ew", pady=PADY) + self.hex_entry.grid(sticky=tk.EW, pady=PADY) self.display = tk.Frame(frame, background=self.color, width=100, height=100) - self.display.grid(sticky="nsew") - frame.grid(row=3, column=0, sticky="nsew", pady=PADY) + self.display.grid(sticky=tk.NSEW) + frame.grid(row=3, column=0, sticky=tk.NSEW, pady=PADY) # button frame frame = ttk.Frame(self.top) - frame.grid(row=4, column=0, sticky="ew") + frame.grid(row=4, column=0, sticky=tk.EW) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) button = ttk.Button(frame, text="OK", command=self.button_ok) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def set_bindings(self) -> None: self.red_entry.bind("", lambda x: self.current_focus("rgb")) diff --git a/daemon/core/gui/dialogs/configserviceconfig.py b/daemon/core/gui/dialogs/configserviceconfig.py index f778cf15..a085afd1 100644 --- a/daemon/core/gui/dialogs/configserviceconfig.py +++ b/daemon/core/gui/dialogs/configserviceconfig.py @@ -111,7 +111,7 @@ class ConfigServiceConfigDialog(Dialog): # draw notebook self.notebook = ttk.Notebook(self.top) - self.notebook.grid(sticky="nsew", pady=PADY) + self.notebook.grid(sticky=tk.NSEW, pady=PADY) self.draw_tab_files() if self.config: self.draw_tab_config() @@ -121,7 +121,7 @@ class ConfigServiceConfigDialog(Dialog): def draw_tab_files(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) - tab.grid(sticky="nsew") + tab.grid(sticky=tk.NSEW) tab.columnconfigure(0, weight=1) self.notebook.add(tab, text="Directories/Files") @@ -131,29 +131,29 @@ class ConfigServiceConfigDialog(Dialog): label.grid(pady=PADY) frame = ttk.Frame(tab) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Directories") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) directories_combobox = ttk.Combobox( frame, values=self.directories, state="readonly" ) - directories_combobox.grid(row=0, column=1, sticky="ew", pady=PADY) + directories_combobox.grid(row=0, column=1, sticky=tk.EW, pady=PADY) if self.directories: directories_combobox.current(0) label = ttk.Label(frame, text="Templates") - label.grid(row=1, column=0, sticky="w", padx=PADX) + label.grid(row=1, column=0, sticky=tk.W, padx=PADX) self.templates_combobox = ttk.Combobox( frame, values=self.templates, state="readonly" ) self.templates_combobox.bind( "<>", self.handle_template_changed ) - self.templates_combobox.grid(row=1, column=1, sticky="ew", pady=PADY) + self.templates_combobox.grid(row=1, column=1, sticky=tk.EW, pady=PADY) self.template_text = CodeText(tab) - self.template_text.grid(sticky="nsew") + self.template_text.grid(sticky=tk.NSEW) tab.rowconfigure(self.template_text.grid_info()["row"], weight=1) if self.templates: self.templates_combobox.current(0) @@ -165,13 +165,13 @@ class ConfigServiceConfigDialog(Dialog): def draw_tab_config(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) - tab.grid(sticky="nsew") + tab.grid(sticky=tk.NSEW) tab.columnconfigure(0, weight=1) self.notebook.add(tab, text="Configuration") if self.modes: frame = ttk.Frame(tab) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Modes") label.grid(row=0, column=0, padx=PADX) @@ -179,17 +179,17 @@ class ConfigServiceConfigDialog(Dialog): frame, values=self.modes, state="readonly" ) self.modes_combobox.bind("<>", self.handle_mode_changed) - self.modes_combobox.grid(row=0, column=1, sticky="ew", pady=PADY) + self.modes_combobox.grid(row=0, column=1, sticky=tk.EW, pady=PADY) logging.info("config service config: %s", self.config) self.config_frame = ConfigFrame(tab, self.app, self.config) self.config_frame.draw_config() - self.config_frame.grid(sticky="nsew", pady=PADY) + self.config_frame.grid(sticky=tk.NSEW, pady=PADY) tab.rowconfigure(self.config_frame.grid_info()["row"], weight=1) def draw_tab_startstop(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) - tab.grid(sticky="nsew") + tab.grid(sticky=tk.NSEW) tab.columnconfigure(0, weight=1) for i in range(3): tab.rowconfigure(i, weight=1) @@ -215,12 +215,12 @@ class ConfigServiceConfigDialog(Dialog): commands = self.validation_commands label_frame.columnconfigure(0, weight=1) label_frame.rowconfigure(0, weight=1) - label_frame.grid(row=i, column=0, sticky="nsew", pady=PADY) + label_frame.grid(row=i, column=0, sticky=tk.NSEW, pady=PADY) listbox_scroll = ListboxScroll(label_frame) for command in commands: listbox_scroll.listbox.insert("end", command) listbox_scroll.listbox.config(height=4) - listbox_scroll.grid(sticky="nsew") + listbox_scroll.grid(sticky=tk.NSEW) if i == 0: self.startup_commands_listbox = listbox_scroll.listbox elif i == 1: @@ -230,23 +230,23 @@ class ConfigServiceConfigDialog(Dialog): def draw_tab_validation(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) - tab.grid(sticky="ew") + tab.grid(sticky=tk.EW) tab.columnconfigure(0, weight=1) - self.notebook.add(tab, text="Validation", sticky="nsew") + self.notebook.add(tab, text="Validation", sticky=tk.NSEW) frame = ttk.Frame(tab) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Validation Time") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) self.validation_time_entry = ttk.Entry(frame) self.validation_time_entry.insert("end", self.validation_time) self.validation_time_entry.config(state=tk.DISABLED) - self.validation_time_entry.grid(row=0, column=1, sticky="ew", pady=PADY) + self.validation_time_entry.grid(row=0, column=1, sticky=tk.EW, pady=PADY) label = ttk.Label(frame, text="Validation Mode") - label.grid(row=1, column=0, sticky="w", padx=PADX) + label.grid(row=1, column=0, sticky=tk.W, padx=PADX) if self.validation_mode == ServiceValidationMode.BLOCKING: mode = "BLOCKING" elif self.validation_mode == ServiceValidationMode.NON_BLOCKING: @@ -258,48 +258,48 @@ class ConfigServiceConfigDialog(Dialog): ) self.validation_mode_entry.insert("end", mode) self.validation_mode_entry.config(state=tk.DISABLED) - self.validation_mode_entry.grid(row=1, column=1, sticky="ew", pady=PADY) + self.validation_mode_entry.grid(row=1, column=1, sticky=tk.EW, pady=PADY) label = ttk.Label(frame, text="Validation Period") - label.grid(row=2, column=0, sticky="w", padx=PADX) + label.grid(row=2, column=0, sticky=tk.W, padx=PADX) self.validation_period_entry = ttk.Entry( frame, state=tk.DISABLED, textvariable=self.validation_period ) - self.validation_period_entry.grid(row=2, column=1, sticky="ew", pady=PADY) + self.validation_period_entry.grid(row=2, column=1, sticky=tk.EW, pady=PADY) label_frame = ttk.LabelFrame(tab, text="Executables", padding=FRAME_PAD) - label_frame.grid(sticky="nsew", pady=PADY) + label_frame.grid(sticky=tk.NSEW, pady=PADY) label_frame.columnconfigure(0, weight=1) label_frame.rowconfigure(0, weight=1) listbox_scroll = ListboxScroll(label_frame) - listbox_scroll.grid(sticky="nsew") + listbox_scroll.grid(sticky=tk.NSEW) tab.rowconfigure(listbox_scroll.grid_info()["row"], weight=1) for executable in self.executables: listbox_scroll.listbox.insert("end", executable) label_frame = ttk.LabelFrame(tab, text="Dependencies", padding=FRAME_PAD) - label_frame.grid(sticky="nsew", pady=PADY) + label_frame.grid(sticky=tk.NSEW, pady=PADY) label_frame.columnconfigure(0, weight=1) label_frame.rowconfigure(0, weight=1) listbox_scroll = ListboxScroll(label_frame) - listbox_scroll.grid(sticky="nsew") + listbox_scroll.grid(sticky=tk.NSEW) tab.rowconfigure(listbox_scroll.grid_info()["row"], weight=1) for dependency in self.dependencies: listbox_scroll.listbox.insert("end", dependency) def draw_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(4): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Apply", command=self.click_apply) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Defaults", command=self.click_defaults) - button.grid(row=0, column=1, sticky="ew", padx=PADX) + button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Copy...", command=self.click_copy) - button.grid(row=0, column=2, sticky="ew", padx=PADX) + button.grid(row=0, column=2, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=3, sticky="ew") + button.grid(row=0, column=3, sticky=tk.EW) def click_apply(self) -> None: current_listbox = self.master.current.listbox diff --git a/daemon/core/gui/dialogs/copyserviceconfig.py b/daemon/core/gui/dialogs/copyserviceconfig.py index b60d5a0d..b205e175 100644 --- a/daemon/core/gui/dialogs/copyserviceconfig.py +++ b/daemon/core/gui/dialogs/copyserviceconfig.py @@ -38,10 +38,10 @@ class CopyServiceConfigDialog(Dialog): label = ttk.Label( self.top, text=f"{self.service} - {self.file_name}", anchor=tk.CENTER ) - label.grid(sticky="ew", pady=PADY) + label.grid(sticky=tk.EW, pady=PADY) listbox_scroll = ListboxScroll(self.top) - listbox_scroll.grid(sticky="nsew", pady=PADY) + listbox_scroll.grid(sticky=tk.NSEW, pady=PADY) self.listbox = listbox_scroll.listbox for node in self.app.core.session.nodes.values(): file_configs = node.service_file_configs.get(self.service) @@ -54,15 +54,15 @@ class CopyServiceConfigDialog(Dialog): self.listbox.insert(tk.END, node.name) frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(3): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Copy", command=self.click_copy) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="View", command=self.click_view) - button.grid(row=0, column=1, sticky="ew", padx=PADX) + button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=2, sticky="ew") + button.grid(row=0, column=2, sticky=tk.EW) def click_copy(self) -> None: selection = self.listbox.curselection() @@ -112,8 +112,8 @@ class ViewConfigDialog(Dialog): self.top.columnconfigure(0, weight=1) self.top.rowconfigure(0, weight=1) self.service_data = CodeText(self.top) - self.service_data.grid(sticky="nsew", pady=PADY) + self.service_data.grid(sticky=tk.NSEW, pady=PADY) self.service_data.text.insert(tk.END, self.data) self.service_data.text.config(state=tk.DISABLED) button = ttk.Button(self.top, text="Close", command=self.destroy) - button.grid(sticky="ew") + button.grid(sticky=tk.EW) diff --git a/daemon/core/gui/dialogs/customnodes.py b/daemon/core/gui/dialogs/customnodes.py index df3bafa7..53451ab1 100644 --- a/daemon/core/gui/dialogs/customnodes.py +++ b/daemon/core/gui/dialogs/customnodes.py @@ -34,47 +34,47 @@ class ServicesSelectDialog(Dialog): self.top.rowconfigure(0, weight=1) frame = ttk.LabelFrame(self.top) - frame.grid(stick="nsew", pady=PADY) + frame.grid(stick=tk.NSEW, pady=PADY) frame.rowconfigure(0, weight=1) for i in range(3): frame.columnconfigure(i, weight=1) label_frame = ttk.LabelFrame(frame, text="Groups", padding=FRAME_PAD) - label_frame.grid(row=0, column=0, sticky="nsew") + label_frame.grid(row=0, column=0, sticky=tk.NSEW) label_frame.rowconfigure(0, weight=1) label_frame.columnconfigure(0, weight=1) self.groups = ListboxScroll(label_frame) - self.groups.grid(sticky="nsew") + self.groups.grid(sticky=tk.NSEW) for group in sorted(self.app.core.services): self.groups.listbox.insert(tk.END, group) self.groups.listbox.bind("<>", self.handle_group_change) self.groups.listbox.selection_set(0) label_frame = ttk.LabelFrame(frame, text="Services") - label_frame.grid(row=0, column=1, sticky="nsew") + label_frame.grid(row=0, column=1, sticky=tk.NSEW) label_frame.columnconfigure(0, weight=1) label_frame.rowconfigure(0, weight=1) self.services = CheckboxList( label_frame, self.app, clicked=self.service_clicked, padding=FRAME_PAD ) - self.services.grid(sticky="nsew") + self.services.grid(sticky=tk.NSEW) label_frame = ttk.LabelFrame(frame, text="Selected", padding=FRAME_PAD) - label_frame.grid(row=0, column=2, sticky="nsew") + label_frame.grid(row=0, column=2, sticky=tk.NSEW) label_frame.rowconfigure(0, weight=1) label_frame.columnconfigure(0, weight=1) self.current = ListboxScroll(label_frame) - self.current.grid(sticky="nsew") + self.current.grid(sticky=tk.NSEW) for service in sorted(self.current_services): self.current.listbox.insert(tk.END, service) frame = ttk.Frame(self.top) - frame.grid(stick="ew") + frame.grid(stick=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Save", command=self.destroy) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.click_cancel) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) # trigger group change self.groups.listbox.event_generate("<>") @@ -127,58 +127,58 @@ class CustomNodesDialog(Dialog): def draw_node_config(self) -> None: frame = ttk.LabelFrame(self.top, text="Nodes", padding=FRAME_PAD) - frame.grid(sticky="nsew", pady=PADY) + frame.grid(sticky=tk.NSEW, pady=PADY) frame.columnconfigure(0, weight=1) frame.rowconfigure(0, weight=1) self.nodes_list = ListboxScroll(frame) - self.nodes_list.grid(row=0, column=0, sticky="nsew", padx=PADX) + self.nodes_list.grid(row=0, column=0, sticky=tk.NSEW, padx=PADX) self.nodes_list.listbox.bind("<>", self.handle_node_select) for name in sorted(self.app.core.custom_nodes): self.nodes_list.listbox.insert(tk.END, name) frame = ttk.Frame(frame) - frame.grid(row=0, column=2, sticky="nsew") + frame.grid(row=0, column=2, sticky=tk.NSEW) frame.columnconfigure(0, weight=1) entry = ttk.Entry(frame, textvariable=self.name) - entry.grid(sticky="ew", pady=PADY) + entry.grid(sticky=tk.EW, pady=PADY) self.image_button = ttk.Button( frame, text="Icon", compound=tk.LEFT, command=self.click_icon ) - self.image_button.grid(sticky="ew", pady=PADY) + self.image_button.grid(sticky=tk.EW, pady=PADY) button = ttk.Button(frame, text="Services", command=self.click_services) - button.grid(sticky="ew") + button.grid(sticky=tk.EW) def draw_node_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) for i in range(3): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Create", command=self.click_create) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) self.edit_button = ttk.Button( frame, text="Edit", state=tk.DISABLED, command=self.click_edit ) - self.edit_button.grid(row=0, column=1, sticky="ew", padx=PADX) + self.edit_button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) self.delete_button = ttk.Button( frame, text="Delete", state=tk.DISABLED, command=self.click_delete ) - self.delete_button.grid(row=0, column=2, sticky="ew") + self.delete_button.grid(row=0, column=2, sticky=tk.EW) def draw_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Save", command=self.click_save) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def reset_values(self) -> None: self.name.set("") diff --git a/daemon/core/gui/dialogs/dialog.py b/daemon/core/gui/dialogs/dialog.py index 962170e7..ce05a5d5 100644 --- a/daemon/core/gui/dialogs/dialog.py +++ b/daemon/core/gui/dialogs/dialog.py @@ -30,7 +30,7 @@ class Dialog(tk.Toplevel): self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) self.top: ttk.Frame = ttk.Frame(self, padding=DIALOG_PAD) - self.top.grid(sticky="nsew") + self.top.grid(sticky=tk.NSEW) def show(self) -> None: self.transient(self.master) @@ -44,6 +44,6 @@ class Dialog(tk.Toplevel): def draw_spacer(self, row: int = None) -> None: frame = ttk.Frame(self.top) - frame.grid(row=row, sticky="nsew") + frame.grid(row=row, sticky=tk.NSEW) frame.rowconfigure(0, weight=1) self.top.rowconfigure(frame.grid_info()["row"], weight=1) diff --git a/daemon/core/gui/dialogs/emaneconfig.py b/daemon/core/gui/dialogs/emaneconfig.py index 019eeaa9..f7925d16 100644 --- a/daemon/core/gui/dialogs/emaneconfig.py +++ b/daemon/core/gui/dialogs/emaneconfig.py @@ -33,20 +33,20 @@ class GlobalEmaneDialog(Dialog): self.top, self.app, session.emane_config, self.enabled ) self.config_frame.draw_config() - self.config_frame.grid(sticky="nsew", pady=PADY) + self.config_frame.grid(sticky=tk.NSEW, pady=PADY) self.draw_spacer() self.draw_buttons() def draw_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) state = tk.NORMAL if self.enabled else tk.DISABLED button = ttk.Button(frame, text="Apply", command=self.click_apply, state=state) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_apply(self) -> None: self.config_frame.parse_config() @@ -87,20 +87,20 @@ class EmaneModelDialog(Dialog): self.top.rowconfigure(0, weight=1) self.config_frame = ConfigFrame(self.top, self.app, self.config, self.enabled) self.config_frame.draw_config() - self.config_frame.grid(sticky="nsew", pady=PADY) + self.config_frame.grid(sticky=tk.NSEW, pady=PADY) self.draw_spacer() self.draw_buttons() def draw_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) state = tk.NORMAL if self.enabled else tk.DISABLED button = ttk.Button(frame, text="Apply", command=self.click_apply, state=state) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_apply(self) -> None: self.config_frame.parse_config() @@ -156,30 +156,30 @@ class EmaneConfigDialog(Dialog): ), ) button.image = image - button.grid(sticky="ew", pady=PADY) + button.grid(sticky=tk.EW, pady=PADY) def draw_emane_models(self) -> None: """ create a combobox that has all the known emane models """ frame = ttk.Frame(self.top) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Model") - label.grid(row=0, column=0, sticky="w") + label.grid(row=0, column=0, sticky=tk.W) # create combo box and its binding state = "readonly" if self.enabled else tk.DISABLED combobox = ttk.Combobox( frame, textvariable=self.emane_model, values=self.emane_models, state=state ) - combobox.grid(row=0, column=1, sticky="ew") + combobox.grid(row=0, column=1, sticky=tk.EW) combobox.bind("<>", self.emane_model_change) def draw_emane_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) for i in range(2): frame.columnconfigure(i, weight=1) @@ -192,7 +192,7 @@ class EmaneConfigDialog(Dialog): command=self.click_model_config, ) self.emane_model_button.image = image - self.emane_model_button.grid(row=0, column=0, padx=PADX, sticky="ew") + self.emane_model_button.grid(row=0, column=0, padx=PADX, sticky=tk.EW) image = Images.get(ImageEnum.EDITNODE, 16) button = ttk.Button( @@ -203,18 +203,18 @@ class EmaneConfigDialog(Dialog): command=self.click_emane_config, ) button.image = image - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def draw_apply_and_cancel(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) state = tk.NORMAL if self.enabled else tk.DISABLED button = ttk.Button(frame, text="Apply", command=self.click_apply, state=state) - button.grid(row=0, column=0, padx=PADX, sticky="ew") + button.grid(row=0, column=0, padx=PADX, sticky=tk.EW) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_emane_config(self) -> None: dialog = GlobalEmaneDialog(self, self.app) diff --git a/daemon/core/gui/dialogs/emaneinstall.py b/daemon/core/gui/dialogs/emaneinstall.py index 3ad9396b..9f9f2f5c 100644 --- a/daemon/core/gui/dialogs/emaneinstall.py +++ b/daemon/core/gui/dialogs/emaneinstall.py @@ -1,3 +1,4 @@ +import tkinter as tk import webbrowser from tkinter import ttk @@ -13,13 +14,13 @@ class EmaneInstallDialog(Dialog): def draw(self) -> None: self.top.columnconfigure(0, weight=1) label = ttk.Label(self.top, text="EMANE needs to be installed!") - label.grid(sticky="ew", pady=PADY) + label.grid(sticky=tk.EW, pady=PADY) button = ttk.Button( self.top, text="EMANE Documentation", command=self.click_doc ) - button.grid(sticky="ew", pady=PADY) + button.grid(sticky=tk.EW, pady=PADY) button = ttk.Button(self.top, text="Close", command=self.destroy) - button.grid(sticky="ew") + button.grid(sticky=tk.EW) def click_doc(self) -> None: webbrowser.open_new("https://coreemu.github.io/core/emane.html") diff --git a/daemon/core/gui/dialogs/executepython.py b/daemon/core/gui/dialogs/executepython.py index a4516df1..0bef9dc1 100644 --- a/daemon/core/gui/dialogs/executepython.py +++ b/daemon/core/gui/dialogs/executepython.py @@ -25,13 +25,13 @@ class ExecutePythonDialog(Dialog): frame = ttk.Frame(self.top, padding=FRAME_PAD) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) - frame.grid(row=i, column=0, sticky="nsew") + frame.grid(row=i, column=0, sticky=tk.NSEW) i = i + 1 var = tk.StringVar(value="") self.file_entry = ttk.Entry(frame, textvariable=var) - self.file_entry.grid(row=0, column=0, sticky="ew") + self.file_entry.grid(row=0, column=0, sticky=tk.EW) button = ttk.Button(frame, text="...", command=self.select_file) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) self.top.columnconfigure(0, weight=1) button = ttk.Checkbutton( @@ -40,18 +40,18 @@ class ExecutePythonDialog(Dialog): variable=self.with_options, command=self.add_options, ) - button.grid(row=i, column=0, sticky="ew") + button.grid(row=i, column=0, sticky=tk.EW) i = i + 1 label = ttk.Label( self.top, text="Any command-line options for running the Python script" ) - label.grid(row=i, column=0, sticky="ew") + label.grid(row=i, column=0, sticky=tk.EW) i = i + 1 self.option_entry = ttk.Entry( self.top, textvariable=self.options, state="disabled" ) - self.option_entry.grid(row=i, column=0, sticky="ew") + self.option_entry.grid(row=i, column=0, sticky=tk.EW) i = i + 1 frame = ttk.Frame(self.top, padding=FRAME_PAD) @@ -59,9 +59,9 @@ class ExecutePythonDialog(Dialog): frame.columnconfigure(1, weight=1) frame.grid(row=i, column=0) button = ttk.Button(frame, text="Execute", command=self.script_execute) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew", padx=PADX) + button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) def add_options(self) -> None: if self.with_options.get(): diff --git a/daemon/core/gui/dialogs/find.py b/daemon/core/gui/dialogs/find.py index a4600847..6bfac47b 100644 --- a/daemon/core/gui/dialogs/find.py +++ b/daemon/core/gui/dialogs/find.py @@ -25,25 +25,25 @@ class FindDialog(Dialog): # Find node frame frame = ttk.Frame(self.top, padding=FRAME_PAD) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Find:") label.grid() entry = ttk.Entry(frame, textvariable=self.find_text) - entry.grid(row=0, column=1, sticky="nsew") + entry.grid(row=0, column=1, sticky=tk.NSEW) # node list frame frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) frame.rowconfigure(0, weight=1) - frame.grid(sticky="nsew", pady=PADY) + frame.grid(sticky=tk.NSEW, pady=PADY) self.tree = ttk.Treeview( frame, columns=("nodeid", "name", "location", "detail"), show="headings", selectmode=tk.BROWSE, ) - self.tree.grid(sticky="nsew", pady=PADY) + self.tree.grid(sticky=tk.NSEW, pady=PADY) style = ttk.Style() heading_size = int(self.app.guiconfig.scale * 10) style.configure("Treeview.Heading", font=(None, heading_size, "bold")) @@ -57,21 +57,21 @@ class FindDialog(Dialog): self.tree.heading("detail", text="Detail") self.tree.bind("<>", self.click_select) yscrollbar = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview) - yscrollbar.grid(row=0, column=1, sticky="ns") + yscrollbar.grid(row=0, column=1, sticky=tk.NS) self.tree.configure(yscrollcommand=yscrollbar.set) xscrollbar = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview) - xscrollbar.grid(row=1, sticky="ew") + xscrollbar.grid(row=1, sticky=tk.EW) self.tree.configure(xscrollcommand=xscrollbar.set) # button frame frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) button = ttk.Button(frame, text="Find", command=self.find_node) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.close_dialog) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def clear_treeview_items(self) -> None: """ diff --git a/daemon/core/gui/dialogs/hooks.py b/daemon/core/gui/dialogs/hooks.py index ce7caf29..e831b4f9 100644 --- a/daemon/core/gui/dialogs/hooks.py +++ b/daemon/core/gui/dialogs/hooks.py @@ -27,14 +27,14 @@ class HookDialog(Dialog): # name and states frame = ttk.Frame(self.top) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(0, weight=2) frame.columnconfigure(1, weight=7) frame.columnconfigure(2, weight=1) label = ttk.Label(frame, text="Name") - label.grid(row=0, column=0, sticky="ew", padx=PADX) + label.grid(row=0, column=0, sticky=tk.EW, padx=PADX) entry = ttk.Entry(frame, textvariable=self.name) - entry.grid(row=0, column=1, sticky="ew", padx=PADX) + entry.grid(row=0, column=1, sticky=tk.EW, padx=PADX) values = tuple(x.name for x in SessionState) initial_state = SessionState.RUNTIME.name self.state.set(initial_state) @@ -42,7 +42,7 @@ class HookDialog(Dialog): combobox = ttk.Combobox( frame, textvariable=self.state, values=values, state="readonly" ) - combobox.grid(row=0, column=2, sticky="ew") + combobox.grid(row=0, column=2, sticky=tk.EW) combobox.bind("<>", self.state_change) # data @@ -55,17 +55,17 @@ class HookDialog(Dialog): "# specified state\n" ), ) - self.codetext.grid(sticky="nsew", pady=PADY) + self.codetext.grid(sticky=tk.NSEW, pady=PADY) # button row frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Save", command=lambda: self.save()) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=lambda: self.destroy()) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def state_change(self, event: tk.Event) -> None: if self.editing: @@ -110,7 +110,7 @@ class HooksDialog(Dialog): self.top.rowconfigure(0, weight=1) listbox_scroll = ListboxScroll(self.top) - listbox_scroll.grid(sticky="nsew", pady=PADY) + listbox_scroll.grid(sticky=tk.NSEW, pady=PADY) self.listbox = listbox_scroll.listbox self.listbox.bind("<>", self.select) session = self.app.core.session @@ -118,21 +118,21 @@ class HooksDialog(Dialog): self.listbox.insert(tk.END, file) frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(4): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Create", command=self.click_create) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) self.edit_button = ttk.Button( frame, text="Edit", state=tk.DISABLED, command=self.click_edit ) - self.edit_button.grid(row=0, column=1, sticky="ew", padx=PADX) + self.edit_button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) self.delete_button = ttk.Button( frame, text="Delete", state=tk.DISABLED, command=self.click_delete ) - self.delete_button.grid(row=0, column=2, sticky="ew", padx=PADX) + self.delete_button.grid(row=0, column=2, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=lambda: self.destroy()) - button.grid(row=0, column=3, sticky="ew") + button.grid(row=0, column=3, sticky=tk.EW) def click_create(self) -> None: dialog = HookDialog(self, self.app) diff --git a/daemon/core/gui/dialogs/ipdialog.py b/daemon/core/gui/dialogs/ipdialog.py index 351bfffc..a09ca097 100644 --- a/daemon/core/gui/dialogs/ipdialog.py +++ b/daemon/core/gui/dialogs/ipdialog.py @@ -34,7 +34,7 @@ class IpConfigDialog(Dialog): frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) frame.rowconfigure(0, weight=1) - frame.grid(sticky="nsew", pady=PADY) + frame.grid(sticky=tk.NSEW, pady=PADY) ip4_frame = ttk.LabelFrame(frame, text="IPv4", padding=FRAME_PAD) ip4_frame.columnconfigure(0, weight=1) @@ -42,23 +42,23 @@ class IpConfigDialog(Dialog): ip4_frame.grid(row=0, column=0, stick="nsew") self.ip4_listbox = ListboxScroll(ip4_frame) self.ip4_listbox.listbox.bind("<>", self.select_ip4) - self.ip4_listbox.grid(sticky="nsew", pady=PADY) + self.ip4_listbox.grid(sticky=tk.NSEW, pady=PADY) for index, ip4 in enumerate(self.ip4s): self.ip4_listbox.listbox.insert(tk.END, ip4) if self.ip4 == ip4: self.ip4_listbox.listbox.select_set(index) self.ip4_entry = ttk.Entry(ip4_frame) - self.ip4_entry.grid(sticky="ew", pady=PADY) + self.ip4_entry.grid(sticky=tk.EW, pady=PADY) ip4_button_frame = ttk.Frame(ip4_frame) ip4_button_frame.columnconfigure(0, weight=1) ip4_button_frame.columnconfigure(1, weight=1) - ip4_button_frame.grid(sticky="ew") + ip4_button_frame.grid(sticky=tk.EW) ip4_add = ttk.Button(ip4_button_frame, text="Add", command=self.click_add_ip4) - ip4_add.grid(row=0, column=0, sticky="ew") + ip4_add.grid(row=0, column=0, sticky=tk.EW) ip4_del = ttk.Button( ip4_button_frame, text="Delete", command=self.click_del_ip4 ) - ip4_del.grid(row=0, column=1, sticky="ew") + ip4_del.grid(row=0, column=1, sticky=tk.EW) ip6_frame = ttk.LabelFrame(frame, text="IPv6", padding=FRAME_PAD) ip6_frame.columnconfigure(0, weight=1) @@ -66,23 +66,23 @@ class IpConfigDialog(Dialog): ip6_frame.grid(row=0, column=1, stick="nsew") self.ip6_listbox = ListboxScroll(ip6_frame) self.ip6_listbox.listbox.bind("<>", self.select_ip6) - self.ip6_listbox.grid(sticky="nsew", pady=PADY) + self.ip6_listbox.grid(sticky=tk.NSEW, pady=PADY) for index, ip6 in enumerate(self.ip6s): self.ip6_listbox.listbox.insert(tk.END, ip6) if self.ip6 == ip6: self.ip6_listbox.listbox.select_set(index) self.ip6_entry = ttk.Entry(ip6_frame) - self.ip6_entry.grid(sticky="ew", pady=PADY) + self.ip6_entry.grid(sticky=tk.EW, pady=PADY) ip6_button_frame = ttk.Frame(ip6_frame) ip6_button_frame.columnconfigure(0, weight=1) ip6_button_frame.columnconfigure(1, weight=1) - ip6_button_frame.grid(sticky="ew") + ip6_button_frame.grid(sticky=tk.EW) ip6_add = ttk.Button(ip6_button_frame, text="Add", command=self.click_add_ip6) - ip6_add.grid(row=0, column=0, sticky="ew") + ip6_add.grid(row=0, column=0, sticky=tk.EW) ip6_del = ttk.Button( ip6_button_frame, text="Delete", command=self.click_del_ip6 ) - ip6_del.grid(row=0, column=1, sticky="ew") + ip6_del.grid(row=0, column=1, sticky=tk.EW) # draw buttons frame = ttk.Frame(self.top) @@ -90,9 +90,9 @@ class IpConfigDialog(Dialog): for i in range(2): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Save", command=self.click_save) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_add_ip4(self) -> None: ip4 = self.ip4_entry.get() diff --git a/daemon/core/gui/dialogs/linkconfig.py b/daemon/core/gui/dialogs/linkconfig.py index 1aa2d7f8..a09cfe7f 100644 --- a/daemon/core/gui/dialogs/linkconfig.py +++ b/daemon/core/gui/dialogs/linkconfig.py @@ -73,11 +73,11 @@ class LinkConfigurationDialog(Dialog): label = ttk.Label( self.top, text=f"Link from {source_name} to {dest_name}", anchor=tk.CENTER ) - label.grid(row=0, column=0, sticky="ew", pady=PADY) + label.grid(row=0, column=0, sticky=tk.EW, pady=PADY) frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) - frame.grid(row=1, column=0, sticky="ew", pady=PADY) + frame.grid(row=1, column=0, sticky=tk.EW, pady=PADY) if self.is_symmetric: button = ttk.Button( frame, textvariable=self.symmetry_var, command=self.change_symmetry @@ -86,25 +86,25 @@ class LinkConfigurationDialog(Dialog): button = ttk.Button( frame, textvariable=self.symmetry_var, command=self.change_symmetry ) - button.grid(sticky="ew") + button.grid(sticky=tk.EW) if self.is_symmetric: self.symmetric_frame = self.get_frame() - self.symmetric_frame.grid(row=2, column=0, sticky="ew", pady=PADY) + self.symmetric_frame.grid(row=2, column=0, sticky=tk.EW, pady=PADY) else: self.asymmetric_frame = self.get_frame() - self.asymmetric_frame.grid(row=2, column=0, sticky="ew", pady=PADY) + self.asymmetric_frame.grid(row=2, column=0, sticky=tk.EW, pady=PADY) self.draw_spacer(row=3) frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) - frame.grid(row=4, column=0, sticky="ew") + frame.grid(row=4, column=0, sticky=tk.EW) button = ttk.Button(frame, text="Apply", command=self.click_apply) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def get_frame(self) -> ttk.Frame: frame = ttk.Frame(self.top) @@ -115,76 +115,76 @@ class LinkConfigurationDialog(Dialog): label_name = "Asymmetric Effects: Downstream / Upstream " row = 0 label = ttk.Label(frame, text=label_name, anchor=tk.CENTER) - label.grid(row=row, column=0, columnspan=2, sticky="ew", pady=PADY) + label.grid(row=row, column=0, columnspan=2, sticky=tk.EW, pady=PADY) row = row + 1 label = ttk.Label(frame, text="Bandwidth (bps)") - label.grid(row=row, column=0, sticky="ew") + label.grid(row=row, column=0, sticky=tk.EW) entry = validation.PositiveIntEntry( frame, empty_enabled=False, textvariable=self.bandwidth ) - entry.grid(row=row, column=1, sticky="ew", pady=PADY) + entry.grid(row=row, column=1, sticky=tk.EW, pady=PADY) if not self.is_symmetric: entry = validation.PositiveIntEntry( frame, empty_enabled=False, textvariable=self.down_bandwidth ) - entry.grid(row=row, column=2, sticky="ew", pady=PADY) + entry.grid(row=row, column=2, sticky=tk.EW, pady=PADY) row = row + 1 label = ttk.Label(frame, text="Delay (us)") - label.grid(row=row, column=0, sticky="ew") + label.grid(row=row, column=0, sticky=tk.EW) entry = validation.PositiveIntEntry( frame, empty_enabled=False, textvariable=self.delay ) - entry.grid(row=row, column=1, sticky="ew", pady=PADY) + entry.grid(row=row, column=1, sticky=tk.EW, pady=PADY) if not self.is_symmetric: entry = validation.PositiveIntEntry( frame, empty_enabled=False, textvariable=self.down_delay ) - entry.grid(row=row, column=2, sticky="ew", pady=PADY) + entry.grid(row=row, column=2, sticky=tk.EW, pady=PADY) row = row + 1 label = ttk.Label(frame, text="Jitter (us)") - label.grid(row=row, column=0, sticky="ew") + label.grid(row=row, column=0, sticky=tk.EW) entry = validation.PositiveIntEntry( frame, empty_enabled=False, textvariable=self.jitter ) - entry.grid(row=row, column=1, sticky="ew", pady=PADY) + entry.grid(row=row, column=1, sticky=tk.EW, pady=PADY) if not self.is_symmetric: entry = validation.PositiveIntEntry( frame, empty_enabled=False, textvariable=self.down_jitter ) - entry.grid(row=row, column=2, sticky="ew", pady=PADY) + entry.grid(row=row, column=2, sticky=tk.EW, pady=PADY) row = row + 1 label = ttk.Label(frame, text="Loss (%)") - label.grid(row=row, column=0, sticky="ew") + label.grid(row=row, column=0, sticky=tk.EW) entry = validation.PositiveFloatEntry( frame, empty_enabled=False, textvariable=self.loss ) - entry.grid(row=row, column=1, sticky="ew", pady=PADY) + entry.grid(row=row, column=1, sticky=tk.EW, pady=PADY) if not self.is_symmetric: entry = validation.PositiveFloatEntry( frame, empty_enabled=False, textvariable=self.down_loss ) - entry.grid(row=row, column=2, sticky="ew", pady=PADY) + entry.grid(row=row, column=2, sticky=tk.EW, pady=PADY) row = row + 1 label = ttk.Label(frame, text="Duplicate (%)") - label.grid(row=row, column=0, sticky="ew") + label.grid(row=row, column=0, sticky=tk.EW) entry = validation.PositiveIntEntry( frame, empty_enabled=False, textvariable=self.duplicate ) - entry.grid(row=row, column=1, sticky="ew", pady=PADY) + entry.grid(row=row, column=1, sticky=tk.EW, pady=PADY) if not self.is_symmetric: entry = validation.PositiveIntEntry( frame, empty_enabled=False, textvariable=self.down_duplicate ) - entry.grid(row=row, column=2, sticky="ew", pady=PADY) + entry.grid(row=row, column=2, sticky=tk.EW, pady=PADY) row = row + 1 label = ttk.Label(frame, text="Color") - label.grid(row=row, column=0, sticky="ew") + label.grid(row=row, column=0, sticky=tk.EW) self.color_button = tk.Button( frame, textvariable=self.color, @@ -194,15 +194,15 @@ class LinkConfigurationDialog(Dialog): highlightthickness=0, command=self.click_color, ) - self.color_button.grid(row=row, column=1, sticky="ew", pady=PADY) + self.color_button.grid(row=row, column=1, sticky=tk.EW, pady=PADY) row = row + 1 label = ttk.Label(frame, text="Width") - label.grid(row=row, column=0, sticky="ew") + label.grid(row=row, column=0, sticky=tk.EW) entry = validation.PositiveFloatEntry( frame, empty_enabled=False, textvariable=self.width ) - entry.grid(row=row, column=1, sticky="ew", pady=PADY) + entry.grid(row=row, column=1, sticky=tk.EW, pady=PADY) return frame diff --git a/daemon/core/gui/dialogs/macdialog.py b/daemon/core/gui/dialogs/macdialog.py index 4d89439b..c8cd7f45 100644 --- a/daemon/core/gui/dialogs/macdialog.py +++ b/daemon/core/gui/dialogs/macdialog.py @@ -28,7 +28,7 @@ class MacConfigDialog(Dialog): "provided value below and increment by value in order." ) label = ttk.Label(self.top, text=text) - label.grid(sticky="ew", pady=PADY) + label.grid(sticky=tk.EW, pady=PADY) # draw input frame = ttk.Frame(self.top) @@ -36,9 +36,9 @@ class MacConfigDialog(Dialog): frame.columnconfigure(1, weight=3) frame.grid(stick="ew", pady=PADY) label = ttk.Label(frame, text="Starting MAC") - label.grid(row=0, column=0, sticky="ew", padx=PADX) + label.grid(row=0, column=0, sticky=tk.EW, padx=PADX) entry = ttk.Entry(frame, textvariable=self.mac_var) - entry.grid(row=0, column=1, sticky="ew") + entry.grid(row=0, column=1, sticky=tk.EW) # draw buttons frame = ttk.Frame(self.top) @@ -46,9 +46,9 @@ class MacConfigDialog(Dialog): for i in range(2): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Save", command=self.click_save) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_save(self) -> None: mac = self.mac_var.get() diff --git a/daemon/core/gui/dialogs/mobilityconfig.py b/daemon/core/gui/dialogs/mobilityconfig.py index 857167be..80c3ca22 100644 --- a/daemon/core/gui/dialogs/mobilityconfig.py +++ b/daemon/core/gui/dialogs/mobilityconfig.py @@ -1,6 +1,7 @@ """ mobility configuration """ +import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING, Dict, Optional @@ -37,20 +38,20 @@ class MobilityConfigDialog(Dialog): self.top.rowconfigure(0, weight=1) self.config_frame = ConfigFrame(self.top, self.app, self.config) self.config_frame.draw_config() - self.config_frame.grid(sticky="nsew", pady=PADY) + self.config_frame.grid(sticky=tk.NSEW, pady=PADY) self.draw_apply_buttons() def draw_apply_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Apply", command=self.click_apply) - button.grid(row=0, column=0, padx=PADX, sticky="ew") + button.grid(row=0, column=0, padx=PADX, sticky=tk.EW) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_apply(self) -> None: self.config_frame.parse_config() diff --git a/daemon/core/gui/dialogs/mobilityplayer.py b/daemon/core/gui/dialogs/mobilityplayer.py index 1bee97d2..352a3739 100644 --- a/daemon/core/gui/dialogs/mobilityplayer.py +++ b/daemon/core/gui/dialogs/mobilityplayer.py @@ -74,30 +74,30 @@ class MobilityPlayerDialog(Dialog): file_name = config["file"].value label = ttk.Label(self.top, text=file_name) - label.grid(sticky="ew", pady=PADY) + label.grid(sticky=tk.EW, pady=PADY) self.progressbar = ttk.Progressbar(self.top, mode="indeterminate") - self.progressbar.grid(sticky="ew", pady=PADY) + self.progressbar.grid(sticky=tk.EW, pady=PADY) frame = ttk.Frame(self.top) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) for i in range(3): frame.columnconfigure(i, weight=1) image = self.app.get_icon(ImageEnum.START, ICON_SIZE) self.play_button = ttk.Button(frame, image=image, command=self.click_play) self.play_button.image = image - self.play_button.grid(row=0, column=0, sticky="ew", padx=PADX) + self.play_button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) image = self.app.get_icon(ImageEnum.PAUSE, ICON_SIZE) self.pause_button = ttk.Button(frame, image=image, command=self.click_pause) self.pause_button.image = image - self.pause_button.grid(row=0, column=1, sticky="ew", padx=PADX) + self.pause_button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) image = self.app.get_icon(ImageEnum.STOP, ICON_SIZE) self.stop_button = ttk.Button(frame, image=image, command=self.click_stop) self.stop_button.image = image - self.stop_button.grid(row=0, column=2, sticky="ew", padx=PADX) + self.stop_button.grid(row=0, column=2, sticky=tk.EW, padx=PADX) loop = tk.IntVar(value=int(config["loop"].value == "1")) checkbutton = ttk.Checkbutton( diff --git a/daemon/core/gui/dialogs/nodeconfig.py b/daemon/core/gui/dialogs/nodeconfig.py index 604e933a..d8103283 100644 --- a/daemon/core/gui/dialogs/nodeconfig.py +++ b/daemon/core/gui/dialogs/nodeconfig.py @@ -126,12 +126,12 @@ class NodeConfigDialog(Dialog): # field frame frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) frame.columnconfigure(1, weight=1) # icon field label = ttk.Label(frame, text="Icon") - label.grid(row=row, column=0, sticky="ew", padx=PADX, pady=PADY) + label.grid(row=row, column=0, sticky=tk.EW, padx=PADX, pady=PADY) self.image_button = ttk.Button( frame, text="Icon", @@ -139,49 +139,49 @@ class NodeConfigDialog(Dialog): compound=tk.NONE, command=self.click_icon, ) - self.image_button.grid(row=row, column=1, sticky="ew") + self.image_button.grid(row=row, column=1, sticky=tk.EW) row += 1 # name field label = ttk.Label(frame, text="Name") - label.grid(row=row, column=0, sticky="ew", padx=PADX, pady=PADY) + label.grid(row=row, column=0, sticky=tk.EW, padx=PADX, pady=PADY) entry = validation.NodeNameEntry(frame, textvariable=self.name, state=state) - entry.grid(row=row, column=1, sticky="ew") + entry.grid(row=row, column=1, sticky=tk.EW) row += 1 # node type field if NodeUtils.is_model_node(self.node.type): label = ttk.Label(frame, text="Type") - label.grid(row=row, column=0, sticky="ew", padx=PADX, pady=PADY) + label.grid(row=row, column=0, sticky=tk.EW, padx=PADX, pady=PADY) combobox = ttk.Combobox( frame, textvariable=self.type, values=list(NodeUtils.NODE_MODELS), state=combo_state, ) - combobox.grid(row=row, column=1, sticky="ew") + combobox.grid(row=row, column=1, sticky=tk.EW) row += 1 # container image field if NodeUtils.is_image_node(self.node.type): label = ttk.Label(frame, text="Image") - label.grid(row=row, column=0, sticky="ew", padx=PADX, pady=PADY) + label.grid(row=row, column=0, sticky=tk.EW, padx=PADX, pady=PADY) entry = ttk.Entry(frame, textvariable=self.container_image, state=state) - entry.grid(row=row, column=1, sticky="ew") + entry.grid(row=row, column=1, sticky=tk.EW) row += 1 if NodeUtils.is_container_node(self.node.type): # server - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Server") - label.grid(row=row, column=0, sticky="ew", padx=PADX, pady=PADY) + label.grid(row=row, column=0, sticky=tk.EW, padx=PADX, pady=PADY) servers = ["localhost"] servers.extend(list(sorted(self.app.core.servers.keys()))) combobox = ttk.Combobox( frame, textvariable=self.server, values=servers, state=combo_state ) - combobox.grid(row=row, column=1, sticky="ew") + combobox.grid(row=row, column=1, sticky=tk.EW) row += 1 if NodeUtils.is_rj45_node(self.node.type): @@ -190,7 +190,7 @@ class NodeConfigDialog(Dialog): ifaces = ListboxScroll(frame) ifaces.listbox.config(state=state) ifaces.grid( - row=row, column=0, columnspan=2, sticky="ew", padx=PADX, pady=PADY + row=row, column=0, columnspan=2, sticky=tk.EW, padx=PADX, pady=PADY ) for inf in sorted(response.ifaces[:]): ifaces.listbox.insert(tk.END, inf) @@ -206,13 +206,13 @@ class NodeConfigDialog(Dialog): def draw_ifaces(self) -> None: notebook = ttk.Notebook(self.top) - notebook.grid(sticky="nsew", pady=PADY) + notebook.grid(sticky=tk.NSEW, pady=PADY) self.top.rowconfigure(notebook.grid_info()["row"], weight=1) state = tk.DISABLED if self.app.core.is_runtime() else tk.NORMAL for iface_id in sorted(self.canvas_node.ifaces): iface = self.canvas_node.ifaces[iface_id] tab = ttk.Frame(notebook, padding=FRAME_PAD) - tab.grid(sticky="nsew", pady=PADY) + tab.grid(sticky=tk.NSEW, pady=PADY) tab.columnconfigure(1, weight=1) tab.columnconfigure(2, weight=1) notebook.add(tab, text=iface.name) @@ -226,7 +226,7 @@ class NodeConfigDialog(Dialog): text=f"Configure EMANE {emane_model}", command=lambda: self.click_emane_config(emane_model, iface.id), ) - button.grid(row=row, sticky="ew", columnspan=3, pady=PADY) + button.grid(row=row, sticky=tk.EW, columnspan=3, pady=PADY) row += 1 label = ttk.Label(tab, text="MAC") @@ -243,7 +243,7 @@ class NodeConfigDialog(Dialog): checkbutton.grid(row=row, column=1, padx=PADX) mac = tk.StringVar(value=iface.mac) entry = ttk.Entry(tab, textvariable=mac, state=mac_state) - entry.grid(row=row, column=2, sticky="ew") + entry.grid(row=row, column=2, sticky=tk.EW) func = partial(mac_auto, is_auto, entry, mac) checkbutton.config(command=func) row += 1 @@ -255,7 +255,7 @@ class NodeConfigDialog(Dialog): ip4_net = f"{iface.ip4}/{iface.ip4_mask}" ip4 = tk.StringVar(value=ip4_net) entry = ttk.Entry(tab, textvariable=ip4, state=state) - entry.grid(row=row, column=1, columnspan=2, sticky="ew") + entry.grid(row=row, column=1, columnspan=2, sticky=tk.EW) row += 1 label = ttk.Label(tab, text="IPv6") @@ -265,21 +265,21 @@ class NodeConfigDialog(Dialog): ip6_net = f"{iface.ip6}/{iface.ip6_mask}" ip6 = tk.StringVar(value=ip6_net) entry = ttk.Entry(tab, textvariable=ip6, state=state) - entry.grid(row=row, column=1, columnspan=2, sticky="ew") + entry.grid(row=row, column=1, columnspan=2, sticky=tk.EW) self.ifaces[iface.id] = InterfaceData(is_auto, mac, ip4, ip6) def draw_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) button = ttk.Button(frame, text="Apply", command=self.click_apply) - button.grid(row=0, column=0, padx=PADX, sticky="ew") + button.grid(row=0, column=0, padx=PADX, sticky=tk.EW) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_emane_config(self, emane_model: str, iface_id: int) -> None: dialog = EmaneModelDialog( diff --git a/daemon/core/gui/dialogs/nodeconfigservice.py b/daemon/core/gui/dialogs/nodeconfigservice.py index dee34f71..1c67e4b3 100644 --- a/daemon/core/gui/dialogs/nodeconfigservice.py +++ b/daemon/core/gui/dialogs/nodeconfigservice.py @@ -41,32 +41,32 @@ class NodeConfigServiceDialog(Dialog): for i in range(3): frame.columnconfigure(i, weight=1) label_frame = ttk.LabelFrame(frame, text="Groups", padding=FRAME_PAD) - label_frame.grid(row=0, column=0, sticky="nsew") + label_frame.grid(row=0, column=0, sticky=tk.NSEW) label_frame.rowconfigure(0, weight=1) label_frame.columnconfigure(0, weight=1) self.groups = ListboxScroll(label_frame) - self.groups.grid(sticky="nsew") + self.groups.grid(sticky=tk.NSEW) for group in sorted(self.app.core.config_services_groups): self.groups.listbox.insert(tk.END, group) self.groups.listbox.bind("<>", self.handle_group_change) self.groups.listbox.selection_set(0) label_frame = ttk.LabelFrame(frame, text="Services") - label_frame.grid(row=0, column=1, sticky="nsew") + label_frame.grid(row=0, column=1, sticky=tk.NSEW) label_frame.columnconfigure(0, weight=1) label_frame.rowconfigure(0, weight=1) self.services = CheckboxList( label_frame, self.app, clicked=self.service_clicked, padding=FRAME_PAD ) - self.services.grid(sticky="nsew") + self.services.grid(sticky=tk.NSEW) label_frame = ttk.LabelFrame(frame, text="Selected", padding=FRAME_PAD) - label_frame.grid(row=0, column=2, sticky="nsew") + label_frame.grid(row=0, column=2, sticky=tk.NSEW) label_frame.rowconfigure(0, weight=1) label_frame.columnconfigure(0, weight=1) self.current = ListboxScroll(label_frame) - self.current.grid(sticky="nsew") + self.current.grid(sticky=tk.NSEW) self.draw_current_services() frame = ttk.Frame(self.top) @@ -74,13 +74,13 @@ class NodeConfigServiceDialog(Dialog): for i in range(4): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Configure", command=self.click_configure) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Save", command=self.click_save) - button.grid(row=0, column=1, sticky="ew", padx=PADX) + button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Remove", command=self.click_remove) - button.grid(row=0, column=2, sticky="ew", padx=PADX) + button.grid(row=0, column=2, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.click_cancel) - button.grid(row=0, column=3, sticky="ew") + button.grid(row=0, column=3, sticky=tk.EW) # trigger group change self.handle_group_change() diff --git a/daemon/core/gui/dialogs/nodeservice.py b/daemon/core/gui/dialogs/nodeservice.py index a56736d5..5ec78a93 100644 --- a/daemon/core/gui/dialogs/nodeservice.py +++ b/daemon/core/gui/dialogs/nodeservice.py @@ -37,31 +37,31 @@ class NodeServiceDialog(Dialog): for i in range(3): frame.columnconfigure(i, weight=1) label_frame = ttk.LabelFrame(frame, text="Groups", padding=FRAME_PAD) - label_frame.grid(row=0, column=0, sticky="nsew") + label_frame.grid(row=0, column=0, sticky=tk.NSEW) label_frame.rowconfigure(0, weight=1) label_frame.columnconfigure(0, weight=1) self.groups = ListboxScroll(label_frame) - self.groups.grid(sticky="nsew") + self.groups.grid(sticky=tk.NSEW) for group in sorted(self.app.core.services): self.groups.listbox.insert(tk.END, group) self.groups.listbox.bind("<>", self.handle_group_change) self.groups.listbox.selection_set(0) label_frame = ttk.LabelFrame(frame, text="Services") - label_frame.grid(row=0, column=1, sticky="nsew") + label_frame.grid(row=0, column=1, sticky=tk.NSEW) label_frame.columnconfigure(0, weight=1) label_frame.rowconfigure(0, weight=1) self.services = CheckboxList( label_frame, self.app, clicked=self.service_clicked, padding=FRAME_PAD ) - self.services.grid(sticky="nsew") + self.services.grid(sticky=tk.NSEW) label_frame = ttk.LabelFrame(frame, text="Selected", padding=FRAME_PAD) - label_frame.grid(row=0, column=2, sticky="nsew") + label_frame.grid(row=0, column=2, sticky=tk.NSEW) label_frame.rowconfigure(0, weight=1) label_frame.columnconfigure(0, weight=1) self.current = ListboxScroll(label_frame) - self.current.grid(sticky="nsew") + self.current.grid(sticky=tk.NSEW) for service in sorted(self.current_services): self.current.listbox.insert(tk.END, service) if self.is_custom_service(service): @@ -72,13 +72,13 @@ class NodeServiceDialog(Dialog): for i in range(4): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Configure", command=self.click_configure) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Save", command=self.click_save) - button.grid(row=0, column=1, sticky="ew", padx=PADX) + button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Remove", command=self.click_remove) - button.grid(row=0, column=2, sticky="ew", padx=PADX) + button.grid(row=0, column=2, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=3, sticky="ew") + button.grid(row=0, column=3, sticky=tk.EW) # trigger group change self.handle_group_change() diff --git a/daemon/core/gui/dialogs/observers.py b/daemon/core/gui/dialogs/observers.py index 286fc2c9..b815d45b 100644 --- a/daemon/core/gui/dialogs/observers.py +++ b/daemon/core/gui/dialogs/observers.py @@ -33,60 +33,60 @@ class ObserverDialog(Dialog): def draw_listbox(self) -> None: listbox_scroll = ListboxScroll(self.top) - listbox_scroll.grid(sticky="nsew", pady=PADY) + listbox_scroll.grid(sticky=tk.NSEW, pady=PADY) listbox_scroll.columnconfigure(0, weight=1) listbox_scroll.rowconfigure(0, weight=1) self.observers = listbox_scroll.listbox - self.observers.grid(row=0, column=0, sticky="nsew") + self.observers.grid(row=0, column=0, sticky=tk.NSEW) self.observers.bind("<>", self.handle_observer_change) for name in sorted(self.app.core.custom_observers): self.observers.insert(tk.END, name) def draw_form_fields(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Name") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) entry = ttk.Entry(frame, textvariable=self.name) - entry.grid(row=0, column=1, sticky="ew") + entry.grid(row=0, column=1, sticky=tk.EW) label = ttk.Label(frame, text="Command") - label.grid(row=1, column=0, sticky="w", padx=PADX) + label.grid(row=1, column=0, sticky=tk.W, padx=PADX) entry = ttk.Entry(frame, textvariable=self.cmd) - entry.grid(row=1, column=1, sticky="ew") + entry.grid(row=1, column=1, sticky=tk.EW) def draw_config_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) for i in range(3): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Create", command=self.click_create) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) self.save_button = ttk.Button( frame, text="Save", state=tk.DISABLED, command=self.click_save ) - self.save_button.grid(row=0, column=1, sticky="ew", padx=PADX) + self.save_button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) self.delete_button = ttk.Button( frame, text="Delete", state=tk.DISABLED, command=self.click_delete ) - self.delete_button.grid(row=0, column=2, sticky="ew") + self.delete_button.grid(row=0, column=2, sticky=tk.EW) def draw_apply_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Save", command=self.click_save_config) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_save_config(self) -> None: self.app.guiconfig.observers.clear() diff --git a/daemon/core/gui/dialogs/preferences.py b/daemon/core/gui/dialogs/preferences.py index 839ebd3b..d0c58dfa 100644 --- a/daemon/core/gui/dialogs/preferences.py +++ b/daemon/core/gui/dialogs/preferences.py @@ -34,42 +34,42 @@ class PreferencesDialog(Dialog): def draw_preferences(self) -> None: frame = ttk.LabelFrame(self.top, text="Preferences", padding=FRAME_PAD) - frame.grid(sticky="nsew", pady=PADY) + frame.grid(sticky=tk.NSEW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Theme") - label.grid(row=0, column=0, pady=PADY, padx=PADX, sticky="w") + label.grid(row=0, column=0, pady=PADY, padx=PADX, sticky=tk.W) themes = self.app.style.theme_names() combobox = ttk.Combobox( frame, textvariable=self.theme, values=themes, state="readonly" ) combobox.set(self.theme.get()) - combobox.grid(row=0, column=1, sticky="ew") + combobox.grid(row=0, column=1, sticky=tk.EW) combobox.bind("<>", self.theme_change) label = ttk.Label(frame, text="Editor") - label.grid(row=1, column=0, pady=PADY, padx=PADX, sticky="w") + label.grid(row=1, column=0, pady=PADY, padx=PADX, sticky=tk.W) combobox = ttk.Combobox( frame, textvariable=self.editor, values=appconfig.EDITORS, state="readonly" ) - combobox.grid(row=1, column=1, sticky="ew") + combobox.grid(row=1, column=1, sticky=tk.EW) label = ttk.Label(frame, text="Terminal") - label.grid(row=2, column=0, pady=PADY, padx=PADX, sticky="w") + label.grid(row=2, column=0, pady=PADY, padx=PADX, sticky=tk.W) terminals = sorted(appconfig.TERMINALS.values()) combobox = ttk.Combobox(frame, textvariable=self.terminal, values=terminals) - combobox.grid(row=2, column=1, sticky="ew") + combobox.grid(row=2, column=1, sticky=tk.EW) label = ttk.Label(frame, text="3D GUI") - label.grid(row=3, column=0, pady=PADY, padx=PADX, sticky="w") + label.grid(row=3, column=0, pady=PADY, padx=PADX, sticky=tk.W) entry = ttk.Entry(frame, textvariable=self.gui3d) - entry.grid(row=3, column=1, sticky="ew") + entry.grid(row=3, column=1, sticky=tk.EW) label = ttk.Label(frame, text="Scaling") - label.grid(row=4, column=0, pady=PADY, padx=PADX, sticky="w") + label.grid(row=4, column=0, pady=PADY, padx=PADX, sticky=tk.W) scale_frame = ttk.Frame(frame) - scale_frame.grid(row=4, column=1, sticky="ew") + scale_frame.grid(row=4, column=1, sticky=tk.EW) scale_frame.columnconfigure(0, weight=1) scale = ttk.Scale( scale_frame, @@ -79,7 +79,7 @@ class PreferencesDialog(Dialog): orient=tk.HORIZONTAL, variable=self.gui_scale, ) - scale.grid(row=0, column=0, sticky="ew") + scale.grid(row=0, column=0, sticky=tk.EW) entry = validation.AppScaleEntry( scale_frame, textvariable=self.gui_scale, width=4 ) @@ -90,15 +90,15 @@ class PreferencesDialog(Dialog): def draw_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Save", command=self.click_save) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def theme_change(self, event: tk.Event) -> None: theme = self.theme.get() diff --git a/daemon/core/gui/dialogs/runtool.py b/daemon/core/gui/dialogs/runtool.py index e36c4c9a..45e21182 100644 --- a/daemon/core/gui/dialogs/runtool.py +++ b/daemon/core/gui/dialogs/runtool.py @@ -38,56 +38,56 @@ class RunToolDialog(Dialog): def draw_command_frame(self) -> None: # the main frame frame = ttk.Frame(self.top) - frame.grid(row=0, column=0, sticky="nsew", padx=PADX) + frame.grid(row=0, column=0, sticky=tk.NSEW, padx=PADX) frame.columnconfigure(0, weight=1) frame.rowconfigure(1, weight=1) labeled_frame = ttk.LabelFrame(frame, text="Command", padding=FRAME_PAD) - labeled_frame.grid(sticky="ew", pady=PADY) + labeled_frame.grid(sticky=tk.EW, pady=PADY) labeled_frame.rowconfigure(0, weight=1) labeled_frame.columnconfigure(0, weight=1) entry = ttk.Entry(labeled_frame, textvariable=self.cmd) - entry.grid(sticky="ew") + entry.grid(sticky=tk.EW) # results frame labeled_frame = ttk.LabelFrame(frame, text="Output", padding=FRAME_PAD) - labeled_frame.grid(sticky="nsew", pady=PADY) + labeled_frame.grid(sticky=tk.NSEW, pady=PADY) labeled_frame.columnconfigure(0, weight=1) labeled_frame.rowconfigure(0, weight=1) self.result = CodeText(labeled_frame) self.result.text.config(state=tk.DISABLED, height=15) - self.result.grid(sticky="nsew", pady=PADY) + self.result.grid(sticky=tk.NSEW, pady=PADY) button_frame = ttk.Frame(labeled_frame) - button_frame.grid(sticky="nsew") + button_frame.grid(sticky=tk.NSEW) button_frame.columnconfigure(0, weight=1) button_frame.columnconfigure(1, weight=1) button = ttk.Button(button_frame, text="Run", command=self.click_run) - button.grid(sticky="ew", padx=PADX) + button.grid(sticky=tk.EW, padx=PADX) button = ttk.Button(button_frame, text="Close", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def draw_nodes_frame(self) -> None: labeled_frame = ttk.LabelFrame(self.top, text="Nodes", padding=FRAME_PAD) - labeled_frame.grid(row=0, column=1, sticky="nsew") + labeled_frame.grid(row=0, column=1, sticky=tk.NSEW) labeled_frame.columnconfigure(0, weight=1) labeled_frame.rowconfigure(0, weight=1) self.node_list = ListboxScroll(labeled_frame) self.node_list.listbox.config(selectmode=tk.MULTIPLE) - self.node_list.grid(sticky="nsew", pady=PADY) + self.node_list.grid(sticky=tk.NSEW, pady=PADY) for n in sorted(self.executable_nodes.keys()): self.node_list.listbox.insert(tk.END, n) button_frame = ttk.Frame(labeled_frame, padding=FRAME_PAD) - button_frame.grid(sticky="nsew") + button_frame.grid(sticky=tk.NSEW) button_frame.columnconfigure(0, weight=1) button_frame.columnconfigure(1, weight=1) button = ttk.Button(button_frame, text="All", command=self.click_all) - button.grid(sticky="nsew", padx=PADX) + button.grid(sticky=tk.NSEW, padx=PADX) button = ttk.Button(button_frame, text="None", command=self.click_none) - button.grid(row=0, column=1, sticky="nsew") + button.grid(row=0, column=1, sticky=tk.NSEW) def click_all(self) -> None: self.node_list.listbox.selection_set(0, self.node_list.listbox.size() - 1) diff --git a/daemon/core/gui/dialogs/servers.py b/daemon/core/gui/dialogs/servers.py index 45121a20..38efad22 100644 --- a/daemon/core/gui/dialogs/servers.py +++ b/daemon/core/gui/dialogs/servers.py @@ -37,12 +37,12 @@ class ServersDialog(Dialog): def draw_servers(self) -> None: listbox_scroll = ListboxScroll(self.top) - listbox_scroll.grid(pady=PADY, sticky="nsew") + listbox_scroll.grid(pady=PADY, sticky=tk.NSEW) listbox_scroll.columnconfigure(0, weight=1) listbox_scroll.rowconfigure(0, weight=1) self.servers = listbox_scroll.listbox - self.servers.grid(row=0, column=0, sticky="nsew") + self.servers.grid(row=0, column=0, sticky=tk.NSEW) self.servers.bind("<>", self.handle_server_change) for server in self.app.core.servers: @@ -50,52 +50,52 @@ class ServersDialog(Dialog): def draw_server_configuration(self) -> None: frame = ttk.LabelFrame(self.top, text="Server Configuration", padding=FRAME_PAD) - frame.grid(pady=PADY, sticky="ew") + frame.grid(pady=PADY, sticky=tk.EW) frame.columnconfigure(1, weight=1) frame.columnconfigure(3, weight=1) label = ttk.Label(frame, text="Name") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) entry = ttk.Entry(frame, textvariable=self.name) - entry.grid(row=0, column=1, sticky="ew") + entry.grid(row=0, column=1, sticky=tk.EW) label = ttk.Label(frame, text="Address") - label.grid(row=0, column=2, sticky="w", padx=PADX) + label.grid(row=0, column=2, sticky=tk.W, padx=PADX) entry = ttk.Entry(frame, textvariable=self.address) - entry.grid(row=0, column=3, sticky="ew") + entry.grid(row=0, column=3, sticky=tk.EW) def draw_servers_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(pady=PADY, sticky="ew") + frame.grid(pady=PADY, sticky=tk.EW) for i in range(3): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Create", command=self.click_create) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) self.save_button = ttk.Button( frame, text="Save", state=tk.DISABLED, command=self.click_save ) - self.save_button.grid(row=0, column=1, sticky="ew", padx=PADX) + self.save_button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) self.delete_button = ttk.Button( frame, text="Delete", state=tk.DISABLED, command=self.click_delete ) - self.delete_button.grid(row=0, column=2, sticky="ew") + self.delete_button.grid(row=0, column=2, sticky=tk.EW) def draw_apply_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) button = ttk.Button( frame, text="Save Configuration", command=self.click_save_configuration ) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_save_configuration(self): self.app.guiconfig.servers.clear() diff --git a/daemon/core/gui/dialogs/serviceconfig.py b/daemon/core/gui/dialogs/serviceconfig.py index 13be0bcd..a22b1afd 100644 --- a/daemon/core/gui/dialogs/serviceconfig.py +++ b/daemon/core/gui/dialogs/serviceconfig.py @@ -119,16 +119,16 @@ class ServiceConfigDialog(Dialog): # draw metadata frame = ttk.Frame(self.top) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Meta-data") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) self.metadata_entry = ttk.Entry(frame, textvariable=self.metadata) - self.metadata_entry.grid(row=0, column=1, sticky="ew") + self.metadata_entry.grid(row=0, column=1, sticky=tk.EW) # draw notebook self.notebook = ttk.Notebook(self.top) - self.notebook.grid(sticky="nsew", pady=PADY) + self.notebook.grid(sticky=tk.NSEW, pady=PADY) self.draw_tab_files() self.draw_tab_directories() self.draw_tab_startstop() @@ -138,7 +138,7 @@ class ServiceConfigDialog(Dialog): def draw_tab_files(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) - tab.grid(sticky="nsew") + tab.grid(sticky=tk.NSEW) tab.columnconfigure(0, weight=1) self.notebook.add(tab, text="Files") @@ -148,15 +148,15 @@ class ServiceConfigDialog(Dialog): label.grid() frame = ttk.Frame(tab) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="File Name") - label.grid(row=0, column=0, padx=PADX, sticky="w") + label.grid(row=0, column=0, padx=PADX, sticky=tk.W) self.filename_combobox = ttk.Combobox(frame, values=self.filenames) self.filename_combobox.bind( "<>", self.display_service_file_data ) - self.filename_combobox.grid(row=0, column=1, sticky="ew", padx=PADX) + self.filename_combobox.grid(row=0, column=1, sticky=tk.EW, padx=PADX) button = ttk.Button( frame, image=self.documentnew_img, command=self.add_filename ) @@ -167,7 +167,7 @@ class ServiceConfigDialog(Dialog): button.grid(row=0, column=3) frame = ttk.Frame(tab) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) button = ttk.Radiobutton( frame, @@ -176,16 +176,16 @@ class ServiceConfigDialog(Dialog): value=1, state=tk.DISABLED, ) - button.grid(row=0, column=0, sticky="w", padx=PADX) + button.grid(row=0, column=0, sticky=tk.W, padx=PADX) entry = ttk.Entry(frame, state=tk.DISABLED) - entry.grid(row=0, column=1, sticky="ew", padx=PADX) + entry.grid(row=0, column=1, sticky=tk.EW, padx=PADX) image = Images.get(ImageEnum.FILEOPEN, 16) button = ttk.Button(frame, image=image) button.image = image button.grid(row=0, column=2) frame = ttk.Frame(tab) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(0, weight=1) button = ttk.Radiobutton( frame, @@ -193,7 +193,7 @@ class ServiceConfigDialog(Dialog): text="Use text below for file contents", value=2, ) - button.grid(row=0, column=0, sticky="ew") + button.grid(row=0, column=0, sticky=tk.EW) image = Images.get(ImageEnum.FILEOPEN, 16) button = ttk.Button(frame, image=image) button.image = image @@ -204,7 +204,7 @@ class ServiceConfigDialog(Dialog): button.grid(row=0, column=2) self.service_file_data = CodeText(tab) - self.service_file_data.grid(sticky="nsew") + self.service_file_data.grid(sticky=tk.NSEW) tab.rowconfigure(self.service_file_data.grid_info()["row"], weight=1) if len(self.filenames) > 0: self.filename_combobox.current(0) @@ -218,7 +218,7 @@ class ServiceConfigDialog(Dialog): def draw_tab_directories(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) - tab.grid(sticky="nsew") + tab.grid(sticky=tk.NSEW) tab.columnconfigure(0, weight=1) tab.rowconfigure(2, weight=1) self.notebook.add(tab, text="Directories") @@ -227,33 +227,33 @@ class ServiceConfigDialog(Dialog): tab, text="Directories required by this service that are unique for each node.", ) - label.grid(row=0, column=0, sticky="ew") + label.grid(row=0, column=0, sticky=tk.EW) frame = ttk.Frame(tab, padding=FRAME_PAD) frame.columnconfigure(0, weight=1) - frame.grid(row=1, column=0, sticky="nsew") + frame.grid(row=1, column=0, sticky=tk.NSEW) var = tk.StringVar(value="") self.directory_entry = ttk.Entry(frame, textvariable=var) - self.directory_entry.grid(row=0, column=0, sticky="ew", padx=PADX) + self.directory_entry.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="...", command=self.find_directory_button) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) self.dir_list = ListboxScroll(tab) - self.dir_list.grid(row=2, column=0, sticky="nsew", pady=PADY) + self.dir_list.grid(row=2, column=0, sticky=tk.NSEW, pady=PADY) self.dir_list.listbox.bind("<>", self.directory_select) for d in self.temp_directories: self.dir_list.listbox.insert("end", d) frame = ttk.Frame(tab) - frame.grid(row=3, column=0, sticky="nsew") + frame.grid(row=3, column=0, sticky=tk.NSEW) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) button = ttk.Button(frame, text="Add", command=self.add_directory) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Remove", command=self.remove_directory) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def draw_tab_startstop(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) - tab.grid(sticky="nsew") + tab.grid(sticky=tk.NSEW) tab.columnconfigure(0, weight=1) for i in range(3): tab.rowconfigure(i, weight=1) @@ -279,25 +279,25 @@ class ServiceConfigDialog(Dialog): commands = self.validation_commands label_frame.columnconfigure(0, weight=1) label_frame.rowconfigure(1, weight=1) - label_frame.grid(row=i, column=0, sticky="nsew", pady=PADY) + label_frame.grid(row=i, column=0, sticky=tk.NSEW, pady=PADY) frame = ttk.Frame(label_frame) - frame.grid(row=0, column=0, sticky="nsew", pady=PADY) + frame.grid(row=0, column=0, sticky=tk.NSEW, pady=PADY) frame.columnconfigure(0, weight=1) entry = ttk.Entry(frame, textvariable=tk.StringVar()) entry.grid(row=0, column=0, stick="ew", padx=PADX) button = ttk.Button(frame, image=self.documentnew_img) button.bind("", self.add_command) - button.grid(row=0, column=1, sticky="ew", padx=PADX) + button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, image=self.editdelete_img) - button.grid(row=0, column=2, sticky="ew") + button.grid(row=0, column=2, sticky=tk.EW) button.bind("", self.delete_command) listbox_scroll = ListboxScroll(label_frame) listbox_scroll.listbox.bind("<>", self.update_entry) for command in commands: listbox_scroll.listbox.insert("end", command) listbox_scroll.listbox.config(height=4) - listbox_scroll.grid(row=1, column=0, sticky="nsew") + listbox_scroll.grid(row=1, column=0, sticky=tk.NSEW) if i == 0: self.startup_commands_listbox = listbox_scroll.listbox elif i == 1: @@ -307,23 +307,23 @@ class ServiceConfigDialog(Dialog): def draw_tab_configuration(self) -> None: tab = ttk.Frame(self.notebook, padding=FRAME_PAD) - tab.grid(sticky="nsew") + tab.grid(sticky=tk.NSEW) tab.columnconfigure(0, weight=1) - self.notebook.add(tab, text="Configuration", sticky="nsew") + self.notebook.add(tab, text="Configuration", sticky=tk.NSEW) frame = ttk.Frame(tab) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Validation Time") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) self.validation_time_entry = ttk.Entry(frame) self.validation_time_entry.insert("end", self.validation_time) self.validation_time_entry.config(state=tk.DISABLED) - self.validation_time_entry.grid(row=0, column=1, sticky="ew", pady=PADY) + self.validation_time_entry.grid(row=0, column=1, sticky=tk.EW, pady=PADY) label = ttk.Label(frame, text="Validation Mode") - label.grid(row=1, column=0, sticky="w", padx=PADX) + label.grid(row=1, column=0, sticky=tk.W, padx=PADX) if self.validation_mode == ServiceValidationMode.BLOCKING: mode = "BLOCKING" elif self.validation_mode == ServiceValidationMode.NON_BLOCKING: @@ -335,48 +335,48 @@ class ServiceConfigDialog(Dialog): ) self.validation_mode_entry.insert("end", mode) self.validation_mode_entry.config(state=tk.DISABLED) - self.validation_mode_entry.grid(row=1, column=1, sticky="ew", pady=PADY) + self.validation_mode_entry.grid(row=1, column=1, sticky=tk.EW, pady=PADY) label = ttk.Label(frame, text="Validation Period") - label.grid(row=2, column=0, sticky="w", padx=PADX) + label.grid(row=2, column=0, sticky=tk.W, padx=PADX) self.validation_period_entry = ttk.Entry( frame, state=tk.DISABLED, textvariable=tk.StringVar() ) - self.validation_period_entry.grid(row=2, column=1, sticky="ew", pady=PADY) + self.validation_period_entry.grid(row=2, column=1, sticky=tk.EW, pady=PADY) label_frame = ttk.LabelFrame(tab, text="Executables", padding=FRAME_PAD) - label_frame.grid(sticky="nsew", pady=PADY) + label_frame.grid(sticky=tk.NSEW, pady=PADY) label_frame.columnconfigure(0, weight=1) label_frame.rowconfigure(0, weight=1) listbox_scroll = ListboxScroll(label_frame) - listbox_scroll.grid(sticky="nsew") + listbox_scroll.grid(sticky=tk.NSEW) tab.rowconfigure(listbox_scroll.grid_info()["row"], weight=1) for executable in self.executables: listbox_scroll.listbox.insert("end", executable) label_frame = ttk.LabelFrame(tab, text="Dependencies", padding=FRAME_PAD) - label_frame.grid(sticky="nsew", pady=PADY) + label_frame.grid(sticky=tk.NSEW, pady=PADY) label_frame.columnconfigure(0, weight=1) label_frame.rowconfigure(0, weight=1) listbox_scroll = ListboxScroll(label_frame) - listbox_scroll.grid(sticky="nsew") + listbox_scroll.grid(sticky=tk.NSEW) tab.rowconfigure(listbox_scroll.grid_info()["row"], weight=1) for dependency in self.dependencies: listbox_scroll.listbox.insert("end", dependency) def draw_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(4): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Apply", command=self.click_apply) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Defaults", command=self.click_defaults) - button.grid(row=0, column=1, sticky="ew", padx=PADX) + button.grid(row=0, column=1, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Copy...", command=self.click_copy) - button.grid(row=0, column=2, sticky="ew", padx=PADX) + button.grid(row=0, column=2, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=3, sticky="ew") + button.grid(row=0, column=3, sticky=tk.EW) def add_filename(self) -> None: filename = self.filename_combobox.get() diff --git a/daemon/core/gui/dialogs/sessionoptions.py b/daemon/core/gui/dialogs/sessionoptions.py index 570bfbde..e9b032e0 100644 --- a/daemon/core/gui/dialogs/sessionoptions.py +++ b/daemon/core/gui/dialogs/sessionoptions.py @@ -39,17 +39,17 @@ class SessionOptionsDialog(Dialog): self.top.rowconfigure(0, weight=1) self.config_frame = ConfigFrame(self.top, self.app, self.config, self.enabled) self.config_frame.draw_config() - self.config_frame.grid(sticky="nsew", pady=PADY) + self.config_frame.grid(sticky=tk.NSEW, pady=PADY) frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) state = tk.NORMAL if self.enabled else tk.DISABLED button = ttk.Button(frame, text="Save", command=self.save, state=state) - button.grid(row=0, column=0, padx=PADX, sticky="ew") + button.grid(row=0, column=0, padx=PADX, sticky=tk.EW) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def save(self) -> None: config = self.config_frame.parse_config() diff --git a/daemon/core/gui/dialogs/sessions.py b/daemon/core/gui/dialogs/sessions.py index 83e4001a..4c9ae0ca 100644 --- a/daemon/core/gui/dialogs/sessions.py +++ b/daemon/core/gui/dialogs/sessions.py @@ -62,7 +62,7 @@ class SessionsDialog(Dialog): frame = ttk.Frame(self.top) frame.columnconfigure(0, weight=1) frame.rowconfigure(0, weight=1) - frame.grid(sticky="nsew", pady=PADY) + frame.grid(sticky=tk.NSEW, pady=PADY) self.tree = ttk.Treeview( frame, columns=("id", "state", "nodes"), @@ -72,7 +72,7 @@ class SessionsDialog(Dialog): style = ttk.Style() heading_size = int(self.app.guiconfig.scale * 10) style.configure("Treeview.Heading", font=(None, heading_size, "bold")) - self.tree.grid(sticky="nsew") + self.tree.grid(sticky=tk.NSEW) self.tree.column("id", stretch=tk.YES, anchor="center") self.tree.heading("id", text="ID") self.tree.column("state", stretch=tk.YES, anchor="center") @@ -92,25 +92,25 @@ class SessionsDialog(Dialog): self.tree.bind("<>", self.click_select) yscrollbar = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview) - yscrollbar.grid(row=0, column=1, sticky="ns") + yscrollbar.grid(row=0, column=1, sticky=tk.NS) self.tree.configure(yscrollcommand=yscrollbar.set) xscrollbar = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview) - xscrollbar.grid(row=1, sticky="ew") + xscrollbar.grid(row=1, sticky=tk.EW) self.tree.configure(xscrollcommand=xscrollbar.set) def draw_buttons(self) -> None: frame = ttk.Frame(self.top) for i in range(4): frame.columnconfigure(i, weight=1) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) image = Images.get(ImageEnum.DOCUMENTNEW, 16) b = ttk.Button( frame, image=image, text="New", compound=tk.LEFT, command=self.click_new ) b.image = image - b.grid(row=0, padx=PADX, sticky="ew") + b.grid(row=0, padx=PADX, sticky=tk.EW) image = Images.get(ImageEnum.FILEOPEN, 16) self.connect_button = ttk.Button( @@ -122,7 +122,7 @@ class SessionsDialog(Dialog): state=tk.DISABLED, ) self.connect_button.image = image - self.connect_button.grid(row=0, column=1, padx=PADX, sticky="ew") + self.connect_button.grid(row=0, column=1, padx=PADX, sticky=tk.EW) image = Images.get(ImageEnum.DELETE, 16) self.delete_button = ttk.Button( @@ -134,7 +134,7 @@ class SessionsDialog(Dialog): state=tk.DISABLED, ) self.delete_button.image = image - self.delete_button.grid(row=0, column=2, padx=PADX, sticky="ew") + self.delete_button.grid(row=0, column=2, padx=PADX, sticky=tk.EW) image = Images.get(ImageEnum.CANCEL, 16) if self.is_start_app: @@ -154,7 +154,7 @@ class SessionsDialog(Dialog): command=self.destroy, ) b.image = image - b.grid(row=0, column=3, sticky="ew") + b.grid(row=0, column=3, sticky=tk.EW) def click_new(self) -> None: self.app.core.create_new_session() diff --git a/daemon/core/gui/dialogs/shapemod.py b/daemon/core/gui/dialogs/shapemod.py index 2ca06772..255092ec 100644 --- a/daemon/core/gui/dialogs/shapemod.py +++ b/daemon/core/gui/dialogs/shapemod.py @@ -57,15 +57,15 @@ class ShapeDialog(Dialog): def draw_label_options(self) -> None: label_frame = ttk.LabelFrame(self.top, text="Label", padding=FRAME_PAD) - label_frame.grid(sticky="ew") + label_frame.grid(sticky=tk.EW) label_frame.columnconfigure(0, weight=1) entry = ttk.Entry(label_frame, textvariable=self.shape_text) - entry.grid(sticky="ew", pady=PADY) + entry.grid(sticky=tk.EW, pady=PADY) # font options frame = ttk.Frame(label_frame) - frame.grid(sticky="nsew", pady=PADY) + frame.grid(sticky=tk.NSEW, pady=PADY) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) frame.columnconfigure(2, weight=1) @@ -75,70 +75,70 @@ class ShapeDialog(Dialog): values=sorted(font.families()), state="readonly", ) - combobox.grid(row=0, column=0, sticky="nsew") + combobox.grid(row=0, column=0, sticky=tk.NSEW) combobox = ttk.Combobox( frame, textvariable=self.font_size, values=FONT_SIZES, state="readonly" ) - combobox.grid(row=0, column=1, padx=PADX, sticky="nsew") + combobox.grid(row=0, column=1, padx=PADX, sticky=tk.NSEW) button = ttk.Button(frame, text="Color", command=self.choose_text_color) - button.grid(row=0, column=2, sticky="nsew") + button.grid(row=0, column=2, sticky=tk.NSEW) # style options frame = ttk.Frame(label_frame) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(3): frame.columnconfigure(i, weight=1) button = ttk.Checkbutton(frame, variable=self.bold, text="Bold") - button.grid(row=0, column=0, sticky="ew") + button.grid(row=0, column=0, sticky=tk.EW) button = ttk.Checkbutton(frame, variable=self.italic, text="Italic") - button.grid(row=0, column=1, padx=PADX, sticky="ew") + button.grid(row=0, column=1, padx=PADX, sticky=tk.EW) button = ttk.Checkbutton(frame, variable=self.underline, text="Underline") - button.grid(row=0, column=2, sticky="ew") + button.grid(row=0, column=2, sticky=tk.EW) def draw_shape_options(self) -> None: label_frame = ttk.LabelFrame(self.top, text="Shape", padding=FRAME_PAD) - label_frame.grid(sticky="ew", pady=PADY) + label_frame.grid(sticky=tk.EW, pady=PADY) label_frame.columnconfigure(0, weight=1) frame = ttk.Frame(label_frame) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(1, 3): frame.columnconfigure(i, weight=1) label = ttk.Label(frame, text="Fill Color") - label.grid(row=0, column=0, padx=PADX, sticky="w") + label.grid(row=0, column=0, padx=PADX, sticky=tk.W) self.fill = ttk.Label(frame, text=self.fill_color, background=self.fill_color) - self.fill.grid(row=0, column=1, sticky="ew", padx=PADX) + self.fill.grid(row=0, column=1, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Color", command=self.choose_fill_color) - button.grid(row=0, column=2, sticky="ew") + button.grid(row=0, column=2, sticky=tk.EW) label = ttk.Label(frame, text="Border Color") - label.grid(row=1, column=0, sticky="w", padx=PADX) + label.grid(row=1, column=0, sticky=tk.W, padx=PADX) self.border = ttk.Label( frame, text=self.border_color, background=self.border_color ) - self.border.grid(row=1, column=1, sticky="ew", padx=PADX) + self.border.grid(row=1, column=1, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Color", command=self.choose_border_color) - button.grid(row=1, column=2, sticky="ew") + button.grid(row=1, column=2, sticky=tk.EW) frame = ttk.Frame(label_frame) - frame.grid(sticky="ew", pady=PADY) + frame.grid(sticky=tk.EW, pady=PADY) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Border Width") - label.grid(row=0, column=0, sticky="w", padx=PADX) + label.grid(row=0, column=0, sticky=tk.W, padx=PADX) combobox = ttk.Combobox( frame, textvariable=self.border_width, values=BORDER_WIDTH, state="readonly" ) - combobox.grid(row=0, column=1, sticky="nsew") + combobox.grid(row=0, column=1, sticky=tk.NSEW) def draw_buttons(self) -> None: frame = ttk.Frame(self.top) - frame.grid(sticky="nsew") + frame.grid(sticky=tk.NSEW) frame.columnconfigure(0, weight=1) frame.columnconfigure(1, weight=1) button = ttk.Button(frame, text="Add shape", command=self.click_add) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.cancel) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def choose_text_color(self) -> None: color_picker = ColorPickerDialog(self, self.app, self.text_color) diff --git a/daemon/core/gui/dialogs/throughput.py b/daemon/core/gui/dialogs/throughput.py index 5b3cc9b3..0b59a6ac 100644 --- a/daemon/core/gui/dialogs/throughput.py +++ b/daemon/core/gui/dialogs/throughput.py @@ -37,25 +37,25 @@ class ThroughputDialog(Dialog): variable=self.show_throughput, text="Show Throughput Level On Every Link", ) - button.grid(sticky="ew") + button.grid(sticky=tk.EW) button = ttk.Checkbutton( self.top, variable=self.exponential_weight, text="Use Exponential Weighted Moving Average", ) - button.grid(sticky="ew") + button.grid(sticky=tk.EW) button = ttk.Checkbutton( self.top, variable=self.transmission, text="Include Transmissions" ) - button.grid(sticky="ew") + button.grid(sticky=tk.EW) button = ttk.Checkbutton( self.top, variable=self.reception, text="Include Receptions" ) - button.grid(sticky="ew") + button.grid(sticky=tk.EW) label_frame = ttk.LabelFrame(self.top, text="Link Highlight", padding=FRAME_PAD) label_frame.columnconfigure(0, weight=1) - label_frame.grid(sticky="ew") + label_frame.grid(sticky=tk.EW) scale = ttk.Scale( label_frame, @@ -65,21 +65,21 @@ class ThroughputDialog(Dialog): orient=tk.HORIZONTAL, variable=self.threshold, ) - scale.grid(sticky="ew", pady=PADY) + scale.grid(sticky=tk.EW, pady=PADY) frame = ttk.Frame(label_frame) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) frame.columnconfigure(1, weight=1) label = ttk.Label(frame, text="Threshold Kbps (0 disabled)") - label.grid(row=0, column=0, sticky="ew", padx=PADX) + label.grid(row=0, column=0, sticky=tk.EW, padx=PADX) entry = ttk.Entry(frame, textvariable=self.threshold) - entry.grid(row=0, column=1, sticky="ew", pady=PADY) + entry.grid(row=0, column=1, sticky=tk.EW, pady=PADY) label = ttk.Label(frame, text="Width") - label.grid(row=1, column=0, sticky="ew", padx=PADX) + label.grid(row=1, column=0, sticky=tk.EW, padx=PADX) entry = ttk.Entry(frame, textvariable=self.width) - entry.grid(row=1, column=1, sticky="ew", pady=PADY) + entry.grid(row=1, column=1, sticky=tk.EW, pady=PADY) label = ttk.Label(frame, text="Color") - label.grid(row=2, column=0, sticky="ew", padx=PADX) + label.grid(row=2, column=0, sticky=tk.EW, padx=PADX) self.color_button = tk.Button( frame, text=self.color, @@ -87,18 +87,18 @@ class ThroughputDialog(Dialog): bg=self.color, highlightthickness=0, ) - self.color_button.grid(row=2, column=1, sticky="ew") + self.color_button.grid(row=2, column=1, sticky=tk.EW) self.draw_spacer() frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) button = ttk.Button(frame, text="Save", command=self.click_save) - button.grid(row=0, column=0, sticky="ew", padx=PADX) + button.grid(row=0, column=0, sticky=tk.EW, padx=PADX) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_color(self) -> None: color_picker = ColorPickerDialog(self, self.app, self.color) diff --git a/daemon/core/gui/dialogs/wlanconfig.py b/daemon/core/gui/dialogs/wlanconfig.py index d4595556..283a96cd 100644 --- a/daemon/core/gui/dialogs/wlanconfig.py +++ b/daemon/core/gui/dialogs/wlanconfig.py @@ -1,3 +1,4 @@ +import tkinter as tk from tkinter import ttk from typing import TYPE_CHECKING, Dict, Optional @@ -54,7 +55,7 @@ class WlanConfigDialog(Dialog): self.top.rowconfigure(0, weight=1) self.config_frame = ConfigFrame(self.top, self.app, self.config) self.config_frame.draw_config() - self.config_frame.grid(sticky="nsew", pady=PADY) + self.config_frame.grid(sticky=tk.NSEW, pady=PADY) self.draw_apply_buttons() self.top.bind("", self.remove_ranges) @@ -63,7 +64,7 @@ class WlanConfigDialog(Dialog): create node configuration options """ frame = ttk.Frame(self.top) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) for i in range(2): frame.columnconfigure(i, weight=1) @@ -73,10 +74,10 @@ class WlanConfigDialog(Dialog): self.range_entry.config(validatecommand=(self.positive_int, "%P")) button = ttk.Button(frame, text="Apply", command=self.click_apply) - button.grid(row=0, column=0, padx=PADX, sticky="ew") + button.grid(row=0, column=0, padx=PADX, sticky=tk.EW) button = ttk.Button(frame, text="Cancel", command=self.destroy) - button.grid(row=0, column=1, sticky="ew") + button.grid(row=0, column=1, sticky=tk.EW) def click_apply(self) -> None: """ diff --git a/daemon/core/gui/frames/link.py b/daemon/core/gui/frames/link.py index 093f39eb..339c39f0 100644 --- a/daemon/core/gui/frames/link.py +++ b/daemon/core/gui/frames/link.py @@ -38,7 +38,7 @@ class EdgeInfoFrame(InfoFrameBase): dst_node = self.app.core.session.nodes[link.node2_id] frame = DetailsFrame(self) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) frame.add_detail("Source", src_node.name) iface1 = link.iface1 if iface1: @@ -90,7 +90,7 @@ class WirelessEdgeInfoFrame(InfoFrameBase): iface2 = get_iface(dst_canvas_node, net_id) frame = DetailsFrame(self) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) frame.add_detail("Source", src_node.name) if iface1: mac = iface1.mac if iface1.mac else "auto" diff --git a/daemon/core/gui/frames/node.py b/daemon/core/gui/frames/node.py index 577cc489..394ecd85 100644 --- a/daemon/core/gui/frames/node.py +++ b/daemon/core/gui/frames/node.py @@ -1,3 +1,4 @@ +import tkinter as tk from typing import TYPE_CHECKING from core.gui.frames.base import DetailsFrame, InfoFrameBase @@ -18,7 +19,7 @@ class NodeInfoFrame(InfoFrameBase): self.columnconfigure(0, weight=1) node = self.canvas_node.core_node frame = DetailsFrame(self) - frame.grid(sticky="ew") + frame.grid(sticky=tk.EW) frame.add_detail("ID", node.id) frame.add_detail("Name", node.name) if NodeUtils.is_model_node(node.type): diff --git a/daemon/core/gui/statusbar.py b/daemon/core/gui/statusbar.py index d4304b6e..518a82f9 100644 --- a/daemon/core/gui/statusbar.py +++ b/daemon/core/gui/statusbar.py @@ -34,7 +34,7 @@ class StatusBar(ttk.Frame): self.columnconfigure(3, weight=1) frame = ttk.Frame(self, borderwidth=1, relief=tk.RIDGE) - frame.grid(row=0, column=0, sticky="ew") + frame.grid(row=0, column=0, sticky=tk.EW) frame.columnconfigure(0, weight=1) self.status = ttk.Label( @@ -44,22 +44,22 @@ class StatusBar(ttk.Frame): borderwidth=1, relief=tk.RIDGE, ) - self.status.grid(row=0, column=0, sticky="ew") + self.status.grid(row=0, column=0, sticky=tk.EW) self.zoom = ttk.Label(self, anchor=tk.CENTER, borderwidth=1, relief=tk.RIDGE) - self.zoom.grid(row=0, column=1, sticky="ew") + self.zoom.grid(row=0, column=1, sticky=tk.EW) self.set_zoom(self.app.canvas.ratio) self.cpu_label = ttk.Label( self, anchor=tk.CENTER, borderwidth=1, relief=tk.RIDGE ) - self.cpu_label.grid(row=0, column=2, sticky="ew") + self.cpu_label.grid(row=0, column=2, sticky=tk.EW) self.set_cpu(0.0) self.alerts_button = ttk.Button( self, text="Alerts", command=self.click_alerts, style=self.alert_style ) - self.alerts_button.grid(row=0, column=3, sticky="ew") + self.alerts_button.grid(row=0, column=3, sticky=tk.EW) def set_cpu(self, usage: float) -> None: self.cpu_label.config(text=f"CPU {usage * 100:.2f}%") diff --git a/daemon/core/gui/task.py b/daemon/core/gui/task.py index f56fd54b..b2ab9765 100644 --- a/daemon/core/gui/task.py +++ b/daemon/core/gui/task.py @@ -1,6 +1,7 @@ import logging import threading import time +import tkinter as tk from typing import TYPE_CHECKING, Any, Callable, Optional, Tuple if TYPE_CHECKING: @@ -26,7 +27,7 @@ class ProgressTask: self.time: Optional[float] = None def start(self) -> None: - self.app.progress.grid(sticky="ew", columnspan=2) + self.app.progress.grid(sticky=tk.EW, columnspan=2) self.app.progress.start() self.time = time.perf_counter() thread = threading.Thread(target=self.run, daemon=True) diff --git a/daemon/core/gui/toolbar.py b/daemon/core/gui/toolbar.py index b7b67338..1f5589ba 100644 --- a/daemon/core/gui/toolbar.py +++ b/daemon/core/gui/toolbar.py @@ -95,7 +95,7 @@ class ButtonBar(ttk.Frame): image = self.app.get_icon(image_enum, TOOLBAR_SIZE) button = ttk.Button(self, image=image, command=func) button.image = image - button.grid(sticky="ew") + button.grid(sticky=tk.EW) Tooltip(button, tooltip) if radio: self.radio_buttons.append(button) @@ -124,7 +124,7 @@ class MarkerFrame(ttk.Frame): image = self.app.get_icon(ImageEnum.DELETE, 16) button = ttk.Button(self, image=image, width=2, command=self.click_clear) button.image = image - button.grid(sticky="ew", pady=self.PAD) + button.grid(sticky=tk.EW, pady=self.PAD) Tooltip(button, "Delete Marker") sizes = [1, 3, 8, 10] @@ -132,14 +132,14 @@ class MarkerFrame(ttk.Frame): sizes = ttk.Combobox( self, state="readonly", textvariable=self.size, value=sizes, width=2 ) - sizes.grid(sticky="ew", pady=self.PAD) + sizes.grid(sticky=tk.EW, pady=self.PAD) Tooltip(sizes, "Marker Size") frame_size = TOOLBAR_SIZE self.color_frame = tk.Frame( self, background=self.color, height=frame_size, width=frame_size ) - self.color_frame.grid(sticky="ew") + self.color_frame.grid(sticky=tk.EW) self.color_frame.bind("", self.click_color) Tooltip(self.color_frame, "Marker Color") @@ -207,7 +207,7 @@ class Toolbar(ttk.Frame): def draw_design_frame(self) -> None: self.design_frame = ButtonBar(self, self.app) - self.design_frame.grid(row=0, column=0, sticky="nsew") + self.design_frame.grid(row=0, column=0, sticky=tk.NSEW) self.design_frame.columnconfigure(0, weight=1) self.play_button = self.design_frame.create_button( ImageEnum.START, self.click_start, "Start Session" @@ -239,7 +239,7 @@ class Toolbar(ttk.Frame): def draw_runtime_frame(self) -> None: self.runtime_frame = ButtonBar(self, self.app) - self.runtime_frame.grid(row=0, column=0, sticky="nsew") + self.runtime_frame.grid(row=0, column=0, sticky=tk.NSEW) self.runtime_frame.columnconfigure(0, weight=1) self.stop_button = self.runtime_frame.create_button( ImageEnum.STOP, self.click_stop, "Stop Session" @@ -387,7 +387,7 @@ class Toolbar(ttk.Frame): self.runtime_frame, image=image, direction=tk.RIGHT ) menu_button.image = image - menu_button.grid(sticky="ew") + menu_button.grid(sticky=tk.EW) self.observers_menu = ObserversMenu(menu_button, self.app) menu_button["menu"] = self.observers_menu diff --git a/daemon/core/gui/tooltip.py b/daemon/core/gui/tooltip.py index c2978510..84a3178f 100644 --- a/daemon/core/gui/tooltip.py +++ b/daemon/core/gui/tooltip.py @@ -46,7 +46,7 @@ class Tooltip(object): self.tw.rowconfigure(0, weight=1) self.tw.columnconfigure(0, weight=1) frame = ttk.Frame(self.tw, style=Styles.tooltip_frame, padding=3) - frame.grid(sticky="nsew") + frame.grid(sticky=tk.NSEW) label = ttk.Label(frame, text=self.text, style=Styles.tooltip) label.grid() diff --git a/daemon/core/gui/widgets.py b/daemon/core/gui/widgets.py index 0d5bff22..eff1a2a3 100644 --- a/daemon/core/gui/widgets.py +++ b/daemon/core/gui/widgets.py @@ -47,13 +47,13 @@ class FrameScroll(ttk.Frame): self.columnconfigure(0, weight=1) bg = self.app.style.lookup(".", "background") self.canvas: tk.Canvas = tk.Canvas(self, highlightthickness=0, background=bg) - self.canvas.grid(row=0, sticky="nsew", padx=2, pady=2) + self.canvas.grid(row=0, sticky=tk.NSEW, padx=2, pady=2) self.canvas.columnconfigure(0, weight=1) self.canvas.rowconfigure(0, weight=1) self.scrollbar: ttk.Scrollbar = ttk.Scrollbar( self, orient="vertical", command=self.canvas.yview ) - self.scrollbar.grid(row=0, column=1, sticky="ns") + self.scrollbar.grid(row=0, column=1, sticky=tk.NS) self.frame: ttk.Frame = _cls(self.canvas) self.frame_id: int = self.canvas.create_window( 0, 0, anchor="nw", window=self.frame @@ -108,7 +108,7 @@ class ConfigFrame(ttk.Notebook): self.add(tab, text=group_name) for index, option in enumerate(sorted(group, key=lambda x: x.name)): label = ttk.Label(tab.frame, text=option.label) - label.grid(row=index, pady=PADY, padx=PADX, sticky="w") + label.grid(row=index, pady=PADY, padx=PADX, sticky=tk.W) value = tk.StringVar() if option.type == ConfigOptionType.BOOL: select = ("On", "Off") @@ -116,7 +116,7 @@ class ConfigFrame(ttk.Notebook): combobox = ttk.Combobox( tab.frame, textvariable=value, values=select, state=state ) - combobox.grid(row=index, column=1, sticky="ew") + combobox.grid(row=index, column=1, sticky=tk.EW) if option.value == "1": value.set("On") else: @@ -128,16 +128,16 @@ class ConfigFrame(ttk.Notebook): combobox = ttk.Combobox( tab.frame, textvariable=value, values=select, state=state ) - combobox.grid(row=index, column=1, sticky="ew") + combobox.grid(row=index, column=1, sticky=tk.EW) elif option.type == ConfigOptionType.STRING: value.set(option.value) state = tk.NORMAL if self.enabled else tk.DISABLED if "file" in option.label: file_frame = ttk.Frame(tab.frame) - file_frame.grid(row=index, column=1, sticky="ew") + file_frame.grid(row=index, column=1, sticky=tk.EW) file_frame.columnconfigure(0, weight=1) entry = ttk.Entry(file_frame, textvariable=value, state=state) - entry.grid(row=0, column=0, sticky="ew", padx=PADX) + entry.grid(row=0, column=0, sticky=tk.EW, padx=PADX) func = partial(file_button_click, value, self) button = ttk.Button( file_frame, text="...", command=func, state=state @@ -145,21 +145,21 @@ class ConfigFrame(ttk.Notebook): button.grid(row=0, column=1) else: entry = ttk.Entry(tab.frame, textvariable=value, state=state) - entry.grid(row=index, column=1, sticky="ew") + entry.grid(row=index, column=1, sticky=tk.EW) elif option.type in INT_TYPES: value.set(option.value) state = tk.NORMAL if self.enabled else tk.DISABLED entry = validation.PositiveIntEntry( tab.frame, textvariable=value, state=state ) - entry.grid(row=index, column=1, sticky="ew") + entry.grid(row=index, column=1, sticky=tk.EW) elif option.type == ConfigOptionType.FLOAT: value.set(option.value) state = tk.NORMAL if self.enabled else tk.DISABLED entry = validation.PositiveFloatEntry( tab.frame, textvariable=value, state=state ) - entry.grid(row=index, column=1, sticky="ew") + entry.grid(row=index, column=1, sticky=tk.EW) else: logging.error("unhandled config option type: %s", option.type) self.values[option.name] = value @@ -196,7 +196,7 @@ class ListboxScroll(ttk.Frame): self.columnconfigure(0, weight=1) self.rowconfigure(0, weight=1) self.scrollbar: ttk.Scrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL) - self.scrollbar.grid(row=0, column=1, sticky="ns") + self.scrollbar.grid(row=0, column=1, sticky=tk.NS) self.listbox: tk.Listbox = tk.Listbox( self, selectmode=tk.BROWSE, @@ -204,7 +204,7 @@ class ListboxScroll(ttk.Frame): exportselection=False, ) themes.style_listbox(self.listbox) - self.listbox.grid(row=0, column=0, sticky="nsew") + self.listbox.grid(row=0, column=0, sticky=tk.NSEW) self.scrollbar.config(command=self.listbox.yview) @@ -224,7 +224,7 @@ class CheckboxList(FrameScroll): var = tk.BooleanVar(value=checked) func = partial(self.clicked, name, var) checkbox = ttk.Checkbutton(self.frame, text=name, variable=var, command=func) - checkbox.grid(sticky="w") + checkbox.grid(sticky=tk.W) class CodeFont(font.Font): @@ -250,9 +250,9 @@ class CodeText(ttk.Frame): selectforeground="black", relief=tk.FLAT, ) - self.text.grid(row=0, column=0, sticky="nsew") + self.text.grid(row=0, column=0, sticky=tk.NSEW) yscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL, command=self.text.yview) - yscrollbar.grid(row=0, column=1, sticky="ns") + yscrollbar.grid(row=0, column=1, sticky=tk.NS) self.text.configure(yscrollcommand=yscrollbar.set) From b7e3d1c8775696da39ff7b30e4032c3ba74677d5 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Sun, 2 Aug 2020 10:47:01 -0700 Subject: [PATCH 366/929] pygui: fixed emane config dialog and emane model config dialogs to expand tabs the full height of the dialog --- daemon/core/gui/dialogs/emaneconfig.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/daemon/core/gui/dialogs/emaneconfig.py b/daemon/core/gui/dialogs/emaneconfig.py index f7925d16..d47a3c0d 100644 --- a/daemon/core/gui/dialogs/emaneconfig.py +++ b/daemon/core/gui/dialogs/emaneconfig.py @@ -34,7 +34,6 @@ class GlobalEmaneDialog(Dialog): ) self.config_frame.draw_config() self.config_frame.grid(sticky=tk.NSEW, pady=PADY) - self.draw_spacer() self.draw_buttons() def draw_buttons(self) -> None: @@ -88,7 +87,6 @@ class EmaneModelDialog(Dialog): self.config_frame = ConfigFrame(self.top, self.app, self.config, self.enabled) self.config_frame.draw_config() self.config_frame.grid(sticky=tk.NSEW, pady=PADY) - self.draw_spacer() self.draw_buttons() def draw_buttons(self) -> None: From 06e43f619d5d43f62d54254e299d186225f10133 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 3 Aug 2020 10:40:48 -0700 Subject: [PATCH 367/929] install: update install complete message to avoid implying invoke is needed to run core --- tasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tasks.py b/tasks.py index c3e6d2bb..38b219e6 100644 --- a/tasks.py +++ b/tasks.py @@ -312,7 +312,7 @@ def install(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): install_service(c, hide, prefix) with p.start("installing ospf mdr"): install_ospf_mdr(c, os_info, hide) - print("\nyou may need to open a new terminal to leverage invoke for running core") + print("\ninstall complete!") @task( From f41ce8e3a67f3bb3b24649dc62aa58096612d5a1 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 3 Aug 2020 16:04:07 -0700 Subject: [PATCH 368/929] daemon: add core python environment variable to be able to refer to the virtual environment executable --- daemon/core/emulator/session.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/daemon/core/emulator/session.py b/daemon/core/emulator/session.py index 4127b141..fe0b07bc 100644 --- a/daemon/core/emulator/session.py +++ b/daemon/core/emulator/session.py @@ -8,6 +8,7 @@ import os import pwd import shutil import subprocess +import sys import tempfile import threading import time @@ -991,6 +992,7 @@ class Session: :return: environment variables """ env = os.environ.copy() + env["CORE_PYTHON"] = sys.executable env["SESSION"] = str(self.id) env["SESSION_SHORT"] = self.short_session_id() env["SESSION_DIR"] = self.session_dir From 4bcaa32fdbbdc49f1ad29263b7e02ed052605b20 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 3 Aug 2020 16:29:35 -0700 Subject: [PATCH 369/929] pygui: fixed issue in task handling a returned boolean value, should be doing a none check --- daemon/core/gui/task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemon/core/gui/task.py b/daemon/core/gui/task.py index b2ab9765..02148f5a 100644 --- a/daemon/core/gui/task.py +++ b/daemon/core/gui/task.py @@ -38,7 +38,7 @@ class ProgressTask: values = self.task(*self.args) if values is None: values = () - elif values and not isinstance(values, tuple): + elif values is not None and not isinstance(values, tuple): values = (values,) if self.callback: self.app.after(0, self.callback, *values) From 082677c17bb2cdb8647d9f1e322ca9906f973df3 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Mon, 3 Aug 2020 16:37:31 -0700 Subject: [PATCH 370/929] pygui: fixed issue saving selected background to xml when not located within the ~/.coregui/backgrounds directory --- daemon/core/gui/coreclient.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index 8a881945..c3ca2385 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -22,7 +22,7 @@ from core.api.grpc import ( wlan_pb2, ) from core.gui import appconfig -from core.gui.appconfig import XMLS_PATH, CoreServer, Observer +from core.gui.appconfig import BACKGROUNDS_PATH, XMLS_PATH, CoreServer, Observer from core.gui.dialogs.emaneinstall import EmaneInstallDialog from core.gui.dialogs.error import ErrorDialog from core.gui.dialogs.mobilityplayer import MobilityPlayer @@ -546,11 +546,15 @@ class CoreClient: def set_metadata(self) -> None: # create canvas data - wallpaper = None + wallpaper_path = None if self.app.canvas.wallpaper_file: - wallpaper = Path(self.app.canvas.wallpaper_file).name + wallpaper = Path(self.app.canvas.wallpaper_file) + if BACKGROUNDS_PATH == wallpaper.parent: + wallpaper_path = wallpaper.name + else: + wallpaper_path = str(wallpaper) canvas_config = { - "wallpaper": wallpaper, + "wallpaper": wallpaper_path, "wallpaper-style": self.app.canvas.scale_option.get(), "gridlines": self.app.canvas.show_grid.get(), "fit_image": self.app.canvas.adjust_to_dim.get(), From e2b3a2dc6da8b55480fdfe08365528f4a4643491 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 4 Aug 2020 12:29:08 -0700 Subject: [PATCH 371/929] pygui: fixed issues with configuring services on nodes due to refactoring changes --- daemon/core/gui/dialogs/nodeconfigservice.py | 4 ++-- daemon/core/gui/dialogs/nodeservice.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/daemon/core/gui/dialogs/nodeconfigservice.py b/daemon/core/gui/dialogs/nodeconfigservice.py index 1c67e4b3..fefdc4c5 100644 --- a/daemon/core/gui/dialogs/nodeconfigservice.py +++ b/daemon/core/gui/dialogs/nodeconfigservice.py @@ -101,7 +101,7 @@ class NodeConfigServiceDialog(Dialog): elif not var.get() and name in self.current_services: self.current_services.remove(name) self.draw_current_services() - self.node.config_services[:] = self.current_services + self.node.config_services = self.current_services.copy() def click_configure(self) -> None: current_selection = self.current.listbox.curselection() @@ -130,7 +130,7 @@ class NodeConfigServiceDialog(Dialog): self.current.listbox.itemconfig(tk.END, bg="green") def click_save(self) -> None: - self.node.config_services[:] = self.current_services + self.node.config_services = self.current_services.copy() logging.info("saved node config services: %s", self.node.config_services) self.destroy() diff --git a/daemon/core/gui/dialogs/nodeservice.py b/daemon/core/gui/dialogs/nodeservice.py index 5ec78a93..a35e1d53 100644 --- a/daemon/core/gui/dialogs/nodeservice.py +++ b/daemon/core/gui/dialogs/nodeservice.py @@ -126,7 +126,7 @@ class NodeServiceDialog(Dialog): ) def click_save(self) -> None: - self.node.services[:] = self.current_services + self.node.services = self.current_services.copy() self.destroy() def click_remove(self) -> None: From cd0351c818a7fe556548fbe2cb51fc66d9c048d5 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 4 Aug 2020 16:20:51 -0700 Subject: [PATCH 372/929] pygui: added view option to toggle wireless edges --- daemon/core/gui/graph/edges.py | 9 ++++++--- daemon/core/gui/graph/graph.py | 1 + daemon/core/gui/menubar.py | 5 +++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index b313957d..93749370 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -110,7 +110,9 @@ class Edge: arc_y = (perp_m * arc_x) + b return arc_x, arc_y - def draw(self, src_pos: Tuple[float, float], dst_pos: Tuple[float, float]) -> None: + def draw( + self, src_pos: Tuple[float, float], dst_pos: Tuple[float, float], state: str + ) -> None: arc_pos = self._get_arcpoint(src_pos, dst_pos) self.id = self.canvas.create_line( *src_pos, @@ -120,6 +122,7 @@ class Edge: tags=self.tag, width=self.scaled_width(), fill=self.color, + state=state, ) def redraw(self) -> None: @@ -249,7 +252,7 @@ class CanvasWirelessEdge(Edge): self.width: float = WIRELESS_WIDTH color = link.color if link.color else WIRELESS_COLOR self.color: str = color - self.draw(src_pos, dst_pos) + self.draw(src_pos, dst_pos, self.canvas.show_wireless.state()) if link.label: self.middle_label_text(link.label) self.set_binding() @@ -286,7 +289,7 @@ class CanvasEdge(Edge): self.link: Optional[Link] = None self.asymmetric_link: Optional[Link] = None self.throughput: Optional[float] = None - self.draw(src_pos, dst_pos) + self.draw(src_pos, dst_pos, tk.NORMAL) self.set_binding() self.context: tk.Menu = tk.Menu(self.canvas) self.create_context() diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index bb762bb8..b9dd5dba 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -97,6 +97,7 @@ class CanvasGraph(tk.Canvas): # drawing related self.show_node_labels: ShowVar = ShowVar(self, tags.NODE_LABEL, value=True) self.show_link_labels: ShowVar = ShowVar(self, tags.LINK_LABEL, value=True) + self.show_wireless: ShowVar = ShowVar(self, tags.WIRELESS_EDGE, value=True) self.show_grid: ShowVar = ShowVar(self, tags.GRIDLINE, value=True) self.show_annotations: ShowVar = ShowVar(self, tags.ANNOTATION, value=True) self.show_iface_names: BooleanVar = BooleanVar(value=False) diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index fd1413b6..dfe11eca 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -167,6 +167,11 @@ class Menubar(tk.Menu): command=self.canvas.show_link_labels.click_handler, variable=self.canvas.show_link_labels, ) + menu.add_checkbutton( + label="Wireless Links", + command=self.canvas.show_wireless.click_handler, + variable=self.canvas.show_wireless, + ) menu.add_checkbutton( label="Annotations", command=self.canvas.show_annotations.click_handler, From 5976bca34b2e068c2a6ebf0691d7395f8dcac434 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 4 Aug 2020 16:32:39 -0700 Subject: [PATCH 373/929] pygui: added view toggle for normal links --- daemon/core/gui/graph/edges.py | 1 + daemon/core/gui/graph/graph.py | 1 + daemon/core/gui/menubar.py | 5 +++++ 3 files changed, 7 insertions(+) diff --git a/daemon/core/gui/graph/edges.py b/daemon/core/gui/graph/edges.py index 93749370..d94d47d9 100644 --- a/daemon/core/gui/graph/edges.py +++ b/daemon/core/gui/graph/edges.py @@ -381,6 +381,7 @@ class CanvasEdge(Edge): def check_wireless(self) -> None: if self.is_wireless(): self.canvas.itemconfig(self.id, state=tk.HIDDEN) + self.canvas.dtag(self.id, tags.EDGE) self._check_antenna() def _check_antenna(self) -> None: diff --git a/daemon/core/gui/graph/graph.py b/daemon/core/gui/graph/graph.py index b9dd5dba..cbf3fbb2 100644 --- a/daemon/core/gui/graph/graph.py +++ b/daemon/core/gui/graph/graph.py @@ -97,6 +97,7 @@ class CanvasGraph(tk.Canvas): # drawing related self.show_node_labels: ShowVar = ShowVar(self, tags.NODE_LABEL, value=True) self.show_link_labels: ShowVar = ShowVar(self, tags.LINK_LABEL, value=True) + self.show_links: ShowVar = ShowVar(self, tags.EDGE, value=True) self.show_wireless: ShowVar = ShowVar(self, tags.WIRELESS_EDGE, value=True) self.show_grid: ShowVar = ShowVar(self, tags.GRIDLINE, value=True) self.show_annotations: ShowVar = ShowVar(self, tags.ANNOTATION, value=True) diff --git a/daemon/core/gui/menubar.py b/daemon/core/gui/menubar.py index dfe11eca..ebbac677 100644 --- a/daemon/core/gui/menubar.py +++ b/daemon/core/gui/menubar.py @@ -167,6 +167,11 @@ class Menubar(tk.Menu): command=self.canvas.show_link_labels.click_handler, variable=self.canvas.show_link_labels, ) + menu.add_checkbutton( + label="Links", + command=self.canvas.show_links.click_handler, + variable=self.canvas.show_links, + ) menu.add_checkbutton( label="Wireless Links", command=self.canvas.show_wireless.click_handler, From 9352c0eafeb645b2103814e54cfba6886ec43b71 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Tue, 4 Aug 2020 21:11:17 -0700 Subject: [PATCH 374/929] install: added core-python wrapper script to core virtual environment --- tasks.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tasks.py b/tasks.py index 38b219e6..d76bc01d 100644 --- a/tasks.py +++ b/tasks.py @@ -271,6 +271,18 @@ def install_scripts(c, verbose=False, prefix=DEFAULT_PREFIX): else: c.run(f"sudo cp {script} {dest}", hide=hide) + # setup core python helper + core_python = bin_dir.joinpath("core-python") + temp = NamedTemporaryFile("w", delete=False) + temp.writelines([ + "#!/bin/bash\n", + f'exec "{python}" "$@"\n', + ]) + temp.close() + c.run(f"sudo cp {temp.name} {core_python}", hide=hide) + c.run(f"sudo chmod 755 {core_python}", hide=hide) + os.unlink(temp.name) + # install core configuration file config_dir = "/etc/core" c.run(f"sudo mkdir -p {config_dir}", hide=hide) @@ -402,6 +414,10 @@ def uninstall(c, dev=False, verbose=False, prefix=DEFAULT_PREFIX): dest = bin_dir.joinpath(script.name) c.run(f"sudo rm -f {dest}", hide=hide) + # remove core-python symlink + core_python = bin_dir.joinpath("core-python") + c.run(f"sudo rm -f {core_python}", hide=hide) + # install service systemd_dir = Path("/lib/systemd/system/") service_name = "core-daemon.service" From 8004be6e7c82cec1e807a346eefb69ab9a47e2be Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 5 Aug 2020 09:37:23 -0700 Subject: [PATCH 375/929] grpc: update client edit_node doc --- daemon/core/api/grpc/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/daemon/core/api/grpc/client.py b/daemon/core/api/grpc/client.py index 0674a0eb..e28233fc 100644 --- a/daemon/core/api/grpc/client.py +++ b/daemon/core/api/grpc/client.py @@ -552,11 +552,12 @@ class CoreGrpcClient: source: str = None, ) -> core_pb2.EditNodeResponse: """ - Edit a node, currently only changes position. + Edit a node's icon and/or location, can only use position(x,y) or + geo(lon, lat, alt), not both. :param session_id: session id :param node_id: node id - :param position: position to set node to + :param position: x,y location for node :param icon: path to icon for gui to use for node :param geo: lon,lat,alt location for node :param source: application source From b89a19a18e203e0d74e9a212415987a4ab9d5298 Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Wed, 5 Aug 2020 12:10:27 -0700 Subject: [PATCH 376/929] grpc: update node events to include icon, pygui: updated handling node events to update icon when there is a change --- daemon/core/api/grpc/events.py | 1 + daemon/core/gui/coreclient.py | 2 ++ daemon/core/gui/graph/node.py | 13 +++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/daemon/core/api/grpc/events.py b/daemon/core/api/grpc/events.py index fb6eaff8..aff3c5e5 100644 --- a/daemon/core/api/grpc/events.py +++ b/daemon/core/api/grpc/events.py @@ -32,6 +32,7 @@ def handle_node_event(node_data: NodeData) -> core_pb2.Event: id=node.id, name=node.name, model=node.type, + icon=node.icon, position=position, geo=geo, services=services, diff --git a/daemon/core/gui/coreclient.py b/daemon/core/gui/coreclient.py index c3ca2385..902f780a 100644 --- a/daemon/core/gui/coreclient.py +++ b/daemon/core/gui/coreclient.py @@ -241,6 +241,8 @@ class CoreClient: x = node.position.x y = node.position.y canvas_node.move(x, y) + if node.icon and node.icon != canvas_node.core_node.icon: + canvas_node.update_icon(node.icon) elif event.message_type == MessageType.DELETE: canvas_node = self.canvas_nodes[node.id] self.app.canvas.clear_selection() diff --git a/daemon/core/gui/graph/node.py b/daemon/core/gui/graph/node.py index 100404ef..e63a8b80 100644 --- a/daemon/core/gui/graph/node.py +++ b/daemon/core/gui/graph/node.py @@ -1,12 +1,13 @@ import functools import logging import tkinter as tk +from pathlib import Path from typing import TYPE_CHECKING, Dict, List, Set import grpc from PIL.ImageTk import PhotoImage -from core.gui import themes +from core.gui import nodeutils, themes from core.gui.dialogs.emaneconfig import EmaneConfigDialog from core.gui.dialogs.mobilityconfig import MobilityConfigDialog from core.gui.dialogs.nodeconfig import NodeConfigDialog @@ -17,7 +18,7 @@ from core.gui.frames.node import NodeInfoFrame from core.gui.graph import tags from core.gui.graph.edges import CanvasEdge, CanvasWirelessEdge from core.gui.graph.tooltip import CanvasTooltip -from core.gui.images import ImageEnum +from core.gui.images import ImageEnum, Images from core.gui.nodeutils import ANTENNA_SIZE, NodeUtils from core.gui.wrappers import Interface, Node, NodeType @@ -347,3 +348,11 @@ class CanvasNode: dx = node_x - 16 + (i * 8 * self.app.app_scale) - x dy = node_y - int(23 * self.app.app_scale) - y self.canvas.move(antenna_id, dx, dy) + + def update_icon(self, icon_path: str) -> None: + if not Path(icon_path).exists(): + logging.error(f"node icon does not exist: {icon_path}") + return + self.core_node.icon = icon_path + self.image = Images.create(icon_path, nodeutils.ICON_SIZE) + self.canvas.itemconfig(self.id, image=self.image) From 6dd7ce731e4d90d3bb6230959bbba1a597029f6f Mon Sep 17 00:00:00 2001 From: Blake Harnden <32446120+bharnden@users.noreply.github.com> Date: Fri, 7 Aug 2020 22:04:34 -0700 Subject: [PATCH 377/929] removed invoke run task, since core-python provides a better means to do the same thing, updated install doc page to reflect this, removed old emane install from emane docs page --- docs/emane.md | 17 ----------------- docs/install.md | 25 ++++--------------------- tasks.py | 22 ---------------------- 3 files changed, 4 insertions(+), 60 deletions(-) diff --git a/docs/emane.md b/docs/emane.md index 716d7059..bbd3b0a4 100644 --- a/docs/emane.md +++ b/docs/emane.md @@ -50,23 +50,6 @@ can also subscribe to EMANE location events and move the nodes on the canvas as they are moved in the EMANE emulation. This would occur when an Emulation Script Generator, for example, is running a mobility script. -## EMANE Installation - -EMANE can be installed from deb or RPM packages or from source. See the -[EMANE GitHub](https://github.com/adjacentlink/emane) for full details. - -Here are quick instructions for installing all EMANE packages for Ubuntu 18.04: -```shell -# install dependencies -sudo apt-get install libssl-dev libxml-libxml-perl libxml-simple-perl -wget https://adjacentlink.com/downloads/emane/emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz -tar xzf emane-1.2.5-release-1.ubuntu-18_04.amd64.tar.gz -# install base emane packages -sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/emane*.deb -# install python3 bindings -sudo dpkg -i emane-1.2.5-release-1/deb/ubuntu-18_04/amd64/python3*.deb -``` - ## EMANE Configuration The CORE configuration file **/etc/core/core.conf** has options specific to diff --git a/docs/install.md b/docs/install.md index 604ac509..7b5014c4 100644 --- a/docs/install.md +++ b/docs/install.md @@ -132,30 +132,14 @@ After the installation complete it will have installed the following scripts. If you create your own python scripts to run CORE directly or using the gRPC/TLV APIs you will need to make sure you are running them within context of the -installed virtual environment. +installed virtual environment. To help support this CORE provides the `core-python` +executable. This executable will allow you to enter CORE's python virtual +environment interpreter or to run a script within it. > **NOTE:** the following assumes CORE has been installed successfully -There is an invoke task to help with this case. ```shell -cd -inv -h run -Usage: inv[oke] [--core-opts] run [--options] [other tasks here ...] - -Docstring: - runs a user script in the core virtual environment - -Options: - -f STRING, --file=STRING script file to run in the core virtual environment - -s, --sudo run script as sudo -``` - -Another way would be to enable the core virtual environment shell. Which -would allow you to run scripts in a more **normal** way. -```shell -cd /daemon -poetry shell -python run /path/to/script.py +core-python