Update cloud-init behavior and iso generation

This includes a couple modifications to the cloud-init behavior.
First, the cloud-init wait action will now write a sentinel file
after successfully waiting for cloud-init. This results in subsequent
boots of the machine to skip executing the cloud-init wait command
as cloud-init is only executed on the initial boot.

Second, the cloud-init setup action will check for the sentinel file
written by the cloud-init wait action, and if detected it will skip
the cloud-init setup. When creating the ISO for cloud-init, a second
sentinel file will be used to log the path of the generated ISO
file. If the file exists, the ISO generation process will be skipped.
This commit is contained in:
Chris Roberts 2025-05-13 17:02:32 -07:00
parent 9d25d2155e
commit f4fc2c742a
No known key found for this signature in database
11 changed files with 397 additions and 104 deletions

View file

@ -16,16 +16,38 @@ module Vagrant
end
def call(env)
machine = env[:machine]
catch(:complete) do
machine = env[:machine]
user_data_configs = machine.config.vm.cloud_init_configs
.select { |c| c.type == :user_data }
# The sentinel file in this check is written by the cloud init
# wait action and is only written after cloud init has completed.
@logger.info("Checking cloud-init sentinel file...")
sentinel_path = machine.data_dir.join("action_cloud_init")
if sentinel_path.file?
contents = sentinel_path.read.chomp
if machine.id.to_s == contents
if machine.config.vm.cloud_init_first_boot_only
@logger.info("Sentinel found for cloud-init, skipping")
throw :complete
else
@logger.info("Sentinel found for cloud-init but is configuration enabled")
end
else
@logger.debug("Found stale sentinel file, removing... (#{machine.id} != #{contents})")
end
sentinel_path.unlink
end
if !user_data_configs.empty?
user_data = setup_user_data(machine, env, user_data_configs)
meta_data = { "instance-id" => "i-#{machine.id.split('-').join}" }
user_data_configs = machine.config.vm.cloud_init_configs.select { |c|
c.type == :user_data
}
write_cfg_iso(machine, env, user_data, meta_data)
if !user_data_configs.empty?
user_data = setup_user_data(machine, env, user_data_configs)
meta_data = { "instance-id" => "i-#{machine.id.split('-').join}" }
write_cfg_iso(machine, env, user_data, meta_data)
end
end
# Continue On
@ -88,24 +110,43 @@ module Vagrant
# @param [Vagrant::Util::Mime::Multipart] user_data
# @param [Hash] meta_data
def write_cfg_iso(machine, env, user_data, meta_data)
iso_path = nil
raise Errors::CreateIsoHostCapNotFound if !env[:env].host.capability?(:create_iso)
iso_path = catch(:iso_path) do
# This iso sentinel file is used to store the path of the
# generated iso file and its checksum. If the file does
# not exist, or the actual checksum of the file does not
# match that stored in the sentinel file, it is ignored
# and the iso is generated. This is used to prevent multiple
# iso file from being created over time.
iso_sentinel = env[:machine].data_dir.join("action_cloud_init_iso")
if iso_sentinel.file?
checksum, path = iso_sentinel.read.chomp.split(":", 2)
if File.exist?(path) && Vagrant::Util::FileChecksum.new(path, :sha256).checksum == checksum
throw :iso_path, Pathname.new(path)
end
iso_sentinel.unlink
end
if env[:env].host.capability?(:create_iso)
begin
source_dir = Pathname.new(Dir.mktmpdir(TEMP_PREFIX))
File.open("#{source_dir}/user-data", 'w') { |file| file.write(user_data.to_s) }
File.open("#{source_dir}/meta-data", 'w') { |file| file.write(meta_data.to_yaml) }
iso_path = env[:env].host.capability(:create_iso,
source_dir, volume_id: "cidata")
attach_disk_config(machine, env, iso_path.to_path)
env[:env].host.capability(
:create_iso,
source_dir,
volume_id: "cidata"
).tap { |path|
checksum = Vagrant::Util::FileChecksum.new(path.to_path, :sha256).checksum
iso_sentinel.write("#{checksum}:#{path.to_path}")
}
ensure
FileUtils.remove_entry(source_dir)
end
else
raise Errors::CreateIsoHostCapNotFound
end
attach_disk_config(machine, env, iso_path.to_path)
end
# Adds a new :dvd disk config with the given iso_path to be attached

View file

@ -14,19 +14,37 @@ module Vagrant
end
def call(env)
machine = env[:machine]
cloud_init_wait_cmd = "cloud-init status --wait"
if !machine.config.vm.cloud_init_configs.empty?
if machine.communicate.test("command -v cloud-init")
env[:ui].output(I18n.t("vagrant.cloud_init_waiting"))
result = machine.communicate.sudo(cloud_init_wait_cmd, error_check: false)
if result != 0
raise Vagrant::Errors::CloudInitCommandFailed, cmd: cloud_init_wait_cmd, guest_name: machine.name
catch(:complete) do
machine = env[:machine]
sentinel_path = machine.data_dir.join("action_cloud_init")
@logger.info("Checking cloud-init sentinel file...")
if sentinel_path.file?
contents = sentinel_path.read.chomp
if machine.id.to_s == contents
@logger.info("Sentinel found for cloud-init, skipping")
throw :complete
end
else
raise Vagrant::Errors::CloudInitNotFound, guest_name: machine.name
@logger.debug("Found stale sentinel file, removing... (#{machine.id} != #{contents})")
sentinel_path.unlink
end
cloud_init_wait_cmd = "cloud-init status --wait"
if !machine.config.vm.cloud_init_configs.empty?
if machine.communicate.test("command -v cloud-init")
env[:ui].output(I18n.t("vagrant.cloud_init_waiting"))
result = machine.communicate.sudo(cloud_init_wait_cmd, error_check: false)
if result != 0
raise Vagrant::Errors::CloudInitCommandFailed, cmd: cloud_init_wait_cmd, guest_name: machine.name
end
else
raise Vagrant::Errors::CloudInitNotFound, guest_name: machine.name
end
end
# Write sentinel path
sentinel_path.write(machine.id.to_s)
end
@app.call(env)
end
end

View file

@ -27,7 +27,10 @@ module Vagrant
end
end
write_disk_metadata(machine, configured_disks) unless configured_disks.empty?
# Always write the disk metadata even if the configured
# disks is empty. This ensure that old entries are not
# orphaned in the metadata file.
write_disk_metadata(machine, configured_disks)
# Continue On
@app.call(env)

View file

@ -13,65 +13,74 @@ class DigestClass
def hexdigest; end
end
class FileChecksum
BUFFER_SIZE = 1024 * 8
module Vagrant
module Util
class FileChecksum
BUFFER_SIZE = 1024 * 8
# Supported file checksum
CHECKSUM_MAP = {
:md5 => Digest::MD5,
:sha1 => Digest::SHA1,
:sha256 => Digest::SHA256,
:sha384 => Digest::SHA384,
:sha512 => Digest::SHA512
}.freeze
# Supported file checksum
CHECKSUM_MAP = {
:md5 => Digest::MD5,
:sha1 => Digest::SHA1,
:sha256 => Digest::SHA256,
:sha384 => Digest::SHA384,
:sha512 => Digest::SHA512
}.freeze
# Initializes an object to calculate the checksum of a file. The given
# ``digest_klass`` should implement the ``DigestClass`` interface. Note
# that the built-in Ruby digest classes duck type this properly:
# Digest::MD5, Digest::SHA1, etc.
def initialize(path, digest_klass)
if digest_klass.is_a?(Class)
@digest_klass = digest_klass
else
@digest_klass = load_digest(digest_klass)
end
@path = path
end
# This calculates the checksum of the file and returns it as a
# string.
#
# @return [String]
def checksum
digest = @digest_klass.new
buf = ''
File.open(@path, "rb") do |f|
while !f.eof
begin
f.readpartial(BUFFER_SIZE, buf)
digest.update(buf)
rescue EOFError
# Although we check for EOF earlier, this seems to happen
# sometimes anyways [GH-2716].
break
# Initializes an object to calculate the checksum of a file. The given
# ``digest_klass`` should implement the ``DigestClass`` interface. Note
# that the built-in Ruby digest classes duck type this properly:
# Digest::MD5, Digest::SHA1, etc.
def initialize(path, digest_klass)
if digest_klass.is_a?(Class)
@digest_klass = digest_klass
else
@digest_klass = load_digest(digest_klass)
end
@path = path
end
# This calculates the checksum of the file and returns it as a
# string.
#
# @return [String]
def checksum
digest = @digest_klass.new
buf = ''
File.open(@path, "rb") do |f|
while !f.eof
begin
f.readpartial(BUFFER_SIZE, buf)
digest.update(buf)
rescue EOFError
# Although we check for EOF earlier, this seems to happen
# sometimes anyways [GH-2716].
break
end
end
end
digest.hexdigest
end
private
def load_digest(type)
digest = CHECKSUM_MAP[type.to_s.downcase.to_sym]
if digest.nil?
raise Vagrant::Errors::BoxChecksumInvalidType,
type: type.to_s,
types: CHECKSUM_MAP.keys.join(', ')
end
digest
end
end
digest.hexdigest
end
private
def load_digest(type)
digest = CHECKSUM_MAP[type.to_s.downcase.to_sym]
if digest.nil?
raise Vagrant::Errors::BoxChecksumInvalidType,
type: type.to_s,
types: CHECKSUM_MAP.keys.join(', ')
end
digest
end
end
# NOTE: This class was not originally namespaced
# with the Util module so this is left for backwards
# compatibility.
FileChecksum = Vagrant::Util::FileChecksum

View file

@ -70,7 +70,7 @@ module Vagrant
# @param [String] type of the entity content
def initialize(content, content_type)
if !MIME::Types.include?(content_type)
MIME::Types.add(MIME::Type.new(content_type))
MIME::Types.add(MIME::Type.new("content-type" => content_type))
end
@content = content
@content_type = MIME::Types[content_type].first

View file

@ -47,6 +47,7 @@ module VagrantPlugins
attr_accessor :box_download_insecure
attr_accessor :box_download_location_trusted
attr_accessor :box_download_options
attr_accessor :cloud_init_first_boot_only
attr_accessor :communicator
attr_accessor :graceful_halt_timeout
attr_accessor :guest
@ -87,6 +88,7 @@ module VagrantPlugins
@box_version = UNSET_VALUE
@allow_hosts_modification = UNSET_VALUE
@clone = UNSET_VALUE
@cloud_init_first_boot_only = UNSET_VALUE
@communicator = UNSET_VALUE
@graceful_halt_timeout = UNSET_VALUE
@guest = UNSET_VALUE
@ -536,6 +538,7 @@ module VagrantPlugins
@box_extra_download_options = Vagrant::Util::MapCommandOptions.map_to_command_options(@box_download_options)
@allow_hosts_modification = true if @allow_hosts_modification == UNSET_VALUE
@clone = nil if @clone == UNSET_VALUE
@cloud_init_first_boot_only = @cloud_init_first_boot_only == UNSET_VALUE ? true : !!@cloud_init_first_boot_only
@communicator = nil if @communicator == UNSET_VALUE
@graceful_halt_timeout = 60 if @graceful_halt_timeout == UNSET_VALUE
@guest = nil if @guest == UNSET_VALUE

View file

@ -82,22 +82,14 @@ module VagrantPlugins
b.use ForwardPorts
b.use SetHostname
b.use SaneDefaults
b.use Call, IsEnvSet, :cloud_init do |env, b2|
if env[:result]
b2.use CloudInitSetup
end
end
b.use CloudInitSetup
b.use CleanupDisks
b.use Disk
b.use Customize, "pre-boot"
b.use Boot
b.use Customize, "post-boot"
b.use WaitForCommunicator, [:starting, :running, :paused]
b.use Call, IsEnvSet, :cloud_init do |env, b2|
if env[:result]
b2.use CloudInitWait
end
end
b.use CloudInitWait
b.use Customize, "post-comm"
b.use CheckGuestAdditions
end
@ -424,7 +416,6 @@ module VagrantPlugins
end
end
b.use EnvSet, cloud_init: true
b.use action_start
end
end

View file

@ -6,7 +6,8 @@ require "vagrant/util/mime"
describe Vagrant::Action::Builtin::CloudInitSetup do
let(:app) { lambda { |env| } }
let(:vm) { double("vm", disk: disk, disks: disks) }
let(:vm) { double("vm", disk: disk, disks: disks, cloud_init_first_boot_only: first_boot_only) }
let(:first_boot_only) { true }
let(:disk) { double("disk") }
let(:disks) { double("disk") }
let(:config) { double("config", vm: vm) }
@ -59,10 +60,69 @@ describe Vagrant::Action::Builtin::CloudInitSetup do
expect(subject).not_to receive(:setup_user_data)
expect(subject).not_to receive(:write_cfg_iso)
expect(subject).not_to receive(:attack_disk_config)
expect(subject).not_to receive(:attach_disk_config)
subject.call(env)
end
context "sentinel file" do
let(:sentinel) { double("sentinel") }
let(:sentinel_exists) { false }
let(:sentinel_contents) { "" }
before do
allow(machine).to receive_message_chain(:data_dir, :join).with("action_cloud_init").and_return(sentinel)
allow(sentinel).to receive(:file?).and_return(sentinel_exists)
allow(sentinel).to receive(:read).and_return(sentinel_contents)
allow(sentinel).to receive(:unlink)
allow(vm).to receive(:cloud_init_configs).and_return(cloud_init_configs)
allow(subject).to receive(:setup_user_data)
allow(subject).to receive(:write_cfg_iso)
end
context "when file exists" do
let(:sentinel_exists) { true }
context "when file contains machine id" do
let(:sentinel_contents) { machine.id.to_s }
it "should not write iso configuration" do
expect(subject).not_to receive(:write_cfg_iso)
subject.call(env)
end
context "when configuration enables on all boots" do
let(:first_boot_only) { false }
it "should write the iso configuration" do
expect(subject).to receive(:write_cfg_iso)
subject.call(env)
end
it "should remove sentinel file" do
expect(sentinel).to receive(:unlink)
subject.call(env)
end
end
end
context "when file does not contain machine id" do
let(:sentinel_contents) { "unknown-id" }
it "should write iso configuration" do
expect(subject).to receive(:write_cfg_iso)
subject.call(env)
end
it "should remove sentinel file" do
expect(sentinel).to receive(:unlink)
subject.call(env)
end
end
end
end
end
describe "#setup_user_data" do
@ -117,9 +177,18 @@ describe Vagrant::Action::Builtin::CloudInitSetup do
let(:iso_path) { Pathname.new("fake/iso/path") }
let(:source_dir) { Pathname.new("fake/source/path") }
let(:meta_data_file) { double("meta_data_file") }
let(:sentinel) { double("sentinel") }
let(:sentinel_exists) { false }
let(:file_checksum) { double("file_checksum", checksum: checksum) }
let(:checksum) { "DUMMY-CHECKSUM-VALUE" }
before do
allow(meta_data_file).to receive(:write).and_return(true)
allow(machine).to receive_message_chain(:data_dir, :join).with("action_cloud_init_iso").and_return(sentinel)
allow(sentinel).to receive(:file?).and_return(sentinel_exists)
allow(sentinel).to receive(:write)
allow(Vagrant::Util::FileChecksum).to receive(:new).with(iso_path, :sha256).and_return(file_checksum)
allow(Vagrant::Util::FileChecksum).to receive(:new).with(iso_path.to_s, :sha256).and_return(file_checksum)
end
it "raises an error if the host capability is not supported" do
@ -140,8 +209,111 @@ describe Vagrant::Action::Builtin::CloudInitSetup do
expect(vm.disks).to receive(:each)
expect(meta_data).to receive(:to_yaml)
subject.write_cfg_iso(machine, env, message, meta_data)
end
context "sentinel file" do
let(:user_data) { double("user_data") }
let(:sentinel_contents) { "" }
before do
allow(sentinel).to receive(:read).and_return(sentinel_contents)
allow(sentinel).to receive(:unlink)
allow(host).to receive(:capability?).with(:create_iso).and_return(true)
allow(Dir).to receive(:mktmpdir).and_return(source_dir)
allow(File).to receive(:open).with("#{source_dir}/user-data", 'w').and_return(true)
allow(File).to receive(:open).with("#{source_dir}/meta-data", 'w').and_yield(meta_data_file)
allow(FileUtils).to receive(:remove_entry).with(source_dir).and_return(true)
allow(FileUtils).to receive(:remove_entry).with(source_dir).and_return(true)
allow(host).to receive(:capability).with(:create_iso, source_dir, volume_id: "cidata").and_return(iso_path)
allow(subject).to receive(:attach_disk_config)
end
context "when file exists" do
let(:sentinel_exists) { true }
context "when file contents is iso path" do
let(:sentinel_contents) { "#{checksum}:#{iso_path}" }
context "when file contents path exists" do
before do
expect(File).to receive(:exist?).with(iso_path.to_s).and_return(true)
end
it "should not create iso" do
expect(host).not_to receive(:capability)
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
it "should attach with the iso path" do
expect(subject).to receive(:attach_disk_config).with(machine, env, iso_path.to_path)
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
it "should not write the sentinel file" do
expect(sentinel).not_to receive(:write)
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
end
context "when file contents path does not exist" do
before do
expect(File).to receive(:exist?).with(iso_path.to_s).and_return(false)
end
it "should create iso" do
expect(host).to receive(:capability).with(:create_iso, source_dir, volume_id: "cidata").and_return(iso_path)
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
it "should remove the sentinel file" do
expect(sentinel).to receive(:unlink)
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
it "should write the sentinel file" do
expect(sentinel).to receive(:write).with("#{checksum}:#{iso_path}")
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
end
context "when file contents checksum does not match existing file checksum" do
let(:sentinel_contents) { "BAD-CHECKSUM-VALUE:#{iso_path}" }
it "should create iso" do
expect(host).to receive(:capability).with(:create_iso, source_dir, volume_id: "cidata").and_return(iso_path)
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
it "should remove the sentinel file" do
expect(sentinel).to receive(:unlink)
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
it "should write the sentinel file" do
expect(sentinel).to receive(:write).with("#{checksum}:#{iso_path}")
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
end
end
end
context "when file does not exist" do
let(:sentinel_exists) { false }
it "should create iso" do
expect(host).to receive(:capability).with(:create_iso, source_dir, volume_id: "cidata").and_return(iso_path)
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
it "should write the sentinel file" do
expect(sentinel).to receive(:write).with("#{checksum}:#{iso_path}")
subject.write_cfg_iso(machine, env, user_data, meta_data)
end
end
end
end
describe "#attach_disk_config" do

View file

@ -8,22 +8,30 @@ describe Vagrant::Action::Builtin::CloudInitWait do
let(:app) { lambda { |env| } }
let(:config) { double("config", :vm => vm) }
let(:comm) { double("comm") }
let(:machine) { double("machie", :config => config, :communicate => comm, :name => "test") }
let(:machine) { double("machine", :config => config, :communicate => comm, :name => "test", id: "m-id", data_dir: data_dir) }
let(:data_dir) { double("data_dir") }
let(:ui) { Vagrant::UI::Silent.new }
let(:env) { { machine: machine, ui: ui} }
let(:sentinel) { double("sentinel_path", unlink: nil) }
let(:subject) { described_class.new(app, env) }
describe "#call" do
let(:sentinel_exists) { false }
let(:sentinel_contents) { "" }
before do
allow(data_dir).to receive(:join).with("action_cloud_init").and_return(sentinel)
allow(sentinel).to receive(:file?).and_return(sentinel_exists)
allow(sentinel).to receive(:read).and_return(sentinel_contents)
allow(sentinel).to receive(:write).with(machine.id)
allow(comm).to receive(:test).with("command -v cloud-init").and_return(true)
allow(comm).to receive(:sudo).with("cloud-init status --wait", error_check: false).and_return(0)
end
context "cloud init configuration exists" do
let(:vm) { double("vm", cloud_init_configs: ["some config"]) }
before do
allow(comm).to receive(:test).with("command -v cloud-init").and_return(true)
end
it "waits for cloud init to be executed" do
expect(comm).to receive(:sudo).with("cloud-init status --wait", any_args).and_return(0)
subject.call(env)
@ -40,10 +48,51 @@ describe Vagrant::Action::Builtin::CloudInitWait do
expect { subject.call(env) }.
to raise_error(Vagrant::Errors::CloudInitCommandFailed)
end
context "when sentinel file exists" do
let(:sentinel_exists) { true }
context "when sentinel contents is machine id" do
let(:sentinel_contents) { machine.id.to_s }
it "should not test for cloud-init" do
expect(comm).not_to receive(:test).with(/cloud-init/)
subject.call(env)
end
it "should not run cloud-init" do
expect(comm).not_to receive(:sudo).with(/cloud-init/, anything)
subject.call(env)
end
it "should not write sentinel file" do
expect(sentinel).not_to receive(:write)
subject.call(env)
end
end
context "when sentinel content is not machine id" do
let(:sentinel_contents) { "unknown-id" }
it "should test for cloud-init" do
expect(comm).to receive(:test).with(/cloud-init/)
subject.call(env)
end
it "should run cloud-init" do
expect(comm).to receive(:sudo).with(/cloud-init/, anything)
subject.call(env)
end
it "should write sentinel file" do
expect(sentinel).to receive(:write).with(machine.id)
subject.call(env)
end
end
end
end
context "no cloud init configuration" do
let(:vm) { double("vm", cloud_init_configs: []) }
before do

View file

@ -34,14 +34,14 @@ describe Vagrant::Action::Builtin::Disk do
subject.call(env)
end
it "continues on if no disk config present" do
it "writes a disk_meta file if no disk config is present" do
allow(vm).to receive(:disks).and_return([])
subject = described_class.new(app, env)
expect(app).to receive(:call).with(env).ordered
expect(machine.provider).not_to receive(:capability).with(:configure_disks, disks)
expect(subject).not_to receive(:write_disk_metadata)
expect(subject).to receive(:write_disk_metadata)
subject.call(env)
end
@ -50,6 +50,7 @@ describe Vagrant::Action::Builtin::Disk do
allow(vm).to receive(:disks).and_return(disks)
allow(machine.provider).to receive(:capability?).with(:configure_disks).and_return(false)
subject = described_class.new(app, env)
allow(subject).to receive(:write_disk_metadata)
expect(app).to receive(:call).with(env).ordered
expect(machine.provider).not_to receive(:capability).with(:configure_disks, disks)

View file

@ -121,6 +121,12 @@ the name of the synced folder plugin.
- `config.vm.cloud_init` - Stores various [cloud_init](/vagrant/docs/cloud-init) configurations
on the machine.
- `config.vm.cloud_init_first_boot_only` - (boolean) - If true then the cloud-init
configuration will only be generated and attached on the first successful boot of
the machine. Subsequent boots of the machine will not generate the cloud-init
configuration and the `cloud-init wait` command will not be executed. Defaults
to `true`.
- `config.vm.communicator` (string) - The communicator type to use to connect to the
guest box. By default this is `"ssh"`, but should be changed to `"winrm"` for
Windows guests.