jenkins_pipeline_builder 0.15.4 → 0.16.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.
- checksums.yaml +5 -13
- data/example/pipeline/Example-Pipeline.yaml +21 -21
- data/example/pipeline/Example-Promotion.yaml +32 -0
- data/example/pipeline/Example-Release.yaml +2 -0
- data/example/pipeline/project.yaml +2 -0
- data/jenkins_pipeline_builder.gemspec +1 -0
- data/lib/jenkins_pipeline_builder.rb +5 -0
- data/lib/jenkins_pipeline_builder/cli/describe.rb +5 -3
- data/lib/jenkins_pipeline_builder/custom_errors.rb +20 -0
- data/lib/jenkins_pipeline_builder/extension_dsl.rb +2 -2
- data/lib/jenkins_pipeline_builder/extension_set.rb +12 -5
- data/lib/jenkins_pipeline_builder/extensions.rb +7 -6
- data/lib/jenkins_pipeline_builder/extensions/build_steps.rb +93 -0
- data/lib/jenkins_pipeline_builder/extensions/helpers/build_steps/triggered_job.rb +36 -0
- data/lib/jenkins_pipeline_builder/extensions/helpers/builders/blocking_downstream_helper.rb +2 -2
- data/lib/jenkins_pipeline_builder/extensions/helpers/builders/maven3_helper.rb +2 -2
- data/lib/jenkins_pipeline_builder/extensions/helpers/extension_helper.rb +7 -7
- data/lib/jenkins_pipeline_builder/extensions/helpers/triggers/upstream_helper.rb +2 -2
- data/lib/jenkins_pipeline_builder/extensions/job_attributes.rb +60 -1
- data/lib/jenkins_pipeline_builder/extensions/promotion_conditions.rb +80 -0
- data/lib/jenkins_pipeline_builder/generator.rb +65 -57
- data/lib/jenkins_pipeline_builder/job.rb +1 -7
- data/lib/jenkins_pipeline_builder/job_collection.rb +16 -14
- data/lib/jenkins_pipeline_builder/module_registry.rb +4 -4
- data/lib/jenkins_pipeline_builder/promotion.rb +82 -0
- data/lib/jenkins_pipeline_builder/utils.rb +6 -0
- data/lib/jenkins_pipeline_builder/version.rb +1 -1
- data/lib/jenkins_pipeline_builder/view.rb +1 -4
- data/spec/lib/jenkins_pipeline_builder/extensions/build_steps_spec.rb +198 -0
- data/spec/lib/jenkins_pipeline_builder/extensions/builders_spec.rb +1 -3
- data/spec/lib/jenkins_pipeline_builder/extensions/job_attributes_spec.rb +63 -0
- data/spec/lib/jenkins_pipeline_builder/extensions/promotion_conditions_spec.rb +281 -0
- data/spec/lib/jenkins_pipeline_builder/extensions/registered_spec.rb +11 -3
- data/spec/lib/jenkins_pipeline_builder/fixtures/generator_tests/sample_pipeline/SamplePipeline-30-Release.yaml +3 -1
- data/spec/lib/jenkins_pipeline_builder/fixtures/generator_tests/sample_pipeline/SamplePipeline-40-Promotion.yaml +31 -0
- data/spec/lib/jenkins_pipeline_builder/fixtures/generator_tests/sample_pipeline/project.yaml +3 -1
- data/spec/lib/jenkins_pipeline_builder/fixtures/promotions_test/sample_promotion.yaml +31 -0
- data/spec/lib/jenkins_pipeline_builder/generator_spec.rb +2 -2
- data/spec/lib/jenkins_pipeline_builder/job_spec.rb +1 -19
- data/spec/lib/jenkins_pipeline_builder/promotion_spec.rb +88 -0
- data/spec/lib/jenkins_pipeline_builder/spec_helper.rb +6 -0
- metadata +86 -59
- data/lib/jenkins_pipeline_builder/project.rb +0 -26
@@ -1,7 +1,7 @@
|
|
1
1
|
class BlockingDownstreamHelper < ExtensionHelper
|
2
2
|
attr_reader :colors
|
3
|
-
def initialize(params, builder)
|
4
|
-
super params, builder, defaults
|
3
|
+
def initialize(extension, params, builder)
|
4
|
+
super extension, params, builder, defaults
|
5
5
|
@colors = {
|
6
6
|
'SUCCESS' => { ordinal: 0, color: 'BLUE' },
|
7
7
|
'FAILURE' => { ordinal: 2, color: 'RED' },
|
@@ -1,7 +1,6 @@
|
|
1
1
|
class ExtensionHelper < SimpleDelegator
|
2
2
|
attr_reader :params, :builder
|
3
|
-
|
4
|
-
def initialize(params, builder, defaults = {})
|
3
|
+
def initialize(extension, params, builder, defaults = {})
|
5
4
|
# TODO: We should allow for default values to be passed in here
|
6
5
|
# That will allow for defaults to be pulled out of the extension and it
|
7
6
|
# will also let better enable overriding of those values that do not have
|
@@ -12,12 +11,13 @@ class ExtensionHelper < SimpleDelegator
|
|
12
11
|
params
|
13
12
|
end
|
14
13
|
@builder = builder
|
15
|
-
|
16
|
-
end
|
14
|
+
@extension = extension
|
17
15
|
|
18
|
-
|
19
|
-
|
20
|
-
|
16
|
+
@extension.parameters.try(:each) do |method_name|
|
17
|
+
define_singleton_method(method_name) { self[method_name] }
|
18
|
+
end
|
19
|
+
|
20
|
+
super @params
|
21
21
|
end
|
22
22
|
|
23
23
|
# TODO: Method missing that pulls out of params?
|
@@ -20,6 +20,64 @@
|
|
20
20
|
# THE SOFTWARE.
|
21
21
|
#
|
22
22
|
|
23
|
+
# Promotion specific job attributes
|
24
|
+
job_attribute do
|
25
|
+
name :promotion_description
|
26
|
+
plugin_id 'builtin'
|
27
|
+
description 'This is the description of your promotion.'
|
28
|
+
jenkins_name 'Description'
|
29
|
+
announced false
|
30
|
+
|
31
|
+
xml path: '//hudson.plugins.promoted__builds.PromotionProcess' do |description|
|
32
|
+
description description.to_s
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
job_attribute do
|
37
|
+
name :block_when_downstream_building
|
38
|
+
plugin_id 'builtin'
|
39
|
+
description 'Prevents new builds from being executed until the downstream jobs have finished.'
|
40
|
+
|
41
|
+
xml path: '//hudson.plugins.promoted__builds.PromotionProcess' do |is_enabled|
|
42
|
+
blockBuildWhenDownstreamBuilding is_enabled
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
job_attribute do
|
47
|
+
name :block_when_upstream_building
|
48
|
+
plugin_id 'builtin'
|
49
|
+
description 'Prevents new builds from being executed until the upstream jobs have finished.'
|
50
|
+
|
51
|
+
xml path: '//hudson.plugins.promoted__builds.PromotionProcess' do |is_enabled|
|
52
|
+
blockBuildWhenUpstreamBuilding is_enabled
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
job_attribute do
|
57
|
+
name :is_visible
|
58
|
+
plugin_id 'builtin'
|
59
|
+
# TODO: Verify that this description is actually what this does
|
60
|
+
description 'Set a promotion process to be visible in the UI'
|
61
|
+
|
62
|
+
xml path: '//hudson.plugins.promoted__builds.PromotionProcess' do |is_enabled|
|
63
|
+
isVisible if is_enabled
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
job_attribute do
|
68
|
+
name :promotion_icon
|
69
|
+
plugin_id 'builtin'
|
70
|
+
description 'Set the star color for a promotion process'
|
71
|
+
|
72
|
+
# Should be one main color %[gold silver white blue green orange purple red]
|
73
|
+
# With an optional fill color "e" for empty "w" for white
|
74
|
+
# e.g. "gold" or "gold-w"
|
75
|
+
xml path: '//hudson.plugins.promoted__builds.PromotionProcess' do |icon_name|
|
76
|
+
icon "star-#{icon_name}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Job attributes for jobs
|
23
81
|
job_attribute do
|
24
82
|
name :description
|
25
83
|
plugin_id 'builtin'
|
@@ -27,7 +85,7 @@ job_attribute do
|
|
27
85
|
jenkins_name 'Description'
|
28
86
|
announced false
|
29
87
|
|
30
|
-
before do
|
88
|
+
before do |_param|
|
31
89
|
xpath('//project/description').remove
|
32
90
|
end
|
33
91
|
|
@@ -367,6 +425,7 @@ job_attribute do
|
|
367
425
|
end
|
368
426
|
end
|
369
427
|
end
|
428
|
+
|
370
429
|
job_attribute do
|
371
430
|
name :promoted_builds
|
372
431
|
plugin_id 'promoted-builds'
|
@@ -0,0 +1,80 @@
|
|
1
|
+
promotion_condition do
|
2
|
+
name :manual
|
3
|
+
plugin_id 'promoted-builds'
|
4
|
+
parameters [
|
5
|
+
:users
|
6
|
+
]
|
7
|
+
|
8
|
+
xml do |params|
|
9
|
+
send('hudson.plugins.promoted__builds.conditions.ManualCondition') do
|
10
|
+
users params[:users]
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
promotion_condition do
|
16
|
+
name :self_promotion
|
17
|
+
plugin_id 'promoted-builds'
|
18
|
+
parameters [
|
19
|
+
:even_if_unstable
|
20
|
+
]
|
21
|
+
|
22
|
+
xml do |params|
|
23
|
+
send('hudson.plugins.promoted__builds.conditions.SelfPromotionCondition') do
|
24
|
+
evenIfUnstable true if params[:even_if_unstable].nil?
|
25
|
+
evenIfUnstable params[:even_if_unstable]
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
promotion_condition do
|
31
|
+
name :parameterized_self_promotion
|
32
|
+
plugin_id 'promoted-builds'
|
33
|
+
parameters [
|
34
|
+
:parameter_name,
|
35
|
+
:parameter_value,
|
36
|
+
:even_if_unstable
|
37
|
+
]
|
38
|
+
|
39
|
+
xml do |params|
|
40
|
+
send('hudson.plugins.promoted__builds.conditions.ParameterizedSelfPromotionCondition') do
|
41
|
+
parameterName params[:parameter_name]
|
42
|
+
parameterValue true if params[:parameter_value].nil?
|
43
|
+
evenIfUnstable true if params[:even_if_unstable].nil?
|
44
|
+
parameterValue params[:parameter_value]
|
45
|
+
evenIfUnstable params[:even_if_unstable]
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
promotion_condition do
|
51
|
+
name :downstream_pass
|
52
|
+
plugin_id 'promoted-builds'
|
53
|
+
parameters [
|
54
|
+
:jobs,
|
55
|
+
:even_if_unstable
|
56
|
+
]
|
57
|
+
|
58
|
+
xml do |params|
|
59
|
+
send('hudson.plugins.promoted__builds.conditions.DownstreamPassCondition') do
|
60
|
+
jobs params[:jobs] || '{{Example}}-Commit'
|
61
|
+
evenIfUnstable true if params[:even_if_unstable].nil?
|
62
|
+
evenIfUnstable params[:even_if_unstable]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
promotion_condition do
|
68
|
+
name :upstream_promotion
|
69
|
+
plugin_id 'promoted-builds'
|
70
|
+
parameters [
|
71
|
+
:promotion_name
|
72
|
+
]
|
73
|
+
|
74
|
+
xml do |params|
|
75
|
+
send('hudson.plugins.promoted__builds.conditions.UpstreamPromotionCondition') do
|
76
|
+
promotionName '01. Staging Promotion' if params[:promotion_name].nil?
|
77
|
+
promotionName params[:promotion_name]
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
@@ -84,16 +84,6 @@ module JenkinsPipelineBuilder
|
|
84
84
|
File.open(job_name + '.xml', 'w') { |f| f.write xml }
|
85
85
|
end
|
86
86
|
|
87
|
-
def resolve_job_by_name(name, settings = {})
|
88
|
-
job = job_collection.get_item(name)
|
89
|
-
raise "Failed to locate job by name '#{name}'" if job.nil?
|
90
|
-
job_value = job[:value]
|
91
|
-
logger.debug "Compiling job #{name}"
|
92
|
-
compiler = JenkinsPipelineBuilder::Compiler.new self
|
93
|
-
success, payload = compiler.compile_job(job_value, settings)
|
94
|
-
[success, payload]
|
95
|
-
end
|
96
|
-
|
97
87
|
def resolve_project(project)
|
98
88
|
defaults = job_collection.defaults
|
99
89
|
settings = defaults.nil? ? {} : defaults[:value] || {}
|
@@ -101,12 +91,25 @@ module JenkinsPipelineBuilder
|
|
101
91
|
project[:settings] = compiler.get_settings_bag(project, settings)
|
102
92
|
|
103
93
|
errors = process_project project
|
94
|
+
|
104
95
|
print_project_errors errors
|
105
96
|
return false, 'Encountered errors exiting' unless errors.empty?
|
106
97
|
|
107
98
|
[true, project]
|
108
99
|
end
|
109
100
|
|
101
|
+
# Works for jobs, views, and promotions
|
102
|
+
def resolve_job_by_name(name, settings = {})
|
103
|
+
job = job_collection.get_item(name)
|
104
|
+
raise "Failed to locate job by name '#{name}'" if job.nil?
|
105
|
+
job_value = job[:value]
|
106
|
+
logger.debug "Compiling job #{name}"
|
107
|
+
compiler = JenkinsPipelineBuilder::Compiler.new self
|
108
|
+
success, payload = compiler.compile_job(job_value, settings)
|
109
|
+
[success, payload]
|
110
|
+
end
|
111
|
+
alias resolve_section_by_name resolve_job_by_name
|
112
|
+
|
110
113
|
private
|
111
114
|
|
112
115
|
def load_job_collection(path)
|
@@ -125,12 +128,18 @@ module JenkinsPipelineBuilder
|
|
125
128
|
end
|
126
129
|
|
127
130
|
def process_project(project)
|
131
|
+
errors = {}
|
128
132
|
project_body = project[:value]
|
129
|
-
|
133
|
+
|
134
|
+
%i(jobs views promotions).each do |key|
|
135
|
+
next unless project_body[key]
|
136
|
+
|
137
|
+
Utils.symbolize_with_empty_hash!(project_body[key])
|
138
|
+
process_job_changes!(project_body[:jobs]) if key == :jobs
|
139
|
+
process_pipeline_section(project_body[key], project, errors)
|
140
|
+
end
|
141
|
+
|
130
142
|
logger.info project
|
131
|
-
process_job_changes(jobs)
|
132
|
-
errors = process_jobs(jobs, project)
|
133
|
-
errors = process_views(project_body[:views], project, errors) if project_body[:views]
|
134
143
|
errors
|
135
144
|
end
|
136
145
|
|
@@ -143,18 +152,12 @@ module JenkinsPipelineBuilder
|
|
143
152
|
|
144
153
|
def print_project_errors(errors)
|
145
154
|
errors.each do |error|
|
146
|
-
|
147
|
-
|
148
|
-
end
|
149
|
-
end
|
150
|
-
|
151
|
-
def prepare_jobs(jobs)
|
152
|
-
jobs.map! do |job|
|
153
|
-
job.is_a?(String) ? { job.to_sym => {} } : job
|
155
|
+
logger.error 'Encountered errors processing:'
|
156
|
+
logger.error error.inspect
|
154
157
|
end
|
155
158
|
end
|
156
159
|
|
157
|
-
def process_job_changes(jobs)
|
160
|
+
def process_job_changes!(jobs)
|
158
161
|
jobs.each do |job|
|
159
162
|
job_id = job.keys.first
|
160
163
|
j = job_collection.get_item(job_id)
|
@@ -166,45 +169,21 @@ module JenkinsPipelineBuilder
|
|
166
169
|
end
|
167
170
|
end
|
168
171
|
|
169
|
-
def
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
view_id = view.keys.first
|
175
|
-
settings = project[:settings].clone.merge(view[view_id])
|
176
|
-
# TODO: rename resolve_job_by_name properly
|
177
|
-
success, payload = resolve_job_by_name(view_id, settings)
|
178
|
-
if success
|
179
|
-
view[:result] = payload
|
180
|
-
else
|
181
|
-
errors[view_id] = payload
|
182
|
-
end
|
183
|
-
end
|
184
|
-
errors
|
185
|
-
end
|
172
|
+
def process_pipeline_section(section, project, errors = {})
|
173
|
+
section.each do |item|
|
174
|
+
item_id = item.keys.first
|
175
|
+
settings = project[:settings].clone.merge(item[item_id])
|
176
|
+
success, payload = resolve_section_by_name(item_id, settings)
|
186
177
|
|
187
|
-
def process_jobs(jobs, project, errors = {})
|
188
|
-
jobs.each do |job|
|
189
|
-
job_id = job.keys.first
|
190
|
-
settings = project[:settings].clone.merge(job[job_id])
|
191
|
-
success, payload = resolve_job_by_name(job_id, settings)
|
192
178
|
if success
|
193
|
-
|
179
|
+
item[:result] = payload
|
194
180
|
else
|
195
|
-
errors[
|
181
|
+
errors[item_id] = payload
|
196
182
|
end
|
197
183
|
end
|
198
184
|
errors
|
199
185
|
end
|
200
186
|
|
201
|
-
def create_views(views)
|
202
|
-
views.each do |v|
|
203
|
-
compiled_view = v[:result]
|
204
|
-
view.create(compiled_view)
|
205
|
-
end
|
206
|
-
end
|
207
|
-
|
208
187
|
def create_jobs_and_views(project)
|
209
188
|
success, payload = resolve_project(project)
|
210
189
|
return { project_name: 'Failed to resolve' } unless success
|
@@ -212,9 +191,15 @@ module JenkinsPipelineBuilder
|
|
212
191
|
logger.info 'successfully resolved project'
|
213
192
|
compiled_project = payload
|
214
193
|
|
215
|
-
errors = publish_jobs(compiled_project[:value][:jobs])
|
216
|
-
|
217
|
-
|
194
|
+
errors = publish_jobs(compiled_project[:value][:jobs])
|
195
|
+
|
196
|
+
if compiled_project[:value][:views]
|
197
|
+
publish_views(compiled_project[:value][:views])
|
198
|
+
end
|
199
|
+
|
200
|
+
if compiled_project[:value][:promotions]
|
201
|
+
publish_promotions(compiled_project[:value][:promotions], compiled_project[:value][:jobs])
|
202
|
+
end
|
218
203
|
errors
|
219
204
|
end
|
220
205
|
|
@@ -223,6 +208,29 @@ module JenkinsPipelineBuilder
|
|
223
208
|
create_jobs_and_views(project || raise("Project #{project_name} not found!"))
|
224
209
|
end
|
225
210
|
|
211
|
+
def publish_promotions(promotions, jobs)
|
212
|
+
# Converts a list of jobs that might have a list of promoted_builds to
|
213
|
+
# A hash of promoted_builds names => associated job names
|
214
|
+
promotion_job_pairs = jobs.each_with_object({}) do |j, acc|
|
215
|
+
j[:result][:promoted_builds].each do |promotion_name|
|
216
|
+
acc[promotion_name] = j[:result][:name]
|
217
|
+
end if j[:result][:promoted_builds]
|
218
|
+
end
|
219
|
+
|
220
|
+
promotions.each do |promotion|
|
221
|
+
compiled_promotion = promotion[:result]
|
222
|
+
associated_job_name = promotion_job_pairs[compiled_promotion[:name]]
|
223
|
+
Promotion.new(self).create(compiled_promotion, associated_job_name)
|
224
|
+
end
|
225
|
+
end
|
226
|
+
|
227
|
+
def publish_views(views)
|
228
|
+
views.each do |view|
|
229
|
+
compiled_view = view[:result]
|
230
|
+
View.new(self).create(compiled_view)
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
226
234
|
def publish_jobs(jobs, errors = {})
|
227
235
|
jobs.each do |i|
|
228
236
|
logger.info "Processing #{i}"
|
@@ -20,11 +20,7 @@ module JenkinsPipelineBuilder
|
|
20
20
|
xml = payload
|
21
21
|
return local_output(xml) if JenkinsPipelineBuilder.debug || JenkinsPipelineBuilder.file_mode
|
22
22
|
|
23
|
-
|
24
|
-
JenkinsPipelineBuilder.client.job.update(name, xml)
|
25
|
-
else
|
26
|
-
JenkinsPipelineBuilder.client.job.create(name, xml)
|
27
|
-
end
|
23
|
+
JenkinsPipelineBuilder.client.job.create_or_update(name, xml)
|
28
24
|
[true, nil]
|
29
25
|
end
|
30
26
|
|
@@ -115,9 +111,7 @@ module JenkinsPipelineBuilder
|
|
115
111
|
raise "Job template '#{template_name}' can't be resolved." unless @job_templates.key?(template_name)
|
116
112
|
params.delete(:template)
|
117
113
|
template = @job_templates[template_name]
|
118
|
-
puts "Template found: #{template}"
|
119
114
|
params = template.deep_merge(params)
|
120
|
-
puts "Template merged: #{template}"
|
121
115
|
end
|
122
116
|
|
123
117
|
xml = JenkinsPipelineBuilder.client.job.build_freestyle_config(params)
|
@@ -18,24 +18,16 @@ module JenkinsPipelineBuilder
|
|
18
18
|
JenkinsPipelineBuilder.logger
|
19
19
|
end
|
20
20
|
|
21
|
-
def projects
|
22
|
-
result = []
|
23
|
-
collection.values.each do |item|
|
24
|
-
result << item if item[:type] == :project
|
25
|
-
end
|
26
|
-
result
|
27
|
-
end
|
28
|
-
|
29
21
|
def standalone_jobs
|
30
22
|
jobs.map { |job| { result: job } }
|
31
23
|
end
|
32
24
|
|
25
|
+
def projects
|
26
|
+
collect_type :project
|
27
|
+
end
|
28
|
+
|
33
29
|
def jobs
|
34
|
-
|
35
|
-
collection.values.each do |item|
|
36
|
-
result << item if item[:type] == :job
|
37
|
-
end
|
38
|
-
result
|
30
|
+
collect_type :job
|
39
31
|
end
|
40
32
|
|
41
33
|
def defaults
|
@@ -69,6 +61,10 @@ module JenkinsPipelineBuilder
|
|
69
61
|
|
70
62
|
private
|
71
63
|
|
64
|
+
def collect_type(type_name)
|
65
|
+
collection.values.select { |item| item if item[:type] == type_name }
|
66
|
+
end
|
67
|
+
|
72
68
|
def load_file(path, remote = false)
|
73
69
|
hash = if path.end_with? 'json'
|
74
70
|
JSON.parse(IO.read(path))
|
@@ -80,7 +76,7 @@ module JenkinsPipelineBuilder
|
|
80
76
|
load_section section, remote
|
81
77
|
end
|
82
78
|
rescue StandardError => err
|
83
|
-
raise
|
79
|
+
raise CustomErrors::ParseError.new err.message, path
|
84
80
|
end
|
85
81
|
|
86
82
|
def load_section(section, remote)
|
@@ -92,6 +88,12 @@ module JenkinsPipelineBuilder
|
|
92
88
|
remote_dependencies.load value
|
93
89
|
return
|
94
90
|
end
|
91
|
+
|
92
|
+
raise TypeError, %(Expected Hash received #{value.class}.
|
93
|
+
Verify that the pipeline section is made up of a single {key: Hash/Object} pair
|
94
|
+
See the definition for:
|
95
|
+
\t#{section}).squeeze(' ') unless value.is_a? Hash
|
96
|
+
|
95
97
|
name = value[:name]
|
96
98
|
process_collection! name, key, value, remote
|
97
99
|
end
|