From a29085bbf268a8c5c180d8242f82e37a6b182ae9 Mon Sep 17 00:00:00 2001 From: Jeffrey Clark Date: Tue, 13 Feb 2024 01:05:30 -0600 Subject: [PATCH] (maint) matrix from metadata v3 --- .rubocop_todo.yml | 33 ++- exe/matrix.json | 81 ++++++ exe/matrix_from_metadata_v3 | 305 +++++++++++++++++++++++ spec/exe/matrix_from_metadata_v3_spec.rb | 157 ++++++++++++ spec/spec_helper.rb | 15 ++ 5 files changed, 578 insertions(+), 13 deletions(-) create mode 100644 exe/matrix.json create mode 100755 exe/matrix_from_metadata_v3 create mode 100644 spec/exe/matrix_from_metadata_v3_spec.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 73e1788..00c696e 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,28 +1,28 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2023-04-18 15:58:23 UTC using RuboCop version 1.48.1. +# on 2024-02-13 06:54:13 UTC using RuboCop version 1.50.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 17 +# Offense count: 16 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Max: 116 + Max: 105 -# Offense count: 8 +# Offense count: 10 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode. # AllowedMethods: refine Metrics/BlockLength: - Max: 350 + Max: 348 # Offense count: 8 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/CyclomaticComplexity: Max: 33 -# Offense count: 20 +# Offense count: 18 # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: Max: 79 @@ -30,9 +30,9 @@ Metrics/MethodLength: # Offense count: 3 # Configuration parameters: CountComments, CountAsOne. Metrics/ModuleLength: - Max: 324 + Max: 255 -# Offense count: 7 +# Offense count: 8 # Configuration parameters: AllowedMethods, AllowedPatterns. Metrics/PerceivedComplexity: Max: 38 @@ -66,15 +66,15 @@ RSpec/DescribeClass: - '**/spec/routing/**/*' - '**/spec/system/**/*' - '**/spec/views/**/*' - - 'spec/exe/matrix_from_metadata_v2_spec.rb' + - 'spec/exe/*.rb' - 'spec/lib/puppet_litmus/rake_tasks_spec.rb' -# Offense count: 31 +# Offense count: 22 # Configuration parameters: CountAsOne. RSpec/ExampleLength: Max: 22 -# Offense count: 126 +# Offense count: 106 # Configuration parameters: . # SupportedStyles: have_received, receive RSpec/MessageSpies: @@ -96,15 +96,22 @@ RSpec/NoExpectationExample: Exclude: - 'spec/lib/puppet_litmus/rake_helper_spec.rb' -# Offense count: 113 +# Offense count: 93 RSpec/StubbedMock: Exclude: - 'spec/lib/puppet_litmus/puppet_helpers_spec.rb' - 'spec/lib/puppet_litmus/rake_helper_spec.rb' - 'spec/lib/puppet_litmus/rake_tasks_spec.rb' -# Offense count: 6 +# Offense count: 7 Style/OpenStructUse: Exclude: + - 'exe/matrix_from_metadata_v3' - 'lib/puppet_litmus/puppet_helpers.rb' - 'spec/spec_helper.rb' + +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +Style/StderrPuts: + Exclude: + - 'exe/matrix_from_metadata_v3' diff --git a/exe/matrix.json b/exe/matrix.json new file mode 100644 index 0000000..69f26e9 --- /dev/null +++ b/exe/matrix.json @@ -0,0 +1,81 @@ +{ + "collections": [ + { + "puppet": 7.24, + "ruby": 2.7 + }, + { + "puppet": 8.0, + "ruby": 3.2 + } + ], + "provisioners": { + "provision_service": { + "AlmaLinux": { + "8": { "x86_64": "almalinux-cloud/almalinux-8" } + }, + "CentOS": { + "7": { "x86_64": "centos-7" }, + "8": { "x86_64": "centos-stream-8" } + }, + "Rocky": { + "8": { "x86_64": "rocky-linux-cloud/rocky-linux-8" } + }, + "Debian": { + "10": { "x86_64": "debian-10" }, + "11": { "x86_64": "debian-11" } + }, + "RedHat": { + "7": { "x86_64": "rhel-7" }, + "8": { "x86_64": "rhel-8" }, + "9": { "x86_64": "rhel-9", "arm": "rhel-9-arm64" } + }, + "SLES" : { + "12": { "x86_64": "sles-12" }, + "15": { "x86_64": "sles-15" } + }, + "Ubuntu": { + "20.04": { "x86_64": "ubuntu-2004-lts" }, + "22.04": { "x86_64": "ubuntu-2204-lts", "arm": "ubuntu-2204-lts-arm64" } + }, + "Windows": { + "2016": { "x86_64": "windows-2016" }, + "2019": { "x86_64": "windows-2019" }, + "2022": { "x86_64": "windows-2022" } + } + }, + "docker": { + "CentOS": { + "7": { "x86_64": "litmusimage/centos:7" }, + "8": { "x86_64": "litmusimage/centos:stream8" }, + "9": { "x86_64": "litmusimage/centos:stream9" } + }, + "Rocky": { + "8": { "x86_64": "litmusimage/rockylinux:8" }, + "9": { "x86_64": "litmusimage/rockylinux:9" } + }, + "AlmaLinux": { + "8": { "x86_64": "litmusimage/almalinux:8" }, + "9": { "x86_64": "litmusimage/almalinux:9" } + }, + "Debian": { + "10": { "x86_64": "litmusimage/debian:10" }, + "11": { "x86_64": "litmusimage/debian:11" }, + "12": { "x86_64": "litmusimage/debian:12" } + }, + "OracleLinux": { + "7": { "x86_64": "litmusimage/oraclelinux:7" }, + "8": { "x86_64": "litmusimage/oraclelinux:8" }, + "8": { "x86_64": "litmusimage/oraclelinux:9" } + }, + "Scientific": { + "7": { "x86_64": "litmusimage/scientificlinux:7" } + }, + "Ubuntu": { + "18.04": { "x86_64": "litmusimage/ubuntu:18.04" }, + "20.04": { "x86_64": "litmusimage/ubuntu:20.04" }, + "22.04": { "x86_64": "litmusimage/ubuntu:22.04" } + } + } + } +} diff --git a/exe/matrix_from_metadata_v3 b/exe/matrix_from_metadata_v3 new file mode 100755 index 0000000..ff55db0 --- /dev/null +++ b/exe/matrix_from_metadata_v3 @@ -0,0 +1,305 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'English' +require 'json' +require 'optparse' +require 'ostruct' + +# wrap up running in a Github Action +module Action + class << self + attr_reader :type + + def init(to = 'auto') + @notice = true + @type = if to.eql? 'auto' + ENV['GITHUB_ACTIONS'] ? 'github' : 'stdout' + else + to + end + $stderr = $stdout if @type == 'github' + end + + def config(**args) + error("invalid Action.config: #{args}") unless args.is_a?(Hash) + args.each do |arg| + instance_variable_set(:"@#{arg[0]}", arg[1]) + end + end + + def debug(msg = nil) + return @debug if msg.nil? + + output(msg, '::debug::') if @debug + end + + def notice(msg = nil) + return @notice if msg.nil? + + output(msg, '::notice::') if @notice + end + + def error(msg) + output(msg, '::error::') + exit 1 + end + + def warning(msg) + output(msg, '::warning::') + end + + def group(name, data, **kwargs) + output(name, '::group::') + output(data, **kwargs) + output('', '::endgroup::') if @type == 'github' + + self + end + + def set_output(key, value) + @output ||= @type == 'github' ? ENV.fetch('GITHUB_OUTPUT', nil) : '/dev/stdout' + + if @output.nil? + Action.warning('GITHUB_OUTPUT environment is not set, sending output to stdout') + @output = '/dev/stdout' + end + + File.open(@output, 'a') { |f| f.puts "#{key}=#{JSON.generate(value)}" } + + self + end + + private + + def output(msg, prefix = nil, pretty: false) + $stderr.print prefix if @type == 'github' + $stderr.puts pretty ? JSON.pretty_generate(msg) : msg.to_s + + self + end + end +end + +options = OpenStruct.new( + puppet_exclude: [], + puppet_include: [], + platform_exclude: [], + platform_include: [], + arch_include: [], + arch_exclude: [], + provision_prefer: [], + provision_include: [], + provision_exclude: [] +) + +default_options = { + 'provision-prefer': 'docker', + output: 'auto', + matrix: File.join(File.dirname(__FILE__), 'matrix.json'), + metadata: 'metadata.json' +} + +begin + Action.init + + # hidden argument to setup puppetlabs org defaults + default_options[:'provision-exclude'] = 'provision_service' if ARGV.reject! { |x| x == '--puppetlabs' }.nil? + + # apply default_options if not overridden on the command line + default_options.each do |arg, value| + ARGV.unshift("--#{arg}", value) unless ARGV.grep(/\A--#{arg}(=.*)?\z/).any? + end + + OptionParser.accept(JSON) do |v| + begin + x = JSON.parse(File.read(v)) if v + raise "nothing parsed from file #{v}" if x.empty? + + x + rescue JSON::ParserError + raise "error parsing file #{v}" + end + rescue RuntimeError, Errno::ENOENT + raise OptionParser::InvalidArgument, $ERROR_INFO unless ARGV.grep(/^-(h|help)$/).any? + end + + OutputType = ->(value) {} + OptionParser.accept(OutputType) do |v| + raise OptionParser::InvalidArgument, v \ + unless %w[auto github stdout].include?(v) + + Action.init(v) + end + + OptionParser.accept(Regexp) { |v| Regexp.new(v) } + + OptionParser.new do |opt| + opt.separator "Generate Github Actions Matrices from Puppet metadata.json\n\nOptions:" + opt.on('--matrix FILE', JSON, 'File containing possible collections and provisioners (default: built-in)') { |o| options.matrix = o } + opt.on('--metadata FILE', JSON, "File containing module metadata json (default: #{default_options[:metadata]})\n\n") { |o| options.metadata = o } + + opt.on('--debug', TrueClass, 'Enable debug messages') { |o| options.debug = o } + opt.on('--quiet', TrueClass, 'Disable notice messages') { |o| options.quiet = o } + opt.on('--output TYPE', OutputType, "Type of output to generate; auto, github or stdout (default: #{default_options[:output]})\n\n") { |o| options.output = o } + + opt.on('--puppet-include MAJOR', Integer, 'Select puppet major version') { |o| options.puppet_include << o } + opt.on('--puppet-exclude MAJOR', Integer, 'Filter puppet major version') { |o| options.puppet_exclude << o } + + opt.on('--platform-include REGEX', Regexp, 'Select matching platforms') { |o| options.platform_include << o } + opt.on('--platform-exclude REGEX', Regexp, 'Filter matching platforms') { |o| options.platform_exclude << o } + + opt.on('--arch-include REGEX', Regexp, 'Select architecture') { |o| options.arch_include << o } + opt.on('--arch-exclude REGEX', Regexp, 'Filter architecture') { |o| options.arch_exclude << o } + + opt.on('--provision-prefer NAME', String, "Prefer provisioner (default: #{default_options[:'provision-prefer']})") { |o| options.provision_prefer.push(*o.split(',')) } + opt.on('--provision-include NAME', String, 'Select provisioner (default: all)') { |o| options.provision_include.push(*o.split(',')) } + opt.on('--provision-exclude NAME', String, "Filter provisioner (default: #{default_options[:'provision-exclude'] || 'none'})") { |o| options.provision_exclude.push(*o.split(',')) } + end.parse! + + Action.config(debug: true) if options[:debug] + Action.config(notice: false) if options[:quiet] && !options[:debug] + + # validate provisioners + options[:provision_include].select! do |p| + options[:matrix]['provisioners'].key?(p) or raise OptionParser::InvalidArgument, "--provision-include '#{p}' not found in provisioners" + end + + # filter provisioners + unless options[:provision_include].empty? + options[:matrix]['provisioners'].delete_if do |k, _| + unless options[:provision_include].include?(k.to_s) + Action.debug("provision-include filtered #{k}") + true + end + end + end + options[:matrix]['provisioners'].delete_if do |k, _| + if options[:provision_exclude].include?(k.to_s) + Action.debug("provision-exclude filtered #{k}") + true + end + end + + # sort provisioners + options[:matrix]['provisioners'] = options[:matrix]['provisioners'].sort_by { |key, _| options[:provision_prefer].index(key.to_s) || options[:provision_prefer].length }.to_h \ + unless options[:provision_prefer].empty? + + # union regexp option values + %w[platform arch].each do |c| + ["#{c}_exclude".to_sym, "#{c}_include".to_sym].each do |k| + options[k] = if options[k].empty? + nil + else + Regexp.new(format('\A(?:%s)\z', Regexp.union(options[k])), Regexp::IGNORECASE) + end + end + end + + raise OptionParser::ParseError, 'no provisioners left after filters applied' if options[:matrix]['provisioners'].empty? +rescue OptionParser::ParseError => e + Action.error(e) +end + +matrix = { platforms: [], collection: [] } +spec_matrix = { include: [] } + +# collection matrix +version_re = /([>=<]{1,2})\s*([\d.]+)/ +options[:metadata]['requirements']&.each do |req| + next unless req['name'] == 'puppet' && req['version_requirement'] + + puppet_version_reqs = req['version_requirement'].scan(version_re).map(&:join) + if puppet_version_reqs.empty? + Action.warning("Didn't recognize version_requirement '#{req['version_requirement']}'") + break + end + + options[:matrix]['collections'].each do |collection| + next unless options[:puppet_include].each do |major| + break if major != collection['puppet'].to_i + + Action.debug("puppet-include matched collection #{collection.inspect}") + end + + next unless options[:puppet_exclude].each do |major| + if major.eql? collection['puppet'].to_i + Action.debug("puppet-exclude matched collection #{collection.inspect}") + break + end + end + + # Test against the "largest" puppet version in a collection, e.g. `7.9999` to allow puppet requirements with a non-zero lower bound on minor/patch versions. + # This assumes that such a boundary will always allow the latest actually existing puppet version of a release stream, trading off simplicity vs accuracy here. + gem_req = Gem::Requirement.create(puppet_version_reqs) + next unless gem_req.satisfied_by?(Gem::Version.new("#{collection['puppet'].to_i}.9999")) + + matrix[:collection] << "puppet#{collection['puppet'].to_i}-nightly" + + spec_matrix[:include] << { + puppet_version: "~> #{collection['puppet']}", + ruby_version: collection['ruby'] + } + end +end + +# Set platforms based on declared operating system support +options[:metadata]['operatingsystem_support'].each do |os_sup| + os_sup['operatingsystemrelease'].sort_by(&:to_i).each do |os_ver| + os_ver_platforms = [] + platform_key = [os_sup['operatingsystem'], os_ver] + + # filter platforms + if options[:platform_include]&.match?(platform_key[0].downcase) == false && options[:platform_include]&.match?(platform_key.join('-').downcase) == false + Action.notice("platform-include filtered #{platform_key.join('-')}") + next + end + + if (options[:platform_exclude]&.match? platform_key[0].downcase) || (options[:platform_exclude]&.match? platform_key.join('-').downcase) + Action.notice("platform-exclude filtered #{platform_key.join('-')}") + next + end + + options[:matrix]['provisioners'].each do |provisioner, platforms| + images = platforms.dig(*platform_key) + next if images.nil? + + # filter arch + images.delete_if do |arch, _| + next if options[:arch_include]&.match?(arch.downcase) == true + next unless options[:arch_exclude]&.match?(arch.downcase) + + Action.notice("arch filtered #{platform_key.join('-')}-#{arch} from #{provisioner}") + end + next if images.empty? + + images.each do |arch, image| + label = (arch.eql?('x86_64') ? platform_key : platform_key + [arch]).join('-') + next if os_ver_platforms.any? { |h| h[:label] == label } + + os_ver_platforms << { + label: label, + provider: provisioner, + arch: arch, + image: image + } + end + end + + if os_ver_platforms.empty? + Action.warning("#{platform_key.join('-')} no provisioner found") + else + matrix[:platforms].push(*os_ver_platforms) + end + end +end + +Action.group('matrix', matrix, pretty: true).group('spec_matrix', spec_matrix, pretty: true) if Action.type == 'github' && Action.notice + +Action.error('no supported puppet versions') if matrix[:collection].empty? + +if Action.type == 'stdout' + $stdout.puts JSON.generate({ matrix: matrix, spec_matrix: spec_matrix }) +else + Action.set_output('matrix', matrix).set_output('spec_matrix', spec_matrix) +end diff --git a/spec/exe/matrix_from_metadata_v3_spec.rb b/spec/exe/matrix_from_metadata_v3_spec.rb new file mode 100644 index 0000000..600068e --- /dev/null +++ b/spec/exe/matrix_from_metadata_v3_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'matrix_from_metadata_v3' do + let(:github_output) { Tempfile.new('github_output') } + let(:github_output_content) { github_output.read } + + before do + ENV['GITHUB_ACTIONS'] = '1' + ENV['GITHUB_OUTPUT'] = github_output.path + end + + context 'without arguments' do + let(:result) { run_matrix_from_metadata_v3 } + + it 'run successfully' do + expect(result.status_code).to eq 0 + end + + it 'generates the matrix' do + matrix = [ + 'matrix={', + '"platforms":[', + '{"label":"Ubuntu-18.04","provider":"docker","arch":"x86_64","image":"litmusimage/ubuntu:18.04"},', + '{"label":"Ubuntu-22.04","provider":"docker","arch":"x86_64","image":"litmusimage/ubuntu:22.04"}', + '],', + '"collection":[', + '"puppet7-nightly","puppet8-nightly"', + ']', + '}' + ].join + expect(result.stdout).to include( + '::warning::CentOS-6 no provisioner found', + '::warning::Ubuntu-14.04 no provisioner found', + '::group::matrix', + '::group::spec_matrix' + ) + expect(github_output_content).to include(matrix) + expect(github_output_content).to include( + 'spec_matrix={"include":[{"puppet_version":"~> 7.24","ruby_version":2.7},{"puppet_version":"~> 8.0","ruby_version":3.2}]}' + ) + end + end + + context 'with argument --puppetlabs' do + let(:result) { run_matrix_from_metadata_v3(['--puppetlabs']) } + let(:matrix) do + [ + 'matrix={', + '"platforms":[', + '{"label":"RedHat-8","provider":"provision_service","arch":"x86_64","image":"rhel-8"},', + '{"label":"RedHat-9","provider":"provision_service","arch":"x86_64","image":"rhel-9"},', + '{"label":"RedHat-9-arm","provider":"provision_service","arch":"arm","image":"rhel-9-arm64"},', + '{"label":"Ubuntu-18.04","provider":"docker","arch":"x86_64","image":"litmusimage/ubuntu:18.04"},', + '{"label":"Ubuntu-22.04","provider":"docker","arch":"x86_64","image":"litmusimage/ubuntu:22.04"},', + '{"label":"Ubuntu-22.04-arm","provider":"provision_service","arch":"arm","image":"ubuntu-2204-lts-arm64"}', + '],', + '"collection":[', + '"puppet7-nightly","puppet8-nightly"', + ']', + '}' + ].join + end + + it 'run successfully' do + expect(result.status_code).to eq 0 + end + + it 'generates the matrix' do + expect(result.stdout).to include( + '::warning::CentOS-6 no provisioner found', + '::warning::Ubuntu-14.04 no provisioner found', + '::group::matrix', + '::group::spec_matrix' + ) + expect(github_output_content).to include(matrix) + expect(github_output_content).to include( + 'spec_matrix={"include":[{"puppet_version":"~> 7.24","ruby_version":2.7},{"puppet_version":"~> 8.0","ruby_version":3.2}]}' + ) + end + end + + context 'with --exclude-platforms "ubuntu-18.04"' do + let(:result) { run_matrix_from_metadata_v3(['--puppetlabs', '--platform-exclude', 'ubuntu-18.04']) } + let(:matrix) do + [ + 'matrix={', + '"platforms":[', + '{"label":"RedHat-8","provider":"provision_service","arch":"x86_64","image":"rhel-8"},', + '{"label":"RedHat-9","provider":"provision_service","arch":"x86_64","image":"rhel-9"},', + '{"label":"RedHat-9-arm","provider":"provision_service","arch":"arm","image":"rhel-9-arm64"},', + '{"label":"Ubuntu-22.04","provider":"docker","arch":"x86_64","image":"litmusimage/ubuntu:22.04"},', + '{"label":"Ubuntu-22.04-arm","provider":"provision_service","arch":"arm","image":"ubuntu-2204-lts-arm64"}', + '],', + '"collection":[', + '"puppet7-nightly","puppet8-nightly"', + ']', + '}' + ].join + end + + it 'run successfully' do + expect(result.status_code).to eq 0 + end + + it 'generates the matrix without excluded platforms' do + expect(result.stdout).to include( + '::warning::CentOS-6 no provisioner found', + '::warning::Ubuntu-14.04 no provisioner found', + '::notice::platform-exclude filtered Ubuntu-18.04', + '::group::matrix', + '::group::spec_matrix' + ) + expect(github_output_content).to include(matrix) + expect(github_output_content).to include( + 'spec_matrix={"include":[{"puppet_version":"~> 7.24","ruby_version":2.7},{"puppet_version":"~> 8.0","ruby_version":3.2}]}' + ) + end + end + + context 'with --platform-exclude "ubuntu-(18.04|22.04)" --platform-exclude "redhat-[89]"' do + let(:result) { run_matrix_from_metadata_v3(['--puppetlabs', '--platform-exclude', 'ubuntu-(18.04|22.04)', '--platform-exclude', 'redhat-[89]']) } + let(:matrix) do + [ + 'matrix={', + '"platforms":[', + '],', + '"collection":[', + '"puppet7-nightly","puppet8-nightly"', + ']', + '}' + ].join + end + + it 'run successfully' do + expect(result.status_code).to eq 0 + end + + it 'generates the matrix without excluded platforms' do + expect(result.stdout).to include( + '::warning::CentOS-6 no provisioner found', + '::warning::Ubuntu-14.04 no provisioner found', + '::notice::platform-exclude filtered RedHat-8', + '::notice::platform-exclude filtered RedHat-9', + '::notice::platform-exclude filtered Ubuntu-18.04', + '::notice::platform-exclude filtered Ubuntu-22.04', + '::group::matrix', + '::group::spec_matrix' + ) + expect(github_output_content).to include(matrix) + expect(github_output_content).to include( + 'spec_matrix={"include":[{"puppet_version":"~> 7.24","ruby_version":2.7},{"puppet_version":"~> 8.0","ruby_version":3.2}]}' + ) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index cb6793b..e959670 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -38,6 +38,21 @@ def run_matrix_from_metadata_v2(options = {}) ) end +def run_matrix_from_metadata_v3(options = []) + command = %w[bundle exec ./exe/matrix_from_metadata_v3] + unless options.include? '--metadata' + options << '--metadata' + options << File.join(File.dirname(__FILE__), 'exe', 'fake_metadata.json') + end + command += options + result = Open3.capture3(*command) + OpenStruct.new( + stdout: result[0], + stderr: result[1], + status_code: result[2] + ) +end + # This is basically how `configure!` sets up RSpec in tests. require 'puppet_litmus' RSpec.configure do |config|