blob: a72d9fed6ee5f2d8c015a17c879284f556dfc8d0 [file] [log] [blame]
// Copyright 2024 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package targets
import (
"context"
"encoding/hex"
"fmt"
"io"
"math/rand"
"os"
"path/filepath"
"strings"
"syscall"
"time"
"go.fuchsia.dev/fuchsia/tools/botanist"
"go.fuchsia.dev/fuchsia/tools/botanist/constants"
"go.fuchsia.dev/fuchsia/tools/build"
"go.fuchsia.dev/fuchsia/tools/lib/ffxutil"
"go.fuchsia.dev/fuchsia/tools/lib/logger"
"go.fuchsia.dev/fuchsia/tools/net/sshutil"
"github.com/creack/pty"
)
const (
// DefaultEmulatorNodename is the default nodename given to an emulator target.
DefaultEmulatorNodename = "botanist-target-emu"
// Minimum number of bytes of entropy bits required for the kernel's PRNG.
minEntropyBytes uint = 32 // 256 bits
// qemuSystemPrefix is the prefix of the QEMU binary name, which is of the
// form qemu-system-<QEMU arch suffix>.
qemuSystemPrefix = "qemu-system"
// aemuBinaryName is the name of the AEMU binary.
aemuBinaryName = "emulator"
)
type Target string
const (
TargetARM64 Target = "arm64"
TargetRISCV64 Target = "riscv64"
TargetX64 Target = "x64"
)
// qemuTargetMapping maps the Fuchsia target name to the name recognized by QEMU.
var qemuTargetMapping = map[Target]Target{
TargetX64: "x86_64",
TargetARM64: "aarch64",
TargetRISCV64: "riscv64",
}
// EmulatorConfig gives the common configuration for supported emulators.
type EmulatorConfig struct {
// Path is a path to a directory that contains the emulator executable.
Path string `json:"path"`
// Target is the target to emulate.
Target Target `json:"target"`
// Emulator describes flags to pass to `ffx emu start`.
Emulator build.EmulatorInfo `json:"emulator"`
// Serial gives whether to create a 'serial device' for the emulator instance.
// This option should be used judiciously, as it can slow the process down.
Serial bool `json:"serial"`
// EDK2Dir is a path to a directory of EDK II (UEFI) prebuilts.
EDK2Dir string `json:"edk2_dir"`
// Path to the fvm host tool.
FVMTool string `json:"fvm_tool"`
// Path to the zbi host tool.
ZBITool string `json:"zbi_tool"`
}
// Emulator represents a Fuchsia emulator target. The supported emulator types are
// QEMU, AEMU, and crosvm.
type Emulator struct {
*genericFuchsiaTarget
binary string
c chan error
config EmulatorConfig
mac [6]byte
opts Options
process *os.Process
ptm *os.File
serial io.ReadWriteCloser
}
var _ FuchsiaTarget = (*Emulator)(nil)
func getBinaryName(emuType string, target Target) (string, error) {
switch emuType {
case "aemu":
return aemuBinaryName, nil
case "qemu":
qemuTarget, ok := qemuTargetMapping[target]
if !ok {
return "", fmt.Errorf("invalid target %q", target)
}
return fmt.Sprintf("%s-%s", qemuSystemPrefix, qemuTarget), nil
case "crosvm":
return "crosvm", nil
default:
return "", fmt.Errorf("unknown emulator type found: %q", emuType)
}
}
// NewEmulator returns a new Emulator target with a given configuration.
func NewEmulator(ctx context.Context, config EmulatorConfig, opts Options, emuType string) (*Emulator, error) {
binary, err := getBinaryName(emuType, config.Target)
if err != nil {
return nil, err
}
t := &Emulator{
binary: binary,
c: make(chan error),
config: config,
opts: opts,
}
r := rand.New(rand.NewSource(time.Now().UnixNano()))
if _, err := r.Read(t.mac[:]); err != nil {
return nil, fmt.Errorf("failed to generate random MAC: %w", err)
}
// Ensure that the generated MAC address is unicast
// https://en.wikipedia.org/wiki/MAC_address#Unicast_vs._multicast_(I/G_bit)
t.mac[0] &= ^uint8(0x01)
if config.Serial {
// We can run the emulator 'in a terminal' by creating a pseudoterminal
// slave and attaching it as the process' std(in|out|err) streams.
// Running it in a terminal - and redirecting serial to stdio - allows
// us to use the associated pseudoterminal master as the 'serial device'
// for the instance.
var err error
// TODO(joshuaseaton): Figure out how to manage ownership so that this may
// be closed.
t.ptm, t.serial, err = pty.Open()
if err != nil {
return nil, fmt.Errorf("failed to create ptm/pts pair: %w", err)
}
}
base, err := newGenericFuchsia(ctx, DefaultEmulatorNodename, "", []string{opts.SSHKey}, t.serial)
if err != nil {
return nil, err
}
t.genericFuchsiaTarget = base
return t, nil
}
// Nodename returns the name of the target node.
func (t *Emulator) Nodename() string { return DefaultEmulatorNodename }
// Serial returns the serial device associated with the target for serial i/o.
func (t *Emulator) Serial() io.ReadWriteCloser {
return t.serial
}
// SSHKey returns the private SSH key path associated with a previously embedded authorized key.
func (t *Emulator) SSHKey() string {
return t.opts.SSHKey
}
// SSHClient creates and returns an SSH client connected to the emulator target.
func (t *Emulator) SSHClient() (*sshutil.Client, error) {
addr, err := t.IPv6()
if err != nil {
return nil, err
}
return t.sshClient(addr, "qemu")
}
// Start starts the emulator target.
func (t *Emulator) Start(ctx context.Context, args []string, pbPath string, isBootTest bool) (err error) {
if t.process != nil {
return fmt.Errorf("a process has already been started with PID %d", t.process.Pid)
}
if t.config.Path == "" {
return fmt.Errorf("directory must be set")
}
bin := filepath.Join(t.config.Path, t.binary)
absBin, err := normalizeFile(bin)
if err != nil {
return fmt.Errorf("could not find %s binary %q: %w", t.binary, bin, err)
}
if pbPath == "" {
return fmt.Errorf("missing product bundle")
}
allKernelArgs := []string{
// Manually set nodename, since MAC is randomly generated.
"zircon.nodename=" + t.nodename,
// The system will halt on a kernel panic instead of rebooting.
"kernel.halt-on-panic=true",
// Disable kernel lockup detector in emulated environments to prevent false alarms from
// potentially oversubscribed hosts.
"kernel.lockup-detector.critical-section-threshold-ms=0",
"kernel.lockup-detector.critical-section-fatal-threshold-ms=0",
"kernel.lockup-detector.heartbeat-period-ms=0",
"kernel.lockup-detector.heartbeat-age-threshold-ms=0",
"kernel.lockup-detector.heartbeat-age-fatal-threshold-ms=0",
}
// Add entropy to simulate bootloader entropy.
entropy := make([]byte, minEntropyBytes)
if _, err := rand.Read(entropy); err == nil {
allKernelArgs = append(allKernelArgs, "kernel.entropy-mixin="+hex.EncodeToString(entropy))
}
// Do not print colors.
allKernelArgs = append(allKernelArgs, "TERM=dumb")
if t.config.Target == TargetX64 {
// Necessary to redirect to stdout.
allKernelArgs = append(allKernelArgs, "kernel.serial=legacy")
}
// Add kernel args specified by the builder.
allKernelArgs = append(allKernelArgs, args...)
// Add kernel args specified by the shard environment.
allKernelArgs = append(allKernelArgs, t.config.Emulator.KernelArgs...)
cwd, err := os.Getwd()
if err != nil {
return err
}
edk2Dir := filepath.Join(t.config.EDK2Dir, "qemu-"+string(t.config.Target))
tools := ffxutil.EmuTools{
Emulator: absBin,
FVM: t.config.FVMTool,
ZBI: t.config.ZBITool,
UEFI_arm64: filepath.Join(edk2Dir, "QEMU_EFI.fd"),
UEFI_x64: filepath.Join(edk2Dir, "OVMF_CODE.fd"),
}
startArgs := ffxutil.EmuStartArgs{
Engine: strings.ToLower(os.Getenv("FUCHSIA_DEVICE_TYPE")),
ProductBundle: filepath.Join(cwd, pbPath),
KernelArgs: allKernelArgs,
Device: t.config.Emulator.Device,
Accel: t.config.Emulator.Accel,
}
if t.config.Emulator.Uefi {
startArgs.ExtraEmuArgs = []string{
"--uefi",
"--vbmeta-key",
t.config.Emulator.VbmetaKey,
"--vbmeta-key-metadata",
t.config.Emulator.VbmetaKeyMetadata,
}
// TODO(https://fxbug.dev/369416059): ffx emu --uefi calls mkfs-msdosfs which
// looks for the FUCHSIA_BUILD_DIR env var. Remove once this dependency is no
// longer needed.
if err := os.Setenv("FUCHSIA_BUILD_DIR", cwd); err != nil {
return err
}
}
cmd, err := t.ffx.EmuStartConsole(ctx, cwd, DefaultEmulatorNodename, tools, startArgs)
if err != nil {
return err
}
stdout, stderr, flush := botanist.NewStdioWriters(ctx, t.binary)
// Since serial is already printed to stdout, we can copy it to the
// serial logfile as well.
var serialLog *os.File
closeSerialLog := func() {
if serialLog == nil {
return
}
if err := serialLog.Close(); err != nil {
logger.Debugf(ctx, "failed to close %s", serialLog.Name())
}
}
// If t.config.Serial, the serial output will be captured through the CaptureSerialLog()
// function. Otherwise copy the emulator output.
if !t.config.Serial && t.opts.SerialLogDir != "" {
logfile := filepath.Join(t.opts.SerialLogDir, "serial_log.txt")
logfileAbsPath, err := filepath.Abs(logfile)
if err != nil {
return fmt.Errorf("cannot get absolute path for %q: %w", logfile, err)
}
if err := os.MkdirAll(filepath.Dir(logfileAbsPath), os.ModePerm); err != nil {
return fmt.Errorf("failed to make parent dirs of %q: %w", logfileAbsPath, err)
}
serialLog, err := os.Create(logfileAbsPath)
if err != nil {
return fmt.Errorf("failed to create %s", logfileAbsPath)
}
if err := os.Setenv(constants.SerialLogEnvKey, logfileAbsPath); err != nil {
logger.Debugf(ctx, "failed to set %s to %s", constants.SerialLogEnvKey, logfileAbsPath)
}
serialWriter := botanist.NewLineWriter(botanist.NewTimestampWriter(serialLog), "")
stdout = io.MultiWriter(stdout, serialWriter)
stderr = io.MultiWriter(stderr, serialWriter)
}
if t.ptm != nil {
cmd.Stdin = t.ptm
cmd.Stdout = io.MultiWriter(t.ptm, stdout)
cmd.Stderr = io.MultiWriter(t.ptm, stderr)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setctty: true,
Setsid: true,
}
} else {
cmd.Stdout = stdout
cmd.Stderr = stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
// Set a process group ID so we can kill the entire group, meaning
// the process and any of its children.
Setpgid: true,
}
}
logger.Debugf(ctx, "%s invocation:\n%s", t.binary, strings.Join(append([]string{cmd.Path}, cmd.Args...), " "))
if err := cmd.Start(); err != nil {
flush()
closeSerialLog()
return fmt.Errorf("failed to start: %w", err)
}
t.process = cmd.Process
go func() {
err := cmd.Wait()
flush()
closeSerialLog()
if err != nil {
err = fmt.Errorf("%s invocation error: %w", t.binary, err)
}
t.c <- err
}()
return nil
}
func (t *Emulator) CaptureSerialLog(filename string) error {
if !t.config.Serial {
// Don't do anything here. We'll copy the output from
// the emulator instead.
return nil
}
return t.genericFuchsiaTarget.CaptureSerialLog(filename)
}
// Stop stops the emulator target.
func (t *Emulator) Stop() error {
var err error
if err = t.ffx.EmuStopAll(context.Background()); err != nil {
logger.Debugf(t.targetCtx, "failed to stop emulator: %s", err)
if t.process == nil {
return fmt.Errorf("%s target has not yet been started", t.binary)
}
logger.Debugf(t.targetCtx, "Sending SIGKILL to %d", t.process.Pid)
err = t.process.Kill()
}
t.process = nil
t.genericFuchsiaTarget.Stop()
return err
}
// Wait waits for the QEMU target to stop.
func (t *Emulator) Wait(ctx context.Context) error {
select {
case err := <-t.c:
return err
case <-ctx.Done():
return ctx.Err()
}
}
// Config returns fields describing the target.
func (t *Emulator) TestConfig(expectsSSH bool) (any, error) {
return TargetInfo(t, expectsSSH, nil)
}
func normalizeFile(path string) (string, error) {
if _, err := os.Stat(path); err != nil {
return "", err
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", err
}
return absPath, nil
}