diff --git a/lib/puppet/module_tool/metadata.rb b/lib/puppet/module_tool/metadata.rb index 32725288e..64a1802bc 100644 --- a/lib/puppet/module_tool/metadata.rb +++ b/lib/puppet/module_tool/metadata.rb @@ -1,198 +1,198 @@ require 'puppet/util/methodhelper' require 'puppet/module_tool' require 'puppet/network/format_support' require 'uri' require 'json' require 'set' module Puppet::ModuleTool # This class provides a data structure representing a module's metadata. # @api private class Metadata include Puppet::Network::FormatSupport attr_accessor :module_name DEFAULTS = { 'name' => nil, 'version' => nil, 'author' => nil, 'summary' => nil, - 'license' => 'Apache 2.0', + 'license' => 'Apache-2.0', 'source' => '', 'project_page' => nil, 'issues_url' => nil, 'dependencies' => Set.new.freeze, } def initialize @data = DEFAULTS.dup @data['dependencies'] = @data['dependencies'].dup end # Returns a filesystem-friendly version of this module name. def dashed_name @data['name'].tr('/', '-') if @data['name'] end # Returns a string that uniquely represents this version of this module. def release_name return nil unless @data['name'] && @data['version'] [ dashed_name, @data['version'] ].join('-') end alias :name :module_name alias :full_module_name :dashed_name # Merges the current set of metadata with another metadata hash. This # method also handles the validation of module names and versions, in an # effort to be proactive about module publishing constraints. def update(data) process_name(data) if data['name'] process_version(data) if data['version'] process_source(data) if data['source'] merge_dependencies(data) if data['dependencies'] @data.merge!(data) return self end # Validates the name and version_requirement for a dependency, then creates # the Dependency and adds it. # Returns the Dependency that was added. def add_dependency(name, version_requirement=nil, repository=nil) validate_name(name) validate_version_range(version_requirement) if version_requirement if dup = @data['dependencies'].find { |d| d.full_module_name == name && d.version_requirement != version_requirement } raise ArgumentError, "Dependency conflict for #{full_module_name}: Dependency #{name} was given conflicting version requirements #{version_requirement} and #{dup.version_requirement}. Verify that there are no duplicates in the metadata.json or the Modulefile." end dep = Dependency.new(name, version_requirement, repository) @data['dependencies'].add(dep) dep end # Provides an accessor for the now defunct 'description' property. This # addresses a regression in Puppet 3.6.x where previously valid templates # refering to the 'description' property were broken. # @deprecated def description @data['description'] end def dependencies @data['dependencies'].to_a end # Returns a hash of the module's metadata. Used by Puppet's automated # serialization routines. # # @see Puppet::Network::FormatSupport#to_data_hash def to_hash @data end alias :to_data_hash :to_hash def to_json data = @data.dup.merge('dependencies' => dependencies) contents = data.keys.map do |k| value = (JSON.pretty_generate(data[k]) rescue data[k].to_json) "#{k.to_json}: #{value}" end "{\n" + contents.join(",\n").gsub(/^/, ' ') + "\n}\n" end # Expose any metadata keys as callable reader methods. def method_missing(name, *args) return @data[name.to_s] if @data.key? name.to_s super end private # Do basic validation and parsing of the name parameter. def process_name(data) validate_name(data['name']) author, @module_name = data['name'].split(/[-\/]/, 2) data['author'] ||= author if @data['author'] == DEFAULTS['author'] end # Do basic validation on the version parameter. def process_version(data) validate_version(data['version']) end # Do basic parsing of the source parameter. If the source is hosted on # GitHub, we can predict sensible defaults for both project_page and # issues_url. def process_source(data) if data['source'] =~ %r[://] source_uri = URI.parse(data['source']) else source_uri = URI.parse("http://#{data['source']}") end if source_uri.host =~ /^(www\.)?github\.com$/ source_uri.scheme = 'https' source_uri.path.sub!(/\.git$/, '') data['project_page'] ||= @data['project_page'] || source_uri.to_s data['issues_url'] ||= @data['issues_url'] || source_uri.to_s.sub(/\/*$/, '') + '/issues' end rescue URI::Error return end # Validates and parses the dependencies. def merge_dependencies(data) data['dependencies'].each do |dep| add_dependency(dep['name'], dep['version_requirement'], dep['repository']) end # Clear dependencies so @data dependencies are not overwritten data.delete 'dependencies' end # Validates that the given module name is both namespaced and well-formed. def validate_name(name) return if name =~ /\A[a-z0-9]+[-\/][a-z][a-z0-9_]*\Z/i namespace, modname = name.split(/[-\/]/, 2) modname = :namespace_missing if namespace == '' err = case modname when nil, '', :namespace_missing "the field must be a namespaced module name" when /[^a-z0-9_]/i "the module name contains non-alphanumeric (or underscore) characters" when /^[^a-z]/i "the module name must begin with a letter" else "the namespace contains non-alphanumeric characters" end raise ArgumentError, "Invalid 'name' field in metadata.json: #{err}" end # Validates that the version string can be parsed as per SemVer. def validate_version(version) return if SemVer.valid?(version) err = "version string cannot be parsed as a valid Semantic Version" raise ArgumentError, "Invalid 'version' field in metadata.json: #{err}" end # Validates that the version range can be parsed by Semantic. def validate_version_range(version_range) Semantic::VersionRange.parse(version_range) rescue ArgumentError => e raise ArgumentError, "Invalid 'version_range' field in metadata.json: #{e}" end end end diff --git a/spec/unit/module_tool/metadata_spec.rb b/spec/unit/module_tool/metadata_spec.rb index fce9c8f8d..8cdb6918f 100644 --- a/spec/unit/module_tool/metadata_spec.rb +++ b/spec/unit/module_tool/metadata_spec.rb @@ -1,301 +1,301 @@ require 'spec_helper' require 'puppet/module_tool' describe Puppet::ModuleTool::Metadata do let(:data) { {} } let(:metadata) { Puppet::ModuleTool::Metadata.new } describe 'property lookups' do subject { metadata } %w[ name version author summary license source project_page issues_url dependencies dashed_name release_name description ].each do |prop| describe "##{prop}" do it "responds to the property" do subject.send(prop) end end end end describe "#update" do subject { metadata.update(data) } context "with a valid name" do let(:data) { { 'name' => 'billgates-mymodule' } } it "extracts the author name from the name field" do subject.to_hash['author'].should == 'billgates' end it "extracts a module name from the name field" do subject.module_name.should == 'mymodule' end context "and existing author" do before { metadata.update('author' => 'foo') } it "avoids overwriting the existing author" do subject.to_hash['author'].should == 'foo' end end end context "with a valid name and author" do let(:data) { { 'name' => 'billgates-mymodule', 'author' => 'foo' } } it "use the author name from the author field" do subject.to_hash['author'].should == 'foo' end context "and preexisting author" do before { metadata.update('author' => 'bar') } it "avoids overwriting the existing author" do subject.to_hash['author'].should == 'foo' end end end context "with an invalid name" do context "(short module name)" do let(:data) { { 'name' => 'mymodule' } } it "raises an exception" do expect { subject }.to raise_error(ArgumentError, "Invalid 'name' field in metadata.json: the field must be a namespaced module name") end end context "(missing namespace)" do let(:data) { { 'name' => '/mymodule' } } it "raises an exception" do expect { subject }.to raise_error(ArgumentError, "Invalid 'name' field in metadata.json: the field must be a namespaced module name") end end context "(missing module name)" do let(:data) { { 'name' => 'namespace/' } } it "raises an exception" do expect { subject }.to raise_error(ArgumentError, "Invalid 'name' field in metadata.json: the field must be a namespaced module name") end end context "(invalid namespace)" do let(:data) { { 'name' => "dolla'bill$-mymodule" } } it "raises an exception" do expect { subject }.to raise_error(ArgumentError, "Invalid 'name' field in metadata.json: the namespace contains non-alphanumeric characters") end end context "(non-alphanumeric module name)" do let(:data) { { 'name' => "dollabils-fivedolla'" } } it "raises an exception" do expect { subject }.to raise_error(ArgumentError, "Invalid 'name' field in metadata.json: the module name contains non-alphanumeric (or underscore) characters") end end context "(module name starts with a number)" do let(:data) { { 'name' => "dollabills-5dollars" } } it "raises an exception" do expect { subject }.to raise_error(ArgumentError, "Invalid 'name' field in metadata.json: the module name must begin with a letter") end end end context "with an invalid version" do let(:data) { { 'version' => '3.0' } } it "raises an exception" do expect { subject }.to raise_error(ArgumentError, "Invalid 'version' field in metadata.json: version string cannot be parsed as a valid Semantic Version") end end context "with a valid source" do context "which is a GitHub URL" do context "with a scheme" do before { metadata.update('source' => 'https://github.com/billgates/amazingness') } it "predicts a default project_page" do subject.to_hash['project_page'].should == 'https://github.com/billgates/amazingness' end it "predicts a default issues_url" do subject.to_hash['issues_url'].should == 'https://github.com/billgates/amazingness/issues' end end context "without a scheme" do before { metadata.update('source' => 'github.com/billgates/amazingness') } it "predicts a default project_page" do subject.to_hash['project_page'].should == 'https://github.com/billgates/amazingness' end it "predicts a default issues_url" do subject.to_hash['issues_url'].should == 'https://github.com/billgates/amazingness/issues' end end end context "which is not a GitHub URL" do before { metadata.update('source' => 'https://notgithub.com/billgates/amazingness') } it "does not predict a default project_page" do subject.to_hash['project_page'].should be nil end it "does not predict a default issues_url" do subject.to_hash['issues_url'].should be nil end end context "which is not a URL" do before { metadata.update('source' => 'my brain') } it "does not predict a default project_page" do subject.to_hash['project_page'].should be nil end it "does not predict a default issues_url" do subject.to_hash['issues_url'].should be nil end end end context "with a valid dependency" do let(:data) { {'dependencies' => [{'name' => 'puppetlabs-goodmodule'}] }} it "adds the dependency" do subject.dependencies.size.should == 1 end end context "with a invalid dependency name" do let(:data) { {'dependencies' => [{'name' => 'puppetlabsbadmodule'}] }} it "raises an exception" do expect { subject }.to raise_error(ArgumentError) end end context "with a valid dependency version range" do let(:data) { {'dependencies' => [{'name' => 'puppetlabs-badmodule', 'version_requirement' => '>= 2.0.0'}] }} it "adds the dependency" do subject.dependencies.size.should == 1 end end context "with a invalid version range" do let(:data) { {'dependencies' => [{'name' => 'puppetlabsbadmodule', 'version_requirement' => '>= banana'}] }} it "raises an exception" do expect { subject }.to raise_error(ArgumentError) end end context "with duplicate dependencies" do let(:data) { {'dependencies' => [{'name' => 'puppetlabs-dupmodule', 'version_requirement' => '1.0.0'}, {'name' => 'puppetlabs-dupmodule', 'version_requirement' => '0.0.1'}] } } it "raises an exception" do expect { subject }.to raise_error(ArgumentError) end end context "adding a duplicate dependency" do let(:data) { {'dependencies' => [{'name' => 'puppetlabs-origmodule', 'version_requirement' => '1.0.0'}] }} it "with a different version raises an exception" do metadata.add_dependency('puppetlabs-origmodule', '>= 0.0.1') expect { subject }.to raise_error(ArgumentError) end it "with the same version does not add another dependency" do metadata.add_dependency('puppetlabs-origmodule', '1.0.0') subject.dependencies.size.should == 1 end end end describe '#dashed_name' do it 'returns nil in the absence of a module name' do expect(metadata.update('version' => '1.0.0').release_name).to be_nil end it 'returns a hyphenated string containing namespace and module name' do data = metadata.update('name' => 'foo-bar') data.dashed_name.should == 'foo-bar' end it 'properly handles slash-separated names' do data = metadata.update('name' => 'foo/bar') data.dashed_name.should == 'foo-bar' end it 'is unaffected by author name' do data = metadata.update('name' => 'foo/bar', 'author' => 'me') data.dashed_name.should == 'foo-bar' end end describe '#release_name' do it 'returns nil in the absence of a module name' do expect(metadata.update('version' => '1.0.0').release_name).to be_nil end it 'returns nil in the absence of a version' do expect(metadata.update('name' => 'foo/bar').release_name).to be_nil end it 'returns a hyphenated string containing module name and version' do data = metadata.update('name' => 'foo/bar', 'version' => '1.0.0') data.release_name.should == 'foo-bar-1.0.0' end it 'is unaffected by author name' do data = metadata.update('name' => 'foo/bar', 'version' => '1.0.0', 'author' => 'me') data.release_name.should == 'foo-bar-1.0.0' end end describe "#to_hash" do subject { metadata.to_hash } it "contains the default set of keys" do subject.keys.sort.should == %w[ name version author summary license source issues_url project_page dependencies ].sort end describe "['license']" do it "defaults to Apache 2" do - subject['license'].should == "Apache 2.0" + subject['license'].should == "Apache-2.0" end end describe "['dependencies']" do it "defaults to an empty set" do subject['dependencies'].should == Set.new end end context "when updated with non-default data" do subject { metadata.update('license' => 'MIT', 'non-standard' => 'yup').to_hash } it "overrides the defaults" do subject['license'].should == 'MIT' end it 'contains unanticipated values' do subject['non-standard'].should == 'yup' end end end end