Class: Puppet::Modulebuilder::Builder

Inherits:
Object
  • Object
show all
Defined in:
lib/puppet/modulebuilder/builder.rb

Overview

Class to build Puppet Modules from source

Constant Summary collapse

IGNORED =

Due to the way how PathSpec generates the regular expression, ‘/*` doesn’t match directories starting with a dot, so we need ‘/.*` as well.

[
  '/**',
  '/.*',
  '!/CHANGELOG*',
  '!/LICENSE',
  '!/README*',
  '!/REFERENCE.md',
  '!/bolt_plugin.json',
  '!/data/**',
  '!/docs/**',
  '!/examples/**',
  '!/facts.d/**',
  '!/files/**',
  '!/functions/**',
  '!/hiera.yaml',
  '!/lib/**',
  '!/locales/**',
  '!/manifests/**',
  '!/metadata.json',
  '!/plans/**',
  '!/scripts/**',
  '!/tasks/**',
  '!/templates/**',
  '!/types/**',
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source, destination = nil, logger = nil) ⇒ Builder

Returns a new instance of Builder.



39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/puppet/modulebuilder/builder.rb', line 39

def initialize(source, destination = nil, logger = nil)
  unless logger.nil? || logger.is_a?(Logger)
    raise ArgumentError,
          format('logger is expected to be nil or a Logger. Got %<klass>s',
                 klass: logger.class)
  end

  @source_validated = false
  @source = source
  @destination = destination.nil? ? File.join(source, 'pkg') : destination
  @logger = logger.nil? ? ::Logger.new(File.open(File::NULL, 'w')) : logger
end

Instance Attribute Details

#destinationObject (readonly)

Returns the value of attribute destination.



37
38
39
# File 'lib/puppet/modulebuilder/builder.rb', line 37

def destination
  @destination
end

#loggerObject (readonly)

Returns the value of attribute logger.



37
38
39
# File 'lib/puppet/modulebuilder/builder.rb', line 37

def logger
  @logger
end

#release_nameString

The release name is used for the build directory and resulting package file.

The default combines the module name and version into a Forge-compatible dash separated string. Unless you have an unusual use case this isn’t set manually.

Returns:

  • (String)


329
330
331
332
333
334
# File 'lib/puppet/modulebuilder/builder.rb', line 329

def release_name
  @release_name ||= [
    ['name'],
    ['version'],
  ].join('-')
end

Instance Method Details

#buildString

Build a module package from a module directory.

Returns:

  • (String)

    The path to the built package file.



64
65
66
67
68
69
70
71
72
73
# File 'lib/puppet/modulebuilder/builder.rb', line 64

def build
  create_build_dir

  stage_module_in_build_dir
  build_package

  package_file
ensure
  cleanup_build_dir
end

#build_contextObject



83
84
85
86
87
88
# File 'lib/puppet/modulebuilder/builder.rb', line 83

def build_context
  {
    parent_dir: destination,
    build_dir_name: release_name,
  }.freeze
end

#build_dirObject

Return the path to the temporary build directory, which will be placed inside the target directory and match the release name

See Also:



79
80
81
# File 'lib/puppet/modulebuilder/builder.rb', line 79

def build_dir
  @build_dir ||= File.join(build_context[:parent_dir], build_context[:build_dir_name])
end

#build_packageObject

Creates a gzip compressed tarball of the build directory.

If the destination package already exists, it will be removed before creating the new tarball.

Returns:

  • nil.



214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/puppet/modulebuilder/builder.rb', line 214

def build_package
  require 'zlib'
  require 'minitar'
  require 'find'

  FileUtils.rm_f(package_file)

  # The chdir necessary us due to Minitar entry not be able to separate the filename
  # within the TAR versus the source filename to pack
  Dir.chdir(build_context[:parent_dir]) do
    gz = Zlib::GzipWriter.new(File.open(package_file, 'wb'))
    begin
      tar = Minitar::Output.new(gz)
      Find.find(build_context[:build_dir_name]) do |entry|
         = {
          name: entry,
        }

        orig_mode = File.stat(entry).mode
        min_mode = Minitar.dir?(entry) ? 0o755 : 0o644

        [:mode] = orig_mode | min_mode

        if [:mode] != orig_mode
          logger.debug(format('Updated permissions of packaged \'%<entry>s\' to %<new_mode>s', entry: entry,
                                                                                               new_mode: ([:mode] & 0o7777).to_s(8)))
        end

        Minitar.pack_file(, tar)
      end
    ensure
      tar.close
    end
  end
end

#cleanup_build_dirObject

Remove the temporary build directory and all its contents from disk.

Returns:

  • nil.



276
277
278
# File 'lib/puppet/modulebuilder/builder.rb', line 276

def cleanup_build_dir
  FileUtils.rm_rf(build_dir, secure: true)
end

#copy_mtime(path) ⇒ Object



150
151
152
153
154
155
156
157
158
159
# File 'lib/puppet/modulebuilder/builder.rb', line 150

def copy_mtime(path)
  require 'pathname'

  relative_path = Pathname.new(path).relative_path_from(Pathname.new(source))
  dest_path = File.join(build_dir, relative_path)

  validate_path_encoding!(relative_path.to_path)

  fileutils_touch(dest_path, mtime: file_stat(path).mtime)
end

#create_build_dirObject

Create a temporary build directory where the files to be included in the package will be staged before building the tarball.

If the directory already exists, remove it first.



267
268
269
270
271
# File 'lib/puppet/modulebuilder/builder.rb', line 267

def create_build_dir
  cleanup_build_dir

  fileutils_mkdir_p(build_dir)
end

#ignored_filesPathSpec

Instantiate a new PathSpec class and populate it with the pattern(s) of files to be ignored.

Returns:

  • (PathSpec)

    The populated ignore path matcher.



254
255
256
257
258
259
260
261
# File 'lib/puppet/modulebuilder/builder.rb', line 254

def ignored_files
  require 'pathspec'

  ignored = PathSpec.new(IGNORED)
  ignored.add("/#{File.basename(destination)}/") if File.realdirpath(destination).start_with?(File.realdirpath(source))

  ignored
end

#ignored_path?(path) ⇒ Boolean

Check if the given path matches one of the patterns listed in the ignore file.

Parameters:

  • path (String)

    The path to be checked.

Returns:

  • (Boolean)

    true if the path matches and should be ignored.



167
168
169
170
171
# File 'lib/puppet/modulebuilder/builder.rb', line 167

def ignored_path?(path)
  path = "#{path}/" if File.directory?(path)

  ignored_files.match_path(path, source)
end

#metadataHash{String => Object}

Read and parse the values from metadata.json for the module that is being built.

Returns:

  • (Hash{String => Object})

    The hash of metadata values.



284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/puppet/modulebuilder/builder.rb', line 284

def 
  return @metadata unless @metadata.nil?

   = File.join(source, 'metadata.json')

  unless file_exists?()
    raise ArgumentError,
          format("'%<file>s' does not exist or is not a file.",
                 file: )
  end

  unless file_readable?()
    raise ArgumentError,
          format("Unable to open '%<file>s' for reading.",
                 file: )
  end

  require 'json'
  begin
    @metadata = JSON.parse(read_file())
  rescue JSON::JSONError => e
    raise ArgumentError, format('Invalid JSON in metadata.json: %<msg>s', msg: e.message)
  end
  @metadata.freeze
end

#package_already_exists?Boolean

Verify if there is an existing package in the target directory and prompts the user if they want to overwrite it.

Returns:

  • (Boolean)


317
318
319
# File 'lib/puppet/modulebuilder/builder.rb', line 317

def package_already_exists?
  file_exists?(package_file)
end

#package_fileObject

Return the path where the built package file will be written to.



311
312
313
# File 'lib/puppet/modulebuilder/builder.rb', line 311

def package_file
  @package_file ||= File.join(destination, "#{release_name}.tar.gz")
end

#sourceString

The source to build the module from

Returns:

  • (String)


54
55
56
57
58
59
# File 'lib/puppet/modulebuilder/builder.rb', line 54

def source
  return @source if @source_validated

  validate_source!
  @source = File.realpath(@source)
end

#stage_module_in_build_dirObject

Iterate through all the files and directories in the module and stage them into the temporary build directory (unless ignored).

Returns:

  • nil



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/puppet/modulebuilder/builder.rb', line 94

def stage_module_in_build_dir
  require 'find'

  directories = [source]

  staged = Find.find(source) do |path|
    next if path == source

    if ignored_path?(path)
      logger.debug("Ignoring #{path} from the build")
      Find.prune
    else
      logger.debug("Staging #{path} for the build")
      directories << path if file_directory?(path)
      stage_path(path)
    end
  end

  # Reset directory mtimes. This must happen after the files have been
  # copied since that modifies a directory's mtime
  directories.each do |directory|
    copy_mtime(directory)
  end

  staged
end

#stage_path(path) ⇒ Object

Stage a file or directory from the module into the build directory.

Parameters:

  • path (String)

    The path to the file or directory.

Returns:

  • nil.



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/puppet/modulebuilder/builder.rb', line 126

def stage_path(path)
  require 'pathname'

  relative_path = Pathname.new(path).relative_path_from(Pathname.new(source))
  dest_path = File.join(build_dir, relative_path)

  validate_path_encoding!(relative_path.to_path)

  begin
    if file_directory?(path)
      fileutils_mkdir_p(dest_path, mode: file_stat(path).mode)
    elsif file_symlink?(path)
      warn_symlink(path)
    else
      validate_ustar_path!(relative_path.to_path)
      fileutils_cp(path, dest_path, preserve: true)
    end
  rescue ArgumentError => e
    raise format(
      '%<message>s Rename the file or exclude it from the package by adding it to the .pdkignore file in your module.', message: e.message
    )
  end
end

#validate_path_encoding!(path) ⇒ nil

Checks if the path contains any non-ASCII characters.

Java will throw an error when it encounters a path containing characters that are not supported by the hosts locale. In order to maximise compatibility we limit the paths to contain only ASCII characters, which should be part of any locale character set.

Parameters:

  • path (String)

    the relative path to be added to the tar file.

Returns:

  • (nil)

Raises:

  • (ArgumentError)

    if the path contains non-ASCII characters.



201
202
203
204
205
206
# File 'lib/puppet/modulebuilder/builder.rb', line 201

def validate_path_encoding!(path)
  return unless /[^\x00-\x7F]/.match?(path)

  raise ArgumentError, format("'%<path>s' can only include ASCII characters in its path or " \
                              'filename in order to be compatible with a wide range of hosts.', path: path)
end

#validate_ustar_path!(path) ⇒ nil

Checks if the path length will fit into the POSIX.1-1998 (ustar) tar header format.

POSIX.1-2001 (which allows paths of infinite length) was adopted by GNU tar in 2004 and is supported by minitar 0.7 and above. Unfortunately much of the Puppet ecosystem still uses minitar 0.6.1.

POSIX.1-1998 tar format does not allow for paths greater than 256 bytes, or paths that can’t be split into a prefix of 155 bytes (max) and a suffix of 100 bytes (max).

This logic was pretty much copied from the private method Archive::Tar::Minitar::Writer#split_name.

Parameters:

  • path (String)

    the relative path to be added to the tar file.

Returns:

  • (nil)

Raises:

  • (ArgumentError)

    if the path is too long or could not be split.



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
# File 'lib/puppet/modulebuilder/builder.rb', line 357

def validate_ustar_path!(path)
  raise ArgumentError, format("The path '%<path>s' is longer than 256 bytes.", path: path) if path.bytesize > 256

  if path.bytesize <= 100
    prefix = ''
  else
    parts = path.split(File::SEPARATOR)
    newpath = parts.pop
    nxt = ''

    loop do
      nxt = parts.pop || ''
      break if newpath.bytesize + 1 + nxt.bytesize >= 100

      newpath = File.join(nxt, newpath)
    end

    prefix = File.join(*parts, nxt)
    path = newpath
  end

  return unless path.bytesize > 100 || prefix.bytesize > 155

  raise ArgumentError, \
        format("'%<path>s' could not be split at a directory separator into two " \
               'parts, the first having a maximum length of 155 bytes and the ' \
               'second having a maximum length of 100 bytes.', path: path)
end

Warn the user about a symlink that would have been included in the built package.

Parameters:

  • path (String)

    The relative or absolute path to the symlink.

Returns:

  • nil.



179
180
181
182
183
184
185
186
187
# File 'lib/puppet/modulebuilder/builder.rb', line 179

def warn_symlink(path)
  require 'pathname'

  symlink_path = Pathname.new(path)
  module_path = Pathname.new(source)

  logger.warn format('Symlinks in modules are not supported and will not be included in the package. Please investigate symlink %<from>s -> %<to>s.',
                     from: symlink_path.relative_path_from(module_path), to: symlink_path.realpath.relative_path_from(module_path))
end