confiner 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 75209090e950ca1c9174bf10ef33e4c33cfbaaaa0d0f77f6cbc55735b7be90b7
4
+ data.tar.gz: c187a788996ec52e9fecd5d42de5933d298345752a4d14c060914ec21826d859
5
+ SHA512:
6
+ metadata.gz: 0e5738fd37387d6c45921c696ac32629afa04da3ac91cf42545fedda2d14fc017caf1319d753344598976f8d3204ccb0ba398c4035e809b0c16adf0a2081dd3a
7
+ data.tar.gz: b8b248eb4f1956f38d3279dde330b8b7d8ee62980796fa89fb5b8b5cb821fb4b77fe7f07bd1888dd802cbffe15fde5243ad76227e2c597e9204b444ea4d32045
data/bin/confiner ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative '../lib/confiner'
5
+
6
+ Confiner::Cli.run(*ARGV)
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'yaml'
5
+
6
+ module Confiner
7
+ class Cli
8
+ include Logger
9
+
10
+ attr_accessor :action, :plugins
11
+ attr_reader :parser, :plugin_options
12
+
13
+ def initialize(*options)
14
+ @plugins = []
15
+ @plugin_options = []
16
+ @rules_files = []
17
+ @rules = []
18
+
19
+ # default logging to standard out
20
+ Logger.log_to $stdout
21
+
22
+ if options.include?('--')
23
+ @plugin_options = options[options.index('--')..]
24
+ options = options[0..options.index('--')]
25
+ end
26
+
27
+ @parser = OptionParser.new do |opts|
28
+ opts.banner = <<~USAGE
29
+ Usage: #{$PROGRAM_NAME} [options] [-- plugin_options]
30
+
31
+ Examples:
32
+ Run all rules within .confiner directory outputting to a log file:
33
+ #{$PROGRAM_NAME} -o confiner.log
34
+ Run a specific rule file overriding specific arguments for plugins:
35
+ #{$PROGRAM_NAME} -r rules.yml -- --arg1=foo --arg2=bar
36
+ Run all all_rules within a specific directory:
37
+ #{$PROGRAM_NAME} -r ./all_rules
38
+
39
+ Options:
40
+ USAGE
41
+
42
+ opts.on('-h', '--help', 'Show the help') do
43
+ puts opts
44
+ exit 0
45
+ end
46
+
47
+ opts.on('-r RULES', '--rules RULES', 'Path to rule yaml file or directory of rules') do |rule|
48
+ rule.strip! # strip any trailing/leading spacess
49
+ rules = File.expand_path(rule)
50
+
51
+ raise "Rule file or directory `#{rules}` does not exist" unless File.exist?(rules)
52
+
53
+ @rules = if File.directory?(rules)
54
+ Dir[File.join(rules, '**', '*.yml')].each_with_object([]) do |definitions, all_rules|
55
+ all_rules << load_yaml(definitions)
56
+ end
57
+ else
58
+ [load_yaml(rules)]
59
+ end
60
+ end
61
+
62
+ opts.on('-v', '--version', 'Show the version') do
63
+ $stdout.puts "#{$PROGRAM_NAME} version #{VERSION}"
64
+ exit(0)
65
+ end
66
+
67
+ opts.on('-o OUTPUT', '--output-to OUTPUT', 'File to output the log to') do |output_to|
68
+ log_to(output_to)
69
+ end
70
+ end
71
+
72
+ @parser.parse!(options)
73
+
74
+ log :confiner, 'Program Start'
75
+
76
+ if @rules.empty?
77
+ # load any and all rules within .confiner
78
+ raise 'No rules to run. Are you missing a .confiner directory or -r argument?' unless Dir.exist?('.confiner')
79
+
80
+ @rules = Dir[File.join('.confiner', '**', '*.yml')].each_with_object([]) do |definitions, rules|
81
+ rules << load_yaml(definitions)
82
+ end
83
+ end
84
+
85
+ log :rules, 'Using rule files:'
86
+ @rules_files.each do |file|
87
+ log :loaded, file, 2
88
+ end
89
+ end
90
+
91
+ # Run the confiner
92
+ def run
93
+ @rules.each do |rules|
94
+ rules.each do |rule|
95
+ process_rule(rule)
96
+ end
97
+ end
98
+
99
+ log :confiner, 'Done'
100
+ end
101
+
102
+ def self.run(*argv)
103
+ new(*argv).run
104
+ end
105
+
106
+ private
107
+
108
+ # Process a singular rule
109
+ # @param [Hash] rule
110
+ def process_rule(rule)
111
+ log :rule, rule.keys.map { |k| "\t#{k}=#{rule[k]}" }.join(',')
112
+
113
+ rule['plugin']['args'].transform_keys!(&:to_sym) # 2.5 compatability
114
+
115
+ plugin = Plugins.const_get(translate_plugin_name(rule['plugin']['name'])).new(**rule['plugin']['args'])
116
+
117
+ # perform verification of actions before execution
118
+ rule['actions'].each do |action|
119
+ raise "YAML is invalid. Action `#{action}` does not exist." unless plugin.respond_to?(action)
120
+ end
121
+
122
+ # execute each action
123
+ rule['actions'].each do |action|
124
+ plugin.run(action) { |p| p.public_send(action) }
125
+ end
126
+ end
127
+
128
+ # Ensure that the rules are well-formed
129
+ def validate_rules(rules, file)
130
+ raise "YAML is invalid. Rules must be an array (from #{file})." unless rules.is_a? Array
131
+
132
+ rules.each do |rule|
133
+ # name is required
134
+ raise "YAML is invalid. Rule must have a name. (from #{file})" unless rule['name']
135
+
136
+ # actions are required
137
+ raise "YAML is invalid. Rule `#{rule['name']}` must have actions and it must be an Array (from #{file})" unless rule['actions']&.is_a? Array
138
+
139
+ # plugin is required and must be well-formed
140
+ raise "YAML is invalid. Rule `#{rule['name']}` must have a plugin and it must have a name (from #{file})" unless rule['plugin'] && rule['plugin']['name']
141
+
142
+ # Plugin must exist
143
+ plugin = begin
144
+ Plugins.const_get(translate_plugin_name(rule['plugin']['name']))
145
+ rescue NameError
146
+ raise "YAML is invalid. Rule `#{rule['name']}` does not have plugin `#{rule['plugin']['name']}` (from #{file})"
147
+ end
148
+
149
+ # Validate the actions
150
+ rule['actions'].each do |action|
151
+ begin
152
+ plugin.instance_method(action)
153
+ rescue NameError
154
+ raise "YAML is invalid. Rule `#{rule['name']}` plugin `#{rule['plugin']['name']}` has no action `#{action}` (from #{file})"
155
+ end
156
+ end
157
+ end
158
+ end
159
+
160
+ # Translate a plugin name from snake_case to PascalCase
161
+ def translate_plugin_name(plugin_name)
162
+ plugin_name.split('_').map(&:capitalize).join
163
+ end
164
+
165
+ # Load yaml file and validate all rules
166
+ def load_yaml(file)
167
+ raise 'File is not a YAML file' unless File.extname(file).match(/yml|yaml/i)
168
+
169
+ @rules_files << file
170
+
171
+ validate_rules(YAML.load_file(file), file)
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Confiner
4
+ # Representation of an example (test)
5
+ class Example
6
+ attr_accessor :status, :name, :classname, :file, :occurrence
7
+
8
+ def initialize(**attributes)
9
+ @status = attributes.fetch('status') { attributes.fetch(:status) }
10
+ @name = attributes.fetch('name') { attributes.fetch(:name) }
11
+ @classname = attributes.fetch('classname') { attributes.fetch(:classname) }
12
+ @file = attributes.fetch('file') { attributes.fetch(:file) }
13
+ @occurrence = attributes.fetch('occurrence') { attributes.fetch(:occurrence) }
14
+
15
+ yield(self) if block_given?
16
+ end
17
+
18
+ # Check if this example had passed
19
+ # @return [Boolean] true if the example had passed
20
+ def passed?
21
+ status == 'success'
22
+ end
23
+
24
+ # Check if this example had failed
25
+ # @return [Boolean] true if the example had failed
26
+ def failed?
27
+ status == 'failed'
28
+ end
29
+
30
+ # Check if this example had been skipped
31
+ # @return [Boolean] true if the example had been skipped
32
+ def skipped?
33
+ status == 'skipped'
34
+ end
35
+
36
+ def to_s; name; end
37
+ end
38
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Confiner
4
+ module Logger
5
+ # Log something with a specific level
6
+ def log(level, message, indentation = 1)
7
+ raise ArgumentError, 'Level must be less than 12 characters' if level.size > 12
8
+
9
+ output = "(#{Time.now.strftime('%F %H:%M:%S')})\t\e[0;35m#{level.to_s.upcase}#{' ' * (12 - level.size)}\e[m#{"\t" * indentation}#{message}"
10
+
11
+ Logger.log_to(File.open(Logger.log_to.strip, 'a+t')) if Logger.log_to.is_a?(String)
12
+ Logger.log_to.puts(output)
13
+ end
14
+
15
+ # Where to output the log
16
+ def self.log_to(file = $stdout)
17
+ @out_file ||= file
18
+ end
19
+
20
+ def run(action)
21
+ log :plugin, "#{self.class}##{action}"
22
+ super
23
+ log :plugin, "#{self.class}##{action} Done"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Confiner
4
+ # Plugin for Confiner
5
+ class Plugin
6
+ prepend Logger
7
+
8
+ attr_reader :examples
9
+
10
+ class << self
11
+ # Define arguments that the plugin will accept
12
+ # @param [Array<Symbol, Hash>] args the arguments that this plugin accepts
13
+ # @option args [Symbol] :argument the argument to pass with no default
14
+ # @option args [Hash] :argument the argument to pass with a default value
15
+ # @note the arguments should be well-formed in the .yml rule file
16
+ def arguments(*args)
17
+ @arguments ||= args
18
+
19
+ args.each do |arg|
20
+ if arg.is_a? Hash
21
+ arg.each do |a, default_value|
22
+ attr_writer a
23
+
24
+ define_method(a) do
25
+ instance_variable_get("@#{a}") || instance_variable_set("@#{a}", default_value)
26
+ end
27
+ end
28
+ else
29
+ attr_accessor arg
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ def initialize(**args)
36
+ args.each do |k, v|
37
+ self.public_send(:"#{k}=", v)
38
+ end
39
+ end
40
+
41
+ # Run the plugin
42
+ def run(action, &block)
43
+ yield self
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Confiner
4
+ module Plugins
5
+ class Debug < Plugin
6
+ arguments :arg1, :arg2
7
+
8
+ def action1
9
+ log :debug, arg1
10
+ end
11
+
12
+ def action2
13
+ log :debug, arg2
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'gitlab'
4
+
5
+ module Confiner
6
+ module Plugins
7
+ class Gitlab < Plugin
8
+ arguments :private_token, # the GitLab API Private-Token to use
9
+ :project_id, # the project where the pipelines are fetch from
10
+ :target_project, # where failure issues will be searched, and where an MR will be filed
11
+ :failure_issue_labels, # what labels to search for when searching for the failure issue
12
+ :failure_issue_prefix, # what prefix an issue has in GitLab Issues to search for the failure issue
13
+ :timeout => 10, # the timeout that HTTParty will consume (timeout of requests)
14
+ :threshold => 3, # the failure / pass threshold
15
+ :endpoint => 'https://gitlab.com/api/v4', # the GitLab API Endpoint (e.g. https://gitlab.com/api/v4)
16
+ :pwd => '.', # the path of the working directory for the examples
17
+ :ref => 'master' # the default Git ref used when updating
18
+
19
+ MERGE_REQUEST_TITLE = '[QUARANTINE] %s'
20
+ QUARANTINE_METADATA = %(, quarantine: { issue: '%s', type: :investigating })
21
+
22
+ def initialize(**args)
23
+ super
24
+
25
+ ENV['GITLAB_API_HTTPARTY_OPTIONS'] = ENV.fetch('GITLAB_API_HTTPARTY_OPTIONS') { "{read_timeout: #{timeout}}" }
26
+
27
+ @gitlab_client = ::Gitlab.client(private_token: private_token, endpoint: endpoint)
28
+ end
29
+
30
+ # Quarantine Action - Automatically Quarantine tests
31
+ def quarantine
32
+ log :gitlab, 'Beginning Quarantine Process', 2
33
+
34
+ # store the examples from the pipelines
35
+ @examples = get_examples
36
+
37
+ @examples.select(&:failed?).map(&:name).uniq.each do |failed_example|
38
+ # count the number of failures consecutively for this example
39
+
40
+ number_of_failures = @examples.select { _1.name == failed_example && _1.failed? }.size
41
+ if number_of_failures >= threshold
42
+ example = @examples.find { _1.name == failed_example }
43
+
44
+ log :quarantine, "Quarantining #{failed_example} (#{number_of_failures} >= #{threshold})", 3
45
+
46
+ # check to see if there is a merge request first
47
+ # if there is no merge request...
48
+ # - Check for an existing issue
49
+ # - Check for an existing Quarantine MR
50
+ # - Add a quarantine tag: `it 'should be quarantined', quarantine: { issue: 'https://issue', type: :investigating }`
51
+ # - File the merge request
52
+
53
+ begin
54
+ # begin the quarantining process
55
+ failure_issue = get_failure_issue_for_example(example)
56
+ file_contents = get_example_file_contents(example)
57
+ new_contents, changed_line_no = add_quarantine_metadata(file_contents, example, failure_issue)
58
+
59
+ branch = create_branch(failure_issue, example)
60
+ commit_changes(branch, example, new_contents)
61
+ create_merge_request(example, branch, changed_line_no, failure_issue)
62
+
63
+ log :quarantine, "Done Quarantining #{failed_example}", 3
64
+ rescue => e
65
+ log :fatal, "There was an issue quarantining #{example}. Error was #{e.message}\n#{e.backtrace}"
66
+ end
67
+ end
68
+ end
69
+
70
+ log :gitlab, 'Done with Quarantine Process', 2
71
+ end
72
+
73
+ # Dequarantine Action - Automatically Dequarantine tests
74
+ def dequarantine
75
+
76
+ end
77
+
78
+ private
79
+
80
+ # Get last n amount of runs
81
+ # @param [Integer] threshold the amount of pipelines to fetch
82
+ # For instance, if threshold: 3, the amount of pipelines will return 6. (3x Successful, 3x Failed)
83
+ # @return [Array<Gitlab::ObjectifiedHash>] an array of pipelines returned from GitLab
84
+ def get_last_n_runs(threshold:)
85
+ pipelines = [] # collection of both passing and failing pipelines
86
+
87
+ pipelines << @gitlab_client.pipelines(project_id, per_page: threshold, status: :success)
88
+ pipelines << @gitlab_client.pipelines(project_id, per_page: threshold, status: :failed)
89
+ end
90
+
91
+ # Get examples from pipelines
92
+ # @param [Integer] threshold the amount of pipelines to fetch
93
+ # @note The threshold defaults to default threshold multiplied by two to the amount of pipelines
94
+ # @return [Array<Example>] array of examples
95
+ def get_examples(threshold: self.threshold * 2)
96
+ examples = []
97
+
98
+ get_last_n_runs(threshold: threshold).each do |run|
99
+ run.each do |pipeline|
100
+ # fetch the pipeline test report
101
+ @gitlab_client.pipeline_test_report(project_id, pipeline['id'])['test_suites'].each do |suite|
102
+ log :suite, "Suite: #{suite['name']} (#{pipeline['web_url']})", 4
103
+ suite['test_cases'].each do |example|
104
+ examples << Example.new(**example, occurrence: { job: suite['name'], pipeline_url: pipeline['web_url'] })
105
+ log :test, example['name'], 5
106
+ end
107
+ end
108
+ end
109
+ end
110
+
111
+ examples
112
+ end
113
+
114
+ # Query the GitLab Issues API to fetch QA failure issues
115
+ # @param [String,Example] example the name of the example to search for
116
+ # @option example [String] the name of the example to search for
117
+ # @option example [Example] an instance of Example
118
+ def get_failure_issue_for_example(example)
119
+ issues = @gitlab_client.issues(target_project,
120
+ labels: failure_issue_labels,
121
+ state: :opened,
122
+ search: "#{failure_issue_prefix}#{example}",
123
+ in: :title,
124
+ per_page: 1
125
+ )
126
+
127
+ if issues.any?
128
+ log :issue, "Found issue #{issues.first['web_url']} for `#{example}`", 4
129
+
130
+ issues.first
131
+ else
132
+ log :fatal, "No failure issue exists for `#{example}`. Skipping."
133
+ end
134
+ end
135
+
136
+ # Get the file contents of an example
137
+ # @param [Example] example the example to get the contents of
138
+ def get_example_file_contents(example)
139
+ example.file = example.file.sub('./', File.join(pwd, '/')) unless pwd == '.'
140
+
141
+ @gitlab_client.file_contents(target_project, example.file)
142
+ end
143
+
144
+ # Add quarantine metadata to the file content and replace it
145
+ # @param [String] content the content to
146
+ # @param [Example] example the example to find and replace
147
+ # @param [Gitlab::ObjectifiedHash] failure_issue the failure issue
148
+ # @return [Array<String, Integer>] first value holds the new content, the second value holds the line number of the test
149
+ def add_quarantine_metadata(content, example, failure_issue)
150
+ content_to_return = content.dup
151
+
152
+ best_match = -1
153
+ lines = content_to_return.split("\n")
154
+ example.name.split.reverse.each do |word|
155
+ # given a full-descriptive RSpec example name:
156
+ # Plan Group Iterations creates a group iteration
157
+ # scan these words backwards, incrementing the index until we find the best match
158
+ new_match = content_to_return.index(word)
159
+ best_match = new_match if new_match > best_match
160
+ end
161
+
162
+ # the matched line where the example is
163
+ match = content_to_return[best_match..].split("\n").first
164
+ matched_line_no = 0
165
+
166
+ lines.each_with_index do |line, line_no|
167
+ if line.match?(match)
168
+ matched_line_no = line_no + 1
169
+
170
+ log :rspec, "Found match on line #{example.file}:#{matched_line_no}", 4
171
+
172
+ if line.include?(',')
173
+ line[line.index(',')] = QUARANTINE_METADATA % failure_issue['web_url'] << ','
174
+ else
175
+ line[line.rindex(' ')] = QUARANTINE_METADATA % failure_issue['web_url'] << ' '
176
+ end
177
+ end
178
+ end
179
+
180
+ [lines.join("\n") << "\n", matched_line_no]
181
+ end
182
+
183
+ # Create a branch from the ref
184
+ # @param [Gitlab::ObjectifiedHash] failure_issue the existing failure issue
185
+ # @return [Gitlab::ObjectifiedHash] the new branch
186
+ def create_branch(failure_issue, example)
187
+ branch = @gitlab_client.create_branch(target_project, "#{failure_issue['iid']}-quarantine-#{example.name.tr(' ', '-')}", ref)
188
+
189
+ log :branch, "Created branch #{branch['name']} (#{branch['web_url']})", 4
190
+
191
+ branch
192
+ end
193
+
194
+ # Commit changes to a branch
195
+ # @param [Gitlab::ObjectifiedHash] branch the branch to commit to
196
+ # @param [Example] example the example
197
+ # @param [Gitlab::ObjectifiedHash] new_content the new content to commit
198
+ # @return [Gitlab::ObjectifiedHash] the commit
199
+ def commit_changes(branch, example, new_content)
200
+ message = <<~COMMIT
201
+ Quarantine end-to-end test
202
+
203
+ Quarantine #{example.name}
204
+ COMMIT
205
+
206
+ commit = @gitlab_client.create_commit(target_project, branch['name'], message, [
207
+ { action: :update, file_path: example.file, content: new_content}
208
+ ])
209
+
210
+ log :commit, "Created commit #{commit['id']} (#{commit['web_url']}) on #{branch['name']}", 4
211
+
212
+ commit
213
+ end
214
+
215
+ # Create a Merge Request with a given branch
216
+ # @param [Example] example the example
217
+ # @param [Gitlab::ObjectifiedHash] branch the branch
218
+ # @param [Integer] changed_line_number the line that was changed to quarantine
219
+ # @param [Gitlab::ObjectifiedHash] failure_issue the failure issue
220
+ # @return [Gitlab::ObjectifiedHash] the created merge request
221
+ def create_merge_request(example, branch, changed_line_number, failure_issue)
222
+ occurrences = @examples.select { _1.name == example.name && _1.failed? }
223
+ markdown_occurrences = []
224
+
225
+ @examples.map do |eggsample|
226
+ if eggsample.name == example.name && eggsample.failed?
227
+ markdown_occurrences << "1. [#{eggsample.occurrence[:job]}](#{eggsample.occurrence[:pipeline_url]})"
228
+ end
229
+ end
230
+
231
+ description = <<~MARKDOWN
232
+ ## What does this MR do?
233
+
234
+ Quarantines the test `#{example.name}` (#{example.file}:#{changed_line_number})
235
+
236
+ This test has been found by [Confiner](https://gitlab.com/gitlab-org/quality/confiner) to have been failing for
237
+ more than (or equal to) #{threshold} times. This test has failed #{occurrences.size} times.
238
+
239
+ #{markdown_occurrences.join("\n")}
240
+
241
+ > #{failure_issue['web_url']}
242
+
243
+ <div align="center">
244
+ (This MR was automatically generated by [Confiner](https://gitlab.com/gitlab-org/quality/confiner) at #{Time.now.utc})
245
+ </div>
246
+ MARKDOWN
247
+
248
+ merge_request = @gitlab_client.create_merge_request(target_project, MERGE_REQUEST_TITLE % example.name, source_branch: branch['name'], target_branch: ref, description: description, labels: failure_issue_labels, squash: true, remove_source_branch: true)
249
+
250
+ log :mr, "Created merge request #{merge_request['iid']} (#{merge_request['web_url']})", 4
251
+
252
+ merge_request
253
+ end
254
+ end
255
+ end
256
+ end
data/lib/confiner.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zeitwerk'
4
+
5
+ loader = Zeitwerk::Loader.new
6
+ loader.push_dir(__dir__)
7
+ loader.setup
8
+
9
+ module Confiner
10
+ VERSION = '0.1.0'
11
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: confiner
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - GitLab Quality
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-01-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 3.10.0
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 3.10.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: zeitwerk
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 2.5.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 2.5.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: gitlab
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 4.17.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 4.17.0
55
+ description:
56
+ email:
57
+ - quality+confiner@gitlab.com
58
+ executables:
59
+ - confiner
60
+ extensions: []
61
+ extra_rdoc_files: []
62
+ files:
63
+ - bin/confiner
64
+ - lib/confiner.rb
65
+ - lib/confiner/cli.rb
66
+ - lib/confiner/example.rb
67
+ - lib/confiner/logger.rb
68
+ - lib/confiner/plugin.rb
69
+ - lib/confiner/plugins/debug.rb
70
+ - lib/confiner/plugins/gitlab.rb
71
+ homepage: https://gitlab.com/gitlab-org/quality/confiner
72
+ licenses:
73
+ - MIT
74
+ metadata: {}
75
+ post_install_message:
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '2.5'
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.2.22
91
+ signing_key:
92
+ specification_version: 4
93
+ summary: Yaml rule-based confinement
94
+ test_files: []