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 }