cyclid 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +174 -0
  3. data/README.md +54 -0
  4. data/app/cyclid.rb +61 -0
  5. data/app/cyclid/config.rb +38 -0
  6. data/app/cyclid/controllers.rb +123 -0
  7. data/app/cyclid/controllers/auth.rb +34 -0
  8. data/app/cyclid/controllers/auth/token.rb +78 -0
  9. data/app/cyclid/controllers/health.rb +96 -0
  10. data/app/cyclid/controllers/organizations.rb +104 -0
  11. data/app/cyclid/controllers/organizations/collection.rb +134 -0
  12. data/app/cyclid/controllers/organizations/config.rb +128 -0
  13. data/app/cyclid/controllers/organizations/document.rb +135 -0
  14. data/app/cyclid/controllers/organizations/job.rb +266 -0
  15. data/app/cyclid/controllers/organizations/members.rb +145 -0
  16. data/app/cyclid/controllers/organizations/stages.rb +251 -0
  17. data/app/cyclid/controllers/users.rb +47 -0
  18. data/app/cyclid/controllers/users/collection.rb +131 -0
  19. data/app/cyclid/controllers/users/document.rb +133 -0
  20. data/app/cyclid/health_helpers.rb +40 -0
  21. data/app/cyclid/job.rb +3 -0
  22. data/app/cyclid/job/helpers.rb +67 -0
  23. data/app/cyclid/job/job.rb +164 -0
  24. data/app/cyclid/job/runner.rb +275 -0
  25. data/app/cyclid/job/stage.rb +67 -0
  26. data/app/cyclid/log_buffer.rb +104 -0
  27. data/app/cyclid/models.rb +3 -0
  28. data/app/cyclid/models/job_record.rb +25 -0
  29. data/app/cyclid/models/organization.rb +64 -0
  30. data/app/cyclid/models/plugin_config.rb +25 -0
  31. data/app/cyclid/models/stage.rb +42 -0
  32. data/app/cyclid/models/step.rb +29 -0
  33. data/app/cyclid/models/user.rb +60 -0
  34. data/app/cyclid/models/user_permissions.rb +28 -0
  35. data/app/cyclid/monkey_patches.rb +37 -0
  36. data/app/cyclid/plugin_registry.rb +75 -0
  37. data/app/cyclid/plugins.rb +125 -0
  38. data/app/cyclid/plugins/action.rb +48 -0
  39. data/app/cyclid/plugins/action/command.rb +89 -0
  40. data/app/cyclid/plugins/action/email.rb +207 -0
  41. data/app/cyclid/plugins/action/email/html.erb +58 -0
  42. data/app/cyclid/plugins/action/email/text.erb +13 -0
  43. data/app/cyclid/plugins/action/script.rb +90 -0
  44. data/app/cyclid/plugins/action/slack.rb +129 -0
  45. data/app/cyclid/plugins/action/slack/note.erb +5 -0
  46. data/app/cyclid/plugins/api.rb +195 -0
  47. data/app/cyclid/plugins/api/github.rb +111 -0
  48. data/app/cyclid/plugins/api/github/callback.rb +66 -0
  49. data/app/cyclid/plugins/api/github/methods.rb +201 -0
  50. data/app/cyclid/plugins/api/github/status.rb +67 -0
  51. data/app/cyclid/plugins/builder.rb +80 -0
  52. data/app/cyclid/plugins/builder/mist.rb +107 -0
  53. data/app/cyclid/plugins/dispatcher.rb +89 -0
  54. data/app/cyclid/plugins/dispatcher/local.rb +167 -0
  55. data/app/cyclid/plugins/provisioner.rb +40 -0
  56. data/app/cyclid/plugins/provisioner/debian.rb +90 -0
  57. data/app/cyclid/plugins/provisioner/ubuntu.rb +98 -0
  58. data/app/cyclid/plugins/source.rb +39 -0
  59. data/app/cyclid/plugins/source/git.rb +64 -0
  60. data/app/cyclid/plugins/transport.rb +63 -0
  61. data/app/cyclid/plugins/transport/ssh.rb +155 -0
  62. data/app/cyclid/sinatra/api_helpers.rb +66 -0
  63. data/app/cyclid/sinatra/auth_helpers.rb +127 -0
  64. data/app/cyclid/sinatra/warden/strategies/api_token.rb +62 -0
  65. data/app/cyclid/sinatra/warden/strategies/basic.rb +58 -0
  66. data/app/cyclid/sinatra/warden/strategies/hmac.rb +76 -0
  67. data/app/db.rb +51 -0
  68. data/bin/cyclid-db-init +107 -0
  69. data/db/schema.rb +92 -0
  70. data/lib/cyclid/app.rb +4 -0
  71. metadata +407 -0
@@ -0,0 +1,58 @@
1
+ <html>
2
+ <head>
3
+ <title><%= info[:title] %></title>
4
+ <style>
5
+ body {
6
+ font-family: Sans-Serif;
7
+ }
8
+ table {
9
+ width: 100%;
10
+ border: 1px solid black;
11
+ border-collapse: separate;
12
+ border-radius: 3px;
13
+ }
14
+ th {
15
+ border-radius: 3px;
16
+ height: 50px;
17
+ color: white;
18
+ font-size: x-large;
19
+ background: <%= info[:color] %>;
20
+ }
21
+ td {
22
+ padding: 4px;
23
+ /*font-size: smaller;*/
24
+ }
25
+ td.title {
26
+ font-weight: bold;
27
+ width: 10%;
28
+ }
29
+ td.data {
30
+ width: 90%;
31
+ }
32
+ td.header {
33
+ border-radius: 3px;
34
+ height: 35px;
35
+ background: lightgray;
36
+ font-size: large;
37
+ font-weight: bold;
38
+ color: black;
39
+ }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <table>
44
+ <th colspan="2"><%= info[:title] %></th>
45
+ <tr><td class="title">Job ID:</td><td class="data"><%= ctx[:job_id] %></td></tr>
46
+ <tr><td class="title">Job name:</td><td class="data"><%= ctx[:job_name] %></td></tr>
47
+ <tr><td class="title">Organization:</td><td class="data"><%= ctx[:organization] %></td></tr>
48
+ <tr><td class="title">Started:</td><td class="data"><%= ctx[:start] %></td></tr>
49
+ <tr><td class="title">Ended:</td><td class="data"><%= ctx[:end] %></td></tr>
50
+ <tr><td class="header" colspan="2">Message</td></tr>
51
+ <tr><td class="data" colspan="2"><%= message %></td></tr>
52
+ <tr><td class="header" colspan="2">Context</td></tr>
53
+ <% ctx.each do |ctx_key, ctx_val| -%>
54
+ <tr><td class="title"><%= ctx_key %><td class="data"><%= ctx_val %></td></tr>
55
+ <% end -%>
56
+ </table>
57
+ </body>
58
+ </html>
@@ -0,0 +1,13 @@
1
+ Job ID: <%= ctx[:job_id] %>
2
+ Job name: <%= ctx[:job_name] %>
3
+ Organization: <%= ctx[:organization] %>
4
+ Started: <%= ctx[:start] %>
5
+ Ended: <%= ctx[:end] %>
6
+
7
+ <%= message %>
8
+
9
+ Context
10
+
11
+ <% ctx.each do |ctx_key, ctx_val| -%>
12
+ <%= ctx_key %>: <%= ctx_val %>
13
+ <% end -%>
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+ # Copyright 2016 Liqwyd Ltd.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ require 'securerandom'
17
+
18
+ # Top level module for the core Cyclid code.
19
+ module Cyclid
20
+ # Module for the Cyclid API
21
+ module API
22
+ # Module for Cyclid Plugins
23
+ module Plugins
24
+ # Script plugin
25
+ class Script < Action
26
+ def initialize(args = {})
27
+ args.symbolize_keys!
28
+
29
+ # At a bare minimum there has to be a script to execute.
30
+ raise 'a command action requires a script' unless args.include? :script
31
+
32
+ # Scripts can either be a single string, or an array of strings
33
+ # which we will join back together
34
+ @script = if args[:script].is_a? String
35
+ args[:script]
36
+ elsif args[:script].is_a? Array
37
+ args[:script].join("\n")
38
+ end
39
+
40
+ # If no explicit path was given, create a temporary filename.
41
+ # XXX This assumes the remote system has a /tmp, that it's writable
42
+ # and not mounted NOEXEC, but there's no easy way to do this?
43
+ @path = if args.include? :path
44
+ args[:path]
45
+ else
46
+ file = "cyclid_#{SecureRandom.hex(16)}"
47
+ File.join('/', 'tmp', file)
48
+ end
49
+
50
+ @env = args[:env] if args.include? :env
51
+
52
+ Cyclid.logger.debug "script: '#{@script}' path: #{@path}"
53
+ end
54
+
55
+ # Run the script action
56
+ def perform(log)
57
+ begin
58
+ # Export the environment data to the build host, if necesary
59
+ env = @env.interpolate(@ctx) if @env
60
+ @transport.export_env(env)
61
+
62
+ # Add context data
63
+ script = @script % @ctx
64
+ path = @path % @ctx
65
+
66
+ # Create an IO object containing the script and upload it to the
67
+ # build host
68
+ log.write("Uploading script to #{path}\n")
69
+
70
+ io = StringIO.new(script)
71
+ @transport.upload(io, path)
72
+
73
+ # Execute the script
74
+ log.write("Running script from #{path}...\n")
75
+ success = @transport.exec("chmod +x #{path} && #{path}")
76
+ rescue KeyError => ex
77
+ # Interpolation failed
78
+ log.write "#{ex.message}\n"
79
+ success = false
80
+ end
81
+
82
+ [success, @transport.exit_code]
83
+ end
84
+
85
+ # Register this plugin
86
+ register_plugin 'script'
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+ # Copyright 2016 Liqwyd Ltd.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ require 'slack-notifier'
17
+
18
+ # Top level module for the core Cyclid code.
19
+ module Cyclid
20
+ # Module for the Cyclid API
21
+ module API
22
+ # Module for Cyclid Plugins
23
+ module Plugins
24
+ # Slack notification plugin
25
+ class Slack < Action
26
+ def initialize(args = {})
27
+ args.symbolize_keys!
28
+
29
+ raise 'a slack action requires a subject' unless args.include? :subject
30
+
31
+ @subject = args[:subject]
32
+ @url = args[:url] if args.include? :url
33
+ @color = args[:color] || 'good'
34
+ @message = args[:message] if args.include? :message
35
+ end
36
+
37
+ # Send a Slack notification to the configured endpoint; the message is
38
+ # rendered via. an ERB template which inserts additional information
39
+ # from the context and is attached as a Slack message note
40
+ def perform(log)
41
+ begin
42
+ plugin_data = self.class.get_config(@ctx[:organization])
43
+ Cyclid.logger.debug "using plugin config #{plugin_data}"
44
+ config = plugin_data['config']
45
+
46
+ subject = @subject % @ctx
47
+
48
+ url = @url || config['webhook_url']
49
+ raise 'no webhook URL given' if url.nil?
50
+
51
+ url = url % @ctx
52
+ Cyclid.logger.debug "sending notification to #{url}"
53
+
54
+ message_text = @message % @ctx if @message
55
+
56
+ # Create a binding for the template
57
+ bind = binding
58
+ bind.local_variable_set(:ctx, @ctx)
59
+
60
+ # Generate the context information from a templete
61
+ template_path = File.expand_path(File.join(__FILE__, '..', 'slack', 'note.erb'))
62
+ template = ERB.new(File.read(template_path), nil, '%<>-')
63
+
64
+ context_text = template.result(bind)
65
+
66
+ # Create a "note" and send it as part of the message
67
+ fields = if @message
68
+ [{ title: 'Message', value: message_text }]
69
+ else
70
+ []
71
+ end
72
+ fields << { title: 'Information',
73
+ value: context_text,
74
+ short: false }
75
+
76
+ note = { fallback: message_text || subject,
77
+ color: @color,
78
+ fields: fields }
79
+
80
+ # Send the notification to the Slack webhook
81
+ notifier = ::Slack::Notifier.new url
82
+ notifier.username = 'Cyclid'
83
+
84
+ res = notifier.ping subject, attachments: [note]
85
+
86
+ rc = res.code
87
+ success = rc == '200'
88
+ rescue StandardError => ex
89
+ log.write "#{ex.message}\n"
90
+ success = false
91
+ rc = 0
92
+ end
93
+
94
+ [success, rc]
95
+ end
96
+
97
+ # Register this plugin
98
+ register_plugin 'slack'
99
+
100
+ # Static methods for handling plugin config data
101
+ class << self
102
+ # Update the plugin configuration
103
+ def update_config(current, new)
104
+ current.merge! new
105
+ end
106
+
107
+ # Default configuration for the Slack plugin
108
+ def default_config
109
+ config = {}
110
+ config['webhook_url'] = nil
111
+
112
+ return config
113
+ end
114
+
115
+ # Config schema for the Slack plugin
116
+ def config_schema
117
+ schema = []
118
+ schema << { name: 'webhook_url',
119
+ type: 'string',
120
+ description: 'Slack incoming webhook URL for your team',
121
+ default: nil }
122
+
123
+ return schema
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,5 @@
1
+ Job ID: <%= ctx[:job_id] %>
2
+ Job name: <%= ctx[:job_name] %>
3
+ Organization: <%= ctx[:organization] %>
4
+ Started: <%= ctx[:start] %>
5
+ Ended: <%= ctx[:end] %>
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+ # Copyright 2016 Liqwyd Ltd.
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ # Top level module for the core Cyclid code.
17
+ module Cyclid
18
+ # Module for the Cyclid API
19
+ module API
20
+ # Module for Cyclid Plugins
21
+ module Plugins
22
+ # Container for the Sinatra related controllers modules
23
+ module ApiExtension
24
+ # Sinatra controller; this is more complex than usual to allow the
25
+ # plugin to connect it's own set of methods as callbacks.
26
+ class Controller < Module
27
+ attr_reader :plugin_methods
28
+
29
+ def initialize(methods = nil)
30
+ @plugin_methods = methods
31
+ end
32
+
33
+ # Sinatra callback
34
+ def registered(app)
35
+ include Errors::HTTPErrors
36
+
37
+ app.get do
38
+ Cyclid.logger.debug 'ApiExtension::Controller::get'
39
+
40
+ org = Organization.find_by(name: params[:name])
41
+ halt_with_json_response(404, INVALID_ORG, 'organization does not exist') \
42
+ if org.nil?
43
+
44
+ config = controller_plugin.get_config(org)
45
+
46
+ get(http_headers(request.env), config['config'])
47
+ end
48
+
49
+ app.post do
50
+ Cyclid.logger.debug 'ApiExtension::Controller::post'
51
+
52
+ payload = parse_request_body
53
+
54
+ org = Organization.find_by(name: params[:name])
55
+ halt_with_json_response(404, INVALID_ORG, 'organization does not exist') \
56
+ if org.nil?
57
+
58
+ config = controller_plugin.get_config(org)
59
+
60
+ post(payload, http_headers(request.env), config['config'])
61
+ end
62
+
63
+ app.put do
64
+ Cyclid.logger.debug 'ApiExtension::Controller::put'
65
+
66
+ payload = parse_request_body
67
+
68
+ org = Organization.find_by(name: params[:name])
69
+ halt_with_json_response(404, INVALID_ORG, 'organization does not exist') \
70
+ if org.nil?
71
+
72
+ config = controller_plugin.get_config(org)
73
+
74
+ put(payload, http_headers(request.env), config['config'])
75
+ end
76
+
77
+ app.delete do
78
+ Cyclid.logger.debug 'ApiExtension::Controller::delete'
79
+
80
+ org = Organization.find_by(name: params[:name])
81
+ halt_with_json_response(404, INVALID_ORG, 'organization does not exist') \
82
+ if org.nil?
83
+
84
+ config = controller_plugin.get_config(org)
85
+
86
+ delete(http_headers(request.env), config['config'])
87
+ end
88
+
89
+ app.helpers do
90
+ include Helpers
91
+ include Job::Helpers
92
+ end
93
+ end
94
+ end
95
+
96
+ # Default method callbacks.
97
+ #
98
+ # The use of a 405 response here is slightly wrong as technically each
99
+ # method *is* implemented. We're supposed to send back an Allow: header
100
+ # to indicate which methods we do support, but that'd be all four of
101
+ # them...
102
+ module Methods
103
+ # GET callback
104
+ def get(_headers, _config)
105
+ authorize('get')
106
+ return_failure(405, 'not implemented')
107
+ end
108
+
109
+ # POST callback
110
+ def post(_data, _headers, _config)
111
+ authorize('post')
112
+ return_failure(405, 'not implemented')
113
+ end
114
+
115
+ # PUT callback
116
+ def put(_data, _headers, _config)
117
+ authorize('put')
118
+ return_failure(405, 'not implemented')
119
+ end
120
+
121
+ # DELETE callback
122
+ def delete(_headers, _config)
123
+ authorize('delete')
124
+ return_failure(405, 'not implemented')
125
+ end
126
+ end
127
+
128
+ # Standard helpers for API extensions. Mostly the point is to try to
129
+ # hide as much of the underlying Sinatra implementation as possible and
130
+ # simplify (& therefore control) the plugins ability to interact with
131
+ # Sinatra.
132
+ module Helpers
133
+ # Wrapper around the standard Warden authn/authz
134
+ #
135
+ # ApiExtension methods can choose to be authenticated or
136
+ # unauthenticated; for example a callback hook from an external SCM
137
+ # could accept unauthenticated POST's that trigger some action.
138
+ #
139
+ # The callback method implementations can choose to call authorize()
140
+ # if the endpoint would be authenticated, or not to call it in which
141
+ # case the method would be unauthenticated.
142
+ def authorize(method)
143
+ operation = if method.casecmp 'get'
144
+ Operations::READ
145
+ elsif method.casecmp 'put'
146
+ Operations::WRITE
147
+ elsif method.casecmp 'post' or
148
+ method.casecmp 'delete'
149
+ Operations::ADMIN
150
+ else
151
+ raise "invalid method '#{method}'"
152
+ end
153
+
154
+ authorized_for!(params[:name], operation)
155
+ end
156
+
157
+ # Return a standard Cyclid style failure.
158
+ def return_failure(code, message)
159
+ halt_with_json_response(code, Errors::HTTPErrors::PLUGIN_ERROR, message)
160
+ end
161
+
162
+ # Extract headers from the raw request & pretty them up
163
+ def http_headers(environment)
164
+ http_headers = headers
165
+ environment.each do |env|
166
+ key, value = env
167
+ match = key.match(/\AHTTP_(.*)\Z/)
168
+ next unless match
169
+
170
+ header = match[1].split('_').map(&:capitalize).join('-')
171
+ http_headers[header] = value
172
+ end
173
+
174
+ return http_headers
175
+ end
176
+ end
177
+ end
178
+
179
+ # Base class for Api plugins
180
+ class Api < Base
181
+ # Return a new instance of the Sinatra controller
182
+ def self.controller
183
+ return ApiExtension::Controller.new(ApiExtension::Methods)
184
+ end
185
+
186
+ # Return the 'human' name for the plugin type
187
+ def self.human_name
188
+ 'api'
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
194
+
195
+ require_rel 'api/*.rb'