File: task.rb

package info (click to toggle)
puppet-agent 8.10.0-6
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 27,404 kB
  • sloc: ruby: 286,820; sh: 492; xml: 116; makefile: 88; cs: 68
file content (288 lines) | stat: -rw-r--r-- 10,984 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
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
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
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
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
# frozen_string_literal: true

require_relative '../../puppet/util/logging'

class Puppet::Module
  class Task
    class Error < Puppet::Error
      attr_accessor :kind, :details

      def initialize(message, kind, details = nil)
        super(message)
        @details = details || {}
        @kind = kind
      end

      def to_h
        {
          msg: message,
          kind: kind,
          details: details
        }
      end
    end

    class InvalidName < Error
      def initialize(name)
        msg = _("Task names must start with a lowercase letter and be composed of only lowercase letters, numbers, and underscores")
        super(msg, 'puppet.tasks/invalid-name')
      end
    end

    class InvalidFile < Error
      def initialize(msg)
        super(msg, 'puppet.tasks/invalid-file')
      end
    end

    class InvalidTask < Error
    end

    class InvalidMetadata < Error
    end

    class TaskNotFound < Error
      def initialize(task_name, module_name)
        msg = _("Task %{task_name} not found in module %{module_name}.") %
              { task_name: task_name, module_name: module_name }
        super(msg, 'puppet.tasks/task-not-found', { 'name' => task_name })
      end
    end

    FORBIDDEN_EXTENSIONS = %w[.conf .md]
    MOUNTS = %w[files lib scripts tasks]

    def self.is_task_name?(name)
      return true if name =~ /^[a-z][a-z0-9_]*$/

      false
    end

    def self.is_tasks_file?(path)
      File.file?(path) && is_tasks_filename?(path)
    end

    # Determine whether a file has a legal name for either a task's executable or metadata file.
    def self.is_tasks_filename?(path)
      name_less_extension = File.basename(path, '.*')
      return false unless is_task_name?(name_less_extension)

      FORBIDDEN_EXTENSIONS.each do |ext|
        return false if path.end_with?(ext)
      end
      true
    end

    def self.get_file_details(path, mod)
      # This gets the path from the starting point onward
      # For files this should be the file subpath from the metadata
      # For directories it should be the directory subpath plus whatever we globbed
      # Partition matches on the first instance it finds of the parameter
      name = "#{mod.name}#{path.partition(mod.path).last}"

      { "name" => name, "path" => path }
    end
    private_class_method :get_file_details

    # Find task's required lib files and retrieve paths for both 'files' and 'implementation:files' metadata keys
    def self.find_extra_files(metadata, envname = nil)
      return [] if metadata.nil?

      files = metadata.fetch('files', [])
      unless files.is_a?(Array)
        msg = _("The 'files' task metadata expects an array, got %{files}.") % { files: files }
        raise InvalidMetadata.new(msg, 'puppet.tasks/invalid-metadata')
      end
      impl_files = metadata.fetch('implementations', []).flat_map do |impl|
        file_array = impl.fetch('files', [])
        unless file_array.is_a?(Array)
          msg = _("The 'files' task metadata expects an array, got %{files}.") % { files: file_array }
          raise InvalidMetadata.new(msg, 'puppet.tasks/invalid-metadata')
        end
        file_array
      end

      combined_files = files + impl_files
      combined_files.uniq.flat_map do |file|
        module_name, mount, endpath = file.split("/", 3)
        # If there's a mount directory with no trailing slash this will be nil
        # We want it to be empty to construct a path
        endpath ||= ''

        pup_module = Puppet::Module.find(module_name, envname)
        if pup_module.nil?
          msg = _("Could not find module %{module_name} containing task file %{filename}" %
                  { module_name: module_name, filename: endpath })
          raise InvalidMetadata.new(msg, 'puppet.tasks/invalid-metadata')
        end

        unless MOUNTS.include? mount
          msg = _("Files must be saved in module directories that Puppet makes available via mount points: %{mounts}" %
                  { mounts: MOUNTS.join(', ') })
          raise InvalidMetadata.new(msg, 'puppet.tasks/invalid-metadata')
        end

        path = File.join(pup_module.path, mount, endpath)
        unless File.absolute_path(path) == File.path(path).chomp('/')
          msg = _("File pathnames cannot include relative paths")
          raise InvalidMetadata.new(msg, 'puppet.tasks/invalid-metadata')
        end

        unless File.exist?(path)
          msg = _("Could not find %{path} on disk" % { path: path })
          raise InvalidFile, msg
        end

        last_char = file[-1] == '/'
        if File.directory?(path)
          unless last_char
            msg = _("Directories specified in task metadata must include a trailing slash: %{dir}" % { dir: file })
            raise InvalidMetadata.new(msg, 'puppet.tasks/invalid-metadata')
          end
          dir_files = Dir.glob("#{path}**/*").select { |f| File.file?(f) }
          dir_files.map { |f| get_file_details(f, pup_module) }
        else
          if last_char
            msg = _("Files specified in task metadata cannot include a trailing slash: %{file}" % { file: file })
            raise InvalidMetadata.new(msg, 'puppet.task/invalid-metadata')
          end
          get_file_details(path, pup_module)
        end
      end
    end
    private_class_method :find_extra_files

    # Executables list should contain the full path of all possible implementation files
    def self.find_implementations(name, directory, metadata, executables)
      basename = name.split('::')[1] || 'init'
      # If 'implementations' is defined, it needs to mention at least one
      # implementation, and everything it mentions must exist.
      metadata ||= {}
      if metadata.key?('implementations')
        unless metadata['implementations'].is_a?(Array)
          msg = _("Task metadata for task %{name} does not specify implementations as an array" % { name: name })
          raise InvalidMetadata.new(msg, 'puppet.tasks/invalid-metadata')
        end

        implementations = metadata['implementations'].map do |impl|
          unless impl['requirements'].is_a?(Array) || impl['requirements'].nil?
            msg = _("Task metadata for task %{name} does not specify requirements as an array" % { name: name })
            raise InvalidMetadata.new(msg, 'puppet.tasks/invalid-metadata')
          end
          path = executables.find { |real_impl| File.basename(real_impl) == impl['name'] }
          unless path
            msg = _("Task metadata for task %{name} specifies missing implementation %{implementation}" % { name: name, implementation: impl['name'] })
            raise InvalidTask.new(msg, 'puppet.tasks/missing-implementation', { missing: [impl['name']] })
          end
          { "name" => impl['name'], "path" => path }
        end
        return implementations
      end

      # If implementations isn't defined, then we use executables matching the
      # task name, and only one may exist.
      implementations = executables.select { |impl| File.basename(impl, '.*') == basename }
      if implementations.empty?
        msg = _('No source besides task metadata was found in directory %{directory} for task %{name}') %
              { name: name, directory: directory }
        raise InvalidTask.new(msg, 'puppet.tasks/no-implementation')
      elsif implementations.length > 1
        msg = _("Multiple executables were found in directory %{directory} for task %{name}; define 'implementations' in metadata to differentiate between them") %
              { name: name, directory: implementations[0] }
        raise InvalidTask.new(msg, 'puppet.tasks/multiple-implementations')
      end

      [{ "name" => File.basename(implementations.first), "path" => implementations.first }]
    end
    private_class_method :find_implementations

    def self.find_files(name, directory, metadata, executables, envname = nil)
      # PXP agent relies on 'impls' (which is the task file) being first if there is no metadata
      find_implementations(name, directory, metadata, executables) + find_extra_files(metadata, envname)
    end

    def self.is_tasks_metadata_filename?(name)
      is_tasks_filename?(name) && name.end_with?('.json')
    end

    def self.is_tasks_executable_filename?(name)
      is_tasks_filename?(name) && !name.end_with?('.json')
    end

    def self.tasks_in_module(pup_module)
      task_files = Dir.glob(File.join(pup_module.tasks_directory, '*'))
                      .keep_if { |f| is_tasks_file?(f) }

      module_executables = task_files.reject(&method(:is_tasks_metadata_filename?)).map.to_a

      tasks = task_files.group_by { |f| task_name_from_path(f) }

      tasks.map do |task, executables|
        new_with_files(pup_module, task, executables, module_executables)
      end
    end

    attr_reader :name, :module, :metadata_file

    # file paths must be relative to the modules task directory
    def initialize(pup_module, task_name, module_executables, metadata_file = nil)
      unless Puppet::Module::Task.is_task_name?(task_name)
        raise InvalidName, _("Task names must start with a lowercase letter and be composed of only lowercase letters, numbers, and underscores")
      end

      name = task_name == "init" ? pup_module.name : "#{pup_module.name}::#{task_name}"

      @module = pup_module
      @name = name
      @metadata_file = metadata_file
      @module_executables = module_executables || []
    end

    def self.read_metadata(file)
      if file
        content = Puppet::FileSystem.read(file, :encoding => 'utf-8')
        content.empty? ? {} : Puppet::Util::Json.load(content)
      end
    rescue SystemCallError, IOError => err
      msg = _("Error reading metadata: %{message}" % { message: err.message })
      raise InvalidMetadata.new(msg, 'puppet.tasks/unreadable-metadata')
    rescue Puppet::Util::Json::ParseError => err
      raise InvalidMetadata.new(err.message, 'puppet.tasks/unparseable-metadata')
    end

    def metadata
      @metadata ||= self.class.read_metadata(@metadata_file)
    end

    def files
      @files ||= self.class.find_files(@name, @module.tasks_directory, metadata, @module_executables, environment_name)
    end

    def validate
      files
      true
    end

    def ==(other)
      name == other.name &&
        self.module == other.module
    end

    def environment_name
      @module.environment.respond_to?(:name) ? @module.environment.name : 'production'
    end
    private :environment_name

    def self.new_with_files(pup_module, name, task_files, module_executables)
      metadata_file = task_files.find { |f| is_tasks_metadata_filename?(f) }
      Puppet::Module::Task.new(pup_module, name, module_executables, metadata_file)
    end
    private_class_method :new_with_files

    # Abstracted here so we can add support for subdirectories later
    def self.task_name_from_path(path)
      File.basename(path, '.*')
    end
    private_class_method :task_name_from_path
  end
end