github_snap_builder 0.1.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.
@@ -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