From 0234b38665d9b23d750a01b868c316358a0587ba Mon Sep 17 00:00:00 2001 From: Tom Beech Date: Thu, 13 Aug 2020 10:28:02 -0700 Subject: [PATCH] (GH-2078) Add Bolt guide pages This adds a new CLI command and PowerShell cmdlet which display information about various Bolt topics. The guides are read from text files that are saved in the `guides` directory, and are only loaded when a specific guide is being requested. To show a list of available guides: - **Unix shell command** ``` $ bolt guide ``` - **PowerShell cmdlet** ``` Get-Help about_bolt_* ``` To view the guide for a specific topic: - **Unix shell command** ``` $ bolt guide inventory ``` - **PowerShell cmdlet** ``` Get-Help about_bolt_inventory ``` This also adds an analytics event for both known guides and unknown guides. This allows Bolt to collect data about which guides users are viewing and which guides users may expect to be available. !feature * **View information about Bolt concepts and features from the CLI** ([#2078](https://github.com/puppetlabs/bolt/issues/2078)) Bolt can now display information about various Bolt features and concepts with the new CLI command `bolt guide`. --- .gitignore | 1 + CODEOWNERS | 1 + bolt.gemspec | 3 +- guides/README.md | 43 +++++++++++++++++ guides/inventory.txt | 19 ++++++++ guides/project.txt | 22 +++++++++ lib/bolt/bolt_option_parser.rb | 27 +++++++++++ lib/bolt/cli.rb | 53 +++++++++++++++++++- lib/bolt/outputter/human.rb | 10 ++++ lib/bolt/outputter/json.rb | 11 +++++ lib/bolt/outputter/rainbow.rb | 15 ++++++ rakelib/pwsh.rake | 18 +++++++ spec/bolt/cli_spec.rb | 75 +++++++++++++++++++++++++++++ spec/bolt/outputter/human_spec.rb | 18 +++++++ spec/bolt/outputter/json_spec.rb | 20 ++++++++ spec/bolt/outputter/rainbow_spec.rb | 26 ++++++++++ 16 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 guides/README.md create mode 100644 guides/inventory.txt create mode 100644 guides/project.txt diff --git a/.gitignore b/.gitignore index b51f4ae6fd..7ea715fbbe 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ plans/ pwsh_module/PuppetBolt.psm1 pwsh_module/PuppetBolt.psd1 pwsh_module/autogenerated.tests.ps1 +pwsh_module/en-US 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..b3b042516f 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/*.txt'] spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] diff --git a/guides/README.md b/guides/README.md new file mode 100644 index 0000000000..f8e0093402 --- /dev/null +++ b/guides/README.md @@ -0,0 +1,43 @@ +# Topic guides + +Topic guides are concise descriptions of Bolt's features and concepts with links +to relevant documentation. They act as a reference for users who are looking to +better understand Bolt's features and concepts and quickly get to the +information they are looking for. + +## Adding new topic guides + +To add a new topic guide, create a text file with the name `.txt` in this +directory. Topics should be a single word containing only lowercase letters. The +format for a guide should follow the same format as existing guides. + +## Adding guides to Bolt packages + +During the packaging process, Bolt will typically include all guides in this +directory automatically. However, an extra step is required when adding new +guides to ensure they are added to the Bolt PowerShell module when building the +Windows package. + +To add a guide to the Bolt PowerShell module, you will need to add the file as a +WiX component in `bolt-vanagon`, the tool used to build Bolt packages. To add a +component, modify the following XML and add it to [this +file](https://github.com/puppetlabs/bolt-vanagon/blob/main/resources/windows/wix/powershell.wxs.erb): + +```xml + + + +``` + +> **Note:** Replace `` with the name of the new topic and `` with a +> Globally Unique Identifier (GUID). You can generate a GUID in PowerShell using +> the `Get-Guid` cmdlet. + +Once you have modified this file, open a [pull request against +`bolt-vanagon`](https://github.com/puppetlabs/bolt-vanagon/pulls). 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..9c5958b52b 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] + %w[format], + 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..df1af51f91 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,48 @@ 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| + next if file !~ /\.txt\z/ + topic = File.basename(file, '.txt') + 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) + + begin + guide = File.read(guides[topic]) + rescue SystemCallError => e + raise Bolt::FileError("#{e.message}: unable to load guide page", filepath) + end + + outputter.print_guide(guide, 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..dda67d1692 100644 --- a/lib/bolt/outputter/human.rb +++ b/lib/bolt/outputter/human.rb @@ -286,6 +286,16 @@ 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(guide, _topic) + @stream.puts(guide) + 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..b7bf257324 100644 --- a/lib/bolt/outputter/json.rb +++ b/lib/bolt/outputter/json.rb @@ -83,6 +83,17 @@ def print_plan_result(result) @stream.puts result.to_json end + def print_topics(topics) + print_table('topics' => topics) + end + + def print_guide(guide, topic) + @stream.puts({ + 'topic' => topic, + 'guide' => guide + }.to_json) + 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..d989aebb14 100644 --- a/lib/bolt/outputter/rainbow.rb +++ b/lib/bolt/outputter/rainbow.rb @@ -86,6 +86,21 @@ 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(guide, _topic) + @stream.puts colorize(:rainbow, guide) + 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/rakelib/pwsh.rake b/rakelib/pwsh.rake index 2c08dcab05..ecc68eafcf 100644 --- a/rakelib/pwsh.rake +++ b/rakelib/pwsh.rake @@ -2,6 +2,7 @@ require 'bolt/cli' require 'erb' +require 'fileutils' namespace :pwsh do desc "Generate pwsh from Bolt's command line options" @@ -44,6 +45,10 @@ namespace :pwsh do @mapped_options = {} Bolt::CLI::COMMANDS.each do |subcommand, actions| + # The 'bolt guide' command is handled by PowerShell's help system, so + # don't create a cmdlet for it. + next if subcommand == 'guide' + actions << nil if actions.empty? actions.each do |action| help_text = parser.get_help_text(subcommand, action) @@ -301,6 +306,19 @@ namespace :pwsh do end end + # Move 'guides' to 'en-US' directory in module, renaming the text files + # so they are recognized by the PowerShell help system + source = File.expand_path(File.join(__dir__, '..', 'guides')) + dest = File.expand_path(File.join(__dir__, '..', 'pwsh_module', 'en-US')) + + FileUtils.mkdir(dest) + + Dir.children(source).each do |file| + next if file !~ /\.txt\z/ + topic = File.basename(file, '.txt') + FileUtils.cp(File.join(source, file), File.join(dest, "about_bolt_#{topic}.help.txt")) + end + # pwsh_module.psm1 ==> PuppetBolt.psm1 content = File.read('pwsh_module/pwsh_bolt_internal.ps1') + File.read('pwsh_module/pwsh_bolt.psm1.erb') diff --git a/spec/bolt/cli_spec.rb b/spec/bolt/cli_spec.rb index ebbc9e145c..92b39a198c 100644 --- a/spec/bolt/cli_spec.rb +++ b/spec/bolt/cli_spec.rb @@ -89,6 +89,81 @@ 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' => %r{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 + Tempfile.create do |file| + content = "The trials and tribulations of Bolty McBoltface\n" + File.write(file, content) + + cli = Bolt::CLI.new(['guide', topic]) + allow(cli).to receive(:guides).and_return(topic => file.path) + + expect(cli.outputter).to receive(:print_guide).with(content, topic) + cli.show_guide(topic) + end + 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..e22ef732d9 100644 --- a/spec/bolt/outputter/human_spec.rb +++ b/spec/bolt/outputter/human_spec.rb @@ -339,4 +339,22 @@ 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 + guide = "The trials and tribulations of Bolty McBoltface\n" + outputter.print_guide(guide, 'boltymcboltface') + expect(output.string).to eq(guide) + end end diff --git a/spec/bolt/outputter/json_spec.rb b/spec/bolt/outputter/json_spec.rb index 1493182b92..80bd4af998 100644 --- a/spec/bolt/outputter/json_spec.rb +++ b/spec/bolt/outputter/json_spec.rb @@ -145,4 +145,24 @@ 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 + topic = 'boltymcboltface' + guide = "The trials and tribulations of Bolty McBoltface.\n" + + outputter.print_guide(guide, 'boltymcboltface') + parsed = JSON.parse(output.string) + + expect(parsed['topic']).to eq(topic) + expect(parsed['guide']).to eq(guide) + end end diff --git a/spec/bolt/outputter/rainbow_spec.rb b/spec/bolt/outputter/rainbow_spec.rb index 5f27730b19..e9b9824552 100644 --- a/spec/bolt/outputter/rainbow_spec.rb +++ b/spec/bolt/outputter/rainbow_spec.rb @@ -47,4 +47,30 @@ 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 + guide = "The trials and tribulations of Bolty McBoltface.\n" + expect(outputter).to receive(:colorize).with(:rainbow, guide).and_call_original + outputter.print_guide(guide, 'boltymcboltface') + expect(output.string).to eq(guide) + 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