diff --git a/Gemfile b/Gemfile index dd790c2..03648e5 100644 --- a/Gemfile +++ b/Gemfile @@ -63,4 +63,5 @@ group :test do gem 'vcr', '~> 2' gem 'webmock' gem "fakefs", :require => "fakefs/safe" + gem 'test_after_commit' end diff --git a/Gemfile.lock b/Gemfile.lock index 98d2ddf..899e24c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -224,6 +224,7 @@ GEM tilt (~> 1.1, != 1.3.0) sqlite3 (1.3.5) temple (0.4.0) + test_after_commit (0.0.1) therubyracer (0.10.2) libv8 (~> 3.3.10) thor (0.14.6) @@ -290,6 +291,7 @@ DEPENDENCIES sinatra slim sqlite3 + test_after_commit twitter-bootstrap-rails uglifier (>= 1.0.3) vcr (~> 2) diff --git a/app/controllers/listener_controller.rb b/app/controllers/listener_controller.rb new file mode 100644 index 0000000..bfa71fe --- /dev/null +++ b/app/controllers/listener_controller.rb @@ -0,0 +1,38 @@ +class ListenerController < ApplicationController + before_filter :validate_signature + + def github + if params[:ref] == 'refs/heads/master' + repo_name = params[:repository][:name] + owner_name = params[:repository][:owner][:name] + project_url = "git@github.com:#{owner_name}/#{repo_name}.git" + if project = Project.find_by_url(project_url) + project.jobs.create({:task => 'deploy', :branch => 'master'}) + end + end + head :no_content + end + + private + + def validate_signature + if signature = request.headers['HTTP_X_HUB_SIGNATURE'] + digest = OpenSSL::Digest::Digest.new("sha1") + signature = signature.split("=").last + + sig = OpenSSL::HMAC.hexdigest( + digest, + Strano.github_hook_secret, + request.body.read + ) + + return true if signature == OpenSSL::HMAC.hexdigest( + digest, + Strano.github_hook_secret, + request.body.read + ) + end + + head :unauthorized + end +end diff --git a/app/models/job.rb b/app/models/job.rb index b0b3991..33e1a1c 100644 --- a/app/models/job.rb +++ b/app/models/job.rb @@ -4,7 +4,7 @@ class Job < ActiveRecord::Base belongs_to :project belongs_to :user - after_create :execute_task + after_commit :execute_task, :on => :create default_scope order('created_at DESC') default_scope where(:deleted_at => nil) diff --git a/app/models/project.rb b/app/models/project.rb index 7b32cba..835f68f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -12,7 +12,8 @@ class Project < ActiveRecord::Base validate :url, :presence => true, :uniqueness => { :case_sensitive => false } before_create :ensure_allowed_repo - after_create :clone_repo + after_commit :clone_repo, :on => :create + after_commit :create_hook, :on => :create before_save :update_data after_destroy :remove_repo @@ -151,6 +152,12 @@ def clone_repo CloneRepo.perform_async id end + def create_hook + if github? && Strano.base_url + github.hook("#{Strano.base_url}listener", Strano.github_hook_secret) + end + end + def remove_repo RemoveRepo.perform_async id end diff --git a/config/routes.rb b/config/routes.rb index c25e45e..80cdbb4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,6 +12,8 @@ end end + post '/listener' => 'listener#github' + require 'sidekiq/web' mount Sidekiq::Web => '/sidekiq' diff --git a/config/strano.example.yml b/config/strano.example.yml index 34a4fac..66710e6 100644 --- a/config/strano.example.yml +++ b/config/strano.example.yml @@ -9,6 +9,14 @@ defaults: &defaults # Strano's Github application secret. See https://github.com/settings/applications github_secret: github-application-secret + # Secret used to sign hook requests from GitHub. + github_hook_secret: github-hook-secret + + # The application URL with a trailing slash, required for github hooks. + # Can be passed as environment variable STRANO_BASE_URL='' + # + # base_url: http://example.com/ + # The path to where Strano will clone your project's repos. # # clone_path: vendor/repos diff --git a/lib/github/repo.rb b/lib/github/repo.rb index baeeb80..526fc7c 100644 --- a/lib/github/repo.rb +++ b/lib/github/repo.rb @@ -12,6 +12,18 @@ def inspect end alias :to_hash :inspect + def hook(url, secret) + post "/repos/#{user_name}/#{repo_name}/hooks", { + "name" => "web", + "active" => true, + "config" => { + "url" => url, + "secret" => secret, + "content_type" => "json" + } + } + end + private @@ -20,4 +32,4 @@ def repo end end -end \ No newline at end of file +end diff --git a/lib/strano/configuration.rb b/lib/strano/configuration.rb index 935ff99..9e020a0 100644 --- a/lib/strano/configuration.rb +++ b/lib/strano/configuration.rb @@ -8,6 +8,8 @@ module Configuration :clone_path, :github_key, :github_secret, + :github_hook_secret, + :base_url, :allow_organizations, :allow_users].freeze @@ -26,6 +28,12 @@ module Configuration # https://github.com/account/applications DEFAULT_GITHUB_SECRET = nil + # Secret used to sign hook requests from GitHub. + DEFAULT_GITHUB_HOOK_SECRET = 'topsecret' + + # The application URL with a trailing slash, required for github hooks. + DEFAULT_BASE_URL = nil + # Allow project creation from repos for Github organization accounts. # Default value is true, which allows any and all organizations. Set to # false to disallow creating projects from organizations completely. @@ -117,9 +125,11 @@ def reset self.clone_path = DEFAULT_CLONE_PATH self.github_key = DEFAULT_GITHUB_KEY self.github_secret = DEFAULT_GITHUB_SECRET + self.github_hook_secret = DEFAULT_GITHUB_HOOK_SECRET + self.base_url = DEFAULT_BASE_URL self.allow_organizations = DEFAULT_ALLOW_ORGANIZATIONS self.allow_users = DEFAULT_ALLOW_USERS self end end -end \ No newline at end of file +end diff --git a/spec/controllers/listener_controller_spec.rb b/spec/controllers/listener_controller_spec.rb new file mode 100644 index 0000000..e666c1b --- /dev/null +++ b/spec/controllers/listener_controller_spec.rb @@ -0,0 +1,59 @@ +require "spec_helper" + +# helper method to simulate POST from GitHub +def post_github(request, sig=nil) + # load webhook example from file + payload = File.open(Rails.root.join('spec/fixtures/github/webhook.json'), 'r') { |f| f.read } + + # make valid signature if necessary + unless sig + digest = OpenSSL::Digest::Digest.new("sha1") + sig = OpenSSL::HMAC.hexdigest( + digest, + Strano.github_hook_secret, + payload + ) + request.env['HTTP_X_HUB_SIGNATURE'] = "sha1=#{sig}" + end + + request.env['HTTP_CONTENT_TYPE'] = 'application/json' + request.env['RAW_POST_DATA'] = payload + post :github, JSON.parse(payload) +end + +describe ListenerController do + describe "POST #github" do + context "with a valid signature" do + let(:url) { 'git@github.com:yevgenko/cap-foobar.git' } + let(:jobs) { stub(:jobs, create: true) } + let(:project) { Project.new } + + before(:each) do + project.stub(:jobs).and_return(jobs) + Project.stub(:find_by_url).and_return(project) + end + + it "should respond with 'No Content'" do + post_github(@request) + should respond_with 204 + end + + it "finds the project" do + Project.should_receive(:find_by_url).with(url) + post_github(@request) + end + + it "creates the job" do + jobs.should_receive(:create) + post_github(@request) + end + end + + context "with invalid signature" do + it "should respond with 'Unauthorized'" do + post_github(@request, 'invalid-signature') + should respond_with 401 + end + end + end +end diff --git a/spec/fixtures/github/webhook.json b/spec/fixtures/github/webhook.json new file mode 100644 index 0000000..e3aab22 --- /dev/null +++ b/spec/fixtures/github/webhook.json @@ -0,0 +1,45 @@ +{ + "pusher":{"name":"none"}, + "repository":{ + "name":"cap-foobar", + "size":112, + "created_at":"2012-12-18T02:41:31-08:00", + "has_wiki":false, + "watchers":0, + "private":false, + "fork":false, + "language":"Ruby", + "url":"https://github.com/yevgenko/cap-foobar", + "id":7221723, + "pushed_at":"2012-12-18T02:42:05-08:00", + "open_issues":0, + "has_downloads":true, + "has_issues":false, + "forks":0, + "stargazers":0, + "owner":{ + "name":"yevgenko", + "email":"craftsman@yevgenko.me" + } + }, + "forced":false, + "head_commit":{ + "modified":[], + "added":["Capfile","config/deploy.rb"], + "author":{"name":"Yevgeniy A. Viktorov","username":"yevgenko","email":"craftsman@yevgenko.me"}, + "removed":[], + "timestamp":"2012-12-18T02:41:22-08:00", + "url":"https://github.com/yevgenko/cap-foobar/commit/42ccc8a12d4db45547626e34c048990a21551981", + "id":"42ccc8a12d4db45547626e34c048990a21551981", + "distinct":true, + "message":"Initial commit", + "committer":{"name":"Yevgeniy A. Viktorov","username":"yevgenko","email":"craftsman@yevgenko.me"} + }, + "after":"42ccc8a12d4db45547626e34c048990a21551981", + "deleted":false, + "commits":[], + "ref":"refs/heads/master", + "compare":"https://github.com/yevgenko/cap-foobar/compare/42ccc8a12d4d...42ccc8a12d4d", + "before":"42ccc8a12d4db45547626e34c048990a21551981", + "created":false +} diff --git a/spec/lib/github/repo_spec.rb b/spec/lib/github/repo_spec.rb new file mode 100644 index 0000000..6652e8a --- /dev/null +++ b/spec/lib/github/repo_spec.rb @@ -0,0 +1,15 @@ +require "spec_helper" + +describe Github::Repo do + + let(:url) { 'git@github.com:yevgenko/strano.git' } + let(:repo) { Strano::Repo.new(url) } + let(:github) { Github.new('somerandomkey') } + + describe "#hook" do + use_vcr_cassette :erb => true + + it { github.repo(repo.user_name, repo.repo_name).hook('http://example.com/listener', 'secret') } + end + +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 15ac5e3..7661977 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1,26 +1,51 @@ require 'spec_helper' describe Project do - let(:url) { 'git@github.com:joelmoss/strano.git' } let(:user) { FactoryGirl.create(:user) } let(:cloned_project) { FactoryGirl.build_stubbed(:project) } before(:each) do Github.strano_user_token = user.github_access_token - @project = Project.create :url => url end - it "should set the data after save", :vcr => { :cassette_name => 'Github_Repo/_repo' } do - @project.data.should_not be_empty - end + context "#create" do + let(:project) { Project.create :url => url } - describe "#repo", :vcr => { :cassette_name => 'Github_Repo/_repo' } do - it { @project.repo.should be_a(Strano::Repo) } - end + it "should set the data after save", :vcr => { :cassette_name => 'Github_Repo/_repo' } do + project.data.should_not be_empty + end - describe "#github", :vcr => { :cassette_name => 'Github_Repo/_repo' } do - it { @project.github.should be_a(Github::Repo) } + describe "#repo", :vcr => { :cassette_name => 'Github_Repo/_repo' } do + it { project.repo.should be_a(Strano::Repo) } + end + + describe "#github", :vcr => { :cassette_name => 'Github_Repo/_repo' } do + it { project.github.should be_a(Github::Repo) } + end end + describe "#creat_hook" do + let(:project) { Project.new :url => url } + + context "when Strano.base_url is nil", :vcr => { :cassette_name => 'Github_Repo/_repo' } do + it "shouldn't create a hook" do + project.github.should_not_receive(:hook) + end + end + + context "when Strano.base_url is not nil", :vcr => { :cassette_name => 'Github_Repo/_repo' } do + before(:each) do + Strano.stub(:base_url).and_return('http://example.com/listener') + end + + it "should create a hook" do + project.github.should_receive(:hook) + end + end + + after(:each) do + project.save + end + end end diff --git a/spec/vcr_casettes/Github_Repo/_hook.yml b/spec/vcr_casettes/Github_Repo/_hook.yml new file mode 100644 index 0000000..d297061 --- /dev/null +++ b/spec/vcr_casettes/Github_Repo/_hook.yml @@ -0,0 +1,58 @@ +--- +http_interactions: +- request: + method: post + uri: https://api.github.com/repos/yevgenko/strano/hooks + body: + encoding: UTF-8 + string: ! '{"name":"web","active":true,"config":{"url":"http://example.com/listener","secret":"secret","content_type":"json"}}' + headers: + Accept: + - ! '*/*' + User-Agent: + - Strano + Content-Type: + - application/json + Authorization: + - token somerandomkey + response: + status: + code: 201 + message: Created + headers: + Server: + - nginx + Date: + - Wed, 19 Dec 2012 16:36:45 GMT + Content-Type: + - application/json; charset=utf-8 + Connection: + - keep-alive + Status: + - 201 Created + X-Oauth-Scopes: + - repo, user + Cache-Control: + - max-age=0, private, must-revalidate + X-Accepted-Oauth-Scopes: + - public_repo, repo + Content-Length: + - '422' + X-Ratelimit-Remaining: + - '4989' + Etag: + - ! '"8ae8d027cb67f2de8b24a3c571cdb05a"' + X-Content-Type-Options: + - nosniff + X-Github-Media-Type: + - github.beta + X-Ratelimit-Limit: + - '5000' + Location: + - https://api.github.com/repos/yevgenko/strano/hooks/608506 + body: + encoding: US-ASCII + string: ! '{"config":{"secret":"secret","url":"http://example.com/listener","content_type":"json"},"active":true,"events":["push"],"url":"https://api.github.com/repos/yevgenko/strano/hooks/608506","updated_at":"2012-12-19T16:36:45Z","test_url":"https://api.github.com/repos/yevgenko/strano/hooks/608506/test","last_response":{"status":"unused","message":null,"code":null},"name":"web","created_at":"2012-12-19T16:36:45Z","id":608506}' + http_version: + recorded_at: Wed, 19 Dec 2012 16:36:42 GMT +recorded_with: VCR 2.0.1