From 881688fc1a87885b2cd14f48aaf8414964c48fec Mon Sep 17 00:00:00 2001 From: "Yevgeniy A. Viktorov" Date: Fri, 14 Dec 2012 21:41:22 +0200 Subject: [PATCH 1/4] Use after_commit callback To trigger methods only after the entire transaction is complete. --- app/models/job.rb | 2 +- app/models/project.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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..9b47f76 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -12,7 +12,7 @@ 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 before_save :update_data after_destroy :remove_repo From d72bcc5a19337f8578435307fea1b7bf09b30172 Mon Sep 17 00:00:00 2001 From: "Yevgeniy A. Viktorov" Date: Thu, 20 Dec 2012 22:32:09 +0200 Subject: [PATCH 2/4] Introduce automatic deployments for GitHub projects What it does? 1. Registers service hook when you create github project; 2. Triggers deploy task when somebody push to the master branch; To enable feature you should specify `base_url` in strano.yml or through environment variable: `STRANO_BASE_URL='http://example.com/'` --- Gemfile | 1 + Gemfile.lock | 2 + app/controllers/listener_controller.rb | 38 ++++++++++++ app/models/project.rb | 7 +++ config/routes.rb | 2 + config/strano.example.yml | 8 +++ lib/github/repo.rb | 14 ++++- lib/strano/configuration.rb | 12 +++- spec/controllers/listener_controller_spec.rb | 63 ++++++++++++++++++++ spec/fixtures/github/webhook.json | 45 ++++++++++++++ spec/lib/github/repo_spec.rb | 15 +++++ spec/models/project_spec.rb | 49 +++++++++++---- spec/vcr_casettes/Github_Repo/_hook.yml | 58 ++++++++++++++++++ 13 files changed, 300 insertions(+), 14 deletions(-) create mode 100644 app/controllers/listener_controller.rb create mode 100644 spec/controllers/listener_controller_spec.rb create mode 100644 spec/fixtures/github/webhook.json create mode 100644 spec/lib/github/repo_spec.rb create mode 100644 spec/vcr_casettes/Github_Repo/_hook.yml 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/project.rb b/app/models/project.rb index 9b47f76..835f68f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -13,6 +13,7 @@ class Project < ActiveRecord::Base before_create :ensure_allowed_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..24a1278 --- /dev/null +++ b/spec/controllers/listener_controller_spec.rb @@ -0,0 +1,63 @@ +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 + it "should respond with 'No Content'" do + post_github(@request) + should respond_with 204 + end + + it "finds the project" do + jobs = double('jobs') + jobs.stub(:create).and_return(true) + project = Project.new + project.stub(:jobs).and_return(jobs) + + url = 'git@github.com:yevgenko/cap-foobar.git' + Project.should_receive(:find_by_url).with(url).and_return(project) + + post_github(@request) + end + + it "creates the job" do + jobs = double('jobs') + project = Project.new + project.stub(:jobs).and_return(jobs) + Project.stub(:find_by_url).and_return(project) + + jobs.should_receive(:create).and_return(true) + + 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..40aa16b 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -6,21 +6,46 @@ 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 + context "after #create" do + 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 + it "should set the data after save", :vcr => { :cassette_name => 'Github_Repo/_repo' } do + @project.data.should_not be_empty + end - describe "#repo", :vcr => { :cassette_name => 'Github_Repo/_repo' } do - it { @project.repo.should be_a(Strano::Repo) } - end + 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) } + describe "#github", :vcr => { :cassette_name => 'Github_Repo/_repo' } do + it { @project.github.should be_a(Github::Repo) } + end end + describe "Strano.base_url" do + before(:each) do + Github.strano_user_token = user.github_access_token + Strano.stub(:base_url).and_return('http://example.com/listener') + @project = Project.new :url => url + end + + context "is nil", :vcr => { :cassette_name => 'Github_Repo/_repo' } do + it { @project.github.should_not_receive(:hook) } + end + + context "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 { @project.github.should_receive(:hook) } + 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 From 996b7fbd7cb35091ce514194a4760ee01cb5a9b7 Mon Sep 17 00:00:00 2001 From: "Yevgeniy A. Viktorov" Date: Mon, 24 Dec 2012 02:17:33 +0200 Subject: [PATCH 3/4] Refactor specs for ListenerController --- spec/controllers/listener_controller_spec.rb | 26 +++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/spec/controllers/listener_controller_spec.rb b/spec/controllers/listener_controller_spec.rb index 24a1278..e666c1b 100644 --- a/spec/controllers/listener_controller_spec.rb +++ b/spec/controllers/listener_controller_spec.rb @@ -24,31 +24,27 @@ def post_github(request, sig=nil) 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 - jobs = double('jobs') - jobs.stub(:create).and_return(true) - project = Project.new - project.stub(:jobs).and_return(jobs) - - url = 'git@github.com:yevgenko/cap-foobar.git' - Project.should_receive(:find_by_url).with(url).and_return(project) - + Project.should_receive(:find_by_url).with(url) post_github(@request) end it "creates the job" do - jobs = double('jobs') - project = Project.new - project.stub(:jobs).and_return(jobs) - Project.stub(:find_by_url).and_return(project) - - jobs.should_receive(:create).and_return(true) - + jobs.should_receive(:create) post_github(@request) end end From 9860f6ab6ad8a1d64496162f3f509a8ed69d496f Mon Sep 17 00:00:00 2001 From: "Yevgeniy A. Viktorov" Date: Mon, 24 Dec 2012 13:22:06 +0200 Subject: [PATCH 4/4] Refactor specs for Project model --- spec/models/project_spec.rb | 40 ++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 40aa16b..7661977 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1,51 +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) } - context "after #create" do - before(:each) do - Github.strano_user_token = user.github_access_token - @project = Project.create :url => url - end + before(:each) do + Github.strano_user_token = user.github_access_token + end + + context "#create" do + let(:project) { Project.create :url => url } it "should set the data after save", :vcr => { :cassette_name => 'Github_Repo/_repo' } do - @project.data.should_not be_empty + project.data.should_not be_empty end describe "#repo", :vcr => { :cassette_name => 'Github_Repo/_repo' } do - it { @project.repo.should be_a(Strano::Repo) } + 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) } + it { project.github.should be_a(Github::Repo) } end end - describe "Strano.base_url" do - before(:each) do - Github.strano_user_token = user.github_access_token - Strano.stub(:base_url).and_return('http://example.com/listener') - @project = Project.new :url => url - end + describe "#creat_hook" do + let(:project) { Project.new :url => url } - context "is nil", :vcr => { :cassette_name => 'Github_Repo/_repo' } do - it { @project.github.should_not_receive(:hook) } + 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 "is not nil", :vcr => { :cassette_name => 'Github_Repo/_repo' } do + 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 { @project.github.should_receive(:hook) } + it "should create a hook" do + project.github.should_receive(:hook) + end end after(:each) do - @project.save + project.save end end end