require 'json' require 'spdx-licenses' require 'optparse' require 'semantic_puppet' require 'metadata-json-lint/schema' require 'metadata-json-lint/version_requirement' module MetadataJsonLint MIN_PUPPET_VER = '4.10.0'.freeze def options @options ||= Struct.new( :fail_on_warnings, :strict_license, :strict_dependencies, :strict_puppet_version, :format, ).new( true, # fail_on_warnings true, # strict_license false, # strict_dependencies false, # strict_puppet_version 'text', # format ) end module_function :options def run OptionParser.new do |opts| opts.banner = 'Usage: metadata-json-lint [options] [metadata.json]' opts.on('--[no-]strict-dependencies', "Fail on open-ended module version dependencies. Defaults to '#{options[:strict_dependencies]}'.") do |v| options[:strict_dependencies] = v end opts.on('--[no-]strict-license', "Don't fail on strict license check. Defaults to '#{options[:strict_license]}'.") do |v| options[:strict_license] = v end opts.on('--[no-]fail-on-warnings', "Fail on any warnings. Defaults to '#{options[:fail_on_warnings]}'.") do |v| options[:fail_on_warnings] = v end opts.on('--[no-]strict-puppet-version', "Fail on strict Puppet Version check based on current supported Puppet versions. Defaults to '#{options[:strict_puppet_version]}'.") do |v| options[:strict_puppet_version] = v end opts.on('-f', '--format FORMAT', %i[text json], 'The format in which results will be output (text, json)') do |format| options[:format] = format end end.parse! mj = if ARGV[0].nil? if File.readable?('metadata.json') 'metadata.json' else abort('Error: metadata.json is not readable or does not exist.') end else ARGV[0] end exit(MetadataJsonLint.parse(mj) ? 0 : 1) end module_function :run def parse(metadata) @errors = [] @warnings = [] # Small hack to use the module settings as defaults but allow overriding for different rake tasks options = options().clone # Configuration from rake tasks yield options if block_given? begin f = File.read(metadata) rescue Exception => e abort("Error: Unable to read metadata file: #{e.exception}") end begin parsed = JSON.parse(f) rescue Exception => e abort("Error: Unable to parse metadata.json: #{e.exception}") end # Validate basic structure against JSON schema schema_errors = Schema.new.validate(parsed) schema_errors.each do |err| error ((err[:field] == 'root') ? :required_fields : err[:field]), err[:message] end validate_dependencies!(parsed['dependencies']) if parsed['dependencies'] # Deprecated fields # From: https://docs.puppetlabs.com/puppet/latest/reference/modules_publishing.html#write-a-metadatajson-file deprecated_fields = %w[types checksum] deprecated_fields.each do |field| error :deprecated_fields, "Deprecated field '#{field}' found in metadata.json." unless parsed[field].nil? end # The nested 'requirements' name of 'pe' is deprecated as well. # https://groups.google.com/forum/?utm_medium=email&utm_source=footer#!msg/puppet-users/nkRPvG4q0Oo/GmXa109aJQAJ validate_requirements!(parsed['requirements']) if parsed['requirements'] # Shoulds/recommendations # From: https://docs.puppetlabs.com/puppet/latest/reference/modules_publishing.html#write-a-metadatajson-file # if options[:strict_license] && !parsed['license'].nil? && !SpdxLicenses.exist?(parsed['license']) && parsed['license'] != 'proprietary' msg = "License identifier #{parsed['license']} is not in the SPDX list: http://spdx.org/licenses/" warn(:license, msg) end if !@errors.empty? || !@warnings.empty? result = @errors.empty? ? "Warnings found in #{metadata}" : "Errors found in #{metadata}" case options[:format] when :json puts JSON.fast_generate(result: result, warnings: @warnings, errors: @errors) else @warnings.each { |warn| puts "(WARN) #{warn}" } @errors.each { |err| puts "(ERROR) #{err}" } puts result end return false if !@errors.empty? || (!@warnings.empty? && (options[:fail_on_warnings] == true)) end true end module_function :parse def validate_requirements_unique(requirements) names = requirements.map { |x| x['name'] } counts = Hash.new(0) names.each { |name| counts[name.downcase] += 1 } counts.each do |k, v| error :requirements, "Duplicate entries in the 'requirements' list with the name '#{k}'" if v > 1 end end module_function :validate_requirements_unique def validate_requirements!(requirements) return unless requirements.is_a?(Array) requirements.each do |requirement| warn :requirements, "The 'pe' requirement is no longer supported by the Forge." if requirement['name'] == 'pe' begin puppet_req = VersionRequirement.new(requirement.fetch('version_requirement', '')) rescue ArgumentError => e # Raised when the version_requirement provided could not be parsed error :requirements, "Invalid 'version_requirement' field in metadata.json: #{e}" end validate_puppet_ver!(puppet_req) unless puppet_req.instance_variable_get(:@requirement).nil? end validate_requirements_unique(requirements) end module_function :validate_requirements! def validate_puppet_ver!(requirement) if options[:strict_puppet_version] && requirement.open_ended? warn(:requirement, "Puppet has an open ended version requirement #{requirement.ver_range}") end if options[:strict_puppet_version] && requirement.puppet_eol? warn(:requirement, "#{requirement.min} is no longer supported. Minimum supported version is #{MIN_PUPPET_VER}") end return unless requirement.mixed_syntax? warn(:requirement, 'Mixing "x" or "*" version syntax with operators is not recommended in ' \ "metadata.json, use one style in the puppet version: #{requirement.instance_variable_get(:@requirement)}") end module_function :validate_puppet_ver! def validate_dependencies!(deps) dep_names = [] deps.each do |dep| warn :dependencies, "Duplicate dependencies on #{dep['name']}" if dep_names.include?(dep['name']) dep_names << dep['name'] begin requirement = VersionRequirement.new(dep.fetch('version_requirement', '')) rescue ArgumentError => e # Raised when the version_requirement provided could not be parsed error :dependencies, "Invalid 'version_requirement' field in metadata.json: #{e}" # Skip to the next dependency next end validate_version_requirement!(dep, requirement) # 'version_range' is no longer used by the forge # See https://tickets.puppetlabs.com/browse/PUP-2781 if dep.key?('version_range') warn :dependencies, "Dependency #{dep['name']} has a 'version_range' attribute " \ 'which is no longer used by the forge.' end end end module_function :validate_dependencies! def validate_version_requirement!(dep, requirement) # Open ended dependency # From: https://docs.puppet.com/puppet/latest/reference/modules_metadata.html#best-practice-set-an-upper-bound-for-dependencies if options[:strict_dependencies] && requirement.open_ended? msg = "Dependency #{dep['name']} has an open " \ "ended dependency version requirement #{dep['version_requirement']}" warn(:dependencies, msg) end # Mixing operator and wildcard version syntax # From: https://docs.puppet.com/puppet/latest/modules_metadata.html#version-specifiers # Supported in Puppet 5 and higher, but the syntax is unclear and incompatible with older versions return unless requirement.mixed_syntax? warn(:dependencies, 'Mixing "x" or "*" version syntax with operators is not recommended in ' \ "metadata.json, use one style in the #{dep['name']} dependency: #{dep['version_requirement']}") end module_function :validate_version_requirement! def format_error(check, msg) case options[:format] when :json { check: check, msg: msg } else "#{check}: #{msg}" end end module_function :format_error def warn(check, msg) @warnings ||= [] @warnings << format_error(check, msg) end module_function :warn def error(check, msg) @errors ||= [] @errors << format_error(check, msg) end module_function :error end