Skip to content

Commit

Permalink
(puppetlabsGH-2230) Print Puppetfile diff when using 'module add'
Browse files Browse the repository at this point in the history
This adds a new message to the outputter that describes changes made to
a Puppetfile when using the `bolt module add` / `Add-BoltModule`
command. The message will display which modules have been added,
removed, upgraded, and downgraded. Upgraded and downgraded modules will
have their old version and new version displayed, while added and
removed modules display the version of the module that was added or
removed.

!feature

* **Print changes made to Puppetfile when adding modules**
  ([puppetlabs#2230](puppetlabs#2230))

  The `bolt module add` command and `Add-BoltModule` cmdlet now display
  a message describing changes made to the Puppetfile, including modules
  that have been added, removed, upgraded, or downgraded.
  • Loading branch information
beechtom committed Oct 9, 2020
1 parent bda1b58 commit 81f7717
Show file tree
Hide file tree
Showing 14 changed files with 175 additions and 38 deletions.
1 change: 1 addition & 0 deletions lib/bolt/cli.rb
Expand Up @@ -938,6 +938,7 @@ def assert_project_file(project)
# Loads a Puppetfile and installs its modules.
#
def install_puppetfile(config, puppetfile, moduledir)
@outputter.print_message("Installing modules from Puppetfile")
installer = Bolt::ModuleInstaller.new(outputter, pal)
ok = installer.install_puppetfile(puppetfile, moduledir, config)
ok ? 0 : 1
Expand Down
90 changes: 82 additions & 8 deletions lib/bolt/module_installer.rb
Expand Up @@ -16,29 +16,38 @@ def initialize(outputter, pal)
def add(name, modules, puppetfile_path, moduledir, config_path)
require 'bolt/puppetfile'

@outputter.print_message("Adding module #{name} to project\n\n")

# If the project configuration file already includes this module,
# exit early.
puppetfile = Bolt::Puppetfile.new(modules)
new_module = Bolt::Puppetfile::Module.from_hash('name' => name)

if puppetfile.modules.include?(new_module)
@outputter.print_message "Project configuration file #{config_path} already "\
"includes module #{new_module}. Nothing to do."
@outputter.print_action_step(
"Project configuration file #{config_path} already includes module #{new_module}. Nothing to do."
)
return true
end

# If the Puppetfile exists, make sure it's managed by Bolt.
if puppetfile_path.exist?
assert_managed_puppetfile(puppetfile, puppetfile_path)
existing = Bolt::Puppetfile.parse(puppetfile_path)
else
existing = Bolt::Puppetfile.new
end

# Create a Puppetfile object that includes the new module and its
# dependencies. We error early here so we don't add the new module to the
# project config or modify the Puppetfile.
puppetfile = add_new_module_to_puppetfile(new_module, modules, puppetfile_path)

# Display the diff between the existing Puppetfile and the new Puppetfile.
print_puppetfile_diff(existing, puppetfile)

# Add the module to the project configuration.
@outputter.print_message "Updating project configuration file at #{config_path}"
@outputter.print_action_step("Updating project configuration file at #{config_path}")

data = Bolt::Util.read_yaml_hash(config_path, 'project')
data['modules'] ||= []
Expand All @@ -54,7 +63,7 @@ def add(name, modules, puppetfile_path, moduledir, config_path)
end

# Write the Puppetfile.
@outputter.print_message "Writing Puppetfile at #{puppetfile_path}"
@outputter.print_action_step("Writing Puppetfile at #{puppetfile_path}")
puppetfile.write(puppetfile_path, moduledir)

# Install the modules.
Expand All @@ -64,7 +73,7 @@ def add(name, modules, puppetfile_path, moduledir, config_path)
# Creates a new Puppetfile that includes the new module and its dependencies.
#
private def add_new_module_to_puppetfile(new_module, modules, path)
@outputter.print_message "Resolving module dependencies, this may take a moment"
@outputter.print_action_step("Resolving module dependencies, this may take a moment")

# If there is an existing Puppetfile, add the new module and attempt
# to resolve. This will not update the versions of any installed modules.
Expand Down Expand Up @@ -92,11 +101,76 @@ def add(name, modules, puppetfile_path, moduledir, config_path)
puppetfile
end

# Outputs a diff of an existing Puppetfile and an updated Puppetfile.
#
def print_puppetfile_diff(existing, updated)
diff = String.new

# Build hashes mapping the module title to the module object. This makes it
# a little easier to determine which modules have been added, removed, or
# modified.
existing = existing.modules.each_with_object({}) do |mod, acc|
acc[mod.title] = mod
end

updated = updated.modules.each_with_object({}) do |mod, acc|
acc[mod.title] = mod
end

# New modules are those present in updated but not in existing.
added = updated.reject { |title, _mod| existing.include?(title) }.values

if added.any?
diff += "Adding the following modules:\n"
added.each { |mod| diff += "#{mod.title} #{mod.version}\n" }
diff += "\n"
end

# Removed modules are those present in existing but not in updated.
removed = existing.reject { |title, _mod| updated.include?(title) }.values

if removed.any?
diff += "Removing the following modules:\n"
removed.each { |mod| diff += "#{mod.title} #{mod.version}\n" }
diff += "\n"
end

# Upgraded modules are those that have a newer version in updated than existing.
upgraded = updated.select do |title, mod|
if existing.include?(title)
SemanticPuppet::Version.parse(mod.version) > SemanticPuppet::Version.parse(existing[title].version)
end
end.keys

if upgraded.any?
diff += "Upgrading the following modules:\n"
upgraded.each { |title| diff += "#{title} #{existing[title].version} to #{updated[title].version}" }
diff += "\n"
end

# Downgraded modules are those that have an older version in updated than existing.
downgraded = updated.select do |title, mod|
if existing.include?(title)
SemanticPuppet::Version.parse(mod.version) < SemanticPuppet::Version.parse(existing[title].version)
end
end.keys

if downgraded.any?
diff += "Downgrading the following modules: \n"
downgraded.each { |title| diff += "#{title} #{existing[title].version} to #{updated[title].version}" }
diff += "\n"
end

@outputter.print_action_step(diff) unless diff.empty?
end

# Installs a project's module dependencies.
#
def install(modules, path, moduledir, force: false, resolve: true)
require 'bolt/puppetfile'

@outputter.print_message("Installing project modules\n\n")

puppetfile = Bolt::Puppetfile.new(modules)

# If the Puppetfile exists, check if it includes specs for each declared
Expand All @@ -110,10 +184,10 @@ def install(modules, path, moduledir, force: false, resolve: true)
if path.exist? && !force
assert_managed_puppetfile(puppetfile, path)
else
@outputter.print_message "Resolving module dependencies, this may take a moment"
@outputter.print_action_step("Resolving module dependencies, this may take a moment")
puppetfile.resolve

@outputter.print_message "Writing Puppetfile at #{path}"
@outputter.print_action_step("Writing Puppetfile at #{path}")
# We get here either through 'bolt module install' which uses the
# managed modulepath (which isn't configurable) or through bolt
# project init --modules, which uses the default modulepath. This
Expand All @@ -136,7 +210,7 @@ def install(modules, path, moduledir, force: false, resolve: true)
def install_puppetfile(path, moduledir, config = {})
require 'bolt/puppetfile/installer'

@outputter.print_message "Syncing modules from #{path} to #{moduledir}"
@outputter.print_action_step("Syncing modules from #{path} to #{moduledir}")
ok = Bolt::Puppetfile::Installer.new(config).install(path, moduledir)

# Automatically generate types after installing modules
Expand Down
4 changes: 2 additions & 2 deletions lib/bolt/outputter/human.rb
Expand Up @@ -413,7 +413,7 @@ def print_prompt_error(message)
@stream.puts(colorize(:red, indent(4, message)))
end

def print_migrate_step(step)
def print_action_step(step)
first, *remaining = wrap(step, 76).lines

first = indent(2, "→ #{first}")
Expand All @@ -423,7 +423,7 @@ def print_migrate_step(step)
@stream.puts(step)
end

def print_migrate_error(error)
def print_action_error(error)
# Running everything through 'wrap' messes with newlines. Separating
# into lines and wrapping each individually ensures separate errors are
# distinguishable.
Expand Down
4 changes: 2 additions & 2 deletions lib/bolt/outputter/json.rb
Expand Up @@ -137,10 +137,10 @@ def print_message(message)
end
alias print_error print_message

def print_migrate_step(step)
def print_action_step(step)
$stderr.puts(step)
end
alias print_migrate_error print_migrate_step
alias print_action_error print_action_step
end
end
end
4 changes: 2 additions & 2 deletions lib/bolt/project_migrator/base.rb
Expand Up @@ -12,7 +12,7 @@ def initialize(outputter)

protected def backup_file(origin_path, backup_dir)
unless File.exist?(origin_path)
@outputter.print_migrate_step(
@outputter.print_action_step(
"Could not find file #{origin_path}, skipping backup."
)
return
Expand All @@ -24,7 +24,7 @@ def initialize(outputter)
filename = File.basename(origin_path)
backup_path = File.join(backup_dir, "#{filename}.#{date}.bak")

@outputter.print_migrate_step(
@outputter.print_action_step(
"Backing up #{filename} from #{origin_path} to #{backup_path}"
)

Expand Down
6 changes: 3 additions & 3 deletions lib/bolt/project_migrator/config.rb
Expand Up @@ -39,7 +39,7 @@ def migrate(config_file, project_file, inventory_file, backup_dir)
backup_file(config_file, backup_dir)

begin
@outputter.print_migrate_step(
@outputter.print_action_step(
"Moving transportation configuration options '#{transport_data.keys.join(', ')}' "\
"from bolt.yaml to inventory.yaml"
)
Expand All @@ -51,10 +51,10 @@ def migrate(config_file, project_file, inventory_file, backup_dir)
end
end

@outputter.print_migrate_step("Renaming bolt.yaml to bolt-project.yaml")
@outputter.print_action_step("Renaming bolt.yaml to bolt-project.yaml")
FileUtils.mv(config_file, project_file)

@outputter.print_migrate_step(
@outputter.print_action_step(
"Successfully migrated config. Please add a 'name' key to bolt-project.yaml "\
"to use project-level tasks and plans. Learn more about projects by running "\
"'bolt guide project'."
Expand Down
2 changes: 1 addition & 1 deletion lib/bolt/project_migrator/inventory.rb
Expand Up @@ -28,7 +28,7 @@ def migrate(inventory_file, backup_dir)

begin
File.write(inventory_file, data.to_yaml)
@outputter.print_migrate_step(
@outputter.print_action_step(
"Successfully migrated Bolt inventory to the latest version."
)
true
Expand Down
26 changes: 13 additions & 13 deletions lib/bolt/project_migrator/modules.rb
Expand Up @@ -19,7 +19,7 @@ def migrate(project, configured_modulepath)

# Notify user to manually migrate modules if using non-default modulepath
if configured_modulepath != modulepath
@outputter.print_migrate_step(
@outputter.print_action_step(
"Project has a non-default configured modulepath, unable to automatically "\
"migrate project modules. To migrate project modules manually, see "\
"http://pup.pt/bolt-modules"
Expand All @@ -46,10 +46,10 @@ def migrate(project, configured_modulepath)
require 'bolt/puppetfile/installer'

begin
@outputter.print_migrate_step("Parsing Puppetfile at #{puppetfile_path}")
@outputter.print_action_step("Parsing Puppetfile at #{puppetfile_path}")
puppetfile = Bolt::Puppetfile.parse(puppetfile_path, skip_unsupported_modules: true)
rescue Bolt::Error => e
@outputter.print_migrate_error("#{e.message}\nSkipping module migration.")
@outputter.print_action_error("#{e.message}\nSkipping module migration.")
return false
end

Expand All @@ -62,10 +62,10 @@ def migrate(project, configured_modulepath)
# Attempt to resolve dependencies
begin
@outputter.print_message('')
@outputter.print_migrate_step("Resolving module dependencies, this may take a moment")
@outputter.print_action_step("Resolving module dependencies, this may take a moment")
puppetfile.resolve
rescue Bolt::Error => e
@outputter.print_migrate_error("#{e.message}\nSkipping module migration.")
@outputter.print_action_error("#{e.message}\nSkipping module migration.")
return false
end

Expand All @@ -90,17 +90,17 @@ def migrate(project, configured_modulepath)
# Show the new Puppetfile content
message = "Generated new Puppetfile content:\n\n"
message += puppetfile.modules.map(&:to_spec).join("\n").to_s
@outputter.print_migrate_step(message)
@outputter.print_action_step(message)

# Write Puppetfile
@outputter.print_migrate_step("Updating Puppetfile at #{puppetfile_path}")
@outputter.print_action_step("Updating Puppetfile at #{puppetfile_path}")
puppetfile.write(puppetfile_path, managed_moduledir)

# Install Puppetfile
@outputter.print_migrate_step("Syncing modules from #{puppetfile_path} to #{managed_moduledir}")
@outputter.print_action_step("Syncing modules from #{puppetfile_path} to #{managed_moduledir}")
Bolt::Puppetfile::Installer.new({}).install(puppetfile_path, managed_moduledir)
else
@outputter.print_migrate_step(
@outputter.print_action_step(
"Project does not include any managed modules, deleting Puppetfile "\
"at #{puppetfile_path}"
)
Expand All @@ -112,7 +112,7 @@ def migrate(project, configured_modulepath)
# the selected modules.
#
private def select_modules(modules)
@outputter.print_migrate_step(
@outputter.print_action_step(
"Select modules that are direct dependencies of your project. Bolt will "\
"automatically manage dependencies for each module selected, so do not "\
"select a module's dependencies unless you use content from it directly "\
Expand All @@ -135,7 +135,7 @@ def migrate(project, configured_modulepath)
sources.select! { |source| Dir.exist?(source) }

if sources.any?
@outputter.print_migrate_step(
@outputter.print_action_step(
"Moving modules from #{sources.join(', ')} to #{moduledir}"
)

Expand Down Expand Up @@ -166,7 +166,7 @@ def migrate(project, configured_modulepath)
# Deletes modules from a specified directory.
#
private def delete_modules(moduledir, modules)
@outputter.print_migrate_step("Cleaning up #{moduledir}")
@outputter.print_action_step("Cleaning up #{moduledir}")
moduledir = Pathname.new(moduledir)

modules.each do |mod|
Expand All @@ -178,7 +178,7 @@ def migrate(project, configured_modulepath)
# Adds a list of modules to the project configuration file.
#
private def update_project_config(modules, config_file)
@outputter.print_migrate_step("Updating project configuration at #{config_file}")
@outputter.print_action_step("Updating project configuration at #{config_file}")
data = Bolt::Util.read_optional_yaml_hash(config_file, 'project')
data.merge!('modules' => modules)
data.delete('modulepath')
Expand Down
2 changes: 1 addition & 1 deletion lib/bolt/puppetfile/module.rb
Expand Up @@ -13,7 +13,7 @@ class Module
def initialize(owner, name, version = nil)
@owner = owner
@name = name
@version = version unless version == :latest
@version = version.sub('=', '') if version.is_a?(String)
end

# Creates a new module from a hash.
Expand Down

0 comments on commit 81f7717

Please sign in to comment.