github_snap_builder 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,254 @@
1
+ require 'sinatra/base'
2
+ require 'octokit'
3
+ require 'json'
4
+ require 'openssl' # Verifies the webhook signature
5
+ require 'jwt' # Authenticates a GitHub App
6
+ require 'time' # Gets ISO 8601 representation of a Time object
7
+ require 'logger' # Logs debug statements
8
+ require 'yaml'
9
+ require 'github_snap_builder/config'
10
+ require 'github_snap_builder/snap_builder'
11
+
12
+ module GithubSnapBuilder
13
+ if ENV.include? 'SNAP_BUILDER_CONFIG'
14
+ CONFIG = Config.new(File.read(ENV['SNAP_BUILDER_CONFIG']))
15
+ else
16
+ CONFIG = Config.new('{}')
17
+ end
18
+
19
+ class Application < Sinatra::Application
20
+ set :port, CONFIG.port
21
+ set :bind, CONFIG.bind
22
+
23
+ # Converts the newlines. Expects that the private key has been set as an
24
+ # environment variable in PEM format.
25
+ PRIVATE_KEY = OpenSSL::PKey::RSA.new(CONFIG.github_app_private_key.gsub('\n', "\n")) if CONFIG.valid?
26
+
27
+ # Your registered app must have a secret set. The secret is used to verify
28
+ # that webhooks are sent by GitHub.
29
+ WEBHOOK_SECRET = CONFIG.github_webhook_secret
30
+
31
+ # The GitHub App's identifier (type integer) set when registering an app.
32
+ APP_IDENTIFIER = CONFIG.github_app_id
33
+
34
+ # Turn on Sinatra's verbose logging during development
35
+ configure :development do
36
+ set :logging, Logger::DEBUG
37
+ end
38
+
39
+ # Executed before each request to the `/event_handler` route
40
+ before '/event_handler' do
41
+ get_payload_request(request)
42
+ verify_webhook_signature
43
+ authenticate_app
44
+ # Authenticate the app installation in order to run API operations
45
+ authenticate_installation(@payload)
46
+ end
47
+
48
+ post '/event_handler' do
49
+ if !CONFIG.valid?
50
+ logger.info "Invalid config, ignoring event..."
51
+ return 200
52
+ end
53
+
54
+ case request.env['HTTP_X_GITHUB_EVENT']
55
+ when 'pull_request'
56
+ repo = @payload['pull_request']['base']['repo']['full_name']
57
+ unless CONFIG.repos_include? repo
58
+ logger.info "Not configured for repo '#{repo}'. Ignoring event..."
59
+ return 200 # Ignored, but still successful
60
+ end
61
+
62
+ case @payload['action']
63
+ when "opened", "reopened", "synchronize"
64
+ handle_pull_request_updated_event(@payload)
65
+ end
66
+ end
67
+
68
+ 200 # success status
69
+ end
70
+
71
+ get '/logs/*' do
72
+ logfile = File.join logdir, params[:splat][0]
73
+ if File.file? logfile
74
+ send_file logfile
75
+ else
76
+ 404
77
+ end
78
+ end
79
+
80
+
81
+ helpers do
82
+
83
+ def handle_pull_request_updated_event(payload)
84
+ pull_request = payload['pull_request']
85
+ repo = pull_request['base']['repo']['full_name']
86
+ head_url = pull_request['head']['repo']['html_url']
87
+ base_url = pull_request['base']['repo']['html_url']
88
+ commit_sha = pull_request['head']['sha']
89
+ pr_number = pull_request['number']
90
+
91
+ repo_config = CONFIG.repo(repo) || raise("Config missing repo definition for '#{repo}'")
92
+ channel = repo_config.channel
93
+ token = repo_config.token || raise("'#{repo}' config missing token")
94
+
95
+ relative_logfile_path = File.join repo, "#{commit_sha}.log"
96
+ logfile_path = File.join(logdir, relative_logfile_path)
97
+ FileUtils.mkpath File.dirname(logfile_path)
98
+ FileUtils.chmod_R 0755, File.dirname(logfile_path)
99
+ build_logger = Logger.new(logfile_path)
100
+ log_url = request.url.gsub 'event_handler', "logs/#{relative_logfile_path}"
101
+ status_reporter = GithubStatusReporter.new(@installation_client, repo, commit_sha, log_url)
102
+
103
+ begin
104
+ begin
105
+ status_reporter.pending("Currently building a snap...")
106
+
107
+ builder = SnapBuilder.new(build_logger, base_url, head_url, commit_sha, CONFIG.build_type)
108
+ logger.info "Building snap for '#{repo}'"
109
+ snap_path = builder.build
110
+ rescue Error => e
111
+ logger.error "Failed to build snap: #{e.message}"
112
+ status_reporter.error("Snap failed to build.")
113
+ return
114
+ end
115
+
116
+ begin
117
+ status_reporter.pending("Currently uploading/releasing snap...")
118
+
119
+ full_channel = "#{channel}/pr-#{pr_number}"
120
+ logger.info "Pushing and releasing snap into '#{full_channel}'"
121
+ builder.release(snap_path, token, full_channel)
122
+ rescue Error => e
123
+ logger.error "Failed to push/release snap: #{e.message}"
124
+ status_reporter.error("Snap failed to upload/release.")
125
+ return
126
+ end
127
+
128
+ logger.info 'Built and released snap, all done'
129
+ status_reporter.success("Snap built and released to '#{full_channel}'")
130
+ rescue => e
131
+ logger.error "Unknown error: #{e.message}"
132
+ status_reporter.error("Encountered an error.")
133
+ raise
134
+ ensure
135
+ if !snap_path.nil? and File.file? snap_path
136
+ File.delete snap_path
137
+ end
138
+ end
139
+ end
140
+
141
+ # Saves the raw payload and converts the payload to JSON format
142
+ def get_payload_request(request)
143
+ # request.body is an IO or StringIO object
144
+ # Rewind in case someone already read it
145
+ request.body.rewind
146
+ # The raw text of the body is required for webhook signature verification
147
+ @payload_raw = request.body.read
148
+ begin
149
+ @payload = JSON.parse @payload_raw
150
+ rescue => e
151
+ fail "Invalid JSON (#{e}): #{@payload_raw}"
152
+ end
153
+ end
154
+
155
+ # Instantiate an Octokit client authenticated as a GitHub App.
156
+ # GitHub App authentication requires that you construct a
157
+ # JWT (https://jwt.io/introduction/) signed with the app's private key,
158
+ # so GitHub can be sure that it came from the app an not altererd by
159
+ # a malicious third party.
160
+ def authenticate_app
161
+ payload = {
162
+ # The time that this JWT was issued, _i.e._ now.
163
+ iat: Time.now.to_i,
164
+
165
+ # JWT expiration time (10 minute maximum)
166
+ exp: Time.now.to_i + (10 * 60),
167
+
168
+ # Your GitHub App's identifier number
169
+ iss: APP_IDENTIFIER
170
+ }
171
+
172
+ # Cryptographically sign the JWT.
173
+ jwt = JWT.encode(payload, PRIVATE_KEY, 'RS256')
174
+
175
+ # Create the Octokit client, using the JWT as the auth token.
176
+ @app_client ||= Octokit::Client.new(bearer_token: jwt)
177
+ end
178
+
179
+ # Instantiate an Octokit client, authenticated as an installation of a
180
+ # GitHub App, to run API operations.
181
+ def authenticate_installation(payload)
182
+ @installation_id = payload['installation']['id']
183
+ @installation_token = @app_client.create_app_installation_access_token(@installation_id)[:token]
184
+ @installation_client = Octokit::Client.new(bearer_token: @installation_token)
185
+ end
186
+
187
+ # Check X-Hub-Signature to confirm that this webhook was generated by
188
+ # GitHub, and not a malicious third party.
189
+ #
190
+ # GitHub uses the WEBHOOK_SECRET, registered to the GitHub App, to
191
+ # create the hash signature sent in the `X-HUB-Signature` header of each
192
+ # webhook. This code computes the expected hash signature and compares it to
193
+ # the signature sent in the `X-HUB-Signature` header. If they don't match,
194
+ # this request is an attack, and you should reject it. GitHub uses the HMAC
195
+ # hexdigest to compute the signature. The `X-HUB-Signature` looks something
196
+ # like this: "sha1=123456".
197
+ # See https://developer.github.com/webhooks/securing/ for details.
198
+ def verify_webhook_signature
199
+ their_signature_header = request.env['HTTP_X_HUB_SIGNATURE'] || 'sha1='
200
+ method, their_digest = their_signature_header.split('=')
201
+ our_digest = OpenSSL::HMAC.hexdigest(method, WEBHOOK_SECRET, @payload_raw)
202
+ halt 401 unless their_digest == our_digest
203
+
204
+ # The X-GITHUB-EVENT header provides the name of the event.
205
+ # The action value indicates the which action triggered the event.
206
+ logger.debug "---- received event #{request.env['HTTP_X_GITHUB_EVENT']}"
207
+ logger.debug "---- action #{@payload['action']}" unless @payload['action'].nil?
208
+ end
209
+
210
+ def logdir
211
+ ENV.fetch("SNAP_BUILDER_LOG_DIR", "log")
212
+ end
213
+
214
+ def logfile_path(repo, commit_sha)
215
+ end
216
+
217
+ end
218
+ end
219
+
220
+ class GithubStatusReporter
221
+ def initialize(client, repo, commit_sha, log_url)
222
+ @client = client
223
+ @repo = repo
224
+ @commit_sha = commit_sha
225
+ @log_url = log_url
226
+ end
227
+
228
+ def pending(message)
229
+ create_status 'pending', message
230
+ end
231
+
232
+ def success(message)
233
+ create_status 'success', message
234
+ end
235
+
236
+ def failure(message)
237
+ create_status 'failure', message
238
+ end
239
+
240
+ def error(message)
241
+ create_status 'error', message
242
+ end
243
+
244
+ private
245
+
246
+ def create_status(state, description)
247
+ @client.create_status(@repo, @commit_sha, state, {
248
+ context: "Snap Builder",
249
+ description: description,
250
+ target_url: @log_url
251
+ })
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,89 @@
1
+ require 'fileutils'
2
+ require 'tmpdir'
3
+ require 'tempfile'
4
+ require 'yaml'
5
+ require 'rugged'
6
+ require 'github_snap_builder'
7
+
8
+ Dir[File.join(__dir__, 'builder_implementations', '*.rb')].each {|file| require file }
9
+
10
+ module GithubSnapBuilder
11
+ class SnapBuilder
12
+ def initialize(logger, base_url, head_url, commit_sha, build_type)
13
+ @logger = logger
14
+ @base_url = base_url
15
+ @head_url = head_url
16
+ @commit_sha = commit_sha
17
+ @build_type = build_type
18
+ @base = 'core16'
19
+ end
20
+
21
+ def build
22
+ Dir.mktmpdir do |tempdir|
23
+ # First of all, clone the repository and get on the proper hash. Make
24
+ # sure to include the base repo so `git describe` has meaning.
25
+ repo = Rugged::Repository.clone_at(@base_url, tempdir)
26
+ remote = repo.remotes.create('fork', @head_url)
27
+ remote.fetch
28
+ repo.checkout(@commit_sha, {strategy: :force})
29
+
30
+ # Before we can actually build the snap, we must first determine the
31
+ # base to use. The default is "core".
32
+ snapcraft_yaml = snapcraft_yaml_location(tempdir)
33
+ @base = YAML.safe_load(File.read(snapcraft_yaml)).fetch("base", @base)
34
+ if @base == "core"
35
+ @base = "core16"
36
+ end
37
+
38
+ # Factor out any snaps that existed before we build the new one
39
+ snaps_glob = File.join(tempdir, '*.snap')
40
+ existing_snaps = Dir.glob(snaps_glob)
41
+
42
+ # Now build the snap
43
+ build_implementation.build(tempdir)
44
+
45
+ # Grab the filename of the snap we just built
46
+ new_snaps = Dir.glob(snaps_glob) - existing_snaps
47
+ if new_snaps.empty?
48
+ raise BuildFailedError
49
+ elsif new_snaps.length > 1
50
+ raise TooManySnapsError, new_snaps
51
+ end
52
+
53
+ # The directory we're in right now will be removed shortly. Copy the
54
+ # snap somewhere that will live forever, and hand it to the Snap class
55
+ # (which will remove it when it's done with it).
56
+ snap_file = Tempfile.create [@commit_sha, '.snap']
57
+ FileUtils.cp new_snaps[0], snap_file
58
+ snap_file.path
59
+ end
60
+ end
61
+
62
+ def release(snap_path, token, channel)
63
+ build_implementation.release(snap_path, token, channel)
64
+ end
65
+
66
+ def self.supported_build_types
67
+ Dir[File.join(__dir__, 'builder_implementations', '*.rb')].collect do |f|
68
+ File.basename(f, File.extname(f))
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def build_implementation
75
+ GithubSnapBuilder.const_get("#{@build_type.capitalize}Builder").new(@logger, @base)
76
+ end
77
+
78
+ def snapcraft_yaml_location(project_dir)
79
+ ["snapcraft.yaml", ".snapcraft.yaml", File.join("snap", "snapcraft.yaml")].each do |f|
80
+ path = File.join(project_dir, f)
81
+ if File.file? path
82
+ return path
83
+ end
84
+ end
85
+
86
+ raise MissingSnapcraftYaml
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,3 @@
1
+ module GithubSnapBuilder
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,69 @@
1
+ require 'github_snap_builder/version'
2
+
3
+ module GithubSnapBuilder
4
+ class Error < StandardError; end
5
+
6
+ class MissingSnapcraftError < Error
7
+ def initialize
8
+ super("snapcraft must be installed and in the PATH")
9
+ end
10
+ end
11
+
12
+ class MissingSnapFileError < Error
13
+ def initialize(path)
14
+ super("unable to find snap with path '#{path}'")
15
+ end
16
+ end
17
+
18
+ class BuildFailedError < Error
19
+ def initialize
20
+ super("build failed")
21
+ end
22
+ end
23
+
24
+ class TooManySnapsError < Error
25
+ def initialize(paths)
26
+ super("expected to find a single snap, found #{paths.length}: #{paths}")
27
+ end
28
+ end
29
+
30
+ class SnapPushError < Error
31
+ def initialize
32
+ super("failed to push/release snap")
33
+ end
34
+ end
35
+
36
+ class AuthenticationError < Error
37
+ def initialize(message)
38
+ super("failed to authenticate: #{message}")
39
+ end
40
+ end
41
+
42
+ class MissingSnapcraftYaml < Error
43
+ def initialize
44
+ super("unable to find snapcraft.yaml")
45
+ end
46
+ end
47
+
48
+ class DockerError < Error; end
49
+
50
+ class DockerVersionError < DockerError
51
+ def initialize
52
+ super("docker is either not installed or is incompatible")
53
+ end
54
+ end
55
+
56
+ class DockerRunError < DockerError
57
+ def initialize(command)
58
+ super("command in docker returned non-zero: #{command}")
59
+ end
60
+ end
61
+
62
+ class ConfigurationError < Error; end
63
+
64
+ class ConfigurationFieldError < ConfigurationError
65
+ def initialize(field)
66
+ super("configuration field is invalid: '#{field}'")
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,35 @@
1
+ # REQUIRED. Your registered app must have a secret set. The secret is used to
2
+ # verify that webhooks are sent by github and not someone else on the internet.
3
+ # github_webhook_secret: ""
4
+
5
+ # REQUIRED. The ID of the github app
6
+ # github_app_id: 12345
7
+
8
+ # REQUIRED. The private key for the app, including line breaks.
9
+ # github_app_private_key: |
10
+ # -----BEGIN RSA PRIVATE KEY-----
11
+ # nice long key
12
+ # -----END RSA PRIVATE KEY-----
13
+
14
+ # REQUIRED. Specify the type of build. Options are:
15
+ # - docker: run in an ephemeral docker container
16
+ # build_type: docker
17
+
18
+ # The port on which to listen
19
+ # port: 3000
20
+
21
+ # The address on which to listen
22
+ # bind: "0.0.0.0"
23
+
24
+ # Configure destination channel and credentials for the snap in each repo
25
+ # repos:
26
+ # repo_owner/repo_name:
27
+ # # Snaps will be released to the hotfix channel <channel>/pr-<number>
28
+ # channel: edge
29
+ #
30
+ # # REQUIRED: Snapcraft login token. Generate this with:
31
+ # # $ snapcraft export-login \
32
+ # # --snaps=<snap-name> \
33
+ # # --channels=<channel> \
34
+ # # --acls=package_push,package_release -
35
+ # token: ""
@@ -0,0 +1,85 @@
1
+ require 'fileutils'
2
+ require 'github_snap_builder/builder_implementations/docker'
3
+ require_relative '../test_helpers'
4
+
5
+ module GithubSnapBuilder
6
+ class DockerBuilderTest < SnapBuilderBaseTest
7
+ def setup
8
+ @mock_image = mock('image')
9
+ @mock_container = mock('container')
10
+ @mock_logger = mock('logger')
11
+ @mock_logger.stubs(:info)
12
+ @mock_logger.stubs(:error)
13
+ Docker.stubs(:validate_version!)
14
+ end
15
+
16
+ def test_docker_missing
17
+ Docker.expects(:validate_version!).raises(Excon::Error::Socket)
18
+
19
+ assert_raises DockerVersionError do
20
+ DockerBuilder.new(@mock_logger, 'test-base')
21
+ end
22
+ end
23
+
24
+ def test_build_success
25
+ assert_build 0
26
+ end
27
+
28
+ def test_build_failure
29
+ assert_raises DockerRunError do
30
+ assert_build 1
31
+ end
32
+ end
33
+
34
+ def test_release
35
+ Docker::Image.expects(:create).with('fromImage' => 'kyrofa/github-snap-builder:test-base').returns(@mock_image)
36
+
37
+ # Expect container based on image to be fired up
38
+ @mock_image.expects(:id).returns('1234')
39
+ Docker::Container.expects(:create).with(
40
+ 'Cmd' => ['sh', '-c', "snapcraft login --with /token && snapcraft push test.snap --release=test-channel"],
41
+ 'Image' => '1234',
42
+ 'Env' => ['SNAPCRAFT_MANAGED_HOST=yes'],
43
+ 'WorkingDir' => '/snapcraft',
44
+ 'HostConfig' => {
45
+ 'Binds' => ["/foo:/snapcraft"],
46
+ 'AutoRemove' => true,
47
+ }
48
+ ).returns(@mock_container)
49
+ @mock_container.expects(:store_file).with('/token', 'test-token')
50
+ @mock_container.expects(:start)
51
+ @mock_container.expects(:attach)
52
+ @mock_container.expects(:wait).returns({'StatusCode' => 0})
53
+ @mock_container.expects(:delete).with(force: true)
54
+
55
+ builder = DockerBuilder.new(@mock_logger, 'test-base')
56
+ builder.release("/foo/test.snap", "test-token", "test-channel")
57
+ end
58
+
59
+ private
60
+
61
+ def assert_build(status_code)
62
+ Docker::Image.expects(:create).with('fromImage' => 'kyrofa/github-snap-builder:test-base').returns(@mock_image)
63
+
64
+ # Expect container based on image to be fired up
65
+ @mock_image.expects(:id).returns('1234')
66
+ Docker::Container.expects(:create).with(
67
+ 'Cmd' => ['sh', '-c', "apt update -qq && snapcraft"],
68
+ 'Image' => '1234',
69
+ 'Env' => ['SNAPCRAFT_MANAGED_HOST=yes'],
70
+ 'WorkingDir' => '/snapcraft',
71
+ 'HostConfig' => {
72
+ 'Binds' => ["test-project-dir:/snapcraft"],
73
+ 'AutoRemove' => true,
74
+ }
75
+ ).returns(@mock_container)
76
+ @mock_container.expects(:start)
77
+ @mock_container.expects(:attach)
78
+ @mock_container.expects(:wait).returns({'StatusCode' => status_code})
79
+ @mock_container.expects(:delete).with(force: true)
80
+
81
+ builder = DockerBuilder.new(@mock_logger, 'test-base')
82
+ builder.build('test-project-dir')
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,164 @@
1
+ require 'github_snap_builder/config'
2
+ require_relative 'test_helpers'
3
+
4
+ module GithubSnapBuilder
5
+ class ConfigTest < SnapBuilderBaseTest
6
+ def setup
7
+ @config_data = {
8
+ "github_webhook_secret" => "test-secret",
9
+ "github_app_id" => 123,
10
+ "github_app_private_key" => "test-key",
11
+ "port" => 1234,
12
+ "bind" => "1.2.3.4",
13
+ "build_type" => "docker",
14
+ "repos" => {
15
+ "test/repo" => {
16
+ "channel" => "test-channel",
17
+ "token" => "test-token",
18
+ }
19
+ },
20
+ }
21
+ end
22
+
23
+ def test_valid
24
+ assert config.valid?
25
+ end
26
+
27
+ def test_github_webhook_secret
28
+ assert_equal "test-secret", config.github_webhook_secret
29
+ end
30
+
31
+ def test_github_app_id
32
+ assert_equal 123, config.github_app_id
33
+ end
34
+
35
+ def test_github_app_private_key
36
+ assert_equal "test-key", config.github_app_private_key
37
+ end
38
+
39
+ def test_port
40
+ assert_equal 1234, config.port
41
+ end
42
+
43
+ def test_bind
44
+ assert_equal "1.2.3.4", config.bind
45
+ end
46
+
47
+ def test_build_type
48
+ assert_equal "docker", config.build_type
49
+ end
50
+
51
+ def test_repos
52
+ repos = config.repos
53
+ assert_equal 1, repos.length
54
+ repo = repos[0]
55
+
56
+ assert_equal "test/repo", repo.name
57
+ assert_equal "test-channel", repo.channel
58
+ assert_equal "test-token", repo.token
59
+ end
60
+
61
+ def test_invalid_github_webhook_secret
62
+ @config_data["github_webhook_secret"] = 1
63
+ assert !config.valid?
64
+
65
+ @config_data["github_webhook_secret"] = ''
66
+ assert !config.valid?
67
+
68
+ @config_data.delete "github_webhook_secret"
69
+ assert !config.valid?
70
+ end
71
+
72
+ def test_invalid_github_app_id
73
+ @config_data["github_app_id"] = 'string'
74
+ assert !config.valid?
75
+
76
+ @config_data["github_app_id"] = 0
77
+ assert !config.valid?
78
+
79
+ @config_data.delete "github_app_id"
80
+ assert !config.valid?
81
+ end
82
+
83
+ def test_invalid_github_app_private_key
84
+ @config_data["github_app_private_key"] = 1
85
+ assert !config.valid?
86
+
87
+ @config_data["github_app_private_key"] = ''
88
+ assert !config.valid?
89
+
90
+ @config_data.delete "github_app_private_key"
91
+ assert !config.valid?
92
+ end
93
+
94
+ def test_invalid_port
95
+ @config_data["port"] = 'string'
96
+ assert !config.valid?
97
+
98
+ @config_data["port"] = 0
99
+ assert !config.valid?
100
+ end
101
+
102
+ def test_invalid_bind
103
+ @config_data["bind"] = 1
104
+ assert !config.valid?
105
+
106
+ @config_data["bind"] = ''
107
+ assert !config.valid?
108
+ end
109
+
110
+ def test_invalid_build_type
111
+ @config_data["build_type"] = 1
112
+ assert !config.valid?
113
+
114
+ @config_data["build_type"] = ''
115
+ assert !config.valid?
116
+
117
+ @config_data["build_type"] = 'invalid'
118
+ assert !config.valid?
119
+
120
+ @config_data.delete "build_type"
121
+ assert !config.valid?
122
+ end
123
+
124
+ def test_invalid_channel
125
+ @config_data["repos"]["test/repo"]["channel"] = 1
126
+ assert !config.valid?
127
+
128
+ @config_data["repos"]["test/repo"]["channel"] = ''
129
+ assert !config.valid?
130
+ end
131
+
132
+ def test_invalid_token
133
+ @config_data["repos"]["test/repo"]["token"] = 1
134
+ assert !config.valid?
135
+
136
+ @config_data["repos"]["test/repo"]["token"] = ''
137
+ assert !config.valid?
138
+
139
+ @config_data["repos"]["test/repo"].delete "token"
140
+ assert !config.valid?
141
+ end
142
+
143
+ def test_port_is_optional
144
+ @config_data.delete "port"
145
+ assert config.valid?
146
+ end
147
+
148
+ def test_bind_is_optional
149
+ @config_data.delete "bind"
150
+ assert config.valid?
151
+ end
152
+
153
+ def test_channel_is_optional
154
+ @config_data["repos"]["test/repo"].delete "channel"
155
+ assert config.valid?
156
+ end
157
+
158
+ private
159
+
160
+ def config
161
+ Config.new(@config_data.to_yaml)
162
+ end
163
+ end
164
+ end