Repository: batfish/pybatfish

Issue:
Pretty-printed traces should include NAT steps
Currently there's no indication when a flow is NATted in pretty printed traces. The traces themselves include the NAT steps and make clear what changed. It would be helpful if pretty printed flows included those steps.

Comments:

Patch:
diff --git a/jupyter_notebooks/Introduction to Forwarding Analysis.ipynb b/jupyter_notebooks/Introduction to Forwarding Analysis.ipynb
index 968bcf5..cfc6dce 100644
--- a/jupyter_notebooks/Introduction to Forwarding Analysis.ipynb
+++ b/jupyter_notebooks/Introduction to Forwarding Analysis.ipynb
@@ -309,7 +309,7 @@
        "<span style=\"color:#7c020e; text-weight:bold;\">DENIED_IN</span><br><strong>1</strong>. node: as3core1<br>&nbsp;&nbsp;ORIGINATED(default)<br>&nbsp;&nbsp;FORWARDED(Routes: ibgp [Network: 2.128.0.0/16, Next Hop IP:10.23.21.2])<br>&nbsp;&nbsp;TRANSMITTED(GigabitEthernet1/0)<br><strong>2</strong>. node: as3border1<br>&nbsp;&nbsp;RECEIVED(GigabitEthernet0/0)<br>&nbsp;&nbsp;FORWARDED(Routes: bgp [Network: 2.128.0.0/16, Next Hop IP:10.23.21.2])<br>&nbsp;&nbsp;TRANSMITTED(GigabitEthernet1/0)<br><strong>3</strong>. node: as2border2<br>&nbsp;&nbsp;RECEIVED(GigabitEthernet0/0: OUTSIDE_TO_INSIDE)<br>&nbsp;&nbsp;FORWARDED(Routes: ibgp [Network: 2.128.0.0/24, Next Hop IP:2.34.101.4],ibgp [Network: 2.128.0.0/24, Next Hop IP:2.34.201.4])<br>&nbsp;&nbsp;TRANSMITTED(GigabitEthernet1/0)<br><strong>4</strong>. node: as2core2<br>&nbsp;&nbsp;RECEIVED(GigabitEthernet0/0)<br>&nbsp;&nbsp;FORWARDED(Routes: ibgp [Network: 2.128.0.0/24, Next Hop IP:2.34.101.4],ibgp [Network: 2.128.0.0/24, Next Hop IP:2.34.201.4])<br>&nbsp;&nbsp;TRANSMITTED(GigabitEthernet2/0)<br><strong>5</strong>. node: as2dist2<br>&nbsp;&nbsp;RECEIVED(GigabitEthernet0/0)<br>&nbsp;&nbsp;FORWARDED(Routes: bgp [Network: 2.128.0.0/24, Next Hop IP:2.34.201.4])<br>&nbsp;&nbsp;TRANSMITTED(GigabitEthernet2/0)<br><strong>6</strong>. node: as2dept1<br>&nbsp;&nbsp;RECEIVED(GigabitEthernet1/0)<br>&nbsp;&nbsp;FORWARDED(Routes: connected [Network: 2.128.0.0/24, Next Hop IP:AUTO/NONE(-1l)])<br>&nbsp;&nbsp;TRANSMITTED(GigabitEthernet2/0)<br><strong>7</strong>. node: host1<br>&nbsp;&nbsp;DENIED(eth0: filter::INPUT)"
       ],
       "text/plain": [
-       "Trace(disposition='DENIED_IN', hops=[Hop(node='as3core1', steps=[Step(detail=OriginateStepDetail(originatingVrf='default'), action='ORIGINATED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/16', 'nextHopIp': '10.23.21.2', 'protocol': 'ibgp'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet1/0', outputFilter=None, transformedFlow=None), action='TRANSMITTED')]), Hop(node='as3border1', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='GigabitEthernet0/0', inputVrf='default', inputFilter=None), action='RECEIVED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/16', 'nextHopIp': '10.23.21.2', 'protocol': 'bgp'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet1/0', outputFilter=None, transformedFlow=None), action='TRANSMITTED')]), Hop(node='as2border2', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='GigabitEthernet0/0', inputVrf='default', inputFilter='OUTSIDE_TO_INSIDE'), action='RECEIVED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/24', 'nextHopIp': '2.34.101.4', 'protocol': 'ibgp'}, {'network': '2.128.0.0/24', 'nextHopIp': '2.34.201.4', 'protocol': 'ibgp'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet1/0', outputFilter=None, transformedFlow=None), action='TRANSMITTED')]), Hop(node='as2core2', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='GigabitEthernet0/0', inputVrf='default', inputFilter=None), action='RECEIVED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/24', 'nextHopIp': '2.34.101.4', 'protocol': 'ibgp'}, {'network': '2.128.0.0/24', 'nextHopIp': '2.34.201.4', 'protocol': 'ibgp'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet2/0', outputFilter=None, transformedFlow=None), action='TRANSMITTED')]), Hop(node='as2dist2', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='GigabitEthernet0/0', inputVrf='default', inputFilter=None), action='RECEIVED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/24', 'nextHopIp': '2.34.201.4', 'protocol': 'bgp'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet2/0', outputFilter=None, transformedFlow=None), action='TRANSMITTED')]), Hop(node='as2dept1', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='GigabitEthernet1/0', inputVrf='default', inputFilter=None), action='RECEIVED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/24', 'nextHopIp': 'AUTO/NONE(-1l)', 'protocol': 'connected'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet2/0', outputFilter=None, transformedFlow=None), action='TRANSMITTED')]), Hop(node='host1', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='eth0', inputVrf='default', inputFilter='filter::INPUT'), action='DENIED')])])"
+       "Trace(disposition='DENIED_IN', hops=[Hop(node='as3core1', steps=[Step(detail=OriginateStepDetail(originatingVrf='default'), action='ORIGINATED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/16', 'nextHopIp': '10.23.21.2', 'protocol': 'ibgp'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet1/0', outputFilter=None, flowDiffs=[], transformedFlow=None), action='TRANSMITTED')]), Hop(node='as3border1', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='GigabitEthernet0/0', inputVrf='default', inputFilter=None), action='RECEIVED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/16', 'nextHopIp': '10.23.21.2', 'protocol': 'bgp'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet1/0', outputFilter=None, flowDiffs=[], transformedFlow=None), action='TRANSMITTED')]), Hop(node='as2border2', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='GigabitEthernet0/0', inputVrf='default', inputFilter='OUTSIDE_TO_INSIDE'), action='RECEIVED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/24', 'nextHopIp': '2.34.101.4', 'protocol': 'ibgp'}, {'network': '2.128.0.0/24', 'nextHopIp': '2.34.201.4', 'protocol': 'ibgp'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet1/0', outputFilter=None, flowDiffs=[], transformedFlow=None), action='TRANSMITTED')]), Hop(node='as2core2', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='GigabitEthernet0/0', inputVrf='default', inputFilter=None), action='RECEIVED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/24', 'nextHopIp': '2.34.101.4', 'protocol': 'ibgp'}, {'network': '2.128.0.0/24', 'nextHopIp': '2.34.201.4', 'protocol': 'ibgp'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet2/0', outputFilter=None, flowDiffs=[], transformedFlow=None), action='TRANSMITTED')]), Hop(node='as2dist2', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='GigabitEthernet0/0', inputVrf='default', inputFilter=None), action='RECEIVED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/24', 'nextHopIp': '2.34.201.4', 'protocol': 'bgp'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet2/0', outputFilter=None, flowDiffs=[], transformedFlow=None), action='TRANSMITTED')]), Hop(node='as2dept1', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='GigabitEthernet1/0', inputVrf='default', inputFilter=None), action='RECEIVED'), Step(detail=RoutingStepDetail(routes=[{'network': '2.128.0.0/24', 'nextHopIp': 'AUTO/NONE(-1l)', 'protocol': 'connected'}]), action='FORWARDED'), Step(detail=ExitOutputIfaceStepDetail(outputInterface='GigabitEthernet2/0', outputFilter=None, flowDiffs=[], transformedFlow=None), action='TRANSMITTED')]), Hop(node='host1', steps=[Step(detail=EnterInputIfaceStepDetail(inputInterface='eth0', inputVrf='default', inputFilter='filter::INPUT'), action='DENIED')])])"
       ]
      },
      "execution_count": 6,
diff --git a/pybatfish/datamodel/flow.py b/pybatfish/datamodel/flow.py
index 2ae6fbb..813eaf8 100644
--- a/pybatfish/datamodel/flow.py
+++ b/pybatfish/datamodel/flow.py
@@ -194,6 +194,34 @@ class Flow(DataModelElement):
             return ip
 
 
+@attr.s(frozen=True)
+class FlowDiff(DataModelElement):
+    """A difference between two Flows.
+
+    :ivar fieldName: A Flow field name that has changed.
+    :ivar oldValue: The old value of the field.
+    :ivar newValue: The new value of the field.
+    """
+
+    fieldName = attr.ib(type=str)
+    oldValue = attr.ib(type=str)
+    newValue = attr.ib(type=str)
+
+    @classmethod
+    def from_dict(cls, json_dict):
+        # type: (Dict) -> FlowDiff
+        return FlowDiff(json_dict["fieldName"],
+                        json_dict["oldValue"],
+                        json_dict["newValue"])
+
+    def __str__(self):
+        # type: () -> str
+        return "{fieldName}: {oldValue} -> {newValue}".format(
+            fieldName=self.fieldName,
+            oldValue=self.oldValue,
+            newValue=self.newValue)
+
+
 @attr.s(frozen=True)
 class FlowTrace(DataModelElement):
     """A trace of a flow through the network.
@@ -325,11 +353,13 @@ class ExitOutputIfaceStepDetail(DataModelElement):
 
     :ivar outputInterface: Interface of the Hop from which the flow exits
     :ivar outputFilter: Filter associated with the output interface
+    :ivar flowDiff: Set of changed flow fields
     :ivar transformedFlow: Transformed Flow if a source NAT was applied on the Flow
     """
 
     outputInterface = attr.ib(type=str)
     outputFilter = attr.ib(type=Optional[str])
+    flowDiffs = attr.ib(type=List[FlowDiff])
     transformedFlow = attr.ib(type=Optional[str])
 
     @classmethod
@@ -338,6 +368,7 @@ class ExitOutputIfaceStepDetail(DataModelElement):
         return ExitOutputIfaceStepDetail(
             json_dict.get("outputInterface", {}).get("interface"),
             json_dict.get("outputFilter"),
+            [FlowDiff.from_dict(fd) for fd in json_dict.get("flowDiffs", [])],
             json_dict.get("transformedFlow"))
 
     def __str__(self):
@@ -345,6 +376,9 @@ class ExitOutputIfaceStepDetail(DataModelElement):
         str_output = str(self.outputInterface)
         if self.outputFilter:
             str_output += ": {}".format(self.outputFilter)
+        if self.flowDiffs:
+            str_output += " " + ", ".join(
+                [str(flowDiff) for flowDiff in self.flowDiffs])
         return str_output
 
 


Test patch:
diff --git a/tests/datamodel/test_flow.py b/tests/datamodel/test_flow.py
index f312aa8..11b4a06 100644
--- a/tests/datamodel/test_flow.py
+++ b/tests/datamodel/test_flow.py
@@ -20,12 +20,43 @@ import attr
 import pytest
 
 from pybatfish.datamodel.flow import (EnterInputIfaceStepDetail,
-                                      ExitOutputIfaceStepDetail, Flow,
+                                      ExitOutputIfaceStepDetail, Flow, FlowDiff,
                                       FlowTraceHop, HeaderConstraints, Hop,
                                       MatchTcpFlags, PreSourceNatOutgoingFilterStepDetail, RoutingStepDetail, Step,
                                       TcpFlags)
 
 
+def testExitOutputIfaceStepDetail_str():
+    noDiffDetail = ExitOutputIfaceStepDetail(
+        "iface",
+        "filter",
+        None,
+        None)
+    oneDiffDetail = ExitOutputIfaceStepDetail(
+        "iface",
+        "filter",
+        [FlowDiff("field", "old", "new")],
+        None)
+    twoDiffDetail = ExitOutputIfaceStepDetail(
+        "iface",
+        "filter",
+        [FlowDiff("field1", "old1", "new1"),
+         FlowDiff("field2", "old2", "new2")],
+        None)
+
+    step = Step(noDiffDetail, "ACTION")
+    assert str(step) == "ACTION(iface: filter)"
+
+    step = Step(oneDiffDetail, "ACTION")
+    assert str(step) == "ACTION(iface: filter field: old -> new)"
+
+    step = Step(twoDiffDetail, "ACTION")
+    assert str(step) == ''.join([
+        "ACTION(iface: filter ",
+        "field1: old1 -> new1, ",
+        "field2: old2 -> new2)"])
+
+
 def testFlowDeserialization():
     hopDict = {
         "dscp": 0,
@@ -191,9 +222,10 @@ def test_hop_repr_str():
               "nextHopIp": "1.2.3.4"},
              {"network": "1.1.1.2/24", "protocol": "static",
               "nextHopIp": "1.2.3.5"}]), "FORWARDED"),
-        Step(PreSourceNatOutgoingFilterStepDetail("out_iface1", "preSourceNat_filter"),
+        Step(PreSourceNatOutgoingFilterStepDetail("out_iface1",
+                                                  "preSourceNat_filter"),
              "PERMITTED"),
-        Step(ExitOutputIfaceStepDetail("out_iface1", "out_filter1", None),
+        Step(ExitOutputIfaceStepDetail("out_iface1", "out_filter1", None, None),
              "SENT_OUT")
     ])