berkshelf 3.0.0.beta7 → 3.0.0.beta8

Sign up to get free protection for your applications and to get access to all the features.
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