From 8e43f0046ea0d438b80a7d1c490e8a4d4c7a63b2 Mon Sep 17 00:00:00 2001 From: Tim Sharpe Date: Fri, 22 Sep 2017 17:51:22 +1000 Subject: [PATCH] (PDK-468) Task generation --- lib/pdk/cli/new.rb | 2 +- lib/pdk/cli/new/task.rb | 28 ++++++ lib/pdk/cli/util/option_validator.rb | 1 + lib/pdk/generate.rb | 1 + lib/pdk/generators/module.rb | 1 + lib/pdk/generators/puppet_object.rb | 13 ++- lib/pdk/generators/task.rb | 86 +++++++++++++++++++ spec/unit/pdk/cli/new/task_spec.rb | 101 ++++++++++++++++++++++ spec/unit/pdk/generate/module_spec.rb | 1 + spec/unit/pdk/generate/task_spec.rb | 118 ++++++++++++++++++++++++++ 10 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 lib/pdk/cli/new/task.rb create mode 100644 lib/pdk/generators/task.rb create mode 100644 spec/unit/pdk/cli/new/task_spec.rb create mode 100644 spec/unit/pdk/generate/task_spec.rb diff --git a/lib/pdk/cli/new.rb b/lib/pdk/cli/new.rb index 7be86ff02..4e70235cf 100644 --- a/lib/pdk/cli/new.rb +++ b/lib/pdk/cli/new.rb @@ -1,4 +1,3 @@ - module PDK::CLI @new_cmd = @base_cmd.define_command do name 'new' @@ -14,3 +13,4 @@ module PDK::CLI require 'pdk/cli/new/class' require 'pdk/cli/new/defined_type' require 'pdk/cli/new/module' +require 'pdk/cli/new/task' diff --git a/lib/pdk/cli/new/task.rb b/lib/pdk/cli/new/task.rb new file mode 100644 index 000000000..2c0508120 --- /dev/null +++ b/lib/pdk/cli/new/task.rb @@ -0,0 +1,28 @@ +module PDK::CLI + @new_task_cmd = @new_cmd.define_command do + name 'task' + usage _('task [options] ') + summary _('Create a new task named using given options') + + PDK::CLI.template_url_option(self) + option nil, :description, _('A short description of the purpose of the task'), argument: :required + + run do |opts, args, _cmd| + PDK::CLI::Util.ensure_in_module! + + task_name = args[0] + module_dir = Dir.pwd + + if task_name.nil? || task_name.empty? + puts command.help + exit 1 + end + + unless Util::OptionValidator.valid_task_name?(task_name) + raise PDK::CLI::ExitWithError, _("'%{name}' is not a valid task name") % { name: task_name } + end + + PDK::Generate::Task.new(module_dir, task_name, opts).run + end + end +end diff --git a/lib/pdk/cli/util/option_validator.rb b/lib/pdk/cli/util/option_validator.rb index 43e2b7120..ab8fe4e2a 100644 --- a/lib/pdk/cli/util/option_validator.rb +++ b/lib/pdk/cli/util/option_validator.rb @@ -22,6 +22,7 @@ def self.enum(val, valid_entries, _options = {}) def self.valid_module_name?(string) !(string =~ %r{\A[a-z][a-z0-9_]*\Z}).nil? end + singleton_class.send(:alias_method, :valid_task_name?, :valid_module_name?) # Validate a Puppet namespace against the regular expression in the # documentation: https://docs.puppet.com/puppet/4.10/lang_reserved.html#classes-and-defined-resource-types diff --git a/lib/pdk/generate.rb b/lib/pdk/generate.rb index 7d27671ac..86141dfc3 100644 --- a/lib/pdk/generate.rb +++ b/lib/pdk/generate.rb @@ -1,6 +1,7 @@ require 'pdk/generators/module' require 'pdk/generators/defined_type' require 'pdk/generators/puppet_class' +require 'pdk/generators/task' require 'pdk/module/metadata' require 'pdk/module/templatedir' diff --git a/lib/pdk/generators/module.rb b/lib/pdk/generators/module.rb index 60f7a1c2e..31d053f4e 100644 --- a/lib/pdk/generators/module.rb +++ b/lib/pdk/generators/module.rb @@ -152,6 +152,7 @@ def self.prepare_module_directory(target_dir) [ File.join(target_dir, 'manifests'), File.join(target_dir, 'templates'), + File.join(target_dir, 'tasks'), ].each do |dir| begin FileUtils.mkdir_p(dir) diff --git a/lib/pdk/generators/puppet_object.rb b/lib/pdk/generators/puppet_object.rb index 59d08b5a6..6f0f2fd07 100644 --- a/lib/pdk/generators/puppet_object.rb +++ b/lib/pdk/generators/puppet_object.rb @@ -11,6 +11,7 @@ module Generate class PuppetObject attr_reader :module_dir attr_reader :object_name + attr_reader :options # Initialises the PDK::Generate::PuppetObject object. # @@ -30,6 +31,7 @@ class PuppetObject def initialize(module_dir, object_name, options = {}) @module_dir = module_dir @options = options + @object_name = object_name if [:class, :defined_type].include?(object_type) # rubocop:disable Style/GuardClause object_name_parts = object_name.split('::') @@ -83,10 +85,13 @@ def object_type # # @api public def run - [target_object_path, target_spec_path].each do |target_file| - if File.exist?(target_file) - raise PDK::CLI::ExitWithError, _("Unable to generate %{object_type}; '%{file}' already exists.") % { file: target_file, object_type: object_type } - end + [target_object_path, target_spec_path].compact.each do |target_file| + next unless File.exist?(target_file) + + raise PDK::CLI::ExitWithError, _("Unable to generate %{object_type}; '%{file}' already exists.") % { + file: target_file, + object_type: object_type, + } end with_templates do |template_path, config_hash| diff --git a/lib/pdk/generators/task.rb b/lib/pdk/generators/task.rb new file mode 100644 index 000000000..98c157516 --- /dev/null +++ b/lib/pdk/generators/task.rb @@ -0,0 +1,86 @@ +require 'pdk/generators/puppet_object' + +module PDK + module Generate + class Task < PuppetObject + OBJECT_TYPE = :task + + # Prepares the data needed to render the new task template. + # + # @return [Hash{Symbol => Object}] a hash of information that will be + # provided to the task template during rendering. Additionally, this hash + # (with the :name key removed) makes up the task metadata. + def template_data + { + name: object_name, + puppet_task_version: 1, + supports_noop: true, + description: options.fetch(:description, 'A short description of this task'), + } + end + + # Calculates the path to the file where the new task will be written. + # + # @return [String] the path to the task file. + def target_object_path + @target_object_path ||= File.join(module_dir, 'tasks', "#{task_name}.sh") + end + + # Calculates the path to the file where the tests for the new task will + # be written. + # + # @return [nil] as there is currently no test framework for Tasks. + def target_spec_path + nil + end + + def run + check_if_task_already_exists + + super + + write_task_metadata + end + + # Checks that the task has not already been defined with a different + # extension. + # + # @raise [PDK::CLI::ExitWithError] if files with the same name as the + # task exist in the /tasks/ directory + # + # @api private + def check_if_task_already_exists + error = _("A task named '%{name}' already exists in this module; defined in %{file}") + allowed_extensions = %w[.md .conf] + + Dir.glob(File.join(module_dir, 'tasks', "#{task_name}.*")).each do |file| + next if allowed_extensions.include?(File.extname(file)) + + raise PDK::CLI::ExitWithError, error % { name: task_name, file: file } + end + end + + # Writes the /tasks/.json metadata file for the task. + # + # @api private + def write_task_metadata + task_metadata = template_data.dup + task_metadata.delete(:name) + + File.open(File.join(module_dir, 'tasks', "#{task_name}.json"), 'w') do |f| + f.write(JSON.pretty_generate(task_metadata)) + end + end + + # Calculates the file name of the task files ('init' if the task has the + # same name as the module, otherwise use the specified task name). + # + # @return [String] the base name of the file(s) for the task. + # + # @api private + def task_name + (object_name == module_name) ? 'init' : object_name + end + end + end +end diff --git a/spec/unit/pdk/cli/new/task_spec.rb b/spec/unit/pdk/cli/new/task_spec.rb new file mode 100644 index 000000000..38a0cd02d --- /dev/null +++ b/spec/unit/pdk/cli/new/task_spec.rb @@ -0,0 +1,101 @@ +require 'spec_helper' + +describe 'PDK::CLI new task' do + let(:help_text) { a_string_matching(%r{^USAGE\s+pdk new task}m) } + + before(:each) do + allow(PDK::Util).to receive(:module_root).and_return(module_root) + end + + shared_examples 'it exits non-zero and prints the help text' do + it 'exits non-zero and prints the `pdk new task` help' do + expect { + PDK::CLI.run(args) + }.to raise_error(SystemExit) { |error| + expect(error.status).not_to be_zero + }.and output(help_text).to_stdout + end + end + + shared_examples 'it exits with an error' do |expected_error| + it 'exits with an error' do + expect(logger).to receive(:error).with(a_string_matching(expected_error)) + + expect { + PDK::CLI.run(args) + }.to raise_error(SystemExit) { |error| + expect(error.status).not_to be_zero + } + end + end + + context 'when not run from inside a module' do + let(:module_root) { nil } + let(:args) { %w[new task test_task] } + + it_behaves_like 'it exits with an error', %r{must be run from inside a valid module} + end + + context 'when run from inside a module' do + let(:module_root) { '/path/to/test/module' } + + context 'and not provided with a name for the new task' do + let(:args) { %w[new task] } + + it_behaves_like 'it exits non-zero and prints the help text' + end + + context 'and provided an empty string as the task name' do + let(:args) { ['new', 'task', ''] } + + it_behaves_like 'it exits non-zero and prints the help text' + end + + context 'and provided an invalid task name' do + let(:args) { %w[new task test-task] } + + it_behaves_like 'it exits with an error', %r{'test-task' is not a valid task name} + end + + context 'and provided a valid task name' do + let(:generator) { PDK::Generate::Task } + let(:generator_double) { instance_double(generator) } + let(:generator_opts) { instance_of(Hash) } + + before(:each) do + allow(generator).to receive(:new).with(anything, 'test_task', generator_opts).and_return(generator_double) + end + + it 'generates the task' do + expect(generator_double).to receive(:run) + + PDK::CLI.run(%w[new task test_task]) + end + + context 'and a custom template URL' do + let(:generator_opts) { { :'template-url' => 'https://custom/template' } } + + it 'generates the task from the custom template' do + expect(generator_double).to receive(:run) + + PDK::CLI.run(%w[new task test_task --template-url https://custom/template]) + end + end + + context 'and provided a description for the task' do + let(:generator_opts) do + { + :description => 'test_task description', + :'template-url' => anything, + } + end + + it 'generates the task with the specified description' do + expect(generator_double).to receive(:run) + + PDK::CLI.run(['new', 'task', 'test_task', '--description', 'test_task description']) + end + end + end + end +end diff --git a/spec/unit/pdk/generate/module_spec.rb b/spec/unit/pdk/generate/module_spec.rb index 7b169e9b3..65ac1f773 100644 --- a/spec/unit/pdk/generate/module_spec.rb +++ b/spec/unit/pdk/generate/module_spec.rb @@ -662,6 +662,7 @@ it 'creates a skeleton directory structure' do expect(FileUtils).to receive(:mkdir_p).with(File.join(path, 'manifests')) expect(FileUtils).to receive(:mkdir_p).with(File.join(path, 'templates')) + expect(FileUtils).to receive(:mkdir_p).with(File.join(path, 'tasks')) described_class.prepare_module_directory(path) end diff --git a/spec/unit/pdk/generate/task_spec.rb b/spec/unit/pdk/generate/task_spec.rb new file mode 100644 index 000000000..623dbcd5b --- /dev/null +++ b/spec/unit/pdk/generate/task_spec.rb @@ -0,0 +1,118 @@ +require 'spec_helper' + +describe PDK::Generate::Task do + subject(:generator) { described_class.new(module_dir, given_name, options) } + + subject(:target_object_path) { generator.target_object_path } + + subject(:template_data) { generator.template_data } + + let(:module_name) { 'test_module' } + let(:module_dir) { '/tmp/test_module' } + let(:options) { {} } + let(:expected_name) { given_name } + + before(:each) do + test_metadata = instance_double(PDK::Module::Metadata, data: { 'name' => module_name }) + allow(PDK::Module::Metadata).to receive(:from_file).with(File.join(module_dir, 'metadata.json')).and_return(test_metadata) + end + + describe '#target_object_path' do + subject { generator.target_object_path } + + context 'when the task name is the same as the module name' do + let(:given_name) { module_name } + + it { is_expected.to eq(File.join(module_dir, 'tasks', 'init.sh')) } + end + + context 'when the task name is different to the module name' do + let(:given_name) { 'test_task' } + + it { is_expected.to eq(File.join(module_dir, 'tasks', "#{given_name}.sh")) } + end + end + + describe '#target_spec_path' do + subject { generator.target_spec_path } + + let(:given_name) { 'test_task' } + + it { is_expected.to be_nil } + end + + describe '#check_if_task_already_exists' do + let(:given_name) { 'test_task' } + let(:task_files) { [] } + + before(:each) do + allow(Dir).to receive(:glob).with(File.join(module_dir, 'tasks', "#{given_name}.*")).and_return(task_files) + end + + context 'when no files exist for the task' do + it 'does not raise an error' do + expect { generator.check_if_task_already_exists }.not_to raise_error + end + end + + context 'when a .md file for the task already exists' do + let(:task_files) { [File.join(module_dir, 'tasks', "#{given_name}.md")] } + + it 'does not raise an error' do + expect { generator.check_if_task_already_exists }.not_to raise_error + end + end + + context 'when a .conf file for the task already exists' do + let(:task_files) { [File.join(module_dir, 'tasks', "#{given_name}.conf")] } + + it 'does not raise an error' do + expect { generator.check_if_task_already_exists }.not_to raise_error + end + end + + context 'when a file with any other extension exists' do + let(:task_files) { [File.join(module_dir, 'tasks', "#{given_name}.ps1")] } + + it 'raises ExitWithError' do + expect { + generator.check_if_task_already_exists + }.to raise_error(PDK::CLI::ExitWithError, %r{a task named '#{given_name}' already exists}i) + end + end + end + + describe '#write_task_metadata' do + let(:mock_file) { StringIO.new } + let(:given_name) { 'test_task' } + + before(:each) do + metadata_file = File.join(module_dir, 'tasks', "#{given_name}.json") + allow(File).to receive(:open).with(metadata_file, 'w').and_yield(mock_file) + generator.write_task_metadata + mock_file.rewind + end + + context 'when no description is provided in the options' do + it 'writes the metadata with a sample description' do + expect(JSON.parse(mock_file.read)).to eq( + 'puppet_task_version' => 1, + 'supports_noop' => true, + 'description' => 'A short description of this task', + ) + end + end + + context 'when a description is provided in the options' do + let(:options) { { description: 'This is a test task' } } + + it 'writes the metadata with the provided description' do + expect(JSON.parse(mock_file.read)).to eq( + 'puppet_task_version' => 1, + 'supports_noop' => true, + 'description' => 'This is a test task', + ) + end + end + end +end