diff --git a/CODEOWNERS b/CODEOWNERS index eab509b3a0..4e1259aacf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -2,5 +2,6 @@ * @puppetlabs/bolt /documentation @hestonhoffman @puppetlabs/bolt +/guides @hestonhoffman @puppetlabs/bolt /lib/bolt_server @puppetlabs/skeletor /spec/bolt_server @puppetlabs/skeletor diff --git a/bolt.gemspec b/bolt.gemspec index a5778804ab..39b1d421b0 100644 --- a/bolt.gemspec +++ b/bolt.gemspec @@ -28,7 +28,8 @@ Gem::Specification.new do |spec| Dir['modules/*/locales/**/*'] + Dir['modules/*/plans/**/*.pp'] + Dir['modules/*/tasks/**/*'] + - Dir['Puppetfile'] + Dir['Puppetfile'] + + Dir['guides/*'] spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] diff --git a/guides/inventory.txt b/guides/inventory.txt new file mode 100644 index 0000000000..d4a58704bf --- /dev/null +++ b/guides/inventory.txt @@ -0,0 +1,19 @@ +TOPIC + inventory + +DESCRIPTION + The inventory describes the targets that you run Bolt commands on, along + with any data and configuration for the targets. Targets in an inventory can + belong to one or more groups, allowing you to share data and configuration + across multiple targets and to specify multiple targets for your Bolt + commands without the need to list each target individually. + + In most cases, Bolt loads the inventory from an inventory file in your Bolt + project. The inventory file is a YAML file named 'inventory.yaml'. Because + Bolt loads the inventory file from a Bolt project, you must have an existing + project configuration file named 'bolt-project.yaml' alongside the inventory + file. + +DOCUMENTATION + https://pup.pt/bolt-inventory + https://pup.pt/bolt-inventory-reference diff --git a/guides/project.txt b/guides/project.txt new file mode 100644 index 0000000000..64281711da --- /dev/null +++ b/guides/project.txt @@ -0,0 +1,22 @@ +TOPIC + project + +DESCRIPTION + A Bolt project is a directory that serves as the launching point for Bolt + and allows you to create a shareable orchestration application. Projects + typically include a project configuration file, an inventory file, and any + content you use in your project workflow, such as tasks and plans. + + When you run Bolt, it runs in the context of a project. If the directory you + run Bolt from is not a project, Bolt attempts to find a project by + traversing the parent directories. If Bolt is unable to find a project, it + runs from the default project, located at '~/.puppetlabs/bolt'. + + A directory is only considered a Bolt project when it has a project + configuration file named 'bolt-project.yaml'. Bolt doesn't load project data + and content, including inventory files, unless the data and content are part + of a project. + +DOCUMENTATION + https://pup.pt/bolt-projects + https://pup.pt/bolt-project-reference diff --git a/lib/bolt/bolt_option_parser.rb b/lib/bolt/bolt_option_parser.rb index 9fe9979fb7..d2b39208e6 100644 --- a/lib/bolt/bolt_option_parser.rb +++ b/lib/bolt/bolt_option_parser.rb @@ -61,6 +61,9 @@ def get_help_text(subcommand, action = nil) { flags: OPTIONS[:global], banner: GROUP_HELP } end + when 'guide' + { flags: OPTIONS[:global], + banner: GUIDE_HELP } when 'plan' case action when 'convert' @@ -164,6 +167,7 @@ def get_help_text(subcommand, action = nil) command Run a command remotely file Copy files between the controller and targets group Show the list of groups in the inventory + guide View guides for Bolt concepts and features inventory Show the list of targets an action would run on plan Convert, create, show, and run Bolt plans project Create and migrate Bolt projects @@ -171,6 +175,9 @@ def get_help_text(subcommand, action = nil) script Upload a local script and run it remotely secret Create encryption keys and encrypt and decrypt values task Show and run Bolt tasks + + GUIDES + For a list of guides on Bolt's concepts and features, run 'bolt guide'. HELP APPLY_HELP = <<~HELP @@ -289,6 +296,26 @@ def get_help_text(subcommand, action = nil) Show the list of groups in the inventory. HELP + GUIDE_HELP = <<~HELP + NAME + guide + + USAGE + bolt guide [topic] [options] + + DESCRIPTION + View guides for Bolt's concepts and features. + + Omitting a topic will display a list of available guides, + while providing a topic will display the relevant guide. + + EXAMPLES + View a list of available guides + bolt guide + View the 'project' guide page + bolt guide project + HELP + INVENTORY_HELP = <<~HELP NAME inventory diff --git a/lib/bolt/cli.rb b/lib/bolt/cli.rb index c4b7d99cae..cd9acd486c 100644 --- a/lib/bolt/cli.rb +++ b/lib/bolt/cli.rb @@ -38,7 +38,8 @@ class CLI 'inventory' => %w[show], 'group' => %w[show], 'project' => %w[init migrate], - 'apply' => %w[] }.freeze + 'apply' => %w[], + 'guide' => %w[] }.freeze attr_reader :config, :options @@ -354,7 +355,7 @@ def execute(options) # Initialize inventory and targets. Errors here are better to catch early. # options[:target_args] will contain a string/array version of the targetting options this is passed to plans # options[:targets] will contain a resolved set of Target objects - unless %w[project puppetfile secret].include?(options[:subcommand]) || + unless %w[project puppetfile secret guide].include?(options[:subcommand]) || %w[convert new show].include?(options[:action]) update_targets(options) end @@ -422,6 +423,12 @@ def execute(options) end case options[:subcommand] + when 'guide' + code = if options[:object] + show_guide(options[:object]) + else + list_topics + end when 'project' case options[:action] when 'init' @@ -992,6 +999,40 @@ def convert_plan(plan) pal.convert_plan(plan) end + # Collects the list of Bolt guides and maps them to their topics. + def guides + @guides ||= begin + root_path = File.expand_path(File.join(__dir__, '..', '..', 'guides')) + files = Dir.children(root_path).sort + + files.each_with_object({}) do |file, guides| + topic = File.basename(file, '.*') + guides[topic] = File.join(root_path, file) + end + rescue SystemCallError => e + raise Bolt::FileError.new("#{e.message}: unable to load guides directory", root_path) + end + end + + # Display the list of available Bolt guides. + def list_topics + outputter.print_topics(guides.keys) + 0 + end + + # Display a specific Bolt guide. + def show_guide(topic) + if guides[topic] + analytics.event('Guide', 'known_topic', label: topic) + outputter.print_guide(guides[topic], topic) + else + analytics.event('Guide', 'unknown_topic', label: topic) + outputter.print_message("Did not find guide for topic '#{topic}'.\n\n") + list_topics + end + 0 + end + def validate_file(type, path, allow_dir = false) if path.nil? raise Bolt::CLIError, "A #{type} must be specified" diff --git a/lib/bolt/outputter/human.rb b/lib/bolt/outputter/human.rb index 7172a64e64..a103b418c8 100644 --- a/lib/bolt/outputter/human.rb +++ b/lib/bolt/outputter/human.rb @@ -286,6 +286,19 @@ def print_plans(plans, modulepath) "details and parameters for a specific plan.") end + def print_topics(topics) + print_message("Available topics are:") + print_message(topics.join("\n")) + print_message("\nUse `bolt guide ` to view a specific guide.") + end + + def print_guide(filepath, _topic) + content = File.read(filepath) + @stream.puts(content) + rescue SystemCallError => e + raise Bolt::FileError("#{e.message}: unable to load guide page", filepath) + end + def print_module_list(module_list) module_list.each do |path, modules| if (mod = modules.find { |m| m[:internal_module_group] }) diff --git a/lib/bolt/outputter/json.rb b/lib/bolt/outputter/json.rb index c1e360fbd4..a53b1181bf 100644 --- a/lib/bolt/outputter/json.rb +++ b/lib/bolt/outputter/json.rb @@ -83,6 +83,21 @@ def print_plan_result(result) @stream.puts result.to_json end + def print_topics(topics) + print_table('topics' => topics) + end + + def print_guide(filepath, topic) + guide = { + 'topic' => topic, + 'guide' => File.read(filepath) + } + + @stream.puts(guide.to_json) + rescue SystemCallError => e + raise Bolt::FileError("#{e.message}: unable to load guide page", filepath) + end + def print_puppetfile_result(success, puppetfile, moduledir) @stream.puts({ "success": success, "puppetfile": puppetfile, diff --git a/lib/bolt/outputter/rainbow.rb b/lib/bolt/outputter/rainbow.rb index b7a27d844e..7436918f8d 100644 --- a/lib/bolt/outputter/rainbow.rb +++ b/lib/bolt/outputter/rainbow.rb @@ -86,6 +86,24 @@ def print_summary(results, elapsed_time = nil) total_msg << " in #{duration_to_string(elapsed_time)}" unless elapsed_time.nil? @stream.puts colorize(:rainbow, total_msg) end + + def print_guide(filepath, _topic) + content = File.read(filepath) + @stream.puts colorize(:rainbow, content) + rescue SystemCallError => e + raise Bolt::FileError("#{e.message}: unable to load guide page", filepath) + end + + def print_topics(topics) + content = String.new("Available topics are:\n") + content += topics.join("\n") + content += "\n\nUse `bolt guide ` to view a specific guide." + @stream.puts colorize(:rainbow, content) + end + + def print_message(message) + @stream.puts colorize(:rainbow, message) + end end end end diff --git a/pwsh_module/command.tests.ps1 b/pwsh_module/command.tests.ps1 index e8941722a7..f9a3f8f4cd 100644 --- a/pwsh_module/command.tests.ps1 +++ b/pwsh_module/command.tests.ps1 @@ -34,7 +34,7 @@ Describe "test bolt module" { it "has the correct number of exported functions" { # should count of pwsh functions plus legacy `bolt` function - @($commands).Count | Should -Be 22 + @($commands).Count | Should -Be 23 } } } @@ -112,6 +112,24 @@ Describe "test bolt command syntax" { } + context "bolt guide" { + + BeforeEach { + $command = Get-Command -Name 'Get-BoltGuide' + } + + It "has primary parameter" { + $command.Parameters['topic'] | Should -Be $true + $command.Parameters['topic'].ParameterSets.Values.IsMandatory | Should -Be $false + } + + It "has correct number of parameters" { + ($command.Parameters.Values | Where-Object { + $_.name -notin $common + } | measure-object).Count | Should -Be 2 + } + } + } Describe "test all bolt command examples" { @@ -274,4 +292,15 @@ Describe "test all bolt command examples" { $results | Should -Be "bolt task show 'canary'" } } + + Context "bolt guide" { + It "bolt guide" { + $result = Get-BoltGuide + $result | Should -Be "bolt guide" + } + It "bolt guide topic" { + $result = Get-BoltGuide -Topic topic + $result | Should -Be "bolt guide 'topic'" + } + } } diff --git a/rakelib/pwsh.rake b/rakelib/pwsh.rake index 2c08dcab05..b3f2242351 100644 --- a/rakelib/pwsh.rake +++ b/rakelib/pwsh.rake @@ -20,7 +20,8 @@ namespace :pwsh do 'show' => 'Get', 'upload' => 'Send', # deploy? publish? 'download' => 'Receive', - 'new' => 'New' + 'new' => 'New', + 'guide' => 'Get' } @hardcoded_cmdlets = { @@ -53,6 +54,9 @@ namespace :pwsh do if action.nil? && subcommand == 'apply' cmdlet_verb = 'Invoke' cmdlet_noun = "Bolt#{subcommand.capitalize}" + elsif action.nil? && subcommand == 'guide' + cmdlet_verb = 'Get' + cmdlet_noun = "Bolt#{subcommand.capitalize}" elsif @hardcoded_cmdlets[action] cmdlet_verb = @hardcoded_cmdlets[action]['verb'] cmdlet_noun = @hardcoded_cmdlets[action]['noun'] @@ -212,6 +216,18 @@ namespace :pwsh do ruby_arg: 'bare', validate_not_null_or_empty: true } + when 'guide' + # bolt guide [topic] [options] + @pwsh_command[:options] << { + name: 'Topic', + help_msg: 'The topic to view a guide for', + type: 'string', + switch: false, + mandatory: false, + position: 0, + ruby_arg: 'bare', + validate_not_null_or_empty: true + } end # verbose and debug are commonparameters and are already present in the diff --git a/spec/bolt/cli_spec.rb b/spec/bolt/cli_spec.rb index ebbc9e145c..81f907b52b 100644 --- a/spec/bolt/cli_spec.rb +++ b/spec/bolt/cli_spec.rb @@ -89,6 +89,74 @@ def stub_config(file_content = {}) end end + context 'guide' do + let(:config) { double('config', format: nil) } + let(:topic) { 'project' } + + context '#guides' do + it 'returns a hash of topics and filepaths to guides' do + expect(Dir).to receive(:children).and_return(['milo.txt']) + cli = Bolt::CLI.new(['guide']) + expect(cli.guides).to match( + 'milo' => /guides\/milo.txt/ + ) + end + end + + context '#list_topics' do + it 'lists topics' do + cli = Bolt::CLI.new(['guide']) + expect(cli.outputter).to receive(:print_topics).with(cli.guides.keys) + cli.list_topics + end + + it 'returns 0' do + cli = Bolt::CLI.new(['guide']) + expect(cli.list_topics).to eq(0) + end + end + + context '#show_guide' do + before(:each) do + allow_any_instance_of(Bolt::CLI).to receive(:analytics).and_return(Bolt::Analytics::NoopClient.new) + end + + it 'prints a guide for a known topic' do + cli = Bolt::CLI.new(['guide', topic]) + expect(cli.outputter).to receive(:print_guide).with(cli.guides[topic], topic) + cli.show_guide(topic) + end + + it 'submits a known_topic analytics event' do + cli = Bolt::CLI.new(['guide', topic]) + expect(cli.analytics).to receive(:event).with('Guide', 'known_topic', label: topic) + cli.show_guide(topic) + end + + it 'prints a list of topics when given an unknown topic' do + topic = 'boltymcboltface' + cli = Bolt::CLI.new(['guide', topic]) + allow(cli).to receive(:config).and_return(config) + expect(cli).to receive(:list_topics) + expect(cli.outputter).to receive(:print_message).with(/Did not find guide for topic '#{topic}'/) + cli.show_guide(topic) + end + + it 'submits an uknown_topic analytics event' do + topic = 'boltymcboltface' + cli = Bolt::CLI.new(['guide', topic]) + allow(cli).to receive(:config).and_return(config) + expect(cli.analytics).to receive(:event).with('Guide', 'unknown_topic', label: topic) + cli.show_guide(topic) + end + + it 'returns 0' do + cli = Bolt::CLI.new(['guide', topic]) + expect(cli.show_guide(topic)).to eq(0) + end + end + end + context 'plan new' do let(:project_name) { 'project' } let(:config) { { 'name' => project_name } } diff --git a/spec/bolt/outputter/human_spec.rb b/spec/bolt/outputter/human_spec.rb index b788cfb3ef..99bab65ec3 100644 --- a/spec/bolt/outputter/human_spec.rb +++ b/spec/bolt/outputter/human_spec.rb @@ -339,4 +339,25 @@ expect(str).to eq("1 hr, 2 min, 30 sec") end end + + it 'prints a list of guide topics' do + outputter.print_topics(%w[apple banana carrot]) + expect(output.string).to eq(<<~OUTPUT) + Available topics are: + apple + banana + carrot + + Use `bolt guide ` to view a specific guide. + OUTPUT + end + + it 'prints a guide' do + Tempfile.create do |file| + guide = "The trials and tribulations of Bolty McBoltface.\n" + File.write(file, guide) + outputter.print_guide(file, 'boltymcboltface') + expect(output.string).to eq(guide) + end + end end diff --git a/spec/bolt/outputter/json_spec.rb b/spec/bolt/outputter/json_spec.rb index 1493182b92..e1a236f837 100644 --- a/spec/bolt/outputter/json_spec.rb +++ b/spec/bolt/outputter/json_spec.rb @@ -145,4 +145,27 @@ expect(parsed['items'].size).to eq(2) expect(parsed['_error']['kind']).to eq("bolt/cli-error") end + + it 'prints a list of guides' do + topics = %w[apple banana carrot] + + outputter.print_topics(topics) + parsed = JSON.parse(output.string) + + expect(parsed['topics']).to match_array(topics) + end + + it 'prints a guide page' do + Tempfile.create do |file| + topic = 'boltymcboltface' + guide = "The trials and tribulations of Bolty McBoltface.\n" + + File.write(file, guide) + outputter.print_guide(file, 'boltymcboltface') + parsed = JSON.parse(output.string) + + expect(parsed['topic']).to eq(topic) + expect(parsed['guide']).to eq(guide) + end + end end diff --git a/spec/bolt/outputter/rainbow_spec.rb b/spec/bolt/outputter/rainbow_spec.rb index 5f27730b19..63996b4583 100644 --- a/spec/bolt/outputter/rainbow_spec.rb +++ b/spec/bolt/outputter/rainbow_spec.rb @@ -47,4 +47,33 @@ expect(summary[1]).to eq('Failed on 1 target: target2') expect(summary[2]).to eq('Ran on 2 targets in 10.0 sec') end + + it "colorizes guide output" do + Tempfile.create do |file| + guide = "The trials and tribulations of Bolty McBoltface.\n" + File.write(file, guide) + expect(outputter).to receive(:colorize).with(:rainbow, guide).and_call_original + outputter.print_guide(file, 'boltymcboltface') + expect(output.string).to eq(guide) + end + end + + it "colorizes topics list" do + content = <<~CONTENT.chomp + Available topics are: + foo + bar + + Use `bolt guide ` to view a specific guide. + CONTENT + + expect(outputter).to receive(:colorize).with(:rainbow, content) + outputter.print_topics(%w[foo bar]) + end + + it "colorizes a message" do + message = 'somewhere over the rainbow' + expect(outputter).to receive(:colorize).with(:rainbow, message) + outputter.print_message(message) + end end