lib/gdk/templates/erb_renderer.rb (105 lines of code) (raw):
# frozen_string_literal: true
require 'erb'
require 'fileutils'
require 'tempfile'
require 'json'
module GDK
module Templates
# ErbRenderer is responsible for rendering templates and providing
# them access to configuration data
class ErbRenderer
attr_reader :source, :context
# Initialize the renderer providing source, target and local variables
#
# @param [Pathname] source
# @param [Hash] **locals variables available inside the template
def initialize(source, **locals)
@source = ensure_pathname(source)
@context = ::GDK::Templates::Context.new(**locals)
end
# The safe render take extra steps to avoid unrecoverable changes:
# - Render the new content to a temporary file
# - Display a diff of the changes
# - Make a timestamped backup of the target file
# - Provide instructions on how to restore previous changes
# - Move the temporary file to replace the old one
#
# @param [Pathname] target
# @return [Boolean] whether file was written
def safe_render!(target)
target = ensure_pathname(target)
return false unless should_render?(target)
write_atomically(target, render_to_string) do |file|
next true unless target.exist?
break false if FileUtils.identical?(target, file)
display_changes!(file.path, target)
backup = perform_backup!(target)
warn_overwritten!(backup)
true
end
end
# Render template into target file
#
# @param [Pathname] target
def render(target)
target = ensure_pathname(target)
return unless should_render?(target)
write_atomically(target, render_to_string)
end
# Render template and return its content
#
# @return [String] Rendered content
def render_to_string
raise ArgumentError, "file not found in: #{source}" unless File.exist?(source)
template = File.read(source)
erb = ERB.new(template, trim_mode: '-') # A trim_mode of '-' allows omitting empty lines with <%- -%>
erb.location = source.to_s # define the file location so errors can point to the right file
erb.result(@context.context_bindings)
rescue GDK::ConfigSettings::UnsupportedConfiguration => e
GDK::Output.abort("#{e.message}.", e)
end
private
# Writes +contents+ to +target+ atomically by using +Tempfile+.
#
# To avoid copying across devices, tempfile needs to be created on the same
# filesystem or `File.rename` fails otherwise.
def write_atomically(target, contents)
target.dirname.mkpath
Tempfile.create(".#{target.basename}", target.dirname) do |temp|
temp.write(contents)
temp.rewind # flushes buffers
yield temp if block_given?
ensure
File.rename(temp, target)
end
end
# Compare and display changes between existing and newly rendered content
#
# @param [File] new_temporary_file
# @param [File] existing_file
def display_changes!(new_temporary_file, existing_file)
cmd = %W[git --no-pager diff --no-index #{git_color_args} -u #{existing_file} #{new_temporary_file}]
diff = Shellout.new(cmd).readlines[4..]
return unless diff
GDK::Output.puts
GDK::Output.info("'#{relative_path(existing_file)}' has incoming changes:")
diff_output = <<~DIFF_OUTPUT
-------------------------------------------------------------------------------------------------------------
#{diff.join("\n")}
-------------------------------------------------------------------------------------------------------------
DIFF_OUTPUT
GDK::Output.puts(diff_output, stderr: true)
end
def target_protected?(target)
GDK.config.config_file_protected?(relative_path(target))
end
def should_render?(target)
# if the target is _not_ protected, no need to check any further
return true unless target_protected?(target)
if File.exist?(target)
GDK::Output.warn("Changes to '#{relative_path(target)}' not applied because it's protected in gdk.yml.")
false
else
GDK::Output.warn("Creating missing protected file '#{relative_path(target)}'.")
true
end
end
def warn_overwritten!(backup)
GDK::Output.warn "'#{backup.relative_source_file}' has been overwritten. To recover the previous version, run:"
GDK::Output.puts <<~OVERWRITTEN
#{backup.recover_cmd_string}
If you want to protect this file from being overwritten, see:
https://gitlab.com/gitlab-org/gitlab-development-kit/-/blob/main/doc/configuration.md#overwriting-configuration-files
-------------------------------------------------------------------------------------------------------------
OVERWRITTEN
end
# Perform a backup of given file target
#
# @param [String] target file that will be back up
# @return [GDK::Backup]
def perform_backup!(target)
Backup.new(target).tap { |backup| backup.backup!(advise: false) }
end
def colors?
@colors ||= Shellout.new('tput colors').try_run.chomp.to_i >= 8
end
def git_color_args
if colors?
'--color'
else
'--no-color'
end
end
def relative_path(target)
return target unless target.absolute?
target.relative_path_from(GDK.root)
end
def ensure_pathname(path)
path.is_a?(Pathname) ? path : Pathname.new(path)
end
end
end
end