github.com/nya3jp/tast@v0.0.0-20230601000426-85c8e4d83a9b/src/go.chromium.org/tast/core/dut/dut.go (about)

     1  // Copyright 2023 The ChromiumOS Authors
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  // Package dut provides a connection to a DUT ("Device Under Test")
     6  // for use by remote tests.
     7  package dut
     8  
     9  import (
    10  	"context"
    11  	"fmt"
    12  	"strings"
    13  	"time"
    14  
    15  	"go.chromium.org/tast/core/errors"
    16  	"go.chromium.org/tast/core/ssh"
    17  
    18  	"go.chromium.org/tast/core/internal/linuxssh"
    19  	"go.chromium.org/tast/core/internal/logging"
    20  	"go.chromium.org/tast/core/internal/testingutil"
    21  )
    22  
    23  const (
    24  	pingTimeout    = time.Second
    25  	pingRetryDelay = time.Second
    26  
    27  	connectTimeout      = 10 * time.Second
    28  	reconnectRetryDelay = time.Second
    29  )
    30  
    31  // DUT represents a "Device Under Test" against which remote tests are run.
    32  type DUT struct {
    33  	sopt         ssh.Options
    34  	hst          *ssh.Conn
    35  	beforeReboot func(context.Context, *DUT) error
    36  }
    37  
    38  // New returns a new DUT usable for communication with target
    39  // (of the form "[<user>@]host[:<port>]") using the SSH key at keyFile or
    40  // keys located in keyDir.
    41  // The DUT does not start out in a connected state; Connect must be called.
    42  func New(target, keyFile, keyDir, proxyCommand string, beforeReboot func(context.Context, *DUT) error) (*DUT, error) {
    43  	d := DUT{beforeReboot: beforeReboot}
    44  	if err := ssh.ParseTarget(target, &d.sopt); err != nil {
    45  		return nil, err
    46  	}
    47  	d.sopt.ConnectTimeout = connectTimeout
    48  	d.sopt.KeyFile = keyFile
    49  	d.sopt.KeyDir = keyDir
    50  	d.sopt.ProxyCommand = proxyCommand
    51  
    52  	return &d, nil
    53  }
    54  
    55  // Conn returns the connection to the DUT, or nil if there is no connection.
    56  // The ownership of the connection is managed by *DUT, so don't call Close for
    57  // the connection. To disconnect, call DUT.Disconnect.
    58  // Storing the returned value into a variable is not recommended, because
    59  // after reconnection (e.g. by Reboot), the instance previous Conn returned
    60  // is stale. Always call Conn to get the present connection.
    61  //
    62  // Examples:
    63  //
    64  //	linuxssh.GetFile(ctx, d.Conn(), src, dst, linuxssh.PreserveSymlinks)
    65  //	d.Conn().CommandContext("uptime")
    66  func (d *DUT) Conn() *ssh.Conn {
    67  	if d == nil {
    68  		return nil
    69  	}
    70  	return d.hst
    71  }
    72  
    73  // Close releases the DUT's resources.
    74  func (d *DUT) Close(ctx context.Context) error {
    75  	if d == nil {
    76  		return nil
    77  	}
    78  	return d.Disconnect(ctx)
    79  }
    80  
    81  // Health checks the connection status with the DUT.
    82  func (d *DUT) Health(ctx context.Context) error {
    83  	if d == nil || d.hst == nil {
    84  		return errors.New("ssh.Conn or DUT does not exist")
    85  	}
    86  
    87  	if err := d.hst.Ping(ctx, pingTimeout); err != nil {
    88  		return errors.Wrapf(err, "failed to ping %v", d.sopt.Hostname)
    89  	}
    90  	return nil
    91  }
    92  
    93  // Connected returns true if a usable connection to the DUT is held.
    94  func (d *DUT) Connected(ctx context.Context) bool {
    95  	if d == nil || d.hst == nil {
    96  		return false
    97  	}
    98  	if err := d.hst.Ping(ctx, pingTimeout); err != nil {
    99  		return false
   100  	}
   101  	return true
   102  }
   103  
   104  // Connect establishes a connection to the DUT. If a connection already
   105  // exists, it is closed first.
   106  func (d *DUT) Connect(ctx context.Context) error {
   107  	if d == nil {
   108  		return nil
   109  	}
   110  	d.Disconnect(ctx)
   111  
   112  	var err error
   113  	d.hst, err = ssh.New(ctx, &d.sopt)
   114  	if err != nil {
   115  		return err
   116  	}
   117  	logging.Info(ctx, "Opened DUT SSH connection to ", d.sopt.Hostname)
   118  	return nil
   119  }
   120  
   121  // Disconnect closes the current connection to the DUT. It is a no-op if
   122  // no connection is currently established.
   123  func (d *DUT) Disconnect(ctx context.Context) error {
   124  	if d == nil || d.hst == nil {
   125  		return nil
   126  	}
   127  	defer func() { d.hst = nil }()
   128  	logging.Info(ctx, "Closing DUT SSH connection to ", d.sopt.Hostname)
   129  	return d.hst.Close(ctx)
   130  }
   131  
   132  // GetFile copies a file or directory from the DUT to the local machine.
   133  // dst is the full destination name for the file or directory being copied, not
   134  // a destination directory into which it will be copied. dst will be replaced
   135  // if it already exists.
   136  //
   137  // DEPRECATED: use linuxssh.GetFile(ctx, d.Conn(), src, dst, linuxssh.PreserveSymlinks)
   138  func (d *DUT) GetFile(ctx context.Context, src, dst string) error {
   139  	if d == nil {
   140  		return nil
   141  	}
   142  	return linuxssh.GetFile(ctx, d.hst, src, dst, linuxssh.PreserveSymlinks)
   143  }
   144  
   145  // WaitUnreachable waits for the DUT to become unreachable.
   146  func (d *DUT) WaitUnreachable(ctx context.Context) error {
   147  	startTime := time.Now()
   148  	if d == nil {
   149  		return nil
   150  	}
   151  	if d.hst == nil {
   152  		deadline, ok := ctx.Deadline()
   153  		if ok && deadline.Before(time.Now().Add(d.sopt.ConnectTimeout)) {
   154  			// There isn't enough time to connect
   155  			return errors.Errorf("context timeout too short, need at least %s, got %s", d.sopt.ConnectTimeout, deadline.Sub(time.Now()))
   156  		}
   157  		if err := d.Connect(ctx); err != nil {
   158  			// Return the context's error instead of the one returned by Connect:
   159  			// we should return an error if the context's deadline expired,
   160  			// while returning nil if only Connect returned an error.
   161  			return ctx.Err()
   162  		}
   163  	}
   164  
   165  	logging.Infof(ctx, "Waiting for %s to be unreachable.", d.sopt.Hostname)
   166  	for {
   167  		deadline, ok := ctx.Deadline()
   168  		if ok && deadline.Before(time.Now().Add(pingTimeout)) {
   169  			// There isn't enough time to ping again.
   170  			return errors.Errorf("DUT still reachable after %s", time.Now().Sub(startTime))
   171  		}
   172  		if err := d.hst.Ping(ctx, pingTimeout); err != nil {
   173  			// Return the context's error instead of the one returned by Ping:
   174  			// we should return an error if the context's deadline expired,
   175  			// while returning nil if only Ping returned an error.
   176  			return ctx.Err()
   177  		}
   178  
   179  		select {
   180  		case <-time.After(pingRetryDelay):
   181  			break
   182  		case <-ctx.Done():
   183  			return ctx.Err()
   184  		}
   185  	}
   186  }
   187  
   188  // WaitConnect connects to the DUT, waiting for it to become reachable.
   189  // If a connection already exists, it is closed first.
   190  func (d *DUT) WaitConnect(ctx context.Context) error {
   191  	logging.Infof(ctx, "Waiting for %s to connect.", d.sopt.Hostname)
   192  	for {
   193  		err := d.Connect(ctx)
   194  		if err == nil {
   195  			return nil
   196  		}
   197  
   198  		select {
   199  		case <-time.After(reconnectRetryDelay):
   200  			break
   201  		case <-ctx.Done():
   202  			if err.Error() == ctx.Err().Error() {
   203  				return err
   204  			}
   205  			return fmt.Errorf("%v (%v)", ctx.Err(), err)
   206  		}
   207  	}
   208  }
   209  
   210  // Reboot reboots the DUT.
   211  func (d *DUT) Reboot(ctx context.Context) error {
   212  	if d.beforeReboot != nil {
   213  		if err := d.beforeReboot(ctx, d); err != nil {
   214  			return errors.Wrap(err, "failed while running pre-reboot function")
   215  		}
   216  	}
   217  	readBootID := func(ctx context.Context) (string, error) {
   218  		out, err := d.Conn().CommandContext(ctx, "cat", "/proc/sys/kernel/random/boot_id").Output()
   219  		if err != nil {
   220  			return "", err
   221  		}
   222  		return strings.TrimSpace(string(out)), nil
   223  	}
   224  
   225  	initID, err := readBootID(ctx)
   226  	if err != nil {
   227  		return errors.Wrap(err, "failed to read initial boot_id")
   228  	}
   229  
   230  	// Run the reboot command with a short timeout. This command can block for long time
   231  	// if the network interface of the DUT goes down before the SSH command finishes.
   232  	rebootCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
   233  	defer cancel()
   234  	d.Conn().CommandContext(rebootCtx, "reboot").Run() // ignore the error
   235  
   236  	if err := testingutil.Poll(ctx, func(ctx context.Context) error {
   237  		// Set a short timeout to the iteration in case of any SSH operations
   238  		// blocking for long time. For example, the network interface of the DUT
   239  		// might go down in the middle of readBootID and it might block for
   240  		// long time.
   241  		ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
   242  		defer cancel()
   243  		if err := d.WaitConnect(ctx); err != nil {
   244  			return errors.Wrap(err, "failed to connect to DUT")
   245  		}
   246  		curID, err := readBootID(ctx)
   247  		if err != nil {
   248  			return errors.Wrap(err, "failed to read boot_id")
   249  		}
   250  		if curID == initID {
   251  			return errors.New("boot_id did not change")
   252  		}
   253  		return nil
   254  	}, &testingutil.PollOptions{Timeout: 4 * time.Minute}); err != nil {
   255  		return errors.Wrap(err, "failed to wait for DUT to reboot")
   256  	}
   257  	return nil
   258  }
   259  
   260  // KeyFile returns the path to the SSH private key used to connect to the DUT.
   261  // This is provided for tests that may need to establish SSH connections to additional hosts
   262  // (e.g. a host running a servod instance).
   263  func (d *DUT) KeyFile() string { return d.sopt.KeyFile }
   264  
   265  // KeyDir returns the path to the directory containing SSH private keys used to connect to the DUT.
   266  // This is provided for tests that may need to establish SSH connections to additional hosts
   267  // (e.g. a host running a servod instance).
   268  func (d *DUT) KeyDir() string { return d.sopt.KeyDir }
   269  
   270  // HostName returns the a string representing the "<dut_hostname>:<ssh_port>" used to connect to the DUT.
   271  // This is provided for tests that may need to establish direct SSH connections to hosts.
   272  // (e.g. syzkaller connecting to a host).
   273  func (d *DUT) HostName() string { return d.sopt.Hostname }