1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.tradefed.util.avd; 18 19 import com.android.tradefed.invoker.logger.InvocationMetricLogger; 20 import com.android.tradefed.log.LogUtil.CLog; 21 import com.android.tradefed.util.CommandResult; 22 import com.android.tradefed.util.CommandStatus; 23 import com.android.tradefed.util.IRunUtil; 24 import com.android.tradefed.util.MultiMap; 25 import com.android.tradefed.util.RunUtil; 26 27 import com.google.common.annotations.VisibleForTesting; 28 import com.google.common.base.Strings; 29 import com.google.common.collect.Lists; 30 31 import java.io.FileOutputStream; 32 import java.io.IOException; 33 import java.net.ServerSocket; 34 import java.text.DateFormat; 35 import java.text.SimpleDateFormat; 36 import java.util.ArrayList; 37 import java.util.Arrays; 38 import java.util.Collections; 39 import java.util.HashSet; 40 import java.util.List; 41 import java.util.Map; 42 import java.util.Set; 43 import java.util.concurrent.TimeUnit; 44 import java.util.stream.Collectors; 45 import java.util.stream.Stream; 46 47 /** A class that manages the use of Oxygen client binary to lease or release Oxygen device. */ 48 public class OxygenClient { 49 50 public enum LHPTunnelMode { 51 SSH, 52 ADB, 53 CURL; 54 } 55 56 // A list of commands to be executed to lease or release Oxygen devices, examples: 57 // 1. if the binary is an executable script, execute it directly. 58 // 2. if the binary is a jar file, execute it by using java -jar ${binary_path}. 59 private final List<String> mCmdArgs = Lists.newArrayList(); 60 61 private IRunUtil mRunUtil; 62 63 // A list of attributes to be stored in Oxygen metadata. 64 private static final Set<String> INVOCATION_ATTRIBUTES = 65 new HashSet<>(Arrays.asList("work_unit_id")); 66 67 // We can't be sure from GceDeviceParams use _ in options or -. This is because acloud used - 68 // in its options while Oxygen use _. For compatibility reason, this mapping is needed. 69 public static final Map<String, String> sGceDeviceParamsToOxygenMap = 70 Stream.of( 71 new String[][] { 72 {"--branch", "-build_branch"}, 73 {"--build-branch", "-build_branch"}, 74 {"--build_branch", "-build_branch"}, 75 {"--build-target", "-build_target"}, 76 {"--build_target", "-build_target"}, 77 {"--build-id", "-build_id"}, 78 {"--build_id", "-build_id"}, 79 {"--system-build-id", "-system_build_id"}, 80 {"--system_build_id", "-system_build_id"}, 81 {"--system-build-target", "-system_build_target"}, 82 {"--system_build_target", "-system_build_target"}, 83 {"--kernel-build-id", "-kernel_build_id"}, 84 {"--kernel_build_id", "-kernel_build_id"}, 85 {"--kernel-build-target", "-kernel_build_target"}, 86 {"--kernel_build_target", "-kernel_build_target"}, 87 {"--boot-build-id", "-boot_build_id"}, 88 {"--boot_build_id", "-boot_build_id"}, 89 {"--boot-build-target", "-boot_build_target"}, 90 {"--boot_build_target", "-boot_build_target"}, 91 {"--boot-artifact", "-boot_artifact"}, 92 {"--boot_artifact", "-boot_artifact"}, 93 {"--host_package_build_id", "-host_package_build_id"}, 94 {"--host_package_build_target", "-host_package_build_target"}, 95 {"--bootloader-build-id", "-bootloader_build_id"}, 96 {"--bootloader_build_id", "-bootloader_build_id"}, 97 {"--bootloader-build-target", "-bootloader_build_target"}, 98 {"--bootloader_build_target", "-bootloader_build_target"} 99 }) 100 .collect( 101 Collectors.collectingAndThen( 102 Collectors.toMap(data -> data[0], data -> data[1]), 103 Collections::<String, String>unmodifiableMap)); 104 105 @VisibleForTesting getRunUtil()106 IRunUtil getRunUtil() { 107 return mRunUtil; 108 } 109 110 /** 111 * The constructor of OxygenClient class. 112 * 113 * @param cmdArgs a {@link List<String>} of commands to run Oxygen client. 114 * @param runUtil a {@link IRunUtil} to execute commands. 115 */ 116 @VisibleForTesting OxygenClient(List<String> cmdArgs, IRunUtil runUtil)117 OxygenClient(List<String> cmdArgs, IRunUtil runUtil) { 118 mCmdArgs.addAll(cmdArgs); 119 mRunUtil = runUtil; 120 } 121 122 /** 123 * The constructor of OxygenClient class. 124 * 125 * @param cmdArgs a {@link List<String>} of commands to run Oxygen client. 126 */ OxygenClient(List<String> cmdArgs)127 public OxygenClient(List<String> cmdArgs) { 128 mCmdArgs.addAll(cmdArgs); 129 mRunUtil = RunUtil.getDefault(); 130 } 131 132 /** 133 * Adds invocation attributes to the given list of arguments. 134 * 135 * @param args command line args to call Oxygen client 136 * @param attributes the map of attributes to add 137 */ addInvocationAttributes(List<String> args, MultiMap<String, String> attributes)138 private void addInvocationAttributes(List<String> args, MultiMap<String, String> attributes) { 139 if (attributes == null) { 140 return; 141 } 142 List<String> debugInfo = new ArrayList<>(); 143 for (Map.Entry<String, String> attr : attributes.entries()) { 144 if (INVOCATION_ATTRIBUTES.contains(attr.getKey())) { 145 debugInfo.add(String.format("%s:%s", attr.getKey(), attr.getValue())); 146 } 147 } 148 if (debugInfo.size() > 0) { 149 args.add("-user_debug_info"); 150 args.add(String.join(",", debugInfo)); 151 } 152 } 153 154 /** 155 * Attempt to lease a device by calling Oxygen client binary. 156 * 157 * @param buildTarget build target 158 * @param buildBranch build branch 159 * @param buildId build ID 160 * @param targetRegion target region for Oxygen instance 161 * @param accountingUser Oxygen accounting user email 162 * @param leaseLength number of ms for the lease duration 163 * @param gceDriverParams {@link List<String>} of gce driver params 164 * @param extraOxygenArgs {@link Map<String, String>} of extra Oxygen lease args 165 * @param attributes attributes associated with current invocation 166 * @param gceCmdTimeout number of ms for the command line timeout 167 * @param useOxygenation whether the device is leased from OmniLab Infra or not. 168 * @return a {@link CommandResult} that Oxygen binary returned. 169 */ leaseDevice( String buildTarget, String buildBranch, String buildId, String targetRegion, String accountingUser, long leaseLength, List<String> gceDriverParams, Map<String, String> extraOxygenArgs, MultiMap<String, String> attributes, long gceCmdTimeout, boolean useOxygenation)170 public CommandResult leaseDevice( 171 String buildTarget, 172 String buildBranch, 173 String buildId, 174 String targetRegion, 175 String accountingUser, 176 long leaseLength, 177 List<String> gceDriverParams, 178 Map<String, String> extraOxygenArgs, 179 MultiMap<String, String> attributes, 180 long gceCmdTimeout, 181 boolean useOxygenation) { 182 List<String> oxygenClientArgs = Lists.newArrayList(mCmdArgs); 183 oxygenClientArgs.add("-lease"); 184 // Add options from GceDriverParams 185 int i = 0; 186 String branch = null; 187 Boolean buildIdSet = false; 188 while (i < gceDriverParams.size()) { 189 String gceDriverOption = gceDriverParams.get(i); 190 if (sGceDeviceParamsToOxygenMap.containsKey(gceDriverOption)) { 191 // add device build options in oxygen's way 192 oxygenClientArgs.add(sGceDeviceParamsToOxygenMap.get(gceDriverOption)); 193 // add option's value 194 oxygenClientArgs.add(gceDriverParams.get(i + 1)); 195 if (gceDriverOption.equals("--branch")) { 196 branch = gceDriverParams.get(i + 1); 197 } else if (!buildIdSet 198 && sGceDeviceParamsToOxygenMap.get(gceDriverOption).equals("-build_id")) { 199 buildIdSet = true; 200 } 201 i++; 202 } 203 i++; 204 } 205 206 // In case branch is set through gce-driver-param, but not build-id, set the name of 207 // branch to option `-build-id`, so LKGB will be used. 208 if (branch != null && !buildIdSet) { 209 oxygenClientArgs.add("-build_id"); 210 oxygenClientArgs.add(branch); 211 } 212 213 // check if build info exists after added from GceDriverParams 214 if (!oxygenClientArgs.contains("-build_target")) { 215 oxygenClientArgs.add("-build_target"); 216 oxygenClientArgs.add(buildTarget); 217 oxygenClientArgs.add("-build_branch"); 218 oxygenClientArgs.add(buildBranch); 219 oxygenClientArgs.add("-build_id"); 220 oxygenClientArgs.add(buildId); 221 } 222 223 // add oxygen side lease options 224 oxygenClientArgs.add("-target_region"); 225 oxygenClientArgs.add(targetRegion); 226 oxygenClientArgs.add("-accounting_user"); 227 oxygenClientArgs.add(accountingUser); 228 oxygenClientArgs.add("-lease_length_secs"); 229 oxygenClientArgs.add(Long.toString(leaseLength / 1000)); 230 231 // Check if there is a new CVD path to override 232 if (extraOxygenArgs.containsKey("override_cvd_path")) { 233 oxygenClientArgs.add("-override_cvd_path"); 234 oxygenClientArgs.add(extraOxygenArgs.get("override_cvd_path")); 235 } 236 237 for (Map.Entry<String, String> arg : extraOxygenArgs.entrySet()) { 238 oxygenClientArgs.add("-" + arg.getKey()); 239 if (!Strings.isNullOrEmpty(arg.getValue())) { 240 oxygenClientArgs.add(arg.getValue()); 241 } 242 } 243 244 addInvocationAttributes(oxygenClientArgs, attributes); 245 246 if (useOxygenation) { 247 oxygenClientArgs.add("-use_omnilab"); 248 } 249 250 CLog.i("Leasing device from oxygen client with %s", oxygenClientArgs.toString()); 251 return runOxygenTimedCmd( 252 oxygenClientArgs.toArray(new String[oxygenClientArgs.size()]), gceCmdTimeout); 253 } 254 255 /** 256 * Attempt to lease multiple devices by calling Oxygen client binary. 257 * 258 * @param buildTargets a {@link List<String>} of build targets 259 * @param buildBranches a {@link List<String>} of build branches 260 * @param buildIds a {@link List<String>} of build IDs 261 * @param targetRegion target region for Oxygen instance 262 * @param accountingUser Oxygen accounting user email 263 * @param leaseLength number of ms for the lease duration 264 * @param gceDriverParams {@link List<String>} of gce driver params 265 * @param extraOxygenArgs {@link Map<String, String>} of extra Oxygen lease args 266 * @param attributes attributes associated with current invocation 267 * @param gceCmdTimeout number of ms for the command line timeout 268 * @param useOxygenation whether the device is leased from OmniLab Infra or not. 269 * @return {@link CommandResult} that Oxygen binary returned. 270 */ leaseMultipleDevices( List<String> buildTargets, List<String> buildBranches, List<String> buildIds, String targetRegion, String accountingUser, long leaseLength, Map<String, String> extraOxygenArgs, MultiMap<String, String> attributes, long gceCmdTimeout, boolean useOxygenation)271 public CommandResult leaseMultipleDevices( 272 List<String> buildTargets, 273 List<String> buildBranches, 274 List<String> buildIds, 275 String targetRegion, 276 String accountingUser, 277 long leaseLength, 278 Map<String, String> extraOxygenArgs, 279 MultiMap<String, String> attributes, 280 long gceCmdTimeout, 281 boolean useOxygenation) { 282 List<String> oxygenClientArgs = Lists.newArrayList(mCmdArgs); 283 oxygenClientArgs.add("-lease"); 284 285 if (buildTargets.size() > 0) { 286 oxygenClientArgs.add("-build_target"); 287 oxygenClientArgs.add(String.join(",", buildTargets)); 288 } 289 290 if (buildBranches.size() > 0) { 291 oxygenClientArgs.add("-build_branch"); 292 oxygenClientArgs.add(String.join(",", buildBranches)); 293 } 294 if (buildIds.size() > 0) { 295 oxygenClientArgs.add("-build_id"); 296 oxygenClientArgs.add(String.join(",", buildIds)); 297 } 298 oxygenClientArgs.add("-multidevice_size"); 299 oxygenClientArgs.add(String.valueOf(buildTargets.size())); 300 oxygenClientArgs.add("-target_region"); 301 oxygenClientArgs.add(targetRegion); 302 oxygenClientArgs.add("-accounting_user"); 303 oxygenClientArgs.add(accountingUser); 304 oxygenClientArgs.add("-lease_length_secs"); 305 oxygenClientArgs.add(Long.toString(leaseLength / 1000)); 306 307 for (Map.Entry<String, String> arg : extraOxygenArgs.entrySet()) { 308 oxygenClientArgs.add("-" + arg.getKey()); 309 if (!Strings.isNullOrEmpty(arg.getValue())) { 310 oxygenClientArgs.add(arg.getValue()); 311 } 312 } 313 314 addInvocationAttributes(oxygenClientArgs, attributes); 315 316 if (useOxygenation) { 317 oxygenClientArgs.add("-use_omnilab"); 318 } 319 320 CLog.i("Leasing multiple devices from oxygen client with %s", oxygenClientArgs.toString()); 321 return runOxygenTimedCmd( 322 oxygenClientArgs.toArray(new String[oxygenClientArgs.size()]), gceCmdTimeout); 323 } 324 325 /** 326 * Attempt to release a device by using Oxygen client binary. 327 * 328 * @param instanceName name of the Oxygen instance 329 * @param host hostname of the Oxygen instance 330 * @param targetRegion target region 331 * @param accountingUser name of accounting user email 332 * @param extraOxygenArgs {@link Map<String, String>} of extra Oxygen args 333 * @param gceCmdTimeout number of ms for the command line timeout 334 * @param useOxygenation whether the device is leased from OmniLab Infra or not. 335 * @return a {@link CommandResult} that Oxygen binary returned. 336 */ release( String instanceName, String host, String targetRegion, String accountingUser, Map<String, String> extraOxygenArgs, long gceCmdTimeout, boolean useOxygenation)337 public CommandResult release( 338 String instanceName, 339 String host, 340 String targetRegion, 341 String accountingUser, 342 Map<String, String> extraOxygenArgs, 343 long gceCmdTimeout, 344 boolean useOxygenation) { 345 List<String> oxygenClientArgs = Lists.newArrayList(mCmdArgs); 346 347 // If gceAvdInfo is missing info, then it means the device wasn't get leased successfully. 348 // In such case, there is no need to release the device. 349 if (instanceName == null || host == null) { 350 CommandResult res = new CommandResult(); 351 res.setStatus(CommandStatus.SUCCESS); 352 return res; 353 } 354 355 if (extraOxygenArgs != null) { 356 for (Map.Entry<String, String> arg : extraOxygenArgs.entrySet()) { 357 oxygenClientArgs.add("-" + arg.getKey()); 358 if (!Strings.isNullOrEmpty(arg.getValue())) { 359 oxygenClientArgs.add(arg.getValue()); 360 } 361 } 362 } 363 364 oxygenClientArgs.add("-release"); 365 oxygenClientArgs.add("-target_region"); 366 oxygenClientArgs.add(targetRegion); 367 oxygenClientArgs.add("-server_url"); 368 oxygenClientArgs.add(host); 369 oxygenClientArgs.add("-session_id"); 370 oxygenClientArgs.add(instanceName); 371 oxygenClientArgs.add("-accounting_user"); 372 oxygenClientArgs.add(accountingUser); 373 if (useOxygenation) { 374 oxygenClientArgs.add("-use_omnilab"); 375 } 376 CLog.i("Releasing device from oxygen client with command %s", oxygenClientArgs.toString()); 377 return runOxygenTimedCmd( 378 oxygenClientArgs.toArray(new String[oxygenClientArgs.size()]), gceCmdTimeout); 379 } 380 381 /** 382 * Create an adb or ssh tunnel to a given instance name and assign the endpoint to a device via 383 * LHP based on the given tunnel mode. 384 * 385 * @param mode The mode for oxygen client to talk to the device. 386 * @param portNumber The port number that Host Orchestrator communicates with. 387 * @param sessionId The session id returned by lease method in oxygenation. 388 * @param serverUrl The server url returned by lease method in oxygenation. 389 * @param targetRegion The target region for Oxygen instance 390 * @param accountingUser Oxygen accounting user email 391 * @param oxygenationDeviceId The device id returned by lease method in oxygenation. 392 * @param extraOxygenArgs {@link Map<String, String>} of extra Oxygen lease args 393 * @param tunnelLog {@link FileOutputStream} for storing logs. 394 * @return {@link Process} of the adb over LHP tunnel. 395 */ createTunnelViaLHP( LHPTunnelMode mode, String portNumber, String sessionId, String serverUrl, String targetRegion, String accountingUser, String oxygenationDeviceId, Map<String, String> extraOxygenArgs, FileOutputStream tunnelLog)396 public Process createTunnelViaLHP( 397 LHPTunnelMode mode, 398 String portNumber, 399 String sessionId, 400 String serverUrl, 401 String targetRegion, 402 String accountingUser, 403 String oxygenationDeviceId, 404 Map<String, String> extraOxygenArgs, 405 FileOutputStream tunnelLog) { 406 Process lhpTunnel = null; 407 List<String> oxygenClientArgs = Lists.newArrayList(mCmdArgs); 408 oxygenClientArgs.add("-build_lab_host_proxy_tunnel"); 409 oxygenClientArgs.add("-server_url"); 410 oxygenClientArgs.add(serverUrl); 411 oxygenClientArgs.add("-session_id"); 412 oxygenClientArgs.add(sessionId); 413 414 if (extraOxygenArgs != null) { 415 for (Map.Entry<String, String> arg : extraOxygenArgs.entrySet()) { 416 oxygenClientArgs.add("-" + arg.getKey()); 417 if (!Strings.isNullOrEmpty(arg.getValue())) { 418 oxygenClientArgs.add(arg.getValue()); 419 } 420 } 421 } 422 423 oxygenClientArgs.add("-target_region"); 424 oxygenClientArgs.add(targetRegion); 425 oxygenClientArgs.add("-accounting_user"); 426 oxygenClientArgs.add(accountingUser); 427 oxygenClientArgs.add("-use_omnilab"); 428 oxygenClientArgs.add("-tunnel_type"); 429 if (LHPTunnelMode.ADB.equals(mode)) { 430 oxygenClientArgs.add("adb"); 431 } else if (LHPTunnelMode.CURL.equals(mode)) { 432 oxygenClientArgs.add("curl"); 433 } else { 434 oxygenClientArgs.add("ssh"); 435 } 436 oxygenClientArgs.add("-tunnel_local_port"); 437 oxygenClientArgs.add(portNumber); 438 oxygenClientArgs.add("-device_id"); 439 oxygenClientArgs.add(oxygenationDeviceId); 440 try { 441 DateFormat dateFormat = new SimpleDateFormat("MM/dd/yy HH:mm:SS"); 442 CLog.i( 443 "Building %s tunnel from oxygen client with command %s...", 444 mode, oxygenClientArgs.toString()); 445 tunnelLog.write( 446 String.format( 447 "\n===[%s]Session id: %s, Server URL: %s, Port: %s===\n", 448 dateFormat.format(System.currentTimeMillis()), 449 sessionId, 450 serverUrl, 451 portNumber) 452 .getBytes()); 453 lhpTunnel = getRunUtil().runCmdInBackground(oxygenClientArgs, tunnelLog); 454 // TODO(b/363861223): reduce the waiting time when LHP is stable. 455 getRunUtil().sleep(30 * 1000); 456 } catch (IOException e) { 457 CLog.d("Failed connecting to remote GCE using %s over LHP, %s", mode, e.getMessage()); 458 } 459 if (lhpTunnel == null || !lhpTunnel.isAlive()) { 460 closeLHPConnection(lhpTunnel); 461 InvocationMetricLogger.addInvocationMetrics( 462 InvocationMetricLogger.InvocationMetricKey.PORTFORWARD_LHP_FAIL_COUNT, 1); 463 return null; 464 } 465 InvocationMetricLogger.addInvocationMetrics( 466 InvocationMetricLogger.InvocationMetricKey.PORTFORWARD_LHP_SUCCESS_COUNT, 1); 467 return lhpTunnel; 468 } 469 470 /** Helper to create an unused server socket. */ createServerSocket()471 public Integer createServerSocket() { 472 ServerSocket s = null; 473 try { 474 s = new ServerSocket(0); 475 // even after being closed, socket may remain in TIME_WAIT state 476 // reuse address allows to connect to it even in this state. 477 s.setReuseAddress(true); 478 s.close(); 479 } catch (IOException e) { 480 CLog.d("Failed to connect to remote GCE using adb tunnel %s", e.getMessage()); 481 } 482 return s.getLocalPort(); 483 } 484 485 /** Close the connection to the remote oxygenation device with a given {@link Process}. */ closeLHPConnection(Process p)486 public void closeLHPConnection(Process p) { 487 if (p != null) { 488 p.destroy(); 489 try { 490 boolean res = p.waitFor(20 * 1000, TimeUnit.MILLISECONDS); 491 if (!res) { 492 CLog.e("Tunnel may not have properly terminated."); 493 } 494 } catch (InterruptedException e) { 495 CLog.e("Tunnel interrupted during shutdown: %s", e.getMessage()); 496 } 497 } 498 } 499 500 /** 501 * Utility function to execute a timed Oxygen command with logging. 502 * 503 * @param oxygenCmd command line options. 504 * @param timeout command timeout. 505 * @return {@link CommandResult}. 506 */ runOxygenTimedCmd(String[] oxygenCmd, long timeout)507 private CommandResult runOxygenTimedCmd(String[] oxygenCmd, long timeout) { 508 CommandResult res = getRunUtil().runTimedCmd(timeout, oxygenCmd); 509 CLog.i( 510 "Oxygen client result status: %s, stdout: %s, stderr: %s", 511 res.getStatus(), res.getStdout(), res.getStderr()); 512 return res; 513 } 514 } 515