Change mutex to counting semaphore

The mutex in zuul is great but is limited to run one job at the same
time. Some use cases like using a limited number floating licenses in
jobs cannot be handled with this. Thus this changes the mutex
functionality to a counting semaphore (which defaults to 1).

This is a port of Ida589e49bc6694f4ccc4c586e0d43b391b8c3ae4 to zuulv3
branch.

Change-Id: Icf4013a6215e2b10ca8e6309928b9e5881dda02c
diff --git a/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml b/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml
index d6f083d..60cd434 100644
--- a/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml
+++ b/tests/fixtures/config/in-repo/git/org_project/.zuul.yaml
@@ -6,3 +6,7 @@
     tenant-one-gate:
       jobs:
         - project-test1
+
+- semaphore:
+    name: test-semaphore
+    max: 1
diff --git a/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml b/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml
new file mode 100644
index 0000000..d18ed46
--- /dev/null
+++ b/tests/fixtures/config/multi-tenant-semaphore/git/common-config/zuul.yaml
@@ -0,0 +1,13 @@
+- pipeline:
+    name: check
+    manager: independent
+    source: gerrit
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
diff --git a/tests/fixtures/config/multi-tenant-semaphore/git/org_project1/README b/tests/fixtures/config/multi-tenant-semaphore/git/org_project1/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/multi-tenant-semaphore/git/org_project1/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/multi-tenant-semaphore/git/org_project2/README b/tests/fixtures/config/multi-tenant-semaphore/git/org_project2/README
new file mode 100644
index 0000000..9daeafb
--- /dev/null
+++ b/tests/fixtures/config/multi-tenant-semaphore/git/org_project2/README
@@ -0,0 +1 @@
+test
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/project-test1.yaml b/tests/fixtures/config/multi-tenant-semaphore/git/tenant-one-config/playbooks/project1-test1.yaml
similarity index 100%
copy from tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/project-test1.yaml
copy to tests/fixtures/config/multi-tenant-semaphore/git/tenant-one-config/playbooks/project1-test1.yaml
diff --git a/tests/fixtures/config/multi-tenant-semaphore/git/tenant-one-config/zuul.yaml b/tests/fixtures/config/multi-tenant-semaphore/git/tenant-one-config/zuul.yaml
new file mode 100644
index 0000000..5e377e7
--- /dev/null
+++ b/tests/fixtures/config/multi-tenant-semaphore/git/tenant-one-config/zuul.yaml
@@ -0,0 +1,13 @@
+- job:
+    name: project1-test1
+    semaphore: test-semaphore
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - project1-test1
+
+- semaphore:
+    name: test-semaphore
+    max: 1
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/project-test1.yaml b/tests/fixtures/config/multi-tenant-semaphore/git/tenant-two-config/playbooks/project2-test1.yaml
similarity index 100%
copy from tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/project-test1.yaml
copy to tests/fixtures/config/multi-tenant-semaphore/git/tenant-two-config/playbooks/project2-test1.yaml
diff --git a/tests/fixtures/config/multi-tenant-semaphore/git/tenant-two-config/zuul.yaml b/tests/fixtures/config/multi-tenant-semaphore/git/tenant-two-config/zuul.yaml
new file mode 100644
index 0000000..a310532
--- /dev/null
+++ b/tests/fixtures/config/multi-tenant-semaphore/git/tenant-two-config/zuul.yaml
@@ -0,0 +1,13 @@
+- job:
+    name: project2-test1
+    semaphore: test-semaphore
+
+- project:
+    name: org/project2
+    check:
+      jobs:
+        - project2-test1
+
+- semaphore:
+    name: test-semaphore
+    max: 2
diff --git a/tests/fixtures/config/multi-tenant-semaphore/main.yaml b/tests/fixtures/config/multi-tenant-semaphore/main.yaml
new file mode 100644
index 0000000..b1c47b1
--- /dev/null
+++ b/tests/fixtures/config/multi-tenant-semaphore/main.yaml
@@ -0,0 +1,15 @@
+- tenant:
+    name: tenant-one
+    source:
+      gerrit:
+        config-repos:
+          - common-config
+          - tenant-one-config
+
+- tenant:
+    name: tenant-two
+    source:
+      gerrit:
+        config-repos:
+          - common-config
+          - tenant-two-config
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/mutex-two.yaml b/tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/mutex-two.yaml
deleted file mode 100644
index f679dce..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/mutex-two.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-- hosts: all
-  tasks: []
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex/zuul.yaml b/tests/fixtures/config/single-tenant/git/layout-mutex/zuul.yaml
deleted file mode 100644
index bb92b7a..0000000
--- a/tests/fixtures/config/single-tenant/git/layout-mutex/zuul.yaml
+++ /dev/null
@@ -1,32 +0,0 @@
-- pipeline:
-    name: check
-    manager: independent
-    source: gerrit
-    trigger:
-      gerrit:
-        - event: patchset-created
-    success:
-      gerrit:
-        verified: 1
-    failure:
-      gerrit:
-        verified: -1
-
-- job:
-    name: project-test1
-
-- job:
-    name: mutex-one
-    mutex: test-mutex
-
-- job:
-    name: mutex-two
-    mutex: test-mutex
-
-- project:
-    name: org/project
-    check:
-      jobs:
-        - project-test1
-        - mutex-one
-        - mutex-two
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex-reconfiguration/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/playbooks/project-test1.yaml
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/layout-mutex-reconfiguration/playbooks/project-test1.yaml
rename to tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/playbooks/project-test1.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex-reconfiguration/zuul.yaml b/tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/zuul.yaml
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/layout-mutex-reconfiguration/zuul.yaml
rename to tests/fixtures/config/single-tenant/git/layout-semaphore-reconfiguration/zuul.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/project-test1.yaml
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/project-test1.yaml
rename to tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/project-test1.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-one-test1.yaml
similarity index 100%
copy from tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/project-test1.yaml
copy to tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-one-test1.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/mutex-one.yaml b/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-one-test2.yaml
similarity index 100%
rename from tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/mutex-one.yaml
rename to tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-one-test2.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/project-test1.yaml b/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-two-test1.yaml
similarity index 100%
copy from tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/project-test1.yaml
copy to tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-two-test1.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/mutex-one.yaml b/tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-two-test2.yaml
similarity index 100%
copy from tests/fixtures/config/single-tenant/git/layout-mutex/playbooks/mutex-one.yaml
copy to tests/fixtures/config/single-tenant/git/layout-semaphore/playbooks/semaphore-two-test2.yaml
diff --git a/tests/fixtures/config/single-tenant/git/layout-semaphore/zuul.yaml b/tests/fixtures/config/single-tenant/git/layout-semaphore/zuul.yaml
new file mode 100644
index 0000000..f935112
--- /dev/null
+++ b/tests/fixtures/config/single-tenant/git/layout-semaphore/zuul.yaml
@@ -0,0 +1,52 @@
+- pipeline:
+    name: check
+    manager: independent
+    source: gerrit
+    trigger:
+      gerrit:
+        - event: patchset-created
+    success:
+      gerrit:
+        verified: 1
+    failure:
+      gerrit:
+        verified: -1
+
+- job:
+    name: project-test1
+
+- job:
+    name: semaphore-one-test1
+    semaphore: test-semaphore
+
+- job:
+    name: semaphore-one-test2
+    semaphore: test-semaphore
+
+- job:
+    name: semaphore-two-test1
+    semaphore: test-semaphore-two
+
+- job:
+    name: semaphore-two-test2
+    semaphore: test-semaphore-two
+
+- project:
+    name: org/project
+    check:
+      jobs:
+        - project-test1
+        - semaphore-one-test1
+        - semaphore-one-test2
+
+- project:
+    name: org/project1
+    check:
+      jobs:
+        - project-test1
+        - semaphore-two-test1
+        - semaphore-two-test2
+
+- semaphore:
+    name: test-semaphore-two
+    max: 2
diff --git a/tests/fixtures/layout-mutex-reconfiguration.yaml b/tests/fixtures/layout-mutex-reconfiguration.yaml
deleted file mode 100644
index 76cf1e9..0000000
--- a/tests/fixtures/layout-mutex-reconfiguration.yaml
+++ /dev/null
@@ -1,23 +0,0 @@
-pipelines:
-  - name: check
-    manager: IndependentPipelineManager
-    trigger:
-      gerrit:
-        - event: patchset-created
-    success:
-      gerrit:
-        verified: 1
-    failure:
-      gerrit:
-        verified: -1
-
-jobs:
-  - name: mutex-one
-    mutex: test-mutex
-  - name: mutex-two
-    mutex: test-mutex
-
-projects:
-  - name: org/project
-    check:
-      - project-test1
diff --git a/tests/unit/test_scheduler.py b/tests/unit/test_scheduler.py
index 7de9be0..4b3fbf4 100755
--- a/tests/unit/test_scheduler.py
+++ b/tests/unit/test_scheduler.py
@@ -15,6 +15,8 @@
 # under the License.
 
 import json
+import textwrap
+
 import os
 import re
 import shutil
@@ -2177,58 +2179,68 @@
         self.assertEqual('https://server/job/project-test2/0/',
                          status_jobs[2]['report_url'])
 
-    def test_mutex(self):
-        "Test job mutexes"
-        self.updateConfigLayout('layout-mutex')
+    def test_semaphore_one(self):
+        "Test semaphores with max=1 (mutex)"
+        self.updateConfigLayout('layout-semaphore')
         self.sched.reconfigure(self.config)
 
+        self.waitUntilSettled()
+        tenant = self.sched.abide.tenants.get('openstack')
+
         self.executor_server.hold_jobs_in_build = True
+
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
         B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
-        self.assertFalse('test-mutex' in self.sched.mutex.mutexes)
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
 
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
+
         self.assertEqual(len(self.builds), 3)
         self.assertEqual(self.builds[0].name, 'project-test1')
-        self.assertEqual(self.builds[1].name, 'mutex-one')
+        self.assertEqual(self.builds[1].name, 'semaphore-one-test1')
         self.assertEqual(self.builds[2].name, 'project-test1')
 
-        self.executor_server.release('mutex-one')
+        self.executor_server.release('semaphore-one-test1')
         self.waitUntilSettled()
 
         self.assertEqual(len(self.builds), 3)
         self.assertEqual(self.builds[0].name, 'project-test1')
         self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertEqual(self.builds[2].name, 'mutex-two')
-        self.assertTrue('test-mutex' in self.sched.mutex.mutexes)
+        self.assertEqual(self.builds[2].name, 'semaphore-one-test2')
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
 
-        self.executor_server.release('mutex-two')
+        self.executor_server.release('semaphore-one-test2')
         self.waitUntilSettled()
 
         self.assertEqual(len(self.builds), 3)
         self.assertEqual(self.builds[0].name, 'project-test1')
         self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertEqual(self.builds[2].name, 'mutex-one')
-        self.assertTrue('test-mutex' in self.sched.mutex.mutexes)
+        self.assertEqual(self.builds[2].name, 'semaphore-one-test1')
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
 
-        self.executor_server.release('mutex-one')
+        self.executor_server.release('semaphore-one-test1')
         self.waitUntilSettled()
 
         self.assertEqual(len(self.builds), 3)
         self.assertEqual(self.builds[0].name, 'project-test1')
         self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertEqual(self.builds[2].name, 'mutex-two')
-        self.assertTrue('test-mutex' in self.sched.mutex.mutexes)
+        self.assertEqual(self.builds[2].name, 'semaphore-one-test2')
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
 
-        self.executor_server.release('mutex-two')
+        self.executor_server.release('semaphore-one-test2')
         self.waitUntilSettled()
 
         self.assertEqual(len(self.builds), 2)
         self.assertEqual(self.builds[0].name, 'project-test1')
         self.assertEqual(self.builds[1].name, 'project-test1')
-        self.assertFalse('test-mutex' in self.sched.mutex.mutexes)
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
 
         self.executor_server.hold_jobs_in_build = False
         self.executor_server.release()
@@ -2238,25 +2250,115 @@
 
         self.assertEqual(A.reported, 1)
         self.assertEqual(B.reported, 1)
-        self.assertFalse('test-mutex' in self.sched.mutex.mutexes)
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
 
-    def test_mutex_abandon(self):
-        "Test abandon with job mutexes"
-        self.updateConfigLayout('layout-mutex')
+    def test_semaphore_two(self):
+        "Test semaphores with max>1"
+        self.updateConfigLayout('layout-semaphore')
         self.sched.reconfigure(self.config)
 
+        self.waitUntilSettled()
+        tenant = self.sched.abide.tenants.get('openstack')
+
+        self.executor_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        self.assertFalse('test-semaphore-two' in
+                         tenant.semaphore_handler.semaphores)
+
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 4)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'semaphore-two-test1')
+        self.assertEqual(self.builds[2].name, 'semaphore-two-test2')
+        self.assertEqual(self.builds[3].name, 'project-test1')
+        self.assertTrue('test-semaphore-two' in
+                        tenant.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
+            'test-semaphore-two', [])), 2)
+
+        self.executor_server.release('semaphore-two-test1')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 4)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'semaphore-two-test2')
+        self.assertEqual(self.builds[2].name, 'project-test1')
+        self.assertEqual(self.builds[3].name, 'semaphore-two-test1')
+        self.assertTrue('test-semaphore-two' in
+                        tenant.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
+            'test-semaphore-two', [])), 2)
+
+        self.executor_server.release('semaphore-two-test2')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 4)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertEqual(self.builds[2].name, 'semaphore-two-test1')
+        self.assertEqual(self.builds[3].name, 'semaphore-two-test2')
+        self.assertTrue('test-semaphore-two' in
+                        tenant.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
+            'test-semaphore-two', [])), 2)
+
+        self.executor_server.release('semaphore-two-test1')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertEqual(self.builds[2].name, 'semaphore-two-test2')
+        self.assertTrue('test-semaphore-two' in
+                        tenant.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
+            'test-semaphore-two', [])), 1)
+
+        self.executor_server.release('semaphore-two-test2')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(self.builds[0].name, 'project-test1')
+        self.assertEqual(self.builds[1].name, 'project-test1')
+        self.assertFalse('test-semaphore-two' in
+                         tenant.semaphore_handler.semaphores)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 0)
+
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 1)
+
+    def test_semaphore_abandon(self):
+        "Test abandon with job semaphores"
+        self.updateConfigLayout('layout-semaphore')
+        self.sched.reconfigure(self.config)
+
+        self.waitUntilSettled()
+        tenant = self.sched.abide.tenants.get('openstack')
+
         self.executor_server.hold_jobs_in_build = True
 
         tenant = self.sched.abide.tenants.get('openstack')
         check_pipeline = tenant.layout.pipelines['check']
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        self.assertFalse('test-mutex' in self.sched.mutex.mutexes)
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
 
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
 
-        self.assertTrue('test-mutex' in self.sched.mutex.mutexes)
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
 
         self.fake_gerrit.addEvent(A.getChangeAbandonedEvent())
         self.waitUntilSettled()
@@ -2265,31 +2367,47 @@
         items = check_pipeline.getAllItems()
         self.assertEqual(len(items), 0)
 
-        # The mutex should be released
-        self.assertFalse('test-mutex' in self.sched.mutex.mutexes)
+        # The semaphore should be released
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
 
         self.executor_server.hold_jobs_in_build = False
         self.executor_server.release()
         self.waitUntilSettled()
 
-    def test_mutex_reconfigure(self):
-        "Test reconfigure with job mutexes"
-        self.updateConfigLayout('layout-mutex')
+    def test_semaphore_reconfigure(self):
+        "Test reconfigure with job semaphores"
+        self.updateConfigLayout('layout-semaphore')
         self.sched.reconfigure(self.config)
 
+        self.waitUntilSettled()
+        tenant = self.sched.abide.tenants.get('openstack')
+
         self.executor_server.hold_jobs_in_build = True
 
         A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
-        self.assertFalse('test-mutex' in self.sched.mutex.mutexes)
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
 
         self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
         self.waitUntilSettled()
 
-        self.assertTrue('test-mutex' in self.sched.mutex.mutexes)
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
 
-        self.updateConfigLayout('layout-mutex-reconfiguration')
+        # reconfigure without layout change
         self.sched.reconfigure(self.config)
         self.waitUntilSettled()
+        tenant = self.sched.abide.tenants.get('openstack')
+
+        # semaphore still must be held
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
+
+        self.updateConfigLayout('layout-semaphore-reconfiguration')
+        self.sched.reconfigure(self.config)
+        self.waitUntilSettled()
+        tenant = self.sched.abide.tenants.get('openstack')
 
         self.executor_server.release('project-test1')
         self.waitUntilSettled()
@@ -2297,8 +2415,9 @@
         # There should be no builds anymore
         self.assertEqual(len(self.builds), 0)
 
-        # The mutex should be released
-        self.assertFalse('test-mutex' in self.sched.mutex.mutexes)
+        # The semaphore should be released
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
 
     def test_live_reconfiguration(self):
         "Test that live reconfiguration works"
@@ -4903,3 +5022,239 @@
         self.executor_server.hold_jobs_in_build = False
         self.executor_server.release()
         self.waitUntilSettled()
+
+
+class TestSemaphoreMultiTenant(ZuulTestCase):
+    tenant_config_file = 'config/multi-tenant-semaphore/main.yaml'
+
+    def test_semaphore_tenant_isolation(self):
+        "Test semaphores in multiple tenants"
+
+        self.waitUntilSettled()
+        tenant_one = self.sched.abide.tenants.get('tenant-one')
+        tenant_two = self.sched.abide.tenants.get('tenant-two')
+
+        self.executor_server.hold_jobs_in_build = True
+        A = self.fake_gerrit.addFakeChange('org/project1', 'master', 'A')
+        B = self.fake_gerrit.addFakeChange('org/project1', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project2', 'master', 'C')
+        D = self.fake_gerrit.addFakeChange('org/project2', 'master', 'D')
+        E = self.fake_gerrit.addFakeChange('org/project2', 'master', 'E')
+        self.assertFalse('test-semaphore' in
+                         tenant_one.semaphore_handler.semaphores)
+        self.assertFalse('test-semaphore' in
+                         tenant_two.semaphore_handler.semaphores)
+
+        # add patches to project1 of tenant-one
+        self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(B.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # one build of project1-test1 must run
+        # semaphore of tenant-one must be acquired once
+        # semaphore of tenant-two must not be acquired
+        self.assertEqual(len(self.builds), 1)
+        self.assertEqual(self.builds[0].name, 'project1-test1')
+        self.assertTrue('test-semaphore' in
+                        tenant_one.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant_one.semaphore_handler.semaphores.get(
+            'test-semaphore', [])), 1)
+        self.assertFalse('test-semaphore' in
+                         tenant_two.semaphore_handler.semaphores)
+
+        # add patches to project2 of tenant-two
+        self.fake_gerrit.addEvent(C.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(D.getPatchsetCreatedEvent(1))
+        self.fake_gerrit.addEvent(E.getPatchsetCreatedEvent(1))
+        self.waitUntilSettled()
+
+        # one build of project1-test1 must run
+        # two builds of project2-test1 must run
+        # semaphore of tenant-one must be acquired once
+        # semaphore of tenant-two must be acquired twice
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project1-test1')
+        self.assertEqual(self.builds[1].name, 'project2-test1')
+        self.assertEqual(self.builds[2].name, 'project2-test1')
+        self.assertTrue('test-semaphore' in
+                        tenant_one.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant_one.semaphore_handler.semaphores.get(
+            'test-semaphore', [])), 1)
+        self.assertTrue('test-semaphore' in
+                        tenant_two.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant_two.semaphore_handler.semaphores.get(
+            'test-semaphore', [])), 2)
+
+        self.executor_server.release('project1-test1')
+        self.waitUntilSettled()
+
+        # one build of project1-test1 must run
+        # two builds of project2-test1 must run
+        # semaphore of tenant-one must be acquired once
+        # semaphore of tenant-two must be acquired twice
+        self.assertEqual(len(self.builds), 3)
+        self.assertEqual(self.builds[0].name, 'project2-test1')
+        self.assertEqual(self.builds[1].name, 'project2-test1')
+        self.assertEqual(self.builds[2].name, 'project1-test1')
+        self.assertTrue('test-semaphore' in
+                        tenant_one.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant_one.semaphore_handler.semaphores.get(
+            'test-semaphore', [])), 1)
+        self.assertTrue('test-semaphore' in
+                        tenant_two.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant_two.semaphore_handler.semaphores.get(
+            'test-semaphore', [])), 2)
+
+        self.executor_server.release('project2-test1')
+        self.waitUntilSettled()
+
+        # one build of project1-test1 must run
+        # one build of project2-test1 must run
+        # semaphore of tenant-one must be acquired once
+        # semaphore of tenant-two must be acquired once
+        self.assertEqual(len(self.builds), 2)
+        self.assertTrue('test-semaphore' in
+                        tenant_one.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant_one.semaphore_handler.semaphores.get(
+            'test-semaphore', [])), 1)
+        self.assertTrue('test-semaphore' in
+                        tenant_two.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant_two.semaphore_handler.semaphores.get(
+            'test-semaphore', [])), 1)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+
+        self.waitUntilSettled()
+
+        # no build must run
+        # semaphore of tenant-one must not be acquired
+        # semaphore of tenant-two must not be acquired
+        self.assertEqual(len(self.builds), 0)
+        self.assertFalse('test-semaphore' in
+                         tenant_one.semaphore_handler.semaphores)
+        self.assertFalse('test-semaphore' in
+                         tenant_two.semaphore_handler.semaphores)
+
+        self.assertEqual(A.reported, 1)
+        self.assertEqual(B.reported, 1)
+
+
+class TestSemaphoreInRepo(ZuulTestCase):
+    tenant_config_file = 'config/in-repo/main.yaml'
+
+    def test_semaphore_in_repo(self):
+        "Test semaphores in repo config"
+
+        # This tests dynamic semaphore handling in project repos. The semaphore
+        # max value should not be evaluated dynamically but must be updated
+        # after the change lands.
+
+        self.waitUntilSettled()
+        tenant = self.sched.abide.tenants.get('tenant-one')
+
+        in_repo_conf = textwrap.dedent(
+            """
+            - job:
+                name: project-test2
+                semaphore: test-semaphore
+
+            - project:
+                name: org/project
+                tenant-one-gate:
+                  jobs:
+                    - project-test2
+
+            # the max value in dynamic layout must be ignored
+            - semaphore:
+                name: test-semaphore
+                max: 2
+            """)
+
+        in_repo_playbook = textwrap.dedent(
+            """
+            - hosts: all
+              tasks: []
+            """)
+
+        file_dict = {'.zuul.yaml': in_repo_conf,
+                     'playbooks/project-test2.yaml': in_repo_playbook}
+        A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A',
+                                           files=file_dict)
+        B = self.fake_gerrit.addFakeChange('org/project', 'master', 'B')
+        C = self.fake_gerrit.addFakeChange('org/project', 'master', 'C')
+        B.setDependsOn(A, 1)
+        C.setDependsOn(A, 1)
+
+        self.executor_server.hold_jobs_in_build = True
+
+        A.addApproval('code-review', 2)
+        B.addApproval('code-review', 2)
+        C.addApproval('code-review', 2)
+        self.fake_gerrit.addEvent(A.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(B.addApproval('approved', 1))
+        self.fake_gerrit.addEvent(C.addApproval('approved', 1))
+        self.waitUntilSettled()
+
+        # check that the layout in a queue item still has max value of 1
+        # for test-semaphore
+        pipeline = tenant.layout.pipelines.get('tenant-one-gate')
+        queue = None
+        for queue_candidate in pipeline.queues:
+            if queue_candidate.name == 'org/project':
+                queue = queue_candidate
+                break
+        queue_item = queue.queue[0]
+        item_dynamic_layout = queue_item.current_build_set.layout
+        dynamic_test_semaphore = \
+            item_dynamic_layout.semaphores.get('test-semaphore')
+        self.assertEqual(dynamic_test_semaphore.max, 1)
+
+        # one build must be in queue, one semaphores acquired
+        self.assertEqual(len(self.builds), 1)
+        self.assertEqual(self.builds[0].name, 'project-test2')
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
+            'test-semaphore', [])), 1)
+
+        self.executor_server.release('project-test2')
+        self.waitUntilSettled()
+
+        # change A must be merged
+        self.assertEqual(A.data['status'], 'MERGED')
+        self.assertEqual(A.reported, 2)
+
+        # send change-merged event as the gerrit mock doesn't send it
+        self.fake_gerrit.addEvent(A.getChangeMergedEvent())
+        self.waitUntilSettled()
+
+        # now that change A was merged, the new semaphore max must be effective
+        tenant = self.sched.abide.tenants.get('tenant-one')
+        self.assertEqual(tenant.layout.semaphores.get('test-semaphore').max, 2)
+
+        # two builds must be in queue, two semaphores acquired
+        self.assertEqual(len(self.builds), 2)
+        self.assertEqual(self.builds[0].name, 'project-test2')
+        self.assertEqual(self.builds[1].name, 'project-test2')
+        self.assertTrue('test-semaphore' in
+                        tenant.semaphore_handler.semaphores)
+        self.assertEqual(len(tenant.semaphore_handler.semaphores.get(
+            'test-semaphore', [])), 2)
+
+        self.executor_server.release('project-test2')
+        self.waitUntilSettled()
+
+        self.assertEqual(len(self.builds), 0)
+        self.assertFalse('test-semaphore' in
+                         tenant.semaphore_handler.semaphores)
+
+        self.executor_server.hold_jobs_in_build = False
+        self.executor_server.release()
+
+        self.waitUntilSettled()
+        self.assertEqual(len(self.builds), 0)
+
+        self.assertEqual(A.reported, 2)
+        self.assertEqual(B.reported, 2)
+        self.assertEqual(C.reported, 2)