r10k 3.7.0 → 3.9.3

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.github/pull_request_template.md +1 -1
  3. data/.github/workflows/docker.yml +4 -1
  4. data/.github/workflows/release.yml +3 -2
  5. data/.github/workflows/rspec_tests.yml +1 -1
  6. data/.github/workflows/stale.yml +19 -0
  7. data/.travis.yml +8 -1
  8. data/CHANGELOG.mkd +32 -0
  9. data/CODEOWNERS +2 -2
  10. data/doc/common-patterns.mkd +1 -0
  11. data/doc/dynamic-environments/configuration.mkd +114 -42
  12. data/doc/dynamic-environments/usage.mkd +12 -11
  13. data/doc/puppetfile.mkd +23 -3
  14. data/docker/Gemfile +1 -1
  15. data/docker/Makefile +4 -3
  16. data/docker/docker-compose.yml +18 -0
  17. data/docker/r10k/Dockerfile +1 -1
  18. data/docker/r10k/docker-entrypoint.sh +0 -1
  19. data/docker/r10k/release.Dockerfile +1 -1
  20. data/docker/spec/dockerfile_spec.rb +26 -32
  21. data/integration/tests/git_source/git_source_repeated_remote.rb +2 -2
  22. data/integration/tests/user_scenario/basic_workflow/multi_env_custom_forge_git_module.rb +2 -1
  23. data/integration/tests/user_scenario/basic_workflow/multi_env_custom_forge_git_module_static.rb +2 -1
  24. data/integration/tests/user_scenario/basic_workflow/multi_source_custom_forge_git_module.rb +1 -1
  25. data/integration/tests/user_scenario/basic_workflow/single_env_custom_forge_git_module.rb +2 -1
  26. data/lib/r10k/action/base.rb +10 -0
  27. data/lib/r10k/action/deploy/display.rb +49 -10
  28. data/lib/r10k/action/deploy/environment.rb +101 -51
  29. data/lib/r10k/action/deploy/module.rb +54 -30
  30. data/lib/r10k/action/puppetfile/check.rb +3 -1
  31. data/lib/r10k/action/puppetfile/install.rb +20 -23
  32. data/lib/r10k/action/puppetfile/purge.rb +8 -2
  33. data/lib/r10k/action/runner.rb +33 -0
  34. data/lib/r10k/cli/deploy.rb +13 -7
  35. data/lib/r10k/cli/puppetfile.rb +5 -5
  36. data/lib/r10k/content_synchronizer.rb +83 -0
  37. data/lib/r10k/deployment.rb +1 -1
  38. data/lib/r10k/environment/base.rb +29 -2
  39. data/lib/r10k/environment/git.rb +17 -5
  40. data/lib/r10k/environment/name.rb +22 -4
  41. data/lib/r10k/environment/svn.rb +11 -4
  42. data/lib/r10k/environment/with_modules.rb +46 -30
  43. data/lib/r10k/git.rb +1 -0
  44. data/lib/r10k/git/rugged/credentials.rb +39 -2
  45. data/lib/r10k/initializers.rb +1 -0
  46. data/lib/r10k/module.rb +1 -1
  47. data/lib/r10k/module/base.rb +17 -1
  48. data/lib/r10k/module/forge.rb +29 -11
  49. data/lib/r10k/module/git.rb +50 -27
  50. data/lib/r10k/module/local.rb +2 -1
  51. data/lib/r10k/module/svn.rb +24 -18
  52. data/lib/r10k/puppetfile.rb +66 -83
  53. data/lib/r10k/settings.rb +18 -2
  54. data/lib/r10k/source/base.rb +9 -0
  55. data/lib/r10k/source/git.rb +18 -7
  56. data/lib/r10k/source/hash.rb +5 -5
  57. data/lib/r10k/source/svn.rb +5 -3
  58. data/lib/r10k/util/cleaner.rb +21 -0
  59. data/lib/r10k/util/setopts.rb +33 -12
  60. data/lib/r10k/version.rb +1 -1
  61. data/locales/r10k.pot +98 -82
  62. data/r10k.gemspec +1 -1
  63. data/spec/fixtures/unit/action/r10k_creds.yaml +9 -0
  64. data/spec/r10k-mocks/mock_source.rb +1 -1
  65. data/spec/shared-examples/puppetfile-action.rb +7 -7
  66. data/spec/unit/action/deploy/display_spec.rb +35 -5
  67. data/spec/unit/action/deploy/environment_spec.rb +199 -38
  68. data/spec/unit/action/deploy/module_spec.rb +162 -28
  69. data/spec/unit/action/puppetfile/check_spec.rb +2 -2
  70. data/spec/unit/action/puppetfile/install_spec.rb +31 -10
  71. data/spec/unit/action/puppetfile/purge_spec.rb +25 -5
  72. data/spec/unit/action/runner_spec.rb +48 -1
  73. data/spec/unit/environment/git_spec.rb +19 -2
  74. data/spec/unit/environment/name_spec.rb +28 -0
  75. data/spec/unit/environment/svn_spec.rb +12 -0
  76. data/spec/unit/environment/with_modules_spec.rb +74 -0
  77. data/spec/unit/git/rugged/credentials_spec.rb +78 -1
  78. data/spec/unit/module/forge_spec.rb +21 -13
  79. data/spec/unit/module/git_spec.rb +63 -8
  80. data/spec/unit/module_spec.rb +77 -10
  81. data/spec/unit/puppetfile_spec.rb +63 -60
  82. data/spec/unit/util/purgeable_spec.rb +2 -8
  83. data/spec/unit/util/setopts_spec.rb +25 -1
  84. metadata +11 -12
  85. data/azure-pipelines.yml +0 -87
@@ -13,7 +13,12 @@ class R10K::Module::Forge < R10K::Module::Base
13
13
  R10K::Module.register(self)
14
14
 
15
15
  def self.implement?(name, args)
16
- !!(name.match %r[\w+[/-]\w+]) && valid_version?(args)
16
+ args[:type].to_s == 'forge' ||
17
+ (!!
18
+ ((args.keys & %i{git svn type}).empty? &&
19
+ args.has_key?(:version) &&
20
+ name.match(%r[\w+[/-]\w+]) &&
21
+ valid_version?(args[:version])))
17
22
  end
18
23
 
19
24
  def self.valid_version?(expected_version)
@@ -32,24 +37,37 @@ class R10K::Module::Forge < R10K::Module::Base
32
37
 
33
38
  include R10K::Logging
34
39
 
35
- def initialize(title, dirname, expected_version, environment=nil)
40
+ include R10K::Util::Setopts
41
+
42
+ def initialize(title, dirname, opts, environment=nil)
36
43
  super
37
44
 
38
45
  @metadata_file = R10K::Module::MetadataFile.new(path + 'metadata.json')
39
46
  @metadata = @metadata_file.read
40
47
 
41
- @expected_version = expected_version || current_version || :latest
48
+ setopts(opts, {
49
+ # Standard option interface
50
+ :version => :expected_version,
51
+ :source => ::R10K::Util::Setopts::Ignore,
52
+ :type => ::R10K::Util::Setopts::Ignore,
53
+ }, :raise_on_unhandled => false)
54
+
55
+ @expected_version ||= current_version || :latest
56
+
42
57
  @v3_module = PuppetForge::V3::Module.new(:slug => @title)
43
58
  end
44
59
 
60
+ # @param [Hash] opts Deprecated
45
61
  def sync(opts={})
46
- case status
47
- when :absent
48
- install
49
- when :outdated
50
- upgrade
51
- when :mismatched
52
- reinstall
62
+ if should_sync?
63
+ case status
64
+ when :absent
65
+ install
66
+ when :outdated
67
+ upgrade
68
+ when :mismatched
69
+ reinstall
70
+ end
53
71
  end
54
72
  end
55
73
 
@@ -171,7 +189,7 @@ class R10K::Module::Forge < R10K::Module::Base
171
189
  if (match = title.match(/\A(\w+)[-\/](\w+)\Z/))
172
190
  [match[1], match[2]]
173
191
  else
174
- raise ArgumentError, _("Forge module names must match 'owner/modulename'")
192
+ raise ArgumentError, _("Forge module names must match 'owner/modulename', instead got #{title}")
175
193
  end
176
194
  end
177
195
  end
@@ -8,7 +8,7 @@ class R10K::Module::Git < R10K::Module::Base
8
8
  R10K::Module.register(self)
9
9
 
10
10
  def self.implement?(name, args)
11
- args.is_a? Hash and args.has_key?(:git)
11
+ args.has_key?(:git) || args[:type].to_s == 'git'
12
12
  rescue
13
13
  false
14
14
  end
@@ -28,16 +28,50 @@ class R10K::Module::Git < R10K::Module::Base
28
28
  # @return [String]
29
29
  attr_reader :default_ref
30
30
 
31
- def initialize(title, dirname, args, environment=nil)
32
- super
31
+ # @!attribute [r] default_override_ref
32
+ # @api private
33
+ # @return [String]
34
+ attr_reader :default_override_ref
35
+
36
+ include R10K::Util::Setopts
37
+
38
+ def initialize(title, dirname, opts, environment=nil)
33
39
 
34
- parse_options(@args)
40
+ super
41
+ setopts(opts, {
42
+ # Standard option interface
43
+ :version => :desired_ref,
44
+ :source => :remote,
45
+ :type => ::R10K::Util::Setopts::Ignore,
46
+
47
+ # Type-specific options
48
+ :branch => :desired_ref,
49
+ :tag => :desired_ref,
50
+ :commit => :desired_ref,
51
+ :ref => :desired_ref,
52
+ :git => :remote,
53
+ :default_branch => :default_ref,
54
+ :default_branch_override => :default_override_ref,
55
+ }, :raise_on_unhandled => false)
56
+
57
+ force = @overrides.dig(:modules, :force)
58
+ @force = force == false ? false : true
59
+
60
+ @desired_ref ||= 'master'
61
+
62
+ if @desired_ref == :control_branch
63
+ if @environment && @environment.respond_to?(:ref)
64
+ @desired_ref = @environment.ref
65
+ else
66
+ logger.warn _("Cannot track control repo branch for content '%{name}' when not part of a git-backed environment, will use default if available." % {name: name})
67
+ end
68
+ end
35
69
 
36
70
  @repo = R10K::Git::StatefulRepository.new(@remote, @dirname, @name)
37
71
  end
38
72
 
39
73
  def version
40
- validate_ref(@desired_ref, @default_ref)
74
+ validate_ref(@desired_ref, @default_ref, @default_override_ref)
41
75
  end
42
76
 
43
77
  def properties
@@ -48,9 +82,10 @@ class R10K::Module::Git < R10K::Module::Base
48
82
  }
49
83
  end
50
84
 
85
+ # @param [Hash] opts Deprecated
51
86
  def sync(opts={})
52
- force = opts && opts.fetch(:force, true)
53
- @repo.sync(version, force)
87
+ force = opts[:force] || @force
88
+ @repo.sync(version, force) if should_sync?
54
89
  end
55
90
 
56
91
  def status
@@ -63,9 +98,11 @@ class R10K::Module::Git < R10K::Module::Base
63
98
 
64
99
  private
65
100
 
66
- def validate_ref(desired, default)
101
+ def validate_ref(desired, default, default_override)
67
102
  if desired && desired != :control_branch && @repo.resolve(desired)
68
103
  return desired
104
+ elsif default_override && @repo.resolve(default_override)
105
+ return default_override
69
106
  elsif default && @repo.resolve(default)
70
107
  return default
71
108
  else
@@ -81,6 +118,11 @@ class R10K::Module::Git < R10K::Module::Base
81
118
  msg << "Could not determine desired ref"
82
119
  end
83
120
 
121
+ if default_override
122
+ msg << "or resolve the default branch override '%{default_override}',"
123
+ vars[:default_override] = default_override
124
+ end
125
+
84
126
  if default
85
127
  msg << "or resolve default ref '%{default}'"
86
128
  vars[:default] = default
@@ -91,23 +133,4 @@ class R10K::Module::Git < R10K::Module::Base
91
133
  raise ArgumentError, _(msg.join(' ')) % vars
92
134
  end
93
135
  end
94
-
95
- def parse_options(options)
96
- ref_opts = [:branch, :tag, :commit, :ref]
97
- known_opts = [:git, :default_branch] + ref_opts
98
-
99
- unhandled = options.keys - known_opts
100
- unless unhandled.empty?
101
- raise ArgumentError, _("Unhandled options %{unhandled} specified for %{class}") % {unhandled: unhandled, class: self.class}
102
- end
103
-
104
- @remote = options[:git]
105
-
106
- @desired_ref = ref_opts.find { |key| break options[key] if options.has_key?(key) } || 'master'
107
- @default_ref = options[:default_branch]
108
-
109
- if @desired_ref == :control_branch && @environment && @environment.respond_to?(:ref)
110
- @desired_ref = @environment.ref
111
- end
112
- end
113
136
  end
@@ -9,7 +9,7 @@ class R10K::Module::Local < R10K::Module::Base
9
9
  R10K::Module.register(self)
10
10
 
11
11
  def self.implement?(name, args)
12
- args.is_a?(Hash) && args[:local]
12
+ args.is_a?(Hash) && (args[:local] || args[:type].to_s == 'local')
13
13
  end
14
14
 
15
15
  include R10K::Logging
@@ -30,6 +30,7 @@ class R10K::Module::Local < R10K::Module::Base
30
30
  :insync
31
31
  end
32
32
 
33
+ # @param [Hash] opts Deprecated
33
34
  def sync(opts={})
34
35
  logger.debug1 _("Module %{title} is a local module, always indicating synced.") % {title: title}
35
36
  end
@@ -7,7 +7,7 @@ class R10K::Module::SVN < R10K::Module::Base
7
7
  R10K::Module.register(self)
8
8
 
9
9
  def self.implement?(name, args)
10
- args.is_a? Hash and args.has_key? :svn
10
+ args.has_key?(:svn) || args[:type].to_s == 'svn'
11
11
  end
12
12
 
13
13
  # @!attribute [r] expected_revision
@@ -36,18 +36,21 @@ class R10K::Module::SVN < R10K::Module::Base
36
36
 
37
37
  include R10K::Util::Setopts
38
38
 
39
- INITIALIZE_OPTS = {
40
- :svn => :url,
41
- :rev => :expected_revision,
42
- :revision => :expected_revision,
43
- :username => :self,
44
- :password => :self
45
- }
46
-
47
39
  def initialize(name, dirname, opts, environment=nil)
48
40
  super
49
-
50
- setopts(opts, INITIALIZE_OPTS)
41
+ setopts(opts, {
42
+ # Standard option interface
43
+ :source => :url,
44
+ :version => :expected_revision,
45
+ :type => ::R10K::Util::Setopts::Ignore,
46
+
47
+ # Type-specific options
48
+ :svn => :url,
49
+ :rev => :expected_revision,
50
+ :revision => :expected_revision,
51
+ :username => :self,
52
+ :password => :self
53
+ }, :raise_on_unhandled => false)
51
54
 
52
55
  @working_dir = R10K::SVN::WorkingDir.new(@path, :username => @username, :password => @password)
53
56
  end
@@ -66,14 +69,17 @@ class R10K::Module::SVN < R10K::Module::Base
66
69
  end
67
70
  end
68
71
 
72
+ # @param [Hash] opts Deprecated
69
73
  def sync(opts={})
70
- case status
71
- when :absent
72
- install
73
- when :mismatched
74
- reinstall
75
- when :outdated
76
- update
74
+ if should_sync?
75
+ case status
76
+ when :absent
77
+ install
78
+ when :mismatched
79
+ reinstall
80
+ when :outdated
81
+ update
82
+ end
77
83
  end
78
84
  end
79
85
 
@@ -3,6 +3,7 @@ require 'pathname'
3
3
  require 'r10k/module'
4
4
  require 'r10k/util/purgeable'
5
5
  require 'r10k/errors'
6
+ require 'r10k/content_synchronizer'
6
7
 
7
8
  module R10K
8
9
  class Puppetfile
@@ -42,28 +43,37 @@ class Puppetfile
42
43
  # @return [Boolean] Overwrite any locally made changes
43
44
  attr_accessor :force
44
45
 
45
- # @!attribute [r] modules_by_vcs_cachedir
46
- # @api private Only exposed for testing purposes
47
- # @return [Hash{:none, String => Array<R10K::Module>}]
48
- attr_reader :modules_by_vcs_cachedir
46
+ # @!attribute [r] overrides
47
+ # @return [Hash] Various settings overridden from normal configs
48
+ attr_reader :overrides
49
49
 
50
50
  # @param [String] basedir
51
- # @param [String] moduledir The directory to install the modules, default to #{basedir}/modules
52
- # @param [String] puppetfile_path The path to the Puppetfile, default to #{basedir}/Puppetfile
53
- # @param [String] puppetfile_name The name of the Puppetfile, default to 'Puppetfile'
54
- # @param [Boolean] force Shall we overwrite locally made changes?
55
- def initialize(basedir, moduledir = nil, puppetfile_path = nil, puppetfile_name = nil, force = nil )
51
+ # @param [Hash, String, nil] options_or_moduledir The directory to install the modules or a Hash of options.
52
+ # Usage as moduledir is deprecated. Only use as options, defaults to nil
53
+ # @param [String, nil] puppetfile_path Deprecated - The path to the Puppetfile, defaults to nil
54
+ # @param [String, nil] puppetfile_name Deprecated - The name of the Puppetfile, defaults to nil
55
+ # @param [Boolean, nil] force Deprecated - Shall we overwrite locally made changes?
56
+ def initialize(basedir, options_or_moduledir = nil, deprecated_path_arg = nil, deprecated_name_arg = nil, deprecated_force_arg = nil)
56
57
  @basedir = basedir
57
- @force = force || false
58
- @moduledir = moduledir || File.join(basedir, 'modules')
59
- @puppetfile_name = puppetfile_name || 'Puppetfile'
60
- @puppetfile_path = puppetfile_path || File.join(basedir, @puppetfile_name)
58
+ if options_or_moduledir.is_a? Hash
59
+ options = options_or_moduledir
60
+ deprecated_moduledir_arg = nil
61
+ else
62
+ options = {}
63
+ deprecated_moduledir_arg = options_or_moduledir
64
+ end
65
+
66
+ @force = deprecated_force_arg || options.delete(:force) || false
67
+ @moduledir = deprecated_moduledir_arg || options.delete(:moduledir) || File.join(basedir, 'modules')
68
+ @puppetfile_name = deprecated_name_arg || options.delete(:puppetfile_name) || 'Puppetfile'
69
+ @puppetfile_path = deprecated_path_arg || options.delete(:puppetfile_path) || File.join(basedir, @puppetfile_name)
70
+
71
+ @overrides = options.delete(:overrides) || {}
61
72
 
62
73
  logger.info _("Using Puppetfile '%{puppetfile}'") % {puppetfile: @puppetfile_path}
63
74
 
64
75
  @modules = []
65
76
  @managed_content = {}
66
- @modules_by_vcs_cachedir = {}
67
77
  @forge = 'forgeapi.puppetlabs.com'
68
78
 
69
79
  @loaded = false
@@ -83,7 +93,7 @@ class Puppetfile
83
93
 
84
94
  dsl = R10K::Puppetfile::DSL.new(self)
85
95
  dsl.instance_eval(puppetfile_contents, @puppetfile_path)
86
-
96
+
87
97
  validate_no_duplicate_names(@modules)
88
98
  @loaded = true
89
99
  rescue SyntaxError, LoadError, ArgumentError, NameError => e
@@ -123,29 +133,44 @@ class Puppetfile
123
133
  end
124
134
 
125
135
  # @param [String] name
126
- # @param [*Object] args
136
+ # @param [Hash, String, Symbol] args Calling with anything but a Hash is
137
+ # deprecated. The DSL will now convert String and Symbol versions to
138
+ # Hashes of the shape
139
+ # { version: <String or Symbol> }
140
+ #
127
141
  def add_module(name, args)
128
- if args.is_a?(Hash) && install_path = args.delete(:install_path)
142
+ if !args.is_a?(Hash)
143
+ args = { version: args }
144
+ end
145
+
146
+ args[:overrides] = @overrides
147
+
148
+ if install_path = args.delete(:install_path)
129
149
  install_path = resolve_install_path(install_path)
130
150
  validate_install_path(install_path, name)
131
151
  else
132
152
  install_path = @moduledir
133
153
  end
134
154
 
135
- if args.is_a?(Hash) && @default_branch_override != nil
136
- args[:default_branch] = @default_branch_override
155
+ if @default_branch_override != nil
156
+ args[:default_branch_override] = @default_branch_override
137
157
  end
138
158
 
139
- # Keep track of all the content this Puppetfile is managing to enable purging.
140
- @managed_content[install_path] = Array.new unless @managed_content.has_key?(install_path)
141
159
 
142
160
  mod = R10K::Module.new(name, install_path, args, @environment)
143
- mod.origin = 'Puppetfile'
161
+ mod.origin = :puppetfile
162
+
163
+ # Do not load modules if they would conflict with the attached
164
+ # environment
165
+ if environment && environment.module_conflicts?(mod)
166
+ mod = nil
167
+ return @modules
168
+ end
144
169
 
170
+ # Keep track of all the content this Puppetfile is managing to enable purging.
171
+ @managed_content[install_path] = Array.new unless @managed_content.has_key?(install_path)
145
172
  @managed_content[install_path] << mod.name
146
- cachedir = mod.cachedir
147
- @modules_by_vcs_cachedir[cachedir] ||= []
148
- @modules_by_vcs_cachedir[cachedir] << mod
173
+
149
174
  @modules << mod
150
175
  end
151
176
 
@@ -183,70 +208,22 @@ class Puppetfile
183
208
  def accept(visitor)
184
209
  pool_size = self.settings[:pool_size]
185
210
  if pool_size > 1
186
- concurrent_accept(visitor, pool_size)
211
+ R10K::ContentSynchronizer.concurrent_accept(modules, visitor, self, pool_size, logger)
187
212
  else
188
- serial_accept(visitor)
189
- end
190
- end
191
-
192
- private
193
-
194
- def serial_accept(visitor)
195
- visitor.visit(:puppetfile, self) do
196
- modules.each do |mod|
197
- mod.accept(visitor)
198
- end
213
+ R10K::ContentSynchronizer.serial_accept(modules, visitor, self)
199
214
  end
200
215
  end
201
216
 
202
- def concurrent_accept(visitor, pool_size)
203
- logger.debug _("Updating modules with %{pool_size} threads") % {pool_size: pool_size}
204
- mods_queue = modules_queue(visitor)
205
- thread_pool = pool_size.times.map { visitor_thread(visitor, mods_queue) }
206
- thread_exception = nil
207
-
208
- # If any threads raise an exception the deployment is considered a failure.
209
- # In that event clear the queue, wait for other threads to finish their
210
- # current work, then re-raise the first exception caught.
211
- begin
212
- thread_pool.each(&:join)
213
- rescue => e
214
- logger.error _("Error during concurrent deploy of a module: %{message}") % {message: e.message}
215
- mods_queue.clear
216
- thread_exception ||= e
217
- retry
218
- ensure
219
- raise thread_exception unless thread_exception.nil?
217
+ def sync
218
+ pool_size = self.settings[:pool_size]
219
+ if pool_size > 1
220
+ R10K::ContentSynchronizer.concurrent_sync(modules, pool_size, logger)
221
+ else
222
+ R10K::ContentSynchronizer.serial_sync(modules)
220
223
  end
221
224
  end
222
225
 
223
- def modules_queue(visitor)
224
- Queue.new.tap do |queue|
225
- visitor.visit(:puppetfile, self) do
226
- modules_by_cachedir = modules_by_vcs_cachedir.clone
227
- modules_without_vcs_cachedir = modules_by_cachedir.delete(:none) || []
228
-
229
- modules_without_vcs_cachedir.each {|mod| queue << Array(mod) }
230
- modules_by_cachedir.values.each {|mods| queue << mods }
231
- end
232
- end
233
- end
234
- public :modules_queue
235
-
236
- def visitor_thread(visitor, mods_queue)
237
- Thread.new do
238
- begin
239
- while mods = mods_queue.pop(true) do
240
- mods.each {|mod| mod.accept(visitor) }
241
- end
242
- rescue ThreadError => e
243
- logger.debug _("Module thread %{id} exiting: %{message}") % {message: e.message, id: Thread.current.object_id}
244
- Thread.exit
245
- rescue => e
246
- Thread.main.raise(e)
247
- end
248
- end
249
- end
226
+ private
250
227
 
251
228
  def puppetfile_contents
252
229
  File.read(@puppetfile_path)
@@ -287,7 +264,13 @@ class Puppetfile
287
264
  end
288
265
 
289
266
  def mod(name, args = nil)
290
- @librarian.add_module(name, args)
267
+ if args.is_a?(Hash)
268
+ opts = args
269
+ else
270
+ opts = { version: args }
271
+ end
272
+
273
+ @librarian.add_module(name, opts)
291
274
  end
292
275
 
293
276
  def forge(location)