From 7af4f5215e28927830cbc74d383cdfeb9e4ef587 Mon Sep 17 00:00:00 2001
From: Kamil Trzcinski <ayufan@ayufan.eu>
Date: Mon, 12 Oct 2015 21:12:31 +0200
Subject: [PATCH] Show warning if build doesn't have runners with specified
 tags or runners didn't connect recently

Slightly refactor runner status detection: moving it to Runner class

Signed-off-by: Kamil Trzcinski <ayufan@ayufan.eu>
---
 CHANGELOG                                     |   1 +
 .../ci/admin/runners_controller.rb            |   4 +-
 .../projects/runners_controller.rb            |   2 +-
 app/helpers/runners_helper.rb                 |  26 ++---
 app/models/ci/build.rb                        |  12 +++
 app/models/ci/project.rb                      |   6 +-
 app/models/ci/runner.rb                       |  17 +++
 app/models/commit_status.rb                   |   4 +
 app/services/ci/register_build_service.rb     |   2 +-
 app/views/projects/builds/show.html.haml      |  21 ++++
 .../commit_statuses/_commit_status.html.haml  |   4 +
 spec/models/build_spec.rb                     | 101 ++++++++++++++++++
 spec/models/ci/project_spec.rb                |  13 +++
 spec/models/ci/runner_spec.rb                 |  65 +++++++++++
 14 files changed, 256 insertions(+), 22 deletions(-)

diff --git a/CHANGELOG b/CHANGELOG
index 9e3d21f7e54..04af9cc0f4d 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -27,6 +27,7 @@ v 8.1.0 (unreleased)
   - Move CI triggers page to project settings area
   - Move CI project settings page to CE project settings area
   - Fix bug when removed file was not appearing in merge request diff
+  - Show warning when build cannot be served by any of the available CI runners
   - Note the original location of a moved project when notifying users of the move
   - Improve error message when merging fails
   - Add support of multibyte characters in LDAP UID (Roman Petrov)
diff --git a/app/controllers/ci/admin/runners_controller.rb b/app/controllers/ci/admin/runners_controller.rb
index 9a68add9083..110954a612d 100644
--- a/app/controllers/ci/admin/runners_controller.rb
+++ b/app/controllers/ci/admin/runners_controller.rb
@@ -6,7 +6,7 @@ module Ci
       @runners = Ci::Runner.order('id DESC')
       @runners = @runners.search(params[:search]) if params[:search].present?
       @runners = @runners.page(params[:page]).per(30)
-      @active_runners_cnt = Ci::Runner.where("contacted_at > ?", 1.minutes.ago).count
+      @active_runners_cnt = Ci::Runner.online.count
     end
 
     def show
@@ -66,7 +66,7 @@ module Ci
     end
 
     def runner_params
-      params.require(:runner).permit(:token, :description, :tag_list, :contacted_at, :active)
+      params.require(:runner).permit(:token, :description, :tag_list, :active)
     end
   end
 end
diff --git a/app/controllers/projects/runners_controller.rb b/app/controllers/projects/runners_controller.rb
index 6cb6e3ef6d4..deb07a21416 100644
--- a/app/controllers/projects/runners_controller.rb
+++ b/app/controllers/projects/runners_controller.rb
@@ -60,6 +60,6 @@ class Projects::RunnersController < Projects::ApplicationController
   end
 
   def runner_params
-    params.require(:runner).permit(:description, :tag_list, :contacted_at, :active)
+    params.require(:runner).permit(:description, :tag_list, :active)
   end
 end
diff --git a/app/helpers/runners_helper.rb b/app/helpers/runners_helper.rb
index 5d7d06c8490..f79fef03ae8 100644
--- a/app/helpers/runners_helper.rb
+++ b/app/helpers/runners_helper.rb
@@ -1,20 +1,16 @@
 module RunnersHelper
   def runner_status_icon(runner)
-    unless runner.contacted_at
-      return content_tag :i, nil,
-        class: "fa fa-warning-sign",
-        title: "New runner. Has not connected yet"
-    end
-
-    status =
-      if runner.active?
-        runner.contacted_at > 3.hour.ago ? :online : :offline
-      else
-        :paused
-      end
+    status = runner.status
+    case status
+    when :not_connected
+      content_tag :i, nil,
+                  class: "fa fa-warning-sign",
+                  title: "New runner. Has not connected yet"
 
-    content_tag :i, nil,
-      class: "fa fa-circle runner-status-#{status}",
-      title: "Runner is #{status}, last contact was #{time_ago_in_words(runner.contacted_at)} ago"
+    when :online, :offline, :paused
+      content_tag :i, nil,
+                  class: "fa fa-circle runner-status-#{status}",
+                  title: "Runner is #{status}, last contact was #{time_ago_in_words(runner.contacted_at)} ago"
+    end
   end
 end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index cfafbf6786e..2c7cad46b00 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -231,6 +231,18 @@ module Ci
       end
     end
 
+    def can_be_served?(runner)
+      (tag_list - runner.tag_list).empty?
+    end
+
+    def any_runners_online?
+      project.any_runners? { |runner| runner.active? && runner.online? && can_be_served?(runner) }
+    end
+
+    def show_warning?
+      pending? && !any_runners_online?
+    end
+
     private
 
     def yaml_variables
diff --git a/app/models/ci/project.rb b/app/models/ci/project.rb
index 88ba933a434..ef28353a30c 100644
--- a/app/models/ci/project.rb
+++ b/app/models/ci/project.rb
@@ -115,12 +115,12 @@ module Ci
       web_url
     end
 
-    def any_runners?
-      if runners.active.any?
+    def any_runners?(&block)
+      if runners.active.any?(&block)
         return true
       end
 
-      shared_runners_enabled && Ci::Runner.shared.active.any?
+      shared_runners_enabled && Ci::Runner.shared.active.any?(&block)
     end
 
     def set_default_values
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 6838ccfaaab..02a3e9db1fa 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -20,6 +20,8 @@
 module Ci
   class Runner < ActiveRecord::Base
     extend Ci::Model
+
+    LAST_CONTACT_TIME = 5.minutes.ago
     
     has_many :builds, class_name: 'Ci::Build'
     has_many :runner_projects, dependent: :destroy, class_name: 'Ci::RunnerProject'
@@ -33,6 +35,7 @@ module Ci
     scope :shared, ->() { where(is_shared: true) }
     scope :active, ->() { where(active: true) }
     scope :paused, ->() { where(active: false) }
+    scope :online, ->() { where('contacted_at > ?', LAST_CONTACT_TIME) }
 
     acts_as_taggable
 
@@ -65,6 +68,20 @@ module Ci
       is_shared
     end
 
+    def online?
+      contacted_at && contacted_at > LAST_CONTACT_TIME
+    end
+
+    def status
+      if contacted_at.nil?
+        :not_connected
+      elsif active?
+        online? ? :online : :offline
+      else
+        :paused
+      end
+    end
+
     def belongs_to_one_project?
       runner_projects.count == 1
     end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index b4d91b1b0c3..92905c618eb 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -88,4 +88,8 @@ class CommitStatus < ActiveRecord::Base
   def retry_url
     nil
   end
+
+  def show_warning?
+    false
+  end
 end
diff --git a/app/services/ci/register_build_service.rb b/app/services/ci/register_build_service.rb
index 71b61bbe389..7beb098659c 100644
--- a/app/services/ci/register_build_service.rb
+++ b/app/services/ci/register_build_service.rb
@@ -17,7 +17,7 @@ module Ci
       builds = builds.order('created_at ASC')
 
       build = builds.find do |build|
-        (build.tag_list - current_runner.tag_list).empty?
+        build.can_be_served?(current_runner)
       end
 
 
diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml
index 9c3ae622b72..70a93eb5976 100644
--- a/app/views/projects/builds/show.html.haml
+++ b/app/views/projects/builds/show.html.haml
@@ -39,6 +39,27 @@
         .pull-right
           = @build.updated_at.stamp('19:00 Aug 27')
 
+  - if @build.show_warning?
+    - unless @build.any_runners_online?
+      .bs-callout.bs-callout-warning
+        %p
+          - if no_runners_for_project?(@build.project)
+            This build is stuck, because the project doesn't have runners assigned.
+          - elsif @build.tags.any?
+            This build is stuck.
+            %br
+            This build is stuck, because you don't have any active runners online with these tags assigned to the project:
+            - @build.tags.each do |tag|
+              %span.label.label-primary
+                = tag
+          - else
+            This build is stuck, because you don't have any active runners online that can run this build.
+
+          %br
+          Go to
+          = link_to namespace_project_runners_path(@build.gl_project.namespace, @build.gl_project) do
+            Runners page
+
   .row.prepend-top-default
     .col-md-9
       .clearfix
diff --git a/app/views/projects/commit_statuses/_commit_status.html.haml b/app/views/projects/commit_statuses/_commit_status.html.haml
index 7314f8e79d3..99fdde45402 100644
--- a/app/views/projects/commit_statuses/_commit_status.html.haml
+++ b/app/views/projects/commit_statuses/_commit_status.html.haml
@@ -2,6 +2,10 @@
   %td.status
     = ci_status_with_icon(commit_status.status)
 
+    - if commit_status.show_warning?
+      .pull-right
+        %i.fa.fa-warning-sign.text-warning
+
   %td.commit_status-link
     - if commit_status.target_url
       = link_to commit_status.target_url do
diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb
index d875015b991..6047171a6f1 100644
--- a/spec/models/build_spec.rb
+++ b/spec/models/build_spec.rb
@@ -273,4 +273,105 @@ describe Ci::Build do
       is_expected.to eq(['rec1', pusher_email])
     end
   end
+
+  describe :can_be_served? do
+    let(:runner) { FactoryGirl.create :ci_specific_runner }
+
+    before { build.project.runners << runner }
+
+    context 'runner without tags' do
+      it 'can handle builds without tags' do
+        expect(build.can_be_served?(runner)).to be_truthy
+      end
+
+      it 'cannot handle build with tags' do
+        build.tag_list = ['aa']
+        expect(build.can_be_served?(runner)).to be_falsey
+      end
+    end
+
+    context 'runner with tags' do
+      before { runner.tag_list = ['bb', 'cc'] }
+
+      it 'can handle builds without tags' do
+        expect(build.can_be_served?(runner)).to be_truthy
+      end
+
+      it 'can handle build with matching tags' do
+        build.tag_list = ['bb']
+        expect(build.can_be_served?(runner)).to be_truthy
+      end
+
+      it 'cannot handle build with not matching tags' do
+        build.tag_list = ['aa']
+        expect(build.can_be_served?(runner)).to be_falsey
+      end
+    end
+  end
+
+  describe :any_runners_online? do
+    subject { build.any_runners_online? }
+
+    context 'when no runners' do
+      it { is_expected.to be_falsey }
+    end
+
+    context 'if there are runner' do
+      let(:runner) { FactoryGirl.create :ci_specific_runner }
+
+      before do
+        build.project.runners << runner
+        runner.update_attributes(contacted_at: 1.second.ago)
+      end
+
+      it { is_expected.to be_truthy }
+
+      it 'that is inactive' do
+        runner.update_attributes(active: false)
+        is_expected.to be_falsey
+      end
+
+      it 'that is not online' do
+        runner.update_attributes(contacted_at: nil)
+        is_expected.to be_falsey
+      end
+
+      it 'that cannot handle build' do
+        expect_any_instance_of(Ci::Build).to receive(:can_be_served?).and_return(false)
+        is_expected.to be_falsey
+      end
+
+    end
+  end
+
+  describe :show_warning? do
+    subject { build.show_warning? }
+
+    %w(pending).each do |state|
+      context "if commit_status.status is #{state}" do
+        before { build.status = state }
+
+        it { is_expected.to be_truthy }
+
+        context "and there are specific runner" do
+          let(:runner) { FactoryGirl.create :ci_specific_runner, contacted_at: 1.second.ago }
+
+          before do
+            build.project.runners << runner
+            runner.save
+          end
+
+          it { is_expected.to be_falsey }
+        end
+      end
+    end
+
+    %w(success failed canceled running).each do |state|
+      context "if commit_status.status is #{state}" do
+        before { build.status = state }
+
+        it { is_expected.to be_falsey }
+      end
+    end
+  end
 end
diff --git a/spec/models/ci/project_spec.rb b/spec/models/ci/project_spec.rb
index dec4720a711..c1605d68859 100644
--- a/spec/models/ci/project_spec.rb
+++ b/spec/models/ci/project_spec.rb
@@ -259,5 +259,18 @@ describe Ci::Project do
       FactoryGirl.create(:ci_shared_runner)
       expect(project.any_runners?).to be_falsey
     end
+
+    it "checks the presence of specific runner" do
+      project = FactoryGirl.create(:ci_project)
+      specific_runner = FactoryGirl.create(:ci_specific_runner)
+      project.runners << specific_runner
+      expect(project.any_runners? { |runner| runner == specific_runner }).to be_truthy
+    end
+
+    it "checks the presence of shared runner" do
+      project = FactoryGirl.create(:ci_project, shared_runners_enabled: true)
+      shared_runner = FactoryGirl.create(:ci_shared_runner)
+      expect(project.any_runners? { |runner| runner == shared_runner }).to be_truthy
+    end
   end
 end
diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb
index 757593a7ab8..536a737a33d 100644
--- a/spec/models/ci/runner_spec.rb
+++ b/spec/models/ci/runner_spec.rb
@@ -48,6 +48,71 @@ describe Ci::Runner do
     it { expect(shared_runner.only_for?(project)).to be_truthy }
   end
 
+  describe :online do
+    subject { Ci::Runner.online }
+
+    before do
+      @runner1 = FactoryGirl.create(:ci_shared_runner, contacted_at: 1.year.ago)
+      @runner2 = FactoryGirl.create(:ci_shared_runner, contacted_at: 1.second.ago)
+    end
+
+    it { is_expected.to eq([@runner2])}
+  end
+
+  describe :online? do
+    let(:runner) { FactoryGirl.create(:ci_shared_runner) }
+
+    subject { runner.online? }
+
+    context 'never contacted' do
+      before { runner.contacted_at = nil }
+
+      it { is_expected.to be_falsey }
+    end
+
+    context 'contacted long time ago time' do
+      before { runner.contacted_at = 1.year.ago }
+
+      it { is_expected.to be_falsey }
+    end
+
+    context 'contacted 1s ago' do
+      before { runner.contacted_at = 1.second.ago }
+
+      it { is_expected.to be_truthy }
+    end
+  end
+
+  describe :status do
+    let(:runner) { FactoryGirl.create(:ci_shared_runner, contacted_at: 1.second.ago) }
+
+    subject { runner.status }
+
+    context 'never connected' do
+      before { runner.contacted_at = nil }
+
+      it { is_expected.to eq(:not_connected) }
+    end
+
+    context 'contacted 1s ago' do
+      before { runner.contacted_at = 1.second.ago }
+
+      it { is_expected.to eq(:online) }
+    end
+
+    context 'contacted long time ago' do
+      before { runner.contacted_at = 1.year.ago }
+
+      it { is_expected.to eq(:offline) }
+    end
+
+    context 'inactive' do
+      before { runner.active = false }
+
+      it { is_expected.to eq(:paused) }
+    end
+  end
+
   describe "belongs_to_one_project?" do
     it "returns false if there are two projects runner assigned to" do
       runner = FactoryGirl.create(:ci_specific_runner)
-- 
GitLab