App-FuguVM

 view release on metacpan or  search on metacpan

lib/App/FuguVM/Guest.pm  view on Meta::CPAN

	# An installed system boots its own disk, whether it was freshly
	# installed or restored from the image cache. Such a system must
	# not fail here because the miniroot was pruned.
	my $image_path;

	if ( !$state->is_installed ) {
		$log->info("Checking OpenBSD image...");
		my $image =
		    App::FuguVM::Miniroot->new( $self->_cache_dir, $proxy );
		$image_path = $image->ensure( $config->{version} );

		if ( !defined $image_path ) {
			my $url = $image->url( $config->{version} );
			$log->error(
"Failed to download image for OpenBSD $config->{version}"
			);
			$log->error("URL: $url");
			$log->error("Try downloading manually: curl -fLO $url");
			return EXIT_ERROR;
		}

		$log->info("Using cached image: $image_path");
	}

	# Make sure that the disk exists
	my $disk_path = $state->disk_path;

	if ( !$state->disk_exists ) {
		$log->info("Creating disk image ($config->{disk_size})...");
		my $disk = App::FuguVM::Disk->new( $state->state_dir );
		my $result =
		    $disk->create( $config->{name}, $config->{disk_size} );
		if ( !defined $result ) {
			$log->error("Failed to create disk");
			return EXIT_ERROR;
		}
	}

	# Start the VM
	$log->info("Starting VM...");

	# Attach the install media only when the system is not installed
	my $boot_image = $state->is_installed ? undef : $image_path;
	my $pid        = $self->_start_qemu($boot_image);
	if ( !defined $pid ) {
		$log->error("Failed to start VM");
		return EXIT_ERROR;
	}

	$log->info("Started $config->{name} (PID: $pid)");

	# Install the system if necessary
	if ( !$state->is_installed ) {

		# The proxy already runs. The code above started it for the
		# image download. Use the VM-accessible URL for the
		# installation. The VM connects to the host through the
		# gateway.
		my $install_proxy_url = $proxy_vm_url // 'none';

		# Generate a strong random password for this installation
		my $root_password = Fugu::Random->random_password(32);
		$state->set_root_password($root_password);
		$log->info("Generated secure root password");

		$log->info("Installing OpenBSD...");
		my $expect = App::FuguVM::Console->new(
			host => '127.0.0.1',
			port => $config->{console_port},
		);

		# Use the generated password for the installation
		my $install_config = {
			%$config,
			root_password => $root_password,
			proxy_url     => $install_proxy_url,
		};
		my $ok = $expect->run_install($install_config);
		if ( !$ok ) {
			$log->error("Installation failed");
			return EXIT_ERROR;
		}

		$state->mark_installed;
		$log->info("Installation complete");

		# Stop the VM gracefully through QMP. The image cache
		# captures the disk at exactly this point: installed,
		# pristine, and without the per-checkout SSH key. Thus the
		# capture must know that QEMU is really gone. It must not
		# assume it.
		$log->info("Stopping installation VM...");
		$self->_qmp_quit;
		my $clean_exit = $self->_wait_exit(30);

		if ( !$clean_exit ) {
			$log->warning(
"Installation VM did not exit on request, force stopping"
			);
			$self->_force_stop;
			$self->_wait_exit(10);
		}
		$state->clear_vm_pid;

		# Publish the installed disk as a cached base image. A VM
		# that was force stopped can leave the disk mid-write. Thus
		# the code skips that capture and does not publish it.
		if ( defined $cache_key ) {
			if ($clean_exit) {
				$self->_cache_store( $cache, $cache_key,
					$root_password );
			}
			else {
				$log->warning(
"Skipping image cache: installation VM was force stopped"
				);
			}
		}

		# Restart the VM without the install media
		$log->info("Restarting installed system...");
		$pid = $self->_start_qemu;    # No boot image, no exit_on_halt
		if ( !defined $pid ) {
			$log->error("Failed to restart VM");
			return EXIT_ERROR;
		}
		$log->info("Started $config->{name} (PID: $pid)");

		# Install the SSH authorized key for future key-based
		# authentication
		return $self->_complete_ssh_setup;
	}

	# The VM is installed. Check if the SSH key must be installed or
	# updated.
	if ( $self->_needs_ssh_key_update ) {

		# The SSH key is not installed, or it changed in the
		# configuration. Use password authentication to wait for
		# SSH. Then install the key.
		return $self->_complete_ssh_setup;
	}

	# Wait for SSH. An installed VM uses key-based authentication.
	$log->info("Waiting for SSH...");
	if ( !$self->wait_ssh(120) ) {
		$log->error("Timeout waiting for SSH");
		return EXIT_TIMEOUT;
	}

	$log->info("VM ready");
	return EXIT_SUCCESS;
}

# $self->_image_cache:
#	Return the installed-image cache for this VM's configured
#	cache_dir. Return undef when caching is off. 'up --no-cache'
#	turns caching off for a single invocation. 'image_cache no'
#	turns it off in the configuration. Both stop restore and save
#	together. A half-cached run would leave an overlay with a base
#	that nothing published.
sub _image_cache ($self)
{
	return if $self->{no_cache};

	my $enabled = $self->{config}{image_cache};
	return if defined $enabled && !$enabled;

	return App::FuguVM::DiskCache->new( $self->_cache_dir );
}

# $self->_verify_backing_chain:
#	Make sure that the backing image of the working disk, if the
#	disk has one, is present. Return true when the chain resolves.
#	On a break, log the missing file and a remedy, and return
#	false. Thus a pruned or evicted cache entry fails with an
#	explanation, not with an opaque QEMU open error at boot.
sub _verify_backing_chain ($self)
{
	my $config = $self->{config};
	my $state  = $self->{state};
	my $log    = $self->{log};

	my $disk    = App::FuguVM::Disk->new( $state->state_dir );
	my $backing = $disk->backing_file( $config->{name} );
	return 1 if !defined $backing;
	return 1 if -f $backing;

	$log->error("Backing image missing: $backing");

	my $cache_dir = $self->_cache_dir;
	if ( index( $backing, "$cache_dir/" ) == 0 ) {
		$log->error(
"The image cache no longer holds this disk's base image."
		);
	}
	$log->error("Run 'fuguvm destroy' and 'fuguvm up' to rebuild the VM.");

	return 0;
}

# $self->_cache_restore($cache, $key):
#	Create the working disk as an overlay on a cached base image.
#	Seed the state that the installation would have written. Return
#	true on a cache hit. Return false on a miss or on any failure.
#	In both cases the caller then installs from scratch.
sub _cache_restore ( $self, $cache, $key )
{
	my $config = $self->{config};
	my $state  = $self->{state};
	my $log    = $self->{log};

	my $hit = $cache->lookup($key);
	if ( !defined $hit ) {
		$log->info("No cached image for $key, installing");
		return 0;
	}

	my $disk = App::FuguVM::Disk->new( $state->state_dir );
	my $path =
	    $disk->create( $config->{name}, undef, $hit->{base} );
	if ( !defined $path ) {
		$log->warning(
			"Cannot overlay cached image $key, installing instead");
		return 0;
	}

	# The base was captured from an installed system. Thus the state
	# that the installer would have written comes from the metadata
	# of the base. The later SSH key install authenticates with the
	# root password. That password is baked into the image.
	$state->mark_installed;
	my $password = $hit->{meta}{root_password};
	$state->set_root_password($password) if defined $password;
	$state->data->{cached_from} = $key;
	$state->save;

	$log->info("Using cached image $key");
	return 1;
}

# $self->_cache_store($cache, $key, $root_password):
#	Publish the freshly installed disk as a cached base image. Then
#	replace the working disk with an overlay on that image. The
#	operation is best effort. On any failure it keeps the
#	standalone disk in place and warns. 'up' must never fail
#	because caching failed.
sub _cache_store ( $self, $cache, $key, $root_password )
{
	my $config = $self->{config};
	my $state  = $self->{state};
	my $log    = $self->{log};

	$log->info("Caching installed image as $key...");

	my $base = $cache->store(
		$key,
		$state->disk_path,
		{
			root_password => $root_password,
			version       => $config->{version},
			disk_size     => $config->{disk_size},
		} );
	if ( !defined $base ) {
		$log->warning("Could not cache installed image, continuing");
		return 0;
	}

	if ( !$self->_reparent_disk($base) ) {
		$log->warning(
			"Cached image saved but disk left standalone: $base");
		return 0;
	}

	$log->info("Cached installed image: $base");
	return 1;
}

# $self->_reparent_disk($base):
#	Replace the working disk with a fresh overlay backed by $base.
#	The method moves the old disk aside and does not delete it.
#	Thus a failure to create the overlay cannot leave the VM
#	without a disk.
sub _reparent_disk ( $self, $base )
{
	my $config = $self->{config};
	my $state  = $self->{state};
	my $log    = $self->{log};

	my $disk_path = $state->disk_path;
	my $saved     = "$disk_path.replaced";

	unlink $saved if -f $saved;
	rename $disk_path, $saved or do {
		$log->warning("Cannot move $disk_path aside: $!");
		return 0;
	};

	# Disk::create returns early on an existing path. Thus the
	# rename above is what makes this call create the overlay.
	my $disk = App::FuguVM::Disk->new( $state->state_dir );
	my $path = $disk->create( $config->{name}, undef, $base );
	if ( !defined $path ) {
		rename $saved, $disk_path
		    or $log->error("Cannot restore $disk_path: $!");
		return 0;
	}

	unlink $saved or $log->warning("Cannot remove $saved: $!");
	return 1;
}

sub down ($self)
{
	# Stop the proxy if it runs
	if ( $self->_stop_proxy ) {
		$self->{log}->info("Proxy stopped");
	}

	return $self->stop;

lib/App/FuguVM/Guest.pm  view on Meta::CPAN

# $self->_stop_unclean:
#	Force-stop the VM and record the unclean shutdown, so the next
#	'up' checks the disk.
sub _stop_unclean ($self)
{
	my $state = $self->{state};

	$state->mark_unclean_shutdown;
	$self->_force_stop;
	$state->clear_vm_pid;
	$self->{log}->info("VM stopped");

	return EXIT_SUCCESS;
}

sub status ($self)
{
	my $state  = $self->{state};
	my $config = $self->{config};

	my $running = $self->_is_running;
	my $pid     = $state->get_vm_pid;

	# Query the QEMU status through QMP if the VM runs
	my $qemu_status;
	if ($running) {
		my $qmp = $self->_qmp_connect;
		if ($qmp) {
			my $status = $qmp->query_status;
			$qemu_status = $status->{status} if $status;
			$qmp->disconnect;
		}
	}

	return {
		name  => $config->{name},
		state => $running ? ( $qemu_status // 'running' ) : 'stopped',
		pid   => $pid,
		ssh_port     => $config->{ssh_port},
		console_port => $config->{console_port},
		installed    => $state->is_installed ? 1 : 0,
		disk_exists  => $state->disk_exists  ? 1 : 0,
	};
}

sub is_running ($self)
{
	return $self->_is_running;
}

sub ssh_port ($self)
{
	return $self->{config}{ssh_port};
}

sub console_port ($self)
{
	return $self->{config}{console_port};
}

# $self->wait_ssh($timeout, $password):
#	Wait for SSH to become available. Without a password, the
#	connection uses the SSH agent for authentication. The initial
#	installation gives the root password, because the SSH key is
#	not in yet.
sub wait_ssh ( $self, $timeout = 120, $password = undef )
{
	my $ssh = Fugu::SSH->new(
		host => '127.0.0.1',
		port => $self->{config}{ssh_port},
		user => 'root',
		( defined $password ? ( password => $password ) : () ),
	);

	return $ssh->wait_available($timeout);
}

# $self->_needs_ssh_key_update:
#	Check if the SSH key must be installed or updated. Return true
#	if no key is installed. Also return true if the configured key
#	differs from the installed key.
sub _needs_ssh_key_update ($self)
{
	my $config = $self->{config};
	my $state  = $self->{state};

	my $configured_key = $config->{ssh_pubkey};
	my $installed_key  = $state->get_installed_ssh_pubkey;

	# No key is configured. There is nothing to install.
	return 0 if !defined $configured_key || $configured_key eq '';

	# No key is installed yet
	return 1 if !defined $installed_key;

	# Compare the keys. Normalize the whitespace for the comparison.
	my $configured_normalized = $configured_key =~ s/\s+/ /gr;
	my $installed_normalized  = $installed_key  =~ s/\s+/ /gr;

	return $configured_normalized ne $installed_normalized;
}

# $self->_complete_ssh_setup():
#	Install or update the SSH key on the VM. The method
#	authenticates with the stored root password. It runs to recover
#	from a failed first boot, or when the configured SSH key
#	changed.
sub _complete_ssh_setup ($self)
{
	my $state  = $self->{state};
	my $config = $self->{config};
	my $log    = $self->{log};

	my $root_password = $state->get_root_password;
	if ( !defined $root_password ) {
		$log->error(
			"No root password stored - cannot complete SSH setup");
		return EXIT_ERROR;
	}

	$log->info("Updating SSH key...");

	# Wait for SSH with password authentication
	$log->info("Waiting for SSH...");
	if ( !$self->wait_ssh( 120, $root_password ) ) {
		$log->error("Timeout waiting for SSH");
		return EXIT_TIMEOUT;
	}

	# Install the SSH authorized key
	if ( !$self->_install_ssh_key($root_password) ) {
		$log->error("Failed to install SSH key");
		return EXIT_ERROR;
	}
	$log->info("SSH key installed");

	$log->info("VM ready");
	return EXIT_SUCCESS;
}

# $self->_install_ssh_key($password):
#	Install the SSH public key from the configuration into
#	authorized_keys. The method uses password authentication,
#	because the key is not yet installed.
sub _install_ssh_key ( $self, $password )
{
	my $config = $self->{config};
	my $state  = $self->{state};
	my $log    = $self->{log};

	# Get the SSH public key from the configuration
	my $ssh_pubkey = $config->{ssh_pubkey};
	if ( !defined $ssh_pubkey || $ssh_pubkey eq '' ) {
		$log->error("No ssh_pubkey configured in ~/.fuguvmrc");
		return 0;
	}

	# Connect with the password
	my $ssh = Fugu::SSH->new(
		host     => '127.0.0.1',
		port     => $config->{ssh_port},
		user     => 'root',
		password => $password,
	);

	# Create the .ssh directory
	my $result =
	    $ssh->run_command('mkdir -p /root/.ssh && chmod 700 /root/.ssh');
	if ( $result->{exit_code} != 0 ) {
		return 0;
	}

	# Write the authorized_keys file
	my $authkeys_content = $ssh_pubkey . "\n";
	if (
		$ssh->write_file(
			'/root/.ssh/authorized_keys', $authkeys_content,
			0600
		) != 0
	    )
	{
		return 0;
	}

	# Store the installed pubkey for a future comparison
	$state->mark_ssh_key_installed($ssh_pubkey);
	return 1;
}

# Graceful shutdown with a filesystem sync: sync through SSH, then
# power off through the ACPI power button, and report the result.
sub _graceful_shutdown ($self)
{
	my $config = $self->{config};
	my $log    = $self->{log};

	# Do a best-effort filesystem sync over SSH before the code
	# pulls the power. The sync has a hard time bound. A wedged
	# guest must never stall the shutdown. Also, libssh2 does not
	# reliably obey its own timeout on the connect and handshake. A
	# failure or a timeout here is acceptable. The ACPI powerdown
	# below runs the orderly shutdown of the guest, which syncs. A
	# force stop is the ultimate fallback.
	$self->_bounded(
		Fugu::SSH::DEFAULT_TIMEOUT() + 5,
		sub {
			my $ssh = Fugu::SSH->new(
				host => '127.0.0.1',
				port => $config->{ssh_port},
				user => 'root',
			);
			return $ssh->run_command('sync; sync; sync');
		} );

	# Ask the guest to power off through the ACPI power button. Then
	# wait.
	if ( $self->_qmp_powerdown && $self->_wait_exit(60) ) {
		$log->info("Shutdown via ACPI powerdown");
		return 1;
	}

	return 0;
}



( run in 1.627 second using v1.01-cache-2.11-cpan-4ef0a570458 )