berkshelf 3.0.0.beta7 → 3.0.0.beta8

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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.travis.yml +4 -1
  4. data/CONTRIBUTING.md +1 -1
  5. data/Gemfile +0 -1
  6. data/Guardfile +0 -8
  7. data/README.md +33 -13
  8. data/berkshelf.gemspec +3 -3
  9. data/features/commands/install.feature +16 -88
  10. data/features/commands/search.feature +15 -0
  11. data/features/commands/shelf/show.feature +2 -2
  12. data/features/commands/shelf/uninstall.feature +1 -1
  13. data/features/commands/show.feature +3 -3
  14. data/features/commands/update.feature +29 -1
  15. data/features/commands/upload.feature +172 -7
  16. data/features/commands/vendor.feature +32 -0
  17. data/features/json_formatter.feature +26 -24
  18. data/features/lifecycle.feature +285 -0
  19. data/features/lockfile.feature +9 -7
  20. data/features/step_definitions/chef_server_steps.rb +1 -0
  21. data/features/step_definitions/cli_steps.rb +2 -2
  22. data/features/step_definitions/filesystem_steps.rb +2 -4
  23. data/gem_graph.png +0 -0
  24. data/generator_files/chefignore +0 -2
  25. data/lib/berkshelf.rb +39 -14
  26. data/lib/berkshelf/berksfile.rb +161 -113
  27. data/lib/berkshelf/cached_cookbook.rb +2 -2
  28. data/lib/berkshelf/cli.rb +15 -3
  29. data/lib/berkshelf/commands/shelf.rb +3 -7
  30. data/lib/berkshelf/community_rest.rb +9 -9
  31. data/lib/berkshelf/config.rb +3 -3
  32. data/lib/berkshelf/cookbook_generator.rb +0 -8
  33. data/lib/berkshelf/cookbook_store.rb +1 -2
  34. data/lib/berkshelf/dependency.rb +25 -138
  35. data/lib/berkshelf/downloader.rb +41 -7
  36. data/lib/berkshelf/errors.rb +113 -214
  37. data/lib/berkshelf/formatters/base.rb +42 -0
  38. data/lib/berkshelf/formatters/human.rb +145 -0
  39. data/lib/berkshelf/formatters/json.rb +149 -133
  40. data/lib/berkshelf/formatters/null.rb +8 -18
  41. data/lib/berkshelf/init_generator.rb +1 -1
  42. data/lib/berkshelf/installer.rb +115 -104
  43. data/lib/berkshelf/location.rb +22 -121
  44. data/lib/berkshelf/locations/base.rb +75 -0
  45. data/lib/berkshelf/locations/git.rb +196 -0
  46. data/lib/berkshelf/locations/github.rb +8 -0
  47. data/lib/berkshelf/locations/path.rb +78 -0
  48. data/lib/berkshelf/lockfile.rb +452 -290
  49. data/lib/berkshelf/logger.rb +9 -3
  50. data/lib/berkshelf/mixin/logging.rb +4 -9
  51. data/lib/berkshelf/resolver.rb +12 -12
  52. data/lib/berkshelf/source.rb +13 -1
  53. data/lib/berkshelf/version.rb +1 -1
  54. data/spec/fixtures/cookbooks/example_cookbook-0.5.0/metadata.rb +3 -7
  55. data/spec/fixtures/cookbooks/example_cookbook/metadata.rb +3 -6
  56. data/spec/spec_helper.rb +5 -6
  57. data/spec/support/matchers/file_system_matchers.rb +4 -0
  58. data/spec/support/shared_examples/formatter.rb +11 -0
  59. data/spec/unit/berkshelf/berksfile_spec.rb +25 -28
  60. data/spec/unit/berkshelf/cli_spec.rb +19 -11
  61. data/spec/unit/berkshelf/dependency_spec.rb +4 -164
  62. data/spec/unit/berkshelf/formatters/base_spec.rb +35 -0
  63. data/spec/unit/berkshelf/formatters/human_spec.rb +7 -0
  64. data/spec/unit/berkshelf/formatters/json_spec.rb +7 -0
  65. data/spec/unit/berkshelf/formatters/null_spec.rb +7 -11
  66. data/spec/unit/berkshelf/location_spec.rb +16 -144
  67. data/spec/unit/berkshelf/locations/base_spec.rb +80 -0
  68. data/spec/unit/berkshelf/locations/git_spec.rb +249 -0
  69. data/spec/unit/berkshelf/locations/path_spec.rb +107 -0
  70. data/spec/unit/berkshelf/lockfile_parser_spec.rb +3 -3
  71. data/spec/unit/berkshelf/lockfile_spec.rb +55 -11
  72. data/spec/unit/berkshelf/logger_spec.rb +2 -2
  73. data/spec/unit/berkshelf/mixin/logging_spec.rb +5 -9
  74. data/spec/unit/berkshelf/source_spec.rb +32 -13
  75. data/spec/unit/berkshelf_spec.rb +6 -9
  76. metadata +33 -33
  77. data/.ruby-version +0 -1
  78. data/berkshelf-complete.sh +0 -75
  79. data/lib/berkshelf/formatters.rb +0 -110
  80. data/lib/berkshelf/formatters/human_readable.rb +0 -142
  81. data/lib/berkshelf/git.rb +0 -204
  82. data/lib/berkshelf/locations/git_location.rb +0 -135
  83. data/lib/berkshelf/locations/github_location.rb +0 -55
  84. data/lib/berkshelf/locations/mercurial_location.rb +0 -114
  85. data/lib/berkshelf/locations/path_location.rb +0 -88
  86. data/lib/berkshelf/mercurial.rb +0 -146
  87. data/lib/berkshelf/mixin.rb +0 -7
  88. data/spec/support/mercurial.rb +0 -123
  89. data/spec/unit/berkshelf/formatters_spec.rb +0 -114
  90. data/spec/unit/berkshelf/git_spec.rb +0 -312
  91. data/spec/unit/berkshelf/locations/git_location_spec.rb +0 -126
  92. data/spec/unit/berkshelf/locations/mercurial_location_spec.rb +0 -131
  93. data/spec/unit/berkshelf/locations/path_location_spec.rb +0 -25
  94. data/spec/unit/berkshelf/mercurial_spec.rb +0 -172
@@ -0,0 +1,75 @@
1
+ module Berkshelf
2
+ class BaseLocation
3
+ attr_reader :dependency
4
+ attr_reader :options
5
+
6
+ def initialize(dependency, options = {})
7
+ @dependency = dependency
8
+ @options = options
9
+ end
10
+
11
+ # Determine if this revision is installed.
12
+ #
13
+ # @return [Boolean]
14
+ def installed?
15
+ raise AbstractFunction,
16
+ "#installed? must be implemented on #{self.class.name}!"
17
+ end
18
+
19
+ # Install the given cookbook. Subclasses that implement this method should
20
+ # perform all the installation and validation steps required.
21
+ #
22
+ # @return [void]
23
+ def install
24
+ raise AbstractFunction,
25
+ "#install must be implemented on #{self.class.name}!"
26
+ end
27
+
28
+ # The cached cookbook for this location.
29
+ #
30
+ # @return [CachedCookbook]
31
+ def cached_cookbook
32
+ raise AbstractFunction,
33
+ "#cached_cookbook must be implemented on #{self.class.name}!"
34
+ end
35
+
36
+ # The lockfile representation of this location.
37
+ #
38
+ # @return [string]
39
+ def to_lock
40
+ raise AbstractFunction,
41
+ "#to_lock must be implemented on #{self.class.name}!"
42
+ end
43
+
44
+ # Ensure the given {CachedCookbook} is valid
45
+ #
46
+ # @param [String] path
47
+ # the path to the possible cookbook
48
+ #
49
+ # @raise [NotACookbook]
50
+ # if the cookbook at the path does not have a metadata
51
+ # @raise [CookbookValidationFailure]
52
+ # if given CachedCookbook does not satisfy the constraint of the location
53
+ # @raise [MismatcheCookboookName]
54
+ # if the cookbook does not have a name or if the name is different
55
+ #
56
+ # @return [true]
57
+ def validate_cached!(path)
58
+ unless File.cookbook?(path)
59
+ raise NotACookbook.new(path)
60
+ end
61
+
62
+ cookbook = CachedCookbook.from_path(path)
63
+
64
+ unless @dependency.version_constraint.satisfies?(cookbook.version)
65
+ raise CookbookValidationFailure.new(dependency, cookbook)
66
+ end
67
+
68
+ unless @dependency.name == cookbook.cookbook_name
69
+ raise MismatchedCookbookName.new(dependency, cookbook)
70
+ end
71
+
72
+ true
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,196 @@
1
+ require 'buff/shell_out'
2
+
3
+ module Berkshelf
4
+ class GitLocation < BaseLocation
5
+ class GitError < BerkshelfError; status_code(400); end
6
+
7
+ class GitNotInstalled < GitError
8
+ def initialize
9
+ super 'You need to install Git before you can download ' \
10
+ 'cookbooks from git repositories. For more information, please ' \
11
+ 'see the Git docs: http://git-scm.org.'
12
+ end
13
+ end
14
+
15
+ class GitCommandError < GitError
16
+ def initialize(command, path, stderr = nil)
17
+ out = "Git error: command `git #{command}` failed. If this error "
18
+ out << "persists, try removing the cache directory at '#{path}'."
19
+
20
+ if stderr
21
+ out << "Output from the command:\n\n"
22
+ out << stderr
23
+ end
24
+
25
+ super(out)
26
+ end
27
+ end
28
+
29
+ attr_reader :uri
30
+ attr_reader :branch
31
+ attr_reader :tag
32
+ attr_reader :ref
33
+ attr_reader :revision
34
+ attr_reader :rel
35
+
36
+ def initialize(dependency, options = {})
37
+ super
38
+
39
+ @uri = options[:git]
40
+ @branch = options[:branch]
41
+ @tag = options[:tag]
42
+ @ref = options[:ref]
43
+ @revision = options[:revision]
44
+ @rel = options[:rel]
45
+
46
+ # The revision to parse
47
+ @rev_parse = options[:ref] || options[:branch] || options[:tag] || 'master'
48
+ end
49
+
50
+ # @see BaseLoation#installed?
51
+ def installed?
52
+ revision && install_path.exist?
53
+ end
54
+
55
+ # Install this git cookbook into the cookbook store. This method leverages
56
+ # a cached git copy and a scratch directory to prevent bad cookbooks from
57
+ # making their way into the cookbook store.
58
+ #
59
+ # @see BaseLOcation#install
60
+ def install
61
+ scratch_path = Pathname.new(Dir.mktmpdir)
62
+
63
+ if cached?
64
+ Dir.chdir(cache_path) do
65
+ git %|fetch --force --tags #{uri} "refs/heads/*:refs/heads/*"|
66
+ end
67
+ else
68
+ git %|clone #{uri} "#{cache_path}" --bare --no-hardlinks|
69
+ end
70
+
71
+ Dir.chdir(cache_path) do
72
+ @revision ||= git %|rev-parse #{@rev_parse}|
73
+ end
74
+
75
+ # Clone into a scratch directory for validations
76
+ git %|clone --no-checkout "#{cache_path}" "#{scratch_path}"|
77
+
78
+ # Make sure the scratch directory is up-to-date and account for rel paths
79
+ Dir.chdir(scratch_path) do
80
+ git %|fetch --force --tags "#{cache_path}"|
81
+ git %|reset --hard #{@revision}|
82
+
83
+ if rel
84
+ git %|filter-branch --subdirectory-filter "#{rel}" --force|
85
+ end
86
+ end
87
+
88
+ # Validate the scratched path is a valid cookbook
89
+ validate_cached!(scratch_path)
90
+
91
+ # If we got this far, we should copy
92
+ FileUtils.rm_rf(install_path) if install_path.exist?
93
+ FileUtils.cp_r(scratch_path, install_path)
94
+ install_path.chmod(0777 & ~File.umask)
95
+ ensure
96
+ # Ensure the scratch directory is cleaned up
97
+ FileUtils.rm_rf(scratch_path)
98
+ end
99
+
100
+ # @see BaseLocation#cached_cookbook
101
+ def cached_cookbook
102
+ if installed?
103
+ @cached_cookbook ||= CachedCookbook.from_path(install_path)
104
+ else
105
+ nil
106
+ end
107
+ end
108
+
109
+ def ==(other)
110
+ other.is_a?(GitLocation) &&
111
+ other.uri == uri &&
112
+ other.branch == branch &&
113
+ other.tag == tag &&
114
+ other.shortref == shortref &&
115
+ other.rel == rel
116
+ end
117
+
118
+ def to_s
119
+ info = tag || branch || shortref || @rev_parse
120
+
121
+ if rel
122
+ "#{uri} (at #{info}/#{rel})"
123
+ else
124
+ "#{uri} (at #{info})"
125
+ end
126
+ end
127
+
128
+ def to_lock
129
+ out = " git: #{uri}\n"
130
+ out << " revision: #{revision}\n"
131
+ out << " ref: #{shortref}\n" if shortref
132
+ out << " branch: #{branch}\n" if branch
133
+ out << " tag: #{tag}\n" if tag
134
+ out << " rel: #{rel}\n" if rel
135
+ out
136
+ end
137
+
138
+ protected
139
+
140
+ # The short ref (if one was given).
141
+ #
142
+ # @return [String, nil]
143
+ def shortref
144
+ ref && ref[0...7]
145
+ end
146
+
147
+ private
148
+
149
+ # Perform a git command.
150
+ #
151
+ # @param [String] command
152
+ # the command to run
153
+ # @param [Boolean] error
154
+ # whether to raise error if the command fails
155
+ #
156
+ # @raise [String]
157
+ # the +$stdout+ from the command
158
+ def git(command, error = true)
159
+ unless Berkshelf.which('git') || Berkshelf.which('git.exe')
160
+ raise GitNotInstalled.new
161
+ end
162
+
163
+ response = Buff::ShellOut.shell_out(%|git #{command}|)
164
+
165
+ if error && !response.success?
166
+ raise GitCommandError.new(command, cache_path, stderr = response.stderr)
167
+ end
168
+
169
+ response.stdout.strip
170
+ end
171
+
172
+ # Determine if this git repo has already been downloaded.
173
+ #
174
+ # @return [Boolean]
175
+ def cached?
176
+ cache_path.exist?
177
+ end
178
+
179
+ # The path where this cookbook would live in the store, if it were
180
+ # installed.
181
+ #
182
+ # @return [Pathname, nil]
183
+ def install_path
184
+ Berkshelf.cookbook_store.storage_path
185
+ .join("#{dependency.name}-#{revision}")
186
+ end
187
+
188
+ # The path where this git repository is cached.
189
+ #
190
+ # @return [Pathname]
191
+ def cache_path
192
+ Pathname.new(Berkshelf.berkshelf_path)
193
+ .join('.cache', 'git', Digest::SHA1.hexdigest(uri))
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,8 @@
1
+ module Berkshelf
2
+ class GithubLocation < GitLocation
3
+ def initialize(dependency, options = {})
4
+ options[:git] = "git://github.com/#{options.delete(:github)}.git"
5
+ super
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,78 @@
1
+ module Berkshelf
2
+ class PathLocation < BaseLocation
3
+ # Technically path locations are always installed, but this method
4
+ # intentionally returns +false+ to force validation of the cookbook at the
5
+ # path.
6
+ #
7
+ # @see BaseLocation#installed?
8
+ def installed?
9
+ false
10
+ end
11
+
12
+ # The installation for a path location is actually just a noop
13
+ #
14
+ # @see BaseLocation#install
15
+ def install
16
+ validate_cached!(expanded_path)
17
+ end
18
+
19
+ # @see BaseLocation#cached_cookbook
20
+ def cached_cookbook
21
+ @cached_cookbook ||= CachedCookbook.from_path(expanded_path)
22
+ end
23
+
24
+ # Returns true if the location is a metadata location. By default, no
25
+ # locations are the metadata location.
26
+ #
27
+ # @return [Boolean]
28
+ def metadata?
29
+ !!options[:metadata]
30
+ end
31
+
32
+ # Return this PathLocation's path relative to the associated Berksfile. It
33
+ # is actually the path reative to the associated Berksfile's parent
34
+ # directory.
35
+ #
36
+ # @return [String]
37
+ # the relative path relative to the target
38
+ def relative_path
39
+ my_path = Pathname.new(options[:path]).expand_path
40
+ target_path = Pathname.new(dependency.berksfile.filepath).expand_path
41
+ target_path = target_path.dirname if target_path.file?
42
+
43
+ new_path = my_path.relative_path_from(target_path).to_s
44
+
45
+ return new_path if new_path.index('.') == 0
46
+ "./#{new_path}"
47
+ end
48
+
49
+ # The fully expanded path of this cookbook on disk, relative to the
50
+ # Berksfile.
51
+ #
52
+ # @return [String]
53
+ def expanded_path
54
+ parent = File.expand_path(File.dirname(dependency.berksfile.filepath))
55
+ File.expand_path(relative_path, parent)
56
+ end
57
+
58
+ def ==(other)
59
+ other.is_a?(PathLocation) &&
60
+ other.metadata? == metadata? &&
61
+ other.relative_path == relative_path
62
+ end
63
+
64
+ def to_lock
65
+ out = " path: #{relative_path}\n"
66
+ out << " metadata: true\n" if metadata?
67
+ out
68
+ end
69
+
70
+ def to_s
71
+ "source at #{relative_path}"
72
+ end
73
+
74
+ def inspect
75
+ "#<Berkshelf::PathLocation metadata: #{metadata?}, path: #{relative_path}>"
76
+ end
77
+ end
78
+ end
@@ -21,10 +21,10 @@ module Berkshelf
21
21
  end
22
22
  end
23
23
 
24
- DEFAULT_FILENAME = 'Berksfile.lock'
24
+ DEFAULT_FILENAME = 'Berksfile.lock'.freeze
25
25
 
26
- DEPENDENCIES = 'DEPENDENCIES'
27
- GRAPH = 'GRAPH'
26
+ DEPENDENCIES = 'DEPENDENCIES'.freeze
27
+ GRAPH = 'GRAPH'.freeze
28
28
 
29
29
  include Berkshelf::Mixin::Logging
30
30
 
@@ -79,7 +79,9 @@ module Berkshelf
79
79
  #
80
80
  # 1. All dependencies defined in the Berksfile are present in this
81
81
  # lockfile
82
- # 2. Each dependency's constraint in the Berksfile is still satisifed by
82
+ # 2. Each dependency's transitive dependencies are contained and locked
83
+ # in the lockfile
84
+ # 3. Each dependency's constraint in the Berksfile is still satisifed by
83
85
  # the currently locked version
84
86
  #
85
87
  # This method does _not_ account for leaky dependencies (i.e. dependencies
@@ -89,14 +91,78 @@ module Berkshelf
89
91
  # @return [Boolean]
90
92
  # true if this lockfile is trusted, false otherwise
91
93
  def trusted?
92
- berksfile.dependencies.all? do |dependency|
93
- locked = find(dependency)
94
- graphed = graph.find(dependency)
95
- constraint = dependency.version_constraint
96
-
97
- locked && graphed &&
98
- dependency.location == locked.location &&
99
- constraint.satisfies?(graphed.version)
94
+ Berkshelf.log.info 'Checking if lockfile is trusted'
95
+
96
+ checked = {}
97
+
98
+ berksfile.dependencies.each do |dependency|
99
+ Berkshelf.log.debug "Checking #{dependency}"
100
+
101
+ checked[dependency.name] = true
102
+
103
+ locked = find(dependency)
104
+ if locked.nil?
105
+ Berkshelf.log.debug " Not in lockfile - cannot be trusted!"
106
+ return false
107
+ end
108
+
109
+ graphed = graph.find(dependency)
110
+ if graphed.nil?
111
+ Berkshelf.log.debug " Not in graph - cannot be trusted!"
112
+ return false
113
+ end
114
+
115
+ if cookbook = dependency.cached_cookbook
116
+ Berkshelf.log.debug " Detected there is a cached cookbook"
117
+
118
+ unless (cookbook.dependencies.keys - graphed.dependencies.keys).empty?
119
+ Berkshelf.log.debug " Cached cookbook has different dependencies - cannot be trusted!"
120
+ return false
121
+ end
122
+ end
123
+
124
+ unless dependency.location == locked.location
125
+ Berkshelf.log.debug " Different location - cannot be trusted!"
126
+ Berkshelf.log.debug " Dependency location: #{dependency.location.inspect}"
127
+ Berkshelf.log.debug " Locked location: #{locked.location.inspect}"
128
+ return false
129
+ end
130
+
131
+ unless dependency.version_constraint.satisfies?(graphed.version)
132
+ Berkshelf.log.debug " Version constraint is not satisified - cannot be trusted!"
133
+ return false
134
+ end
135
+
136
+ unless satisfies_transitive?(graphed, checked)
137
+ Berkshelf.log.debug " Transitive dependencies not satisfies - cannot be trusted!"
138
+ return false
139
+ end
140
+ end
141
+
142
+ true
143
+ end
144
+
145
+ # Recursive helper method for checking if transitive dependencies (i.e.
146
+ # those dependencies defined in the metadata) are satisfied. This method is
147
+ # used in calculating the trustworthiness of a lockfile.
148
+ #
149
+ # @param [GraphItem] graph_item
150
+ # the graph item to check transitive dependencies for
151
+ # @param [Hash] checked
152
+ # the list of already checked dependencies
153
+ #
154
+ # @return [Boolean]
155
+ def satisfies_transitive?(graph_item, checked)
156
+ graph_item.dependencies.all? do |name, constraint|
157
+ return true if checked[name]
158
+
159
+ checked[name] = true
160
+
161
+ graphed = graph.find(name)
162
+ return false if graphed.nil?
163
+
164
+ Solve::Constraint.new(constraint).satisfies?(graphed.version) &&
165
+ satisfies_transitive?(graphed, checked)
100
166
  end
101
167
  end
102
168
 
@@ -179,7 +245,7 @@ module Berkshelf
179
245
  # @raise [DependencyNotFound]
180
246
  # if this lockfile does not have the given dependency
181
247
  # @raise [CookbookNotFound]
182
- # if this lockfile has the dependency, but the cookbook is not downloaded
248
+ # if this lockfile has the dependency, but the cookbook is not installed
183
249
  #
184
250
  # @param [String, Dependency] dependency
185
251
  # the dependency or name of the dependency to find
@@ -193,9 +259,10 @@ module Berkshelf
193
259
  raise DependencyNotFound.new(Dependency.name(dependency))
194
260
  end
195
261
 
196
- unless locked.downloaded?
197
- raise CookbookNotFound, "Could not find cookbook '#{locked.to_s}'. " \
198
- "Run `berks install` to download and install the missing cookbook."
262
+ unless locked.installed?
263
+ name = locked.name
264
+ version = locked.locked_version || locked.version_constraint
265
+ raise CookbookNotFound.new(name, version, 'in the cookbook store')
199
266
  end
200
267
 
201
268
  locked.cached_cookbook
@@ -221,20 +288,90 @@ module Berkshelf
221
288
  # dependencies. Then it uses a recursive algorithm to safely remove any
222
289
  # other dependencies from the graph that are no longer needed.
223
290
  #
224
- # @raise [Berkshelf::CookbookNotFound]
291
+ # @raise [CookbookNotFound]
225
292
  # if the provided dependency does not exist
226
293
  #
227
294
  # @param [String] dependency
228
295
  # the name of the cookbook to remove
229
296
  def unlock(dependency)
230
- unless dependency?(dependency)
231
- raise Berkshelf::CookbookNotFound, "'#{dependency}' does not exist in this lockfile!"
232
- end
233
-
234
297
  @dependencies.delete(Dependency.name(dependency))
235
298
  graph.remove(dependency)
236
299
  end
237
300
 
301
+ # Iterate over each top-level dependency defined in the lockfile and
302
+ # check if that dependency is still defined in the Berksfile.
303
+ #
304
+ # If the dependency is no longer present in the Berksfile, it is "safely"
305
+ # removed using {Lockfile#unlock} and {Lockfile#remove}. This prevents
306
+ # the lockfile from "leaking" dependencies when they have been removed
307
+ # from the Berksfile, but still remained locked in the lockfile.
308
+ #
309
+ # If the dependency exists, a constraint comparison is conducted to verify
310
+ # that the locked dependency still satisifes the original constraint. This
311
+ # handles the edge case where a user has updated or removed a constraint
312
+ # on a dependency that already existed in the lockfile.
313
+ #
314
+ # @raise [OutdatedDependency]
315
+ # if the constraint exists, but is no longer satisifed by the existing
316
+ # locked version
317
+ #
318
+ # @return [Array<Dependency>]
319
+ def reduce!
320
+ # Store a list of cookbooks to ungraph
321
+ to_ungraph = {}
322
+ to_ignore = {}
323
+
324
+ # Unlock any locked dependencies that are no longer in the Berksfile
325
+ dependencies.each do |dependency|
326
+ unless berksfile.has_dependency?(dependency.name)
327
+ unlock(dependency)
328
+
329
+ # Keep a record. We know longer trust these dependencies, but simply
330
+ # unlocking them does not guarantee their removal from the graph.
331
+ # Instead, we keep a record of the dependency to unlock it later (in
332
+ # case it is actually removable because it's parent requirer is also
333
+ # being removed in this reduction). It's a form of science. Don't
334
+ # question it too much.
335
+ to_ungraph[dependency.name] = true
336
+ to_ignore[dependency.name] = true
337
+ end
338
+ end
339
+
340
+ # Remove any transitive dependencies
341
+ berksfile.dependencies.each do |dependency|
342
+ graphed = graph.find(dependency)
343
+ next if graphed.nil?
344
+
345
+ unless dependency.version_constraint.satisfies?(graphed.version)
346
+ raise OutdatedDependency.new(graphed, dependency)
347
+ end
348
+
349
+ if cookbook = dependency.cached_cookbook
350
+ graphed.dependencies.each do |name, constraint|
351
+ # Unless the cookbook still depends on this key, we want to queue it
352
+ # for unlocking. This is the magic that prevents transitive
353
+ # dependency leaking.
354
+ unless cookbook.dependencies.has_key?(name)
355
+ to_ungraph[name] = true
356
+
357
+ # We also want to ignore the top-level dependency. We can no
358
+ # longer trust the graph that we have been given for that
359
+ # dependency and therefore need to reduce it.
360
+ to_ignore[dependency.name] = true
361
+ end
362
+ end
363
+ end
364
+ end
365
+
366
+ # Now remove all the unlockable items
367
+ ignore = to_ungraph.merge(to_ignore).keys
368
+
369
+ to_ungraph.each do |name, _|
370
+ graph.remove(name, ignore: ignore)
371
+ end
372
+ end
373
+
374
+
238
375
  # Write the contents of the current statue of the lockfile to disk. This
239
376
  # method uses an atomic file write. A temporary file is created, written,
240
377
  # and then copied over the existing one. This ensures any partial updates
@@ -248,14 +385,7 @@ module Berkshelf
248
385
 
249
386
  tempfile = Tempfile.new(['Berksfile', '.lock'])
250
387
 
251
- tempfile.write(DEPENDENCIES)
252
- tempfile.write("\n")
253
- dependencies.sort.each do |dependency|
254
- tempfile.write(dependency.to_lock)
255
- end
256
-
257
- tempfile.write("\n")
258
- tempfile.write(graph.to_lock)
388
+ tempfile.write(to_lock)
259
389
 
260
390
  tempfile.rewind
261
391
  tempfile.close
@@ -268,6 +398,17 @@ module Berkshelf
268
398
  tempfile.unlink if tempfile
269
399
  end
270
400
 
401
+ # @private
402
+ def to_lock
403
+ out = "#{DEPENDENCIES}\n"
404
+ dependencies.sort.each do |dependency|
405
+ out << dependency.to_lock
406
+ end
407
+ out << "\n"
408
+ out << graph.to_lock
409
+ out
410
+ end
411
+
271
412
  # @private
272
413
  def to_s
273
414
  "#<Berkshelf::Lockfile #{Pathname.new(filepath).basename}>"
@@ -280,313 +421,334 @@ module Berkshelf
280
421
 
281
422
  private
282
423
 
283
- # The class responsible for parsing the lockfile and turning it into a
284
- # useful data structure.
285
- class LockfileParser
286
- NAME_VERSION = '(?! )(.*?)(?: \(([^-]*)(?:-(.*))?\))?'
287
- DEPENDENCY_PATTERN = /^ {2}#{NAME_VERSION}$/
288
- DEPENDENCIES_PATTERN = /^ {4}#{NAME_VERSION}$/
289
- OPTION_PATTERN = /^ {4}(.+)\: (.+)/
424
+ # The class responsible for parsing the lockfile and turning it into a
425
+ # useful data structure.
426
+ class LockfileParser
427
+ NAME_VERSION = '(?! )(.*?)(?: \(([^-]*)(?:-(.*))?\))?'.freeze
428
+ DEPENDENCY_PATTERN = /^ {2}#{NAME_VERSION}$/.freeze
429
+ DEPENDENCIES_PATTERN = /^ {4}#{NAME_VERSION}$/.freeze
430
+ OPTION_PATTERN = /^ {4}(.+)\: (.+)/.freeze
290
431
 
291
- # Create a new lockfile parser.
292
- #
293
- # @param [Lockfile]
294
- def initialize(lockfile)
295
- @lockfile = lockfile
296
- @berksfile = lockfile.berksfile
297
- end
298
-
299
- # Parse the lockfile contents, adding the correct things to the lockfile.
300
- #
301
- # @return [true]
302
- def run
303
- @parsed_dependencies = {}
432
+ # Create a new lockfile parser.
433
+ #
434
+ # @param [Lockfile]
435
+ def initialize(lockfile)
436
+ @lockfile = lockfile
437
+ @berksfile = lockfile.berksfile
438
+ end
304
439
 
305
- contents = File.read(@lockfile.filepath)
440
+ # Parse the lockfile contents, adding the correct things to the lockfile.
441
+ #
442
+ # @return [true]
443
+ def run
444
+ @parsed_dependencies = {}
306
445
 
307
- if contents.strip.empty?
308
- Berkshelf.formatter.warn "Your lockfile at '#{@lockfile.filepath}' " \
309
- "is empty. I am going to parse it anyway, but there is a chance " \
310
- "that a larger problem is at play. If you manually edited your " \
311
- "lockfile, you may have corrupted it."
312
- end
446
+ contents = File.read(@lockfile.filepath)
313
447
 
314
- if contents.strip[0] == '{'
315
- Berkshelf.formatter.warn "It looks like you are using an older " \
316
- "version of the lockfile. Attempting to convert..."
317
-
318
- dependencies = "#{Lockfile::DEPENDENCIES}\n"
319
- graph = "#{Lockfile::GRAPH}\n"
320
-
321
- begin
322
- hash = JSON.parse(contents)
323
- rescue JSON::ParserError
324
- Berkshelf.formatter.warn "Could not convert lockfile! This is a " \
325
- "problem. You see, previous versions of the lockfile were " \
326
- "actually a lie. It lied to you about your version locks, and we " \
327
- "are really sorry about that.\n\n" \
328
- "Here's the good news - we fixed it!\n\n" \
329
- "Here's the bad news - you probably should not trust your old " \
330
- "lockfile. You should manually delete your old lockfile and " \
331
- "re-run the installer."
448
+ if contents.strip.empty?
449
+ Berkshelf.formatter.warn "Your lockfile at '#{@lockfile.filepath}' " \
450
+ "is empty. I am going to parse it anyway, but there is a chance " \
451
+ "that a larger problem is at play. If you manually edited your " \
452
+ "lockfile, you may have corrupted it."
332
453
  end
333
454
 
334
- hash['dependencies'] && hash['dependencies'].sort .each do |name, info|
335
- dependencies << " #{name} (>= 0.0.0)\n"
336
- info.each do |key, value|
337
- unless key == 'locked_version'
338
- dependencies << " #{key}: #{value}\n"
455
+ if contents.strip[0] == '{'
456
+ Berkshelf.formatter.warn "It looks like you are using an older " \
457
+ "version of the lockfile. Attempting to convert..."
458
+
459
+ dependencies = "#{Lockfile::DEPENDENCIES}\n"
460
+ graph = "#{Lockfile::GRAPH}\n"
461
+
462
+ begin
463
+ hash = JSON.parse(contents)
464
+ rescue JSON::ParserError
465
+ Berkshelf.formatter.warn "Could not convert lockfile! This is a " \
466
+ "problem. You see, previous versions of the lockfile were " \
467
+ "actually a lie. It lied to you about your version locks, and we " \
468
+ "are really sorry about that.\n\n" \
469
+ "Here's the good news - we fixed it!\n\n" \
470
+ "Here's the bad news - you probably should not trust your old " \
471
+ "lockfile. You should manually delete your old lockfile and " \
472
+ "re-run the installer."
473
+ end
474
+
475
+ hash['dependencies'] && hash['dependencies'].sort .each do |name, info|
476
+ dependencies << " #{name} (>= 0.0.0)\n"
477
+ info.each do |key, value|
478
+ unless key == 'locked_version'
479
+ dependencies << " #{key}: #{value}\n"
480
+ end
339
481
  end
482
+
483
+ graph << " #{name} (#{info['locked_version']})\n"
340
484
  end
341
485
 
342
- graph << " #{name} (#{info['locked_version']})\n"
486
+ contents = "#{dependencies}\n#{graph}"
343
487
  end
344
488
 
345
- contents = "#{dependencies}\n#{graph}"
346
- end
489
+ contents.split(/(?:\r?\n)+/).each do |line|
490
+ if line == Lockfile::DEPENDENCIES
491
+ @state = :dependency
492
+ elsif line == Lockfile::GRAPH
493
+ @state = :graph
494
+ else
495
+ send("parse_#{@state}", line)
496
+ end
497
+ end
347
498
 
348
- contents.split(/(?:\r?\n)+/).each do |line|
349
- if line == Lockfile::DEPENDENCIES
350
- @state = :dependency
351
- elsif line == Lockfile::GRAPH
352
- @state = :graph
353
- else
354
- send("parse_#{@state}", line)
499
+ @parsed_dependencies.each do |name, options|
500
+ dependency = Dependency.new(@berksfile, name, options)
501
+ @lockfile.add(dependency)
355
502
  end
356
- end
357
503
 
358
- @parsed_dependencies.each do |name, options|
359
- dependency = Dependency.new(@berksfile, name, options)
360
- @lockfile.add(dependency)
504
+ true
361
505
  end
362
506
 
363
- true
364
- end
507
+ private
508
+
509
+ # Parse a dependency line.
510
+ #
511
+ # @param [String] line
512
+ def parse_dependency(line)
513
+ if line =~ DEPENDENCY_PATTERN
514
+ name, version = $1, $2
515
+
516
+ @parsed_dependencies[name] ||= {}
517
+ @parsed_dependencies[name][:constraint] = version if version
518
+ @current_dependency = @parsed_dependencies[name]
519
+ elsif line =~ OPTION_PATTERN
520
+ key, value = $1, $2
521
+ @current_dependency[key.to_sym] = value
522
+ end
523
+ end
365
524
 
366
- private
525
+ # Parse a graph line.
526
+ #
527
+ # @param [String] line
528
+ def parse_graph(line)
529
+ if line =~ DEPENDENCY_PATTERN
530
+ name, version = $1, $2
531
+
532
+ @lockfile.graph.find(name) || @lockfile.graph.add(name, version)
533
+ @current_lock = name
534
+ elsif line =~ DEPENDENCIES_PATTERN
535
+ name, constraint = $1, $2
536
+ @lockfile.graph.find(@current_lock).add_dependency(name, constraint)
537
+ end
538
+ end
539
+ end
367
540
 
368
- # Parse a dependency line.
369
- #
370
- # @param [String] line
371
- def parse_dependency(line)
372
- if line =~ DEPENDENCY_PATTERN
373
- name, version = $1, $2
374
-
375
- @parsed_dependencies[name] ||= {}
376
- @parsed_dependencies[name][:constraint] = version if version
377
- @current_dependency = @parsed_dependencies[name]
378
- elsif line =~ OPTION_PATTERN
379
- key, value = $1, $2
380
- @current_dependency[key.to_sym] = value
541
+ # The class representing an internal graph.
542
+ class Graph
543
+ # Create a new Lockfile graph.
544
+ #
545
+ # Some clarifying terminology:
546
+ #
547
+ # yum-epel (0.2.0) <- lock
548
+ # yum (~> 3.0) <- dependency
549
+ #
550
+ # @return [Graph]
551
+ def initialize(lockfile)
552
+ @lockfile = lockfile
553
+ @berksfile = lockfile.berksfile
554
+ @graph = {}
381
555
  end
382
- end
383
556
 
384
- # Parse a graph line.
385
- #
386
- # @param [String] line
387
- def parse_graph(line)
388
- if line =~ DEPENDENCY_PATTERN
389
- name, version = $1, $2
390
-
391
- @lockfile.graph.find(name) || @lockfile.graph.add(name, version)
392
- @current_lock = name
393
- elsif line =~ DEPENDENCIES_PATTERN
394
- name, constraint = $1, $2
395
- @lockfile.graph.find(@current_lock).add_dependency(name, constraint)
557
+ # The list of locks for this graph. Dependencies are retrieved from the
558
+ # lockfile, then the Berksfile, and finally a new dependency object is
559
+ # created if none of those exist.
560
+ #
561
+ # @return [Hash<String, Dependency>]
562
+ # a key-value hash where the key is the name of the cookbook and the
563
+ # value is the locked dependency
564
+ def locks
565
+ @graph.sort.inject({}) do |hash, (name, item)|
566
+ dependency = @lockfile.find(name) ||
567
+ @berksfile && @berksfile.find(name) ||
568
+ Dependency.new(@berksfile, name)
569
+ dependency.locked_version = item.version
570
+
571
+ hash[item.name] = dependency
572
+ hash
573
+ end
396
574
  end
397
- end
398
- end
399
575
 
400
- # The class representing an internal graph.
401
- class Graph
402
- # Create a new Lockfile graph.
403
- #
404
- # Some clarifying terminology:
405
- #
406
- # yum-epel (0.2.0) <- lock
407
- # yum (~> 3.0) <- dependency
408
- #
409
- # @return [Graph]
410
- def initialize(lockfile)
411
- @lockfile = lockfile
412
- @berksfile = lockfile.berksfile
413
- @graph = {}
414
- end
576
+ # Find a given dependency in the graph.
577
+ #
578
+ # @param [Dependency, String]
579
+ # the name/dependency to find
580
+ #
581
+ # @return [GraphItem, nil]
582
+ # the item for the name
583
+ def find(dependency)
584
+ @graph[Dependency.name(dependency)]
585
+ end
415
586
 
416
- # The list of locks for this graph. Dependencies are retrieved from the
417
- # lockfile, then the Berksfile, and finally a new dependency object is
418
- # created if none of those exist.
419
- #
420
- # @return [Hash<String, Dependency>]
421
- # a key-value hash where the key is the name of the cookbook and the
422
- # value is the locked dependency
423
- def locks
424
- @graph.sort.inject({}) do |hash, (name, item)|
425
- dependency = @lockfile.find(name) ||
426
- @berksfile && @berksfile.find(name) ||
427
- Dependency.new(@berksfile, name)
428
- dependency.locked_version = item.version
429
-
430
- hash[item.name] = dependency
431
- hash
587
+ # Find if the given lock exists?
588
+ #
589
+ # @param [Dependency, String]
590
+ # the name/dependency to find
591
+ #
592
+ # @return [true, false]
593
+ def lock?(dependency)
594
+ !find(dependency).nil?
432
595
  end
433
- end
596
+ alias_method :has_lock?, :lock?
434
597
 
435
- # Find a given dependency in the graph.
436
- #
437
- # @param [Dependency, String]
438
- # the name/dependency to find
439
- #
440
- # @return [GraphItem, nil]
441
- # the item for the name
442
- def find(dependency)
443
- @graph[Dependency.name(dependency)]
444
- end
598
+ # Determine if this graph contains the given dependency. This method is
599
+ # used by the lockfile when adding or removing dependencies to see if a
600
+ # dependency can be safely removed.
601
+ #
602
+ # @param [Dependency, String] dependency
603
+ # the name/dependency to find
604
+ #
605
+ # @option options [String, Array<String>] :ignore
606
+ # the list of dependencies to ignore
607
+ def dependency?(dependency, options = {})
608
+ name = Dependency.name(dependency)
609
+ ignore = Hash[*Array(options[:ignore]).map { |i| [i, true] }.flatten]
445
610
 
446
- # Find if the given lock exists?
447
- #
448
- # @param [Dependency, String]
449
- # the name/dependency to find
450
- #
451
- # @return [true, false]
452
- def lock?(dependency)
453
- !find(dependency).nil?
454
- end
455
- alias_method :has_lock?, :lock?
611
+ @graph.values.each do |item|
612
+ next if ignore[item.name]
456
613
 
457
- # Determine if this graph contains the given dependency. This method is
458
- # used by the lockfile when adding or removing dependencies to see if a
459
- # dependency can be safely removed.
460
- #
461
- # @param [Dependency, String] dependency
462
- # the name/dependency to find
463
- def dependency?(dependency)
464
- @graph.values.any? do |item|
465
- item.dependencies.key?(Dependency.name(dependency))
614
+ if item.dependencies.key?(name)
615
+ return true
616
+ end
617
+ end
618
+
619
+ false
466
620
  end
467
- end
468
- alias_method :has_dependency?, :dependency?
621
+ alias_method :has_dependency?, :dependency?
469
622
 
470
- # Add each a new {GraphItem} to the graph.
471
- #
472
- # @param [#to_s] name
473
- # the name of the cookbook
474
- # @param [#to_s] version
475
- # the version of the lock
476
- #
477
- # @return [GraphItem]
478
- def add(name, version)
479
- @graph[name.to_s] = GraphItem.new(name, version)
480
- end
623
+ # Add each a new {GraphItem} to the graph.
624
+ #
625
+ # @param [#to_s] name
626
+ # the name of the cookbook
627
+ # @param [#to_s] version
628
+ # the version of the lock
629
+ #
630
+ # @return [GraphItem]
631
+ def add(name, version)
632
+ @graph[name.to_s] = GraphItem.new(name, version)
633
+ end
481
634
 
482
- # Recursively remove any dependencies from the graph unless they exist as
483
- # top-level dependencies or nested dependencies.
484
- #
485
- # @param [Dependency, String] dependency
486
- # the name/dependency to remove
487
- def remove(dependency)
488
- name = Dependency.name(dependency)
635
+ # Recursively remove any dependencies from the graph unless they exist as
636
+ # top-level dependencies or nested dependencies.
637
+ #
638
+ # @param [Dependency, String] dependency
639
+ # the name/dependency to remove
640
+ #
641
+ # @option options [String, Array<String>] :ignore
642
+ # the list of dependencies to ignore
643
+ def remove(dependency, options = {})
644
+ name = Dependency.name(dependency)
489
645
 
490
- return if @lockfile.dependency?(name) || dependency?(name)
646
+ if @lockfile.dependency?(name)
647
+ return
648
+ end
491
649
 
492
- # Grab the nested dependencies for this particular entry so we can
493
- # recurse and try to remove them from the graph.
494
- locked = @graph[name]
495
- nested_dependencies = locked && locked.dependencies.keys || []
650
+ if dependency?(name, options)
651
+ return
652
+ end
496
653
 
497
- # Now delete the entry
498
- @graph.delete(name)
654
+ # Grab the nested dependencies for this particular entry so we can
655
+ # recurse and try to remove them from the graph.
656
+ locked = @graph[name]
657
+ nested_dependencies = locked && locked.dependencies.keys || []
499
658
 
500
- # Recursively try to delete the remaining dependencies for this item
501
- nested_dependencies.each(&method(:remove))
502
- end
659
+ # Now delete the entry
660
+ @graph.delete(name)
503
661
 
504
- # Update the graph with the given cookbooks. This method destroys the
505
- # existing dependency graph with this new result!
506
- #
507
- # @param [Array<CachedCookbook>]
508
- # the list of cookbooks to populate the graph with
509
- def update(cookbooks)
510
- @graph = {}
511
-
512
- cookbooks.each do |cookbook|
513
- @graph[cookbook.cookbook_name.to_s] = GraphItem.new(
514
- cookbook.name,
515
- cookbook.version,
516
- cookbook.dependencies,
517
- )
662
+ # Recursively try to delete the remaining dependencies for this item
663
+ nested_dependencies.each(&method(:remove))
518
664
  end
519
- end
520
665
 
521
- # Write the contents of the graph to the lockfile format.
522
- #
523
- # The resulting format looks like:
524
- #
525
- # GRAPH
526
- # apache2 (1.8.14)
527
- # yum-epel (0.2.0)
528
- # yum (~> 3.0)
529
- #
530
- # @example lockfile.graph.to_lock #=> "GRAPH\n apache2 (1.18.14)\n..."
531
- #
532
- # @return [String]
533
- #
534
- def to_lock
535
- out = "#{Lockfile::GRAPH}\n"
536
- @graph.sort.each do |name, item|
537
- out << " #{name} (#{item.version})\n"
538
-
539
- unless item.dependencies.empty?
540
- item.dependencies.sort.each do |name, constraint|
541
- out << " #{name} (#{constraint})\n"
542
- end
666
+ # Update the graph with the given cookbooks. This method destroys the
667
+ # existing dependency graph with this new result!
668
+ #
669
+ # @param [Array<CachedCookbook>]
670
+ # the list of cookbooks to populate the graph with
671
+ def update(cookbooks)
672
+ @graph = {}
673
+
674
+ cookbooks.each do |cookbook|
675
+ @graph[cookbook.cookbook_name.to_s] = GraphItem.new(
676
+ cookbook.name,
677
+ cookbook.version,
678
+ cookbook.dependencies,
679
+ )
543
680
  end
544
681
  end
545
682
 
546
- out
547
- end
548
-
549
- private
550
-
551
- # A single item inside the graph.
552
- class GraphItem
553
- # The name of the cookbook that corresponds to this graph item.
683
+ # Write the contents of the graph to the lockfile format.
554
684
  #
555
- # @return [String]
556
- # the name of the cookbook
557
- attr_reader :name
558
-
559
- # The locked version for this graph item.
685
+ # The resulting format looks like:
686
+ #
687
+ # GRAPH
688
+ # apache2 (1.8.14)
689
+ # yum-epel (0.2.0)
690
+ # yum (~> 3.0)
691
+ #
692
+ # @example lockfile.graph.to_lock #=> "GRAPH\n apache2 (1.18.14)\n..."
560
693
  #
561
694
  # @return [String]
562
- # the locked version of the graph item (as a string)
563
- attr_reader :version
564
-
565
- # The list of dependencies and their constraints.
566
695
  #
567
- # @return [Hash<String, String>]
568
- # the list of dependencies for this graph item, where the key
569
- # corresponds to the name of the dependency and the value is the
570
- # version constraint.
571
- attr_reader :dependencies
572
-
573
- # Create a new graph item.
574
- def initialize(name, version, dependencies = {})
575
- @name = name.to_s
576
- @version = version.to_s
577
- @dependencies = dependencies
578
- end
696
+ def to_lock
697
+ out = "#{Lockfile::GRAPH}\n"
698
+ @graph.sort.each do |name, item|
699
+ out << " #{name} (#{item.version})\n"
700
+
701
+ unless item.dependencies.empty?
702
+ item.dependencies.sort.each do |name, constraint|
703
+ out << " #{name} (#{constraint})\n"
704
+ end
705
+ end
706
+ end
579
707
 
580
- # Add a new dependency to the list.
581
- #
582
- # @param [#to_s] name
583
- # the name to use
584
- # @param [#to_s] constraint
585
- # the version constraint to use
586
- def add_dependency(name, constraint)
587
- @dependencies[name.to_s] = constraint.to_s
708
+ out
588
709
  end
710
+
711
+ private
712
+
713
+ # A single item inside the graph.
714
+ class GraphItem
715
+ # The name of the cookbook that corresponds to this graph item.
716
+ #
717
+ # @return [String]
718
+ # the name of the cookbook
719
+ attr_reader :name
720
+
721
+ # The locked version for this graph item.
722
+ #
723
+ # @return [String]
724
+ # the locked version of the graph item (as a string)
725
+ attr_reader :version
726
+
727
+ # The list of dependencies and their constraints.
728
+ #
729
+ # @return [Hash<String, String>]
730
+ # the list of dependencies for this graph item, where the key
731
+ # corresponds to the name of the dependency and the value is the
732
+ # version constraint.
733
+ attr_reader :dependencies
734
+
735
+ # Create a new graph item.
736
+ def initialize(name, version, dependencies = {})
737
+ @name = name.to_s
738
+ @version = version.to_s
739
+ @dependencies = dependencies
740
+ end
741
+
742
+ # Add a new dependency to the list.
743
+ #
744
+ # @param [#to_s] name
745
+ # the name to use
746
+ # @param [#to_s] constraint
747
+ # the version constraint to use
748
+ def add_dependency(name, constraint)
749
+ @dependencies[name.to_s] = constraint.to_s
750
+ end
751
+ end
589
752
  end
590
- end
591
753
  end
592
754
  end