Skip to content

Commit

Permalink
(puppetlabsGH-2078) Add Bolt guide pages
Browse files Browse the repository at this point in the history
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-BoltGuide
  ```

To view the guide for a specific topic:

- **Unix shell command**

  ```
  $ bolt guide inventory
  ```

- **PowerShell cmdlet**

  ```
  Get-BoltGuide -Topic 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**
  ([puppetlabs#2078](puppetlabs#2078))

  Bolt can now display information about various Bolt features and
  concepts with the new CLI command `bolt guide` and PowerShell cmdlet
  `Get-BoltGuide`.
  • Loading branch information
beechtom committed Aug 17, 2020
1 parent 115aefb commit 1ba0c4b
Show file tree
Hide file tree
Showing 15 changed files with 319 additions and 3 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,4 @@ plans/
pwsh_module/PuppetBolt.psm1
pwsh_module/PuppetBolt.psd1
pwsh_module/autogenerated.tests.ps1
pwsh_module/en-US
1 change: 1 addition & 0 deletions CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
* @puppetlabs/bolt

/documentation @hestonhoffman @puppetlabs/bolt
/guides @hestonhoffman @puppetlabs/bolt
/lib/bolt_server @puppetlabs/skeletor
/spec/bolt_server @puppetlabs/skeletor
3 changes: 2 additions & 1 deletion bolt.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
19 changes: 19 additions & 0 deletions guides/inventory.txt
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions guides/project.txt
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions lib/bolt/bolt_option_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -164,13 +167,17 @@ 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
puppetfile Install and list modules and generate type references
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
Expand Down Expand Up @@ -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
Expand Down
45 changes: 43 additions & 2 deletions lib/bolt/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions lib/bolt/outputter/human.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <topic>` 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] })
Expand Down
15 changes: 15 additions & 0 deletions lib/bolt/outputter/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions lib/bolt/outputter/rainbow.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <topic>` to view a specific guide."
@stream.puts colorize(:rainbow, content)
end

def print_message(message)
@stream.puts colorize(:rainbow, message)
end
end
end
end
17 changes: 17 additions & 0 deletions rakelib/pwsh.rake
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'bolt/cli'
require 'erb'
require 'fileutils'

namespace :pwsh do
desc "Generate pwsh from Bolt's command line options"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -301,6 +306,18 @@ 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|
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')
Expand Down
68 changes: 68 additions & 0 deletions spec/bolt/cli_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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' => %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
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 } }
Expand Down
21 changes: 21 additions & 0 deletions spec/bolt/outputter/human_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 <topic>` 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
Loading

0 comments on commit 1ba0c4b

Please sign in to comment.