App-FuguVM

 view release on metacpan or  search on metacpan

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

# ex:ts=8 sw=4:
# $OpenBSD$
#
# Copyright (c) 2024 Dick Olsson <hi@senzilla.io>
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

use v5.36;

# App::FuguVM::Guest - the lifecycle of one OpenBSD guest.
#
# The module creates, starts, waits for, stops, and destroys one
# guest. It drives QEMU over QMP, so the lifecycle verbs report what
# the hypervisor says and not what a sleep guessed.

package App::FuguVM::Guest;
our $VERSION = '0.3.0';

use App::FuguVM::Arch;
use App::FuguVM::Autoinstall;
use App::FuguVM::Config;
use App::FuguVM::Miniroot;
use App::FuguVM::Mirror;
use App::FuguVM::DiskCache;
use App::FuguVM::Disk;
use App::FuguVM::Console;
use App::FuguVM::Proxy;
use App::FuguVM::QMP;
use App::FuguVM::State;

use Fcntl qw(:flock);
use Fugu::File;
use Fugu::Random;
use Fugu::Process;
use Fugu::SSH;
use Fugu::Timeout;

use constant {
	EXIT_SUCCESS       => 0,
	EXIT_ERROR         => 1,
	EXIT_CONFIG_ERROR  => 3,
	EXIT_VM_RUNNING    => 5,
	EXIT_TIMEOUT       => 7,
	EXIT_EXPECT_FAILED => 9,

	MEMORY_DEFAULT => '1G',
	CPU_COUNT      => 2,

	# The port lock closes the window between the probe and the
	# record of both ports. A probe is quick, so a long wait means
	# a wedged holder, and the probe then runs without the lock.
	PORT_LOCK_TIMEOUT => 30,

	# The bound on one 'qemu --version' run.
	QEMU_VERSION_TIMEOUT => 10,
};

sub new ( $class, %args )
{
	my $self = bless {
		config   => $args{config},
		state    => $args{state},
		log      => $args{log},
		emulate  => $args{emulate}  // 0,
		no_cache => $args{no_cache} // 0,
	}, $class;

	return $self;
}

# The operation is idempotent. It makes sure that the VM runs.
sub up ($self)
{
	my $config = $self->{config};
	my $state  = $self->{state};
	my $log    = $self->{log};

	# The QEMU binary of the architecture must be on PATH before
	# any other work starts.
	return EXIT_CONFIG_ERROR if !$self->_require_qemu;

	return EXIT_ERROR if !$self->_check_installed_arch;

	# Check if the VM already runs
	if ( $self->_is_running ) {

		# The VM runs, but the SSH key is not installed or is not
		# current. This occurs when the first boot failed, or when
		# the key changed in the configuration.
		if ( $state->is_installed && $self->_needs_ssh_key_update ) {
			return $self->_complete_ssh_setup;
		}

		$log->info("VM '$config->{name}' is already running");
		return EXIT_SUCCESS;
	}

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

		}
		$state->clear_vm_pid;

		# The runtime record stays: the restart below uses the
		# same ports, and a sibling that resolves ports in this
		# window must still skip them.

		# 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"
				);
			}
		}

		# The entry is published, or this run cannot publish it.
		# Either way a waiting sibling can proceed now.
		if ( defined $cache_lock ) {
			close $cache_lock;
			undef $cache_lock;
		}

		# 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. Outside the expect mode a guest can
		# carry no configured key: the image must trust the key
		# of the operator already, and the wait below proves it.
		if ( $mode eq 'expect' || $self->_needs_ssh_key_update ) {
			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 )

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

sub wait_ssh ( $self, $timeout = 120, $password = undef )
{
	my $ssh = Fugu::SSH->new(
		host => $self->connect_address,
		port => $self->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     => $self->connect_address,
		port     => $self->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 $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 => $self->connect_address,
				port => $self->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;
}

# $self->_bounded($seconds, $code):
#	Run $code under a hard wall-clock deadline, so a blocked guest
#	interaction cannot stall the caller. The guard itself is
#	Fugu::Timeout; this wrapper adds the log line.
sub _bounded ( $self, $seconds, $code )
{
	my $result = Fugu::Timeout::bounded( $seconds, $code );
	return $result if defined $result;

	$self->{log}->warning("Guest did not respond within ${seconds}s");
	return;
}

# $self->_ensure_proxy:
#	Start the caching proxy if it does not run, and return it. The
#	method returns undef when the proxy cannot start.
#
#	The lifecycle lives here and not in App::FuguVM::State, because a
#	state file must not start a process. That split is what removed
#	the require cycle between the two modules.
sub _ensure_proxy ($self)
{
	my $proxy = $self->_proxy;
	return $proxy if $proxy->is_running;

	# The spawn passes a fixed argument list, so the distfile cap
	# reaches the child through the environment.
	$ENV{FUGUVM_DISTFILE_LIMIT} = $self->{config}{distfile_cache} // 0;

	unless ( defined $proxy->start ) {
		$self->{log}->warning(
			'Proxy did not start: %s',
			$proxy->error // 'unknown'
		);
		return;
	}

	return $proxy;
}

# $self->_stop_proxy:
#	Stop the proxy if it runs. The method returns 1 when it stopped
#	one, and 0 when there was none.
sub _stop_proxy ($self)
{
	my $proxy = $self->_proxy;
	return 0 unless $proxy->is_running;

	$proxy->stop;

	return 1;
}

# $self->_proxy:
#	Build the proxy supervisor over this VM's state.
sub _proxy ($self)
{
	my $state = $self->{state};

	return App::FuguVM::Proxy->new(
		cache => App::FuguVM::Proxy::Cache->new(
			$self->_cache_dir, $self->{config}{distfile_cache} // 0
		),
		pidfile => $state->proxy_pidfile,
		store   => $state->store,
		logfile => $state->vm_state_dir . '/proxy.log',
		log     => $self->{log},

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

	# accelerator by the host capability.
	push @cmd, '-M', $arch->machine;
	push @cmd, $self->_accel_args;

	# Memory and CPU
	push @cmd, '-m',   $config->{memory} // MEMORY_DEFAULT;
	push @cmd, '-smp', CPU_COUNT;

	# EFI firmware of the architecture. Both machines boot through
	# EFI, so a start without a firmware file cannot work. Fail
	# with a message rather than boot into the wrong firmware.
	my $firmware = $self->_find_efi_firmware;
	if ( !defined $firmware ) {
		$self->{log}->error(
			sprintf( "No EFI firmware for %s guests found",
				$self->_arch->name ) );
		return;
	}
	my @args = $self->_firmware_args($firmware);
	return unless @args;
	push @cmd, @args;

	# The main disk with the safe cache mode. The writethrough mode
	# syncs on each write.
	my $disk_path = $state->disk_path;
	push @cmd, '-drive',
	    "file=$disk_path,format=qcow2,if=virtio,cache=writethrough";

	# Boot image (CD-ROM) for installation
	push @cmd, $self->_media_args($boot_image);

	# Network with port forwarding, and the serial console on
	# telnet
	my $console_port = $state->get_runtime->{console_port};
	push @cmd, $self->_network_args;
	push @cmd, $self->_serial_args;

	# QMP control socket
	my $qmp_path = $self->_qmp_socket_path;
	unlink $qmp_path if -S $qmp_path;
	push @cmd, '-qmp', "unix:$qmp_path,server,nowait";

	# PID file for reliable tracking
	push @cmd, '-pidfile', $state->vm_pidfile->path;

	# No graphics display (headless)
	push @cmd, '-display', 'none';

	# Use Fugu::Process to spawn QEMU
	my $log_file = $state->vm_state_dir . '/qemu.log';
	my $result   = Fugu::Process->spawn_command(
		cmd       => \@cmd,
		daemonize => 1,
		stdout    => $log_file,
		stderr    => $log_file,
	);

	return unless $result->{success};

	# Wait until QEMU writes the PID file
	my $pid = Fugu::Timeout::wait_until(
		5, 0.1,
		sub {
			my $qemu_pid = $state->get_vm_pid;
			return $qemu_pid
			    if defined $qemu_pid
			    && Fugu::Process->is_alive($qemu_pid);
			return;
		} );

	unless ( defined $pid ) {
		$self->_dump_qemu_log($log_file);
		return;
	}

	# Arm the crash detection: was_unclean_shutdown reports true
	# when the state says running and the process is gone.
	$state->mark_running;

	# Make sure that QEMU accepts console connections before the
	# installer tries to attach. A QEMU that exited at startup, for
	# example with a bad accelerator or missing firmware, leaves the
	# port closed. This check fails fast with the QEMU log, not with
	# a long telnet timeout later.
	if ( defined $boot_image
		&& !$self->_wait_console_ready( $console_port, 30 ) )
	{
		$self->{log}
		    ->error( 'QEMU console port %d not listening after start',
			$console_port );
		$self->_dump_qemu_log($log_file);
		return;
	}

	return $pid;
}

# $self->_media_args($boot_image):
#	Return the QEMU arguments of the install media, or an empty
#	list when no media is attached. An autoinstall reboots
#	itself, and the miniroot is still attached. -no-reboot makes
#	the reboot an exit, so the guest cannot install a second
#	time.
sub _media_args ( $self, $boot_image = undef )
{
	return () if !defined $boot_image;

	my @args =
	    ( '-drive', "file=$boot_image,format=raw,if=virtio,readonly=on" );
	push @args, '-no-reboot'
	    if ( $self->{config}{install_mode} // '' ) eq 'autoinstall';

	return @args;
}

# $self->_network_args:
#	Return the QEMU network arguments. The ports come from the
#	record that _resolve_ports wrote before the spawn: the public
#	accessors serve a running guest only, and QEMU does not run
#	yet. The forwarded port binds to the configured host address,
#	and the default is loopback.
sub _network_args ($self)
{
	my $bind_address = $self->bind_address;
	my $ssh_port     = $self->{state}->get_runtime->{ssh_port};

	return ( '-device', 'virtio-net-pci,netdev=net0', '-netdev',
		"user,id=net0,hostfwd=tcp:$bind_address:" . "$ssh_port-:22",
	);
}

# $self->_serial_args:
#	Return the QEMU serial-console arguments: a telnet listener on
#	the bind address and the recorded console port.
sub _serial_args ($self)
{
	my $bind_address = $self->bind_address;
	my $console_port = $self->{state}->get_runtime->{console_port};

	return ( '-serial',
		"tcp:$bind_address:$console_port,server,telnet,nowait" );
}

# $self->_wait_console_ready($port, $timeout):
#	Poll the console TCP port until it accepts a connection. Thus
#	the telnet of the installer attaches to a live console. QEMU
#	binds the port at startup, before the guest boots. Thus the
#	poll is quick when QEMU is healthy, and bounded when it is not.
sub _wait_console_ready ( $self, $port, $timeout )
{
	require IO::Socket::INET;

	my $ready = Fugu::Timeout::wait_until(
		$timeout, 0.2,
		sub {
			my $sock = IO::Socket::INET->new(
				PeerAddr => $self->connect_address,
				PeerPort => $port,
				Proto    => 'tcp',
				Timeout  => 2,
			);
			if ( defined $sock ) {
				$sock->close;
				return 'ready';
			}

			# Stop the wait early if QEMU already exited
			my $qemu_pid = $self->{state}->get_vm_pid;
			return 'gone'
			    if defined $qemu_pid
			    && !Fugu::Process->is_alive($qemu_pid);

			return;
		} );

	return defined $ready && $ready eq 'ready' ? 1 : 0;
}

# $self->_dump_qemu_log($log_file):
#	Show the tail of the QEMU log. Thus a startup failure is
#	visible in the CI output, and shell access to the runner is not
#	necessary.
sub _dump_qemu_log ( $self, $log_file )
{
	open my $fh, '<', $log_file or return;
	my @lines = <$fh>;
	close $fh;

	@lines = splice( @lines, -40 ) if @lines > 40;
	$self->{log}->error('QEMU log tail:');
	$self->{log}->error( '  %s', $_ ) for map { chomp; $_ } @lines;

	return;
}

# $self->_accel_args():
#	Return the accelerator arguments, with the matching CPU model.
#	Host CPU passthrough is only valid with hardware acceleration.
#	TCG needs the named model of the architecture. The choice
#	itself comes from accel.
sub _accel_args ($self)
{
	my $arch  = $self->_arch;
	my $accel = $self->accel;

	$self->{log}->debug("Using QEMU accelerator: $accel")
	    if $self->{log};

	return ( '-accel', $accel,
		'-cpu', $accel eq 'tcg' ? $arch->tcg_cpu : 'host' );
}

# $self->_arch:
#	Return the App::FuguVM::Arch object of the configured value,
#	and cache it. The configuration loader is the boundary of the
#	directive, so an unknown value here is a programming error.
sub _arch ($self)
{
	my $name = $self->{config}{arch};

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

			Listen    => 1,
		);
		next if !defined $sock;

		$sock->close;
		return $port;
	}

	return;
}

# $self->_taken_ports:
#	Return the recorded ports of every guest of the project, as a
#	hash reference keyed by port. The method enumerates the state
#	directory, like App::FuguVM::CLI::_disks_backed_by: a record
#	counts whether a 'vm' block still declares its guest or not.
sub _taken_ports ($self)
{
	my %taken;

	my $state_dir = $self->{state}->state_dir;
	return \%taken if !-d $state_dir;

	opendir my $dh, $state_dir or return \%taken;
	my @names = sort grep { !/^\./ && -d "$state_dir/$_" } readdir $dh;
	closedir $dh;

	for my $name (@names) {
		my $sibling = App::FuguVM::State->new( $state_dir, $name )
		    or next;
		my $runtime = $sibling->get_runtime;
		for my $directive (qw(ssh_port console_port)) {
			my $port = $runtime->{$directive};
			$taken{$port} = 1 if defined $port;
		}
	}

	return \%taken;
}

# $self->_lock_ports:
#	Return the locked handle of ports.lock, or undef on the
#	deadline. The lock file lives in the cache directory, so every
#	project that shares that directory probes one port at a time.
#	The record exclusion of _taken_ports covers this project only.
#	A collision with an other project surfaces when QEMU binds the
#	port, as a reported startup failure. The caller probes without
#	the lock when the deadline elapses: a wedged holder must not
#	fail a run.
sub _lock_ports ($self)
{
	my $dir = $self->_cache_dir;
	Fugu::File->ensure_dir($dir) or return;

	my $path = "$dir/ports.lock";
	open my $fh, '>>', $path or do {
		$self->{log}->warning("Cannot open $path: $!");
		return;
	};

	my $locked = Fugu::Timeout::bounded( PORT_LOCK_TIMEOUT,
		sub { flock $fh, LOCK_EX } );
	return $fh if $locked;

	close $fh;
	$self->{log}->warning("Port lock not acquired, probing without it");

	return;
}

# $self->_check_installed_arch:
#	Report if the configured architecture matches the installed
#	disk, once for up and start. A disk belongs to one
#	architecture, so a changed directive must not start the wrong
#	QEMU on an existing disk. An absent record cannot prove a
#	difference, so the check passes.
sub _check_installed_arch ($self)
{
	my $config = $self->{config};

	my $installed = $self->{state}->get_installed_arch;
	return 1 if !defined $installed || $installed eq $config->{arch};

	$self->{log}->error(
"The disk of '$config->{name}' holds an $installed installation, not $config->{arch}"
	);
	$self->{log}->error("Run 'fuguvm destroy' to rebuild the VM.");

	return 0;
}

# _host_arch():
#	Return the host machine architecture from uname.
sub _host_arch ()
{
	require POSIX;
	my @uname = POSIX::uname();
	return $uname[4] // '';
}

# $self->_find_efi_firmware:
#	Return the firmware of the architecture as { code, vars }, or
#	undef. The vars entry is the variable-store template beside
#	the code file. It is undef when -bios boots the code file
#	alone. A code file without its template is not usable, so the
#	search walks on.
sub _find_efi_firmware ($self)
{
	my $arch = $self->_arch;

	my @candidates = $arch->firmware_paths;
	push @candidates, glob( $arch->firmware_glob );

	for my $code (@candidates) {
		next unless -f $code;

		my $vars = $arch->firmware_vars_path($code);
		next if defined $vars && !-f $vars;

		return { code => $code, vars => $vars };
	}



( run in 1.583 second using v1.01-cache-2.11-cpan-85d3896f969 )