Set job class attributes in __init__

The job class attributes have grown to include complex data
structures that may accidentally be modified in-place.  Ensure
that each time a Job is constructed, it is initialized properly.

Nodesets were erroneously relying on this behavior, to correct
this, NodeSets must be comparable, so implement equality checks
for them.

Change-Id: I7eb22fc48f7106e963b12503c30a01fbfba27b11
diff --git a/tests/unit/test_model.py b/tests/unit/test_model.py
index db98d14..ae40416 100644
--- a/tests/unit/test_model.py
+++ b/tests/unit/test_model.py
@@ -62,12 +62,20 @@
             '_source_project': project,
             'name': 'base',
             'timeout': 30,
+            'nodes': [{
+                'name': 'controller',
+                'image': 'base',
+            }],
         })
         layout.addJob(base)
         python27 = configloader.JobParser.fromYaml(layout, {
             '_source_project': project,
             'name': 'python27',
             'parent': 'base',
+            'nodes': [{
+                'name': 'controller',
+                'image': 'new',
+            }],
             'timeout': 40,
         })
         layout.addJob(python27)
@@ -77,6 +85,10 @@
             'branches': [
                 'stable/diablo'
             ],
+            'nodes': [{
+                'name': 'controller',
+                'image': 'old',
+            }],
             'timeout': 50,
         })
         layout.addJob(python27diablo)
@@ -92,6 +104,7 @@
         layout.addProjectConfig(project_config, update_pipeline=False)
 
         change = model.Change(project)
+        # Test master
         change.branch = 'master'
         item = queue.enqueueChange(change)
         item.current_build_set.layout = layout
@@ -105,7 +118,11 @@
         job = item.getJobs()[0]
         self.assertEqual(job.name, 'python27')
         self.assertEqual(job.timeout, 40)
+        nodes = job.nodeset.getNodes()
+        self.assertEqual(len(nodes), 1)
+        self.assertEqual(nodes[0].image, 'new')
 
+        # Test diablo
         change.branch = 'stable/diablo'
         item = queue.enqueueChange(change)
         item.current_build_set.layout = layout
@@ -119,6 +136,9 @@
         job = item.getJobs()[0]
         self.assertEqual(job.name, 'python27')
         self.assertEqual(job.timeout, 50)
+        nodes = job.nodeset.getNodes()
+        self.assertEqual(len(nodes), 1)
+        self.assertEqual(nodes[0].image, 'old')
 
     def test_job_auth_inheritance(self):
         layout = model.Layout()
diff --git a/zuul/model.py b/zuul/model.py
index 53b98a0..189244a 100644
--- a/zuul/model.py
+++ b/zuul/model.py
@@ -436,6 +436,15 @@
         self.name = name or ''
         self.nodes = OrderedDict()
 
+    def __ne__(self, other):
+        return not self.__eq__(other)
+
+    def __eq__(self, other):
+        if not isinstance(other, NodeSet):
+            return False
+        return (self.name == other.name and
+                self.nodes == other.nodes)
+
     def copy(self):
         n = NodeSet(self.name)
         for name, node in self.nodes.items():
@@ -509,35 +518,35 @@
 class Job(object):
     """A Job represents the defintion of actions to perform."""
 
-    attributes = dict(
-        timeout=None,
-        # variables={},
-        nodeset=NodeSet(),
-        auth={},
-        workspace=None,
-        pre_run=None,
-        post_run=None,
-        voting=None,
-        hold_following_changes=None,
-        failure_message=None,
-        success_message=None,
-        failure_url=None,
-        success_url=None,
-        # Matchers.  These are separate so they can be individually
-        # overidden.
-        branch_matcher=None,
-        file_matcher=None,
-        irrelevant_file_matcher=None,  # skip-if
-        tags=set(),
-        mutex=None,
-        attempts=3,
-        source_project=None,
-        source_branch=None,
-        source_configrepo=None,
-        playbook=None,
-    )
-
     def __init__(self, name):
+        self.attributes = dict(
+            timeout=None,
+            # variables={},
+            nodeset=NodeSet(),
+            auth={},
+            workspace=None,
+            pre_run=[],
+            post_run=[],
+            voting=None,
+            hold_following_changes=None,
+            failure_message=None,
+            success_message=None,
+            failure_url=None,
+            success_url=None,
+            # Matchers.  These are separate so they can be individually
+            # overidden.
+            branch_matcher=None,
+            file_matcher=None,
+            irrelevant_file_matcher=None,  # skip-if
+            tags=set(),
+            mutex=None,
+            attempts=3,
+            source_project=None,
+            source_branch=None,
+            source_configrepo=None,
+            playbook=None,
+        )
+
         self.name = name
         for k, v in self.attributes.items():
             setattr(self, k, v)