-
Notifications
You must be signed in to change notification settings - Fork 1
/
server.rb
290 lines (237 loc) · 11.6 KB
/
server.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
require 'sinatra'
require 'logger'
require 'json'
require 'openssl'
require 'octokit'
require 'jwt'
require 'time' # This is necessary to get the ISO 8601 representation of a Time object
require 'git'
require 'simplabs/excellent'
require 'fileutils'
#
#
# This is a boilerplate server for your own GitHub App. You can read more about GitHub Apps here:
# https://developer.github.com/apps/
#
# On its own, this app demonstrates how to use the Checks API to create a CI server, but otherwise
# it doesn't do much. It's up to you to add fun functionality!
#
# Have fun! Please reach out to us TODO HOW if you have any questions, or just to show off what you've built!
#
class GHAapp < Sinatra::Application
# Never, ever, hardcode app tokens or other secrets in your code!
# Always extract from a runtime source, like an environment variable.
# Notice that the private key must be in PEM format, but the newlines should be stripped and replaced with
# the literal `\n`. This can be done in the terminal as such:
# export GITHUB_PRIVATE_KEY=`awk '{printf "%s\\n", $0}' private-key.pem`
PRIVATE_KEY = OpenSSL::PKey::RSA.new(ENV['GITHUB_PRIVATE_KEY'].gsub('\n', "\n")) # convert newlines
# You set the webhook secret when you create your app. This verifies that the webhook is really coming from GH
WEBHOOK_SECRET = ENV['GITHUB_WEBHOOK_SECRET']
# Get the app identifier—an integer—from your app page after you create your app. This isn't actually a secret,
# but it is something easier to configure at runtime
APP_IDENTIFIER = ENV['GITHUB_APP_IDENTIFIER']
########## Configure Sinatra
#
# Let's turn on verbose logging during development
#
configure :development do
set :logging, Logger::DEBUG
end
########## Before each request to our app
#
# Before each request to our app, we want to instantiate an Octokit client. Doing so requires that we construct a JWT.
# https://jwt.io/introduction/
# We have to also sign that JWT with our private key, so GitHub can be sure that
# a) it came from us
# b) it hasn't been altered by a malicious third party
#
before do
payload = {
# The time that this JWT was issued, _i.e._ now.
iat: Time.now.to_i,
# How long is the JWT good for (in seconds)?
# Let's say it can be used for 10 minutes before it needs to be refreshed.
# TODO we don't actually cache this token, we regenerate a new one every time!
exp: Time.now.to_i + (10 * 60),
# Your GitHub App's identifier number, so GitHub knows who issued the JWT, and know what permissions
# this token has.
iss: APP_IDENTIFIER
}
# Cryptographically sign the JWT
jwt = JWT.encode(payload, PRIVATE_KEY, 'RS256')
# Create the Octokit client, using the JWT as the auth token.
# Notice that this client will _not_ have sufficient permissions to do many interesting things!
# We might, for particular endpoints, need to generate an installation token (using the JWT), and instantiate
# a new client object. But we'll cross that bridge when/if we get there!
# TODO Octokit should handle that token exchange transparently for us
@client ||= Octokit::Client.new(bearer_token: jwt)
end
########## Events
#
# This is the webhook endpoint that GH will call with events, and hence where we will do our event handling
#
post '/event_handler' do
# First, a bit of security
check_signature!
# Determine what kind of event this is, and take action as appropriate
# TODO we assume that GitHub will always provide an X-GITHUB-EVENT header in this case, which is a reasonable
# assumption, however we should probably be more careful!
event = request.env['HTTP_X_GITHUB_EVENT'].to_sym
action = @payload['action'].to_sym || nil
logger.debug "---- recevied event #{event}"
logger.debug "---- action #{action}" unless action.nil?
case event
when :check_suite
# A new check_suite has been created or rerequested. Create a new check_run
if action == :requested || action == :rerequested
create_check_run
end
when :check_run
# GH confirms our new check_run has been created, or rerequested. Update it to "running" and run the linter
# TODO this code does not play nice with other apps that are doing check runs! We need to check that these check runs are our own.
# TODO does GitHub do that for us?
case action
when :created
#TODO CHECK THAT THIS IS CORRECT.
initiate_check_run if @payload['check_run']['app']['id'] == APP_IDENTIFIER
when :rerequested
# initiate_check_run
create_check_run if @payload['check_run']['app']['id'] == APP_IDENTIFIER
end
end
'ok' # we have to return _something_ ;)
end
########## Helpers
#
# These functions are going to help us do some tasks that we don't want clogging up the happy paths above, or
# that need to be done repeatedly. You can add anything you like here, really!
#
helpers do
# Create a new Check Run
def create_check_run
# First, we need to exchange our JWT for an installation token against the repository that triggered this check
# suite. This is an important bit of authentication
token = get_installation_token
installation_client = Octokit::Client.new(bearer_token: token)
# Octokit doesn't yet support the Checks API, but it does provide generic HTTP methods we can use!
# https://developer.github.com/v3/checks/runs/#create-a-check-run
result = installation_client.post("#{@payload['repository']['url']}/check-runs", {
accept: 'application/vnd.github.antiope-preview+json', # This header is necessary for beta access to Checks API
name: 'Excellent CI',
# The information we need should probably be pulled from persistent storage, but we can
# use the event that triggered the run creation. However, the structure differs depending on whether
# it was a check run or a check suite event that trigged this call.
head_branch: @payload['check_suite'].nil? ? @payload['check_run']['check_suite']['head_branch'] : @payload['check_suite']['head_branch'],
head_sha: @payload['check_suite'].nil? ? @payload['check_run']['head_sha'] : @payload['check_suite']['head_sha']
})
# We've now requested the creation of a check run from GitHub. We will wait until we get a confirmation
# from GitHub, and then kick off our CI process from there.
result.attrs
end
# Start the CI process
def initiate_check_run
token = get_installation_token
installation_client = Octokit::Client.new(bearer_token: token)
# This method is called in response to GitHub acknowledging our request to create a check run.
# We'll first update the check run to "in progress"
# Then we'll run our CI process
# Then we'll update the check run to "completed" with the CI results.
# Octokit doesn't yet support the Checks API, but it does provide generic HTTP methods we can use!
# https://developer.github.com/v3/checks/runs/#update-a-check-run
# notice the verb! PATCH!
result = installation_client.patch(@payload['check_run']['url'], {
accept: 'application/vnd.github.antiope-preview+json', # This header is necessary for beta access to Checks API
name: 'Excellent CI',
status: :in_progress
})
# DO IT!
# TODO should check `result` first
# TODO should do this async!!
# Use Git to get the code from the SHA1
sha = @payload['check_run']['head_sha']
path = "#{@payload['repository']['full_name']}"
# make sure path is empty
FileUtils.rm_rf(path)
# Use git to clone the repo.
# TODO this won't work for private repos!
# TODO Move this to use the GitHub `tree` API:
# https://developer.github.com/v3/git/trees/#get-a-tree
g = Git.clone(@payload['repository']['html_url'], sha, :path => path)
g.checkout(sha)
# Run Excellent on the code
r = Simplabs::Excellent::Runner.new()
r.check_paths([path])
# And remove the code
FileUtils.rm_rf(path)
# Was this a success?
result = r.warnings.empty? ? :success : :failure
opts = {
accept: 'application/vnd.github.antiope-preview+json', # This header is necessary for beta access to Checks API
name: 'Excellent CI',
status: :completed,
conclusion: result,
completed_at: Time.now.utc.iso8601
}
# If there were warnings generated, add them to the updated check run, with details on filenames and line numbers
if result == :failure
output = {
title: 'Excellent CI Warnings',
summary: 'There were problems with the submitted code',
annotations: []
}
r.warnings.each do |warning|
# we need to take the local relative path, and extract the repo-relative path
filename = warning.filename.split("#{path}/#{sha}/")[-1]
annotation = {
filename: filename,
blob_href: "#{@payload['repository']['blobs_url']}/#{filename}".sub('{/sha}', "/#{sha}"),
start_line: warning.line_number,
end_line: warning.line_number,
warning_level: :warning,
message: warning.message
}
output[:annotations].push annotation
end
opts[:output] = output
end
# Now, mark the check run as complete! And if there are warnings, share them
result = installation_client.patch(@payload['check_run']['url'], opts)
result.attrs
end
def get_installation_token
# We include the accept header because GH Apps are still in beta, and this header requests access to that beta
@client.create_app_installation_access_token(@payload['installation']['id'],
accept: 'application/vnd.github.machine-man-preview+json')['token']
# TODO no error checking being done here.
end
# This is code for checking the security signature that is included in all legitimate webhooks calls from GitHub
def check_signature!
request.body.rewind
payload_raw = request.body.read # We need the raw text of the body to check the webhook signature
begin
@payload = JSON.parse payload_raw
rescue
@payload = {}
end
# Check X-Hub-Signature to confirm that this webhook was generated by GitHub, and not a malicious third party.
# The way this works is: We have registered with GitHub a secret, and we have stored it locally in WEBHOOK_SECRET.
# GitHub will cryptographically sign the request payload with this secret. We will do the same, and if the results
# match, then we know that the request is from GitHub (or, at least, from someone who knows the secret!)
# If they don't match, this request is an attack, and we should reject it.
# The signature comes in with header x-hub-signature, and looks like "sha1=123456"
# We should take the left hand side as the signature method, and the right hand side as the
# HMAC digest (the signature) itself.
their_signature_header = request.env['HTTP_X_HUB_SIGNATURE'] || 'sha1='
method, their_digest = their_signature_header.split('=')
our_digest = OpenSSL::HMAC.hexdigest(method, WEBHOOK_SECRET, payload_raw)
halt 401 unless their_digest == our_digest
@payload
end
end
# Finally some logic to let us run this server directly from the commandline, or with Rack
# Don't worry too much about this code ;) But, for the curious:
# $0 is the executed file
# __FILE__ is the current file
# If they are the same—that is, we are running this file directly, call the Sinatra run method
run! if __FILE__ == $0
end