blob: 5594345df02336fa6f12b7740d57d710cbf199ee [file] [log] [blame]
#!/usr/bin/env ruby
# -*- encoding: utf-8 -*-
Signal.trap("INT") { exit 1 }
$stdout.sync = true
$stderr.sync = true
require "benchmark"
require "digest"
require "json"
require "optparse"
require "ostruct"
require "tempfile"
class Options
NAME = File.basename($0).freeze
def self.parse(args)
options = OpenStruct.new
options.templates = calculate_templates("*.json")
ENV['PACKER_CACHE_DIR'] = "packer_cache"
global = OptionParser.new do |opts|
opts.banner = "Usage: #{NAME} [SUBCOMMAND [options]]"
opts.separator ""
opts.separator <<-COMMANDS.gsub(/^ {8}/, "")
build : build one or more templates
help : prints this help message
list : list all templates in project
normalize : normalize one or more templates
COMMANDS
end
templates_argv_proc = proc { |options|
options.templates = calculate_templates(args) unless args.empty?
options.templates.each do |t|
if !File.exists?("#{t}.json")
$stderr.puts "File #{t}.json does not exist for template '#{t}'"
exit(1)
end
end
}
subcommand = {
help: {
parser: OptionParser.new {},
argv: proc { |options|
puts global
exit(0)
}
},
build: {
class: BuildRunner,
parser: OptionParser.new { |opts|
opts.banner = "Usage: #{NAME} build [options] TEMPLATE[ TEMPLATE ...]"
opts.on("-n", "--[no-]dry-run", "Dry run (what would happen)") do |opt|
options.dry_run = opt
end
opts.on("-d", "--[no-]debug", "Run packer with debug output") do |opt|
options.debug = opt
end
opts.on("-a", "--ask", "Run packer with on-error=ask") do |opt|
options.ask = opt
end
opts.on("-o BUILDS", "--only BUILDS", "Only build some Packer builds") do |opt|
options.builds = opt
end
opts.on("-e BUILDS", "--except BUILDS", "Build all Packer builds except these") do |opt|
options.except = opt
end
opts.on("-m MIRROR", "--mirror MIRROR", "Look for isos at MIRROR") do |opt|
options.mirror = opt
end
opts.on("-v VERSION", "--version VERSION", "Override the version set in the template") do |opt|
options.override_version = opt
end
},
argv: templates_argv_proc
},
normalize: {
class: NormalizeRunner,
parser: OptionParser.new { |opts|
opts.banner = "Usage: #{NAME} normalize TEMPLATE[ TEMPLATE ...]"
opts.on("-d", "--[no-]debug", "Run packer with debug output") do |opt|
options.debug = opt
end
},
argv: templates_argv_proc
},
list: {
class: ListRunner,
parser: OptionParser.new { |opts|
opts.banner = "Usage: #{NAME} list [TEMPLATE ...]"
},
argv: templates_argv_proc
}
}
global.order!
command = args.empty? ? :help : ARGV.shift.to_sym
subcommand.fetch(command).fetch(:parser).order!
subcommand.fetch(command).fetch(:argv).call(options)
options.command = command
options.klass = subcommand.fetch(command).fetch(:class)
options
end
def self.calculate_templates(globs)
Array(globs).
map { |glob| result = Dir.glob(glob); result.empty? ? glob : result }.
flatten.
sort.
delete_if { |file| file =~ /\.variables\./ }.
map { |template| template.sub(/\.json$/, '') }
end
end
module Common
def banner(msg)
puts "==> #{msg}"
end
def info(msg)
puts " #{msg}"
end
def warn(msg)
puts ">>> #{msg}"
end
def duration(total)
total = 0 if total.nil?
minutes = (total / 60).to_i
seconds = (total - (minutes * 60))
format("%dm%.2fs", minutes, seconds)
end
end
module PackerExec
def for_packer_run_with(template)
Tempfile.open("#{template}-metadata.json") do |md_file|
Tempfile.open("#{template}-metadata-var-file") do |var_file|
write_box_metadata(template, md_file)
write_var_file(template, md_file, var_file)
yield md_file, var_file
end
end
end
def write_box_metadata(template, io)
md = BuildMetadata.new(template, build_timestamp, override_version).read
io.write(JSON.pretty_generate(md))
io.close
end
def write_var_file(template, md_file, io)
md = BuildMetadata.new(template, build_timestamp, override_version).read
io.write(JSON.pretty_generate({
box_basename: md[:box_basename],
build_timestamp: md[:build_timestamp],
git_revision: md[:git_revision],
metadata: md_file.path,
version: md[:version]
}))
io.close
end
end
class BuildRunner
include Common
include PackerExec
attr_reader :templates, :dry_run, :debug, :ask, :builds, :except, :mirror, :override_version, :build_timestamp
def initialize(opts)
@templates = opts.templates
@dry_run = opts.dry_run
@debug = opts.debug
@ask = opts.ask
@builds = opts.builds
@except = opts.except
@mirror = opts.mirror
@override_version = opts.override_version
@build_timestamp = Time.now.gmtime.strftime("%Y%m%d%H%M%S")
end
def start
banner("Starting build for templates: #{templates}")
time = Benchmark.measure do
templates.each { |template| build(template) }
end
banner("Build finished in #{duration(time.real)}.")
end
private
def build(template)
for_packer_run_with(template) do |md_file, var_file|
cmd = packer_build_cmd(template, var_file.path)
banner("[#{template}] Building: '#{cmd.join(' ')}'")
time = Benchmark.measure do
system(*cmd) or raise "[#{template}] Error building, exited #{$?}"
write_final_metadata(template)
end
banner("[#{template}] Finished building in #{duration(time.real)}.")
end
end
def packer_build_cmd(template, var_file)
vars = "#{template}.variables.json"
headless = !(RUBY_PLATFORM =~ /darwin/)
cmd = %W[packer build -var-file=#{var_file} #{template}.json]
cmd.insert(2, "-var-file=#{vars}") if File.exist?(vars)
cmd.insert(2, "-only=#{builds}") if builds
cmd.insert(2, "-except=#{except}") if except
# Build the command line in the correct order and without spaces as future input for the splat operator.
cmd.insert(2, "mirror=#{mirror}") if mirror
cmd.insert(2, "-var") if mirror
cmd.insert(2, "headless=true") if headless
cmd.insert(2, "-var") if headless
cmd.insert(2, "-debug") if debug
cmd.insert(2, "-on-error=ask") if ask
cmd.insert(0, "echo") if dry_run
cmd
end
def write_final_metadata(template)
md = BuildMetadata.new(template, build_timestamp, override_version).read
path = File.join(File.dirname(__FILE__), "..", "builds")
filename = File.join(path, "#{md[:box_basename]}.metadata.json")
md[:providers] = ProviderMetadata.new(path, md[:box_basename]).read
if dry_run
banner("(Dry run) Metadata file contents would be something similar to:")
puts JSON.pretty_generate(md)
else
File.open(filename, "wb") { |file| file.write(JSON.pretty_generate(md)) }
end
end
end
class NormalizeRunner
include Common
include PackerExec
attr_reader :templates, :build_timestamp, :debug, :override_version
def initialize(opts)
@templates = opts.templates
@debug = opts.debug
@modified = []
@build_timestamp = Time.now.gmtime.strftime("%Y%m%d%H%M%S")
end
def start
banner("Normalizing for templates: #{templates}")
time = Benchmark.measure do
templates.each do |template|
validate(template)
fix(template)
end
end
if !@modified.empty?
info("")
info("The following templates were modified:")
@modified.sort.each { |template| info(" * #{template}")}
end
banner("Normalizing finished in #{duration(time.real)}.")
end
private
def checksum(file)
Digest::MD5.file(file).hexdigest
end
def fix(template)
file = "#{template}.json"
banner("[#{template}] Fixing")
original_checksum = checksum(file)
output = %x{packer fix #{file}}
raise "[#{template}] Error fixing, exited #{$?}" if $?.exitstatus != 0
# preserve ampersands in shell commands,
# see: https://github.com/mitchellh/packer/issues/784
output.gsub!("\\u0026", "&")
File.open(file, "wb") { |dest| dest.write(output) }
fixed_checksum = checksum(file)
if original_checksum == fixed_checksum
puts("No changes made.")
else
warn("Template #{template} has been modified.")
@modified << template
end
end
def packer_validate_cmd(template, var_file)
vars = "#{template}.variables.json"
cmd = %W[packer validate -var-file=#{var_file} #{template}.json]
cmd.insert(2, "-var-file=#{vars}") if File.exist?(vars)
cmd
end
def validate(template)
for_packer_run_with(template) do |md_file, var_file|
cmd = packer_validate_cmd(template, var_file.path)
banner("[#{template}] Validating: '#{cmd.join(' ')}'")
if debug
banner("[#{template}] DEBUG: var_file(#{var_file.path}) is:")
puts IO.read(var_file.path)
banner("[#{template}] DEBUG: md_file(#{md_file.path}) is:")
puts IO.read(md_file.path)
end
system(*cmd) or raise "[#{template}] Error validating, exited #{$?}"
end
end
end
class ListRunner
include Common
attr_reader :templates
def initialize(opts)
@templates = opts.templates
end
def start
templates.each { |template| puts template }
end
end
class Runner
attr_reader :options
def initialize(options)
@options = options
end
def start
options.klass.new(options).start
end
end
class BuildMetadata
def initialize(template, build_timestamp, override_version)
@template = template
@build_timestamp = build_timestamp
@override_version = override_version
end
def read
{
name: name,
version: version,
build_timestamp: build_timestamp,
git_revision: git_revision,
box_basename: box_basename,
template: template_vars.fetch("template", UNKNOWN),
}
end
private
UNKNOWN = "__unknown__".freeze
attr_reader :template, :build_timestamp, :override_version
def box_basename
"#{name.gsub("/", "__")}-#{version}.git.#{git_revision}"
end
def git_revision
sha = %x{git rev-parse HEAD}.strip
git_clean? ? sha : "#{sha}_dirty"
end
def git_clean?
%x{git status --porcelain}.strip.empty?
end
def merged_vars
@merged_vars ||= begin
if File.exist?("#{template}.variables.json")
template_vars.merge(JSON.load(IO.read("#{template}.variables.json")))
else
template_vars
end
end
end
def name
merged_vars.fetch("name", template)
end
def template_vars
@template_vars ||= JSON.load(IO.read("#{template}.json")).fetch("variables")
end
def version
if override_version
override_version
else
merged_vars.fetch("version", "#{UNKNOWN}.TIMESTAMP").
rpartition(".").first.concat(".#{build_timestamp}")
end
end
end
class ProviderMetadata
def initialize(path, box_basename)
@base = File.join(path, box_basename)
end
def read
Dir.glob("#{base}.*.box").map do |file|
{
name: provider_from_file(file),
file: "#{File.basename(file)}",
checksum_type: "sha256",
checksum: shasum(file)
}
end
end
private
attr_reader :base
def provider_from_file(file)
case provider = file.sub(/^.*\.([^.]+)\.box$/, '\1')
when /vmware/i then "vmware_desktop"
else provider
end
end
def shasum(file)
Digest::SHA256.file(file).hexdigest
end
end
begin
Runner.new(Options.parse(ARGV)).start
rescue => ex
$stderr.puts ">>> #{ex.message}"
exit(($? && $?.exitstatus) || 99)
end