| #!/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 |