1 # Copyright 2015 The Chromium Authors. All rights reserved. 2 # Use of this source code is governed by a BSD-style license that can be 3 # found in the LICENSE file. 4 5 import collections 6 import json 7 import logging 8 import os 9 import re 10 import tempfile 11 import time 12 13 import common 14 from autotest_lib.client.bin import utils 15 from autotest_lib.client.common_lib import error 16 from autotest_lib.site_utils.lxc import constants 17 from autotest_lib.site_utils.lxc import lxc 18 from autotest_lib.site_utils.lxc import utils as lxc_utils 19 20 try: 21 from chromite.lib import metrics 22 except ImportError: 23 metrics = utils.metrics_mock 24 25 26 # Naming convention of test container, e.g., test_300_1422862512_2424, where: 27 # 300: The test job ID. 28 # 1422862512: The tick when container is created. 29 # 2424: The PID of autoserv that starts the container. 30 _TEST_CONTAINER_NAME_FMT = 'test_%s_%d_%d' 31 # Name of the container ID file. 32 _CONTAINER_ID_FILENAME = 'container_id.json' 33 34 35 class ContainerId(collections.namedtuple('ContainerId', 36 ['job_id', 'creation_time', 'pid'])): 37 """An identifier for containers.""" 38 39 # Optimization. Avoids __dict__ creation. Empty because this subclass has 40 # no instance vars of its own. 41 __slots__ = () 42 43 44 def __str__(self): 45 return _TEST_CONTAINER_NAME_FMT % self 46 47 48 def save(self, path): 49 """Saves the ID to the given path. 50 51 @param path: Path to a directory where the container ID will be 52 serialized. 53 """ 54 dst = os.path.join(path, _CONTAINER_ID_FILENAME) 55 with open(dst, 'w') as f: 56 json.dump(self, f) 57 58 @classmethod 59 def load(cls, path): 60 """Reads the ID from the given path. 61 62 @param path: Path to check for a serialized container ID. 63 64 @return: A container ID if one is found on the given path, or None 65 otherwise. 66 67 @raise ValueError: If a JSON load error occurred. 68 @raise TypeError: If the file was valid JSON but didn't contain a valid 69 ContainerId. 70 """ 71 src = os.path.join(path, _CONTAINER_ID_FILENAME) 72 73 try: 74 with open(src, 'r') as f: 75 return cls(*json.load(f)) 76 except IOError: 77 # File not found, or couldn't be opened for some other reason. 78 # Treat all these cases as no ID. 79 return None 80 81 82 @classmethod 83 def create(cls, job_id, ctime=None, pid=None): 84 """Creates a new container ID. 85 86 @param job_id: The first field in the ID. 87 @param ctime: The second field in the ID. Optional. If not provided, 88 the current epoch timestamp is used. 89 @param pid: The third field in the ID. Optional. If not provided, the 90 PID of the current process is used. 91 """ 92 if ctime is None: 93 ctime = int(time.time()) 94 if pid is None: 95 pid = os.getpid() 96 return cls(job_id, ctime, pid) 97 98 99 class Container(object): 100 """A wrapper class of an LXC container. 101 102 The wrapper class provides methods to interact with a container, e.g., 103 start, stop, destroy, run a command. It also has attributes of the 104 container, including: 105 name: Name of the container. 106 state: State of the container, e.g., ABORTING, RUNNING, STARTING, STOPPED, 107 or STOPPING. 108 109 lxc-ls can also collect other attributes of a container including: 110 ipv4: IP address for IPv4. 111 ipv6: IP address for IPv6. 112 autostart: If the container will autostart at system boot. 113 pid: Process ID of the container. 114 memory: Memory used by the container, as a string, e.g., "6.2MB" 115 ram: Physical ram used by the container, as a string, e.g., "6.2MB" 116 swap: swap used by the container, as a string, e.g., "1.0MB" 117 118 For performance reason, such info is not collected for now. 119 120 The attributes available are defined in ATTRIBUTES constant. 121 """ 122 123 def __init__(self, container_path, name, attribute_values, src=None, 124 snapshot=False): 125 """Initialize an object of LXC container with given attribute values. 126 127 @param container_path: Directory that stores the container. 128 @param name: Name of the container. 129 @param attribute_values: A dictionary of attribute values for the 130 container. 131 @param src: An optional source container. If provided, the source 132 continer is cloned, and the new container will point to the 133 clone. 134 @param snapshot: If a source container was specified, this argument 135 specifies whether or not to create a snapshot clone. 136 The default is to attempt to create a snapshot. 137 If a snapshot is requested and creating the snapshot 138 fails, a full clone will be attempted. 139 """ 140 self.container_path = os.path.realpath(container_path) 141 # Path to the rootfs of the container. This will be initialized when 142 # property rootfs is retrieved. 143 self._rootfs = None 144 self.name = name 145 for attribute, value in attribute_values.iteritems(): 146 setattr(self, attribute, value) 147 148 # Clone the container 149 if src is not None: 150 # Clone the source container to initialize this one. 151 lxc_utils.clone(src.container_path, src.name, self.container_path, 152 self.name, snapshot) 153 # Newly cloned containers have no ID. 154 self._id = None 155 else: 156 # This may be an existing container. Try to read the ID. 157 try: 158 self._id = ContainerId.load( 159 os.path.join(self.container_path, self.name)) 160 except (ValueError, TypeError): 161 # Ignore load errors. ContainerBucket currently queries every 162 # container quite frequently, and emitting exceptions here would 163 # cause any invalid containers on a server to block all 164 # ContainerBucket.get_all calls (see crbug/783865). 165 # TODO(kenobi): Containers with invalid ID files are probably 166 # the result of an aborted or failed operation. There is a 167 # non-zero chance that such containers would contain leftover 168 # state, or themselves be corrupted or invalid. Should we 169 # provide APIs for checking if a container is in this state? 170 logging.exception('Error loading ID for container %s:', 171 self.name) 172 self._id = None 173 174 175 @classmethod 176 def create_from_existing_dir(cls, lxc_path, name, **kwargs): 177 """Creates a new container instance for an lxc container that already 178 exists on disk. 179 180 @param lxc_path: The LXC path for the container. 181 @param name: The container name. 182 183 @raise error.ContainerError: If the container doesn't already exist. 184 185 @return: The new container. 186 """ 187 return cls(lxc_path, name, kwargs) 188 189 190 # Containers have a name and an ID. The name is simply the name of the LXC 191 # container. The ID is the actual key that is used to identify the 192 # container to the autoserv system. In the case of a JIT-created container, 193 # we have the ID at the container's creation time so we use that to name the 194 # container. This may not be the case for other types of containers. 195 @classmethod 196 def clone(cls, src, new_name=None, new_path=None, snapshot=False, 197 cleanup=False): 198 """Creates a clone of this container. 199 200 @param src: The original container. 201 @param new_name: Name for the cloned container. If this is not 202 provided, a random unique container name will be 203 generated. 204 @param new_path: LXC path for the cloned container (optional; if not 205 specified, the new container is created in the same 206 directory as the source container). 207 @param snapshot: Whether to snapshot, or create a full clone. Note that 208 snapshot cloning is not supported on all platforms. If 209 this code is running on a platform that does not 210 support snapshot clones, this flag is ignored. 211 @param cleanup: If a container with the given name and path already 212 exist, clean it up first. 213 """ 214 if new_path is None: 215 new_path = src.container_path 216 217 if new_name is None: 218 _, new_name = os.path.split( 219 tempfile.mkdtemp(dir=new_path, prefix='container.')) 220 logging.debug('Generating new name for container: %s', new_name) 221 else: 222 # If a container exists at this location, clean it up first 223 container_folder = os.path.join(new_path, new_name) 224 if lxc_utils.path_exists(container_folder): 225 if not cleanup: 226 raise error.ContainerError('Container %s already exists.' % 227 new_name) 228 container = Container.create_from_existing_dir(new_path, 229 new_name) 230 try: 231 container.destroy() 232 except error.CmdError as e: 233 # The container could be created in a incompleted 234 # state. Delete the container folder instead. 235 logging.warn('Failed to destroy container %s, error: %s', 236 new_name, e) 237 utils.run('sudo rm -rf "%s"' % container_folder) 238 # Create the directory prior to creating the new container. This 239 # puts the ownership of the container under the current process's 240 # user, rather than root. This is necessary to enable the 241 # ContainerId to serialize properly. 242 os.mkdir(container_folder) 243 244 # Create and return the new container. 245 new_container = cls(new_path, new_name, {}, src, snapshot) 246 247 return new_container 248 249 250 def refresh_status(self): 251 """Refresh the status information of the container. 252 """ 253 containers = lxc.get_container_info(self.container_path, name=self.name) 254 if not containers: 255 raise error.ContainerError( 256 'No container found in directory %s with name of %s.' % 257 (self.container_path, self.name)) 258 attribute_values = containers[0] 259 for attribute, value in attribute_values.iteritems(): 260 setattr(self, attribute, value) 261 262 263 @property 264 def rootfs(self): 265 """Path to the rootfs of the container. 266 267 This property returns the path to the rootfs of the container, that is, 268 the folder where the container stores its local files. It reads the 269 attribute lxc.rootfs from the config file of the container, e.g., 270 lxc.rootfs = /usr/local/autotest/containers/t4/rootfs 271 If the container is created with snapshot, the rootfs is a chain of 272 folders, separated by `:` and ordered by how the snapshot is created, 273 e.g., 274 lxc.rootfs = overlayfs:/usr/local/autotest/containers/base/rootfs: 275 /usr/local/autotest/containers/t4_s/delta0 276 This function returns the last folder in the chain, in above example, 277 that is `/usr/local/autotest/containers/t4_s/delta0` 278 279 Files in the rootfs will be accessible directly within container. For 280 example, a folder in host "[rootfs]/usr/local/file1", can be accessed 281 inside container by path "/usr/local/file1". Note that symlink in the 282 host can not across host/container boundary, instead, directory mount 283 should be used, refer to function mount_dir. 284 285 @return: Path to the rootfs of the container. 286 """ 287 if not self._rootfs: 288 lxc_rootfs = self._get_lxc_config('lxc.rootfs')[0] 289 cloned_from_snapshot = ':' in lxc_rootfs 290 if cloned_from_snapshot: 291 self._rootfs = lxc_rootfs.split(':')[-1] 292 else: 293 self._rootfs = lxc_rootfs 294 return self._rootfs 295 296 297 def attach_run(self, command, bash=True): 298 """Attach to a given container and run the given command. 299 300 @param command: Command to run in the container. 301 @param bash: Run the command through bash -c "command". This allows 302 pipes to be used in command. Default is set to True. 303 304 @return: The output of the command. 305 306 @raise error.CmdError: If container does not exist, or not running. 307 """ 308 cmd = 'sudo lxc-attach -P %s -n %s' % (self.container_path, self.name) 309 if bash and not command.startswith('bash -c'): 310 command = 'bash -c "%s"' % utils.sh_escape(command) 311 cmd += ' -- %s' % command 312 # TODO(dshi): crbug.com/459344 Set sudo to default to False when test 313 # container can be unprivileged container. 314 return utils.run(cmd) 315 316 317 def is_network_up(self): 318 """Check if network is up in the container by curl base container url. 319 320 @return: True if the network is up, otherwise False. 321 """ 322 try: 323 self.attach_run('curl --head %s' % constants.CONTAINER_BASE_URL) 324 return True 325 except error.CmdError as e: 326 logging.debug(e) 327 return False 328 329 330 @metrics.SecondsTimerDecorator( 331 '%s/container_start_duration' % constants.STATS_KEY) 332 def start(self, wait_for_network=True): 333 """Start the container. 334 335 @param wait_for_network: True to wait for network to be up. Default is 336 set to True. 337 338 @raise ContainerError: If container does not exist, or fails to start. 339 """ 340 cmd = 'sudo lxc-start -P %s -n %s -d' % (self.container_path, self.name) 341 output = utils.run(cmd).stdout 342 if not self.is_running(): 343 raise error.ContainerError( 344 'Container %s failed to start. lxc command output:\n%s' % 345 (os.path.join(self.container_path, self.name), 346 output)) 347 348 if wait_for_network: 349 logging.debug('Wait for network to be up.') 350 start_time = time.time() 351 utils.poll_for_condition( 352 condition=self.is_network_up, 353 timeout=constants.NETWORK_INIT_TIMEOUT, 354 sleep_interval=constants.NETWORK_INIT_CHECK_INTERVAL) 355 logging.debug('Network is up after %.2f seconds.', 356 time.time() - start_time) 357 358 359 @metrics.SecondsTimerDecorator( 360 '%s/container_stop_duration' % constants.STATS_KEY) 361 def stop(self): 362 """Stop the container. 363 364 @raise ContainerError: If container does not exist, or fails to start. 365 """ 366 cmd = 'sudo lxc-stop -P %s -n %s' % (self.container_path, self.name) 367 output = utils.run(cmd).stdout 368 self.refresh_status() 369 if self.state != 'STOPPED': 370 raise error.ContainerError( 371 'Container %s failed to be stopped. lxc command output:\n' 372 '%s' % (os.path.join(self.container_path, self.name), 373 output)) 374 375 376 @metrics.SecondsTimerDecorator( 377 '%s/container_destroy_duration' % constants.STATS_KEY) 378 def destroy(self, force=True): 379 """Destroy the container. 380 381 @param force: Set to True to force to destroy the container even if it's 382 running. This is faster than stop a container first then 383 try to destroy it. Default is set to True. 384 385 @raise ContainerError: If container does not exist or failed to destroy 386 the container. 387 """ 388 logging.debug('Destroying container %s/%s', 389 self.container_path, 390 self.name) 391 cmd = 'sudo lxc-destroy -P %s -n %s' % (self.container_path, 392 self.name) 393 if force: 394 cmd += ' -f' 395 utils.run(cmd) 396 397 398 def mount_dir(self, source, destination, readonly=False): 399 """Mount a directory in host to a directory in the container. 400 401 @param source: Directory in host to be mounted. 402 @param destination: Directory in container to mount the source directory 403 @param readonly: Set to True to make a readonly mount, default is False. 404 """ 405 # Destination path in container must be relative. 406 destination = destination.lstrip('/') 407 # Create directory in container for mount. Changes to container rootfs 408 # require sudo. 409 utils.run('sudo mkdir -p %s' % os.path.join(self.rootfs, destination)) 410 mount = ('%s %s none bind%s 0 0' % 411 (source, destination, ',ro' if readonly else '')) 412 self._set_lxc_config('lxc.mount.entry', mount) 413 414 def verify_autotest_setup(self, job_folder): 415 """Verify autotest code is set up properly in the container. 416 417 @param job_folder: Name of the job result folder. 418 419 @raise ContainerError: If autotest code is not set up properly. 420 """ 421 # Test autotest code is setup by verifying a list of 422 # (directory, minimum file count) 423 directories_to_check = [ 424 (constants.CONTAINER_AUTOTEST_DIR, 3), 425 (constants.RESULT_DIR_FMT % job_folder, 0), 426 (constants.CONTAINER_SITE_PACKAGES_PATH, 3)] 427 for directory, count in directories_to_check: 428 result = self.attach_run(command=(constants.COUNT_FILE_CMD % 429 {'dir': directory})).stdout 430 logging.debug('%s entries in %s.', int(result), directory) 431 if int(result) < count: 432 raise error.ContainerError('%s is not properly set up.' % 433 directory) 434 # lxc-attach and run command does not run in shell, thus .bashrc is not 435 # loaded. Following command creates a symlink in /usr/bin/ for gsutil 436 # if it's installed. 437 # TODO(dshi): Remove this code after lab container is updated with 438 # gsutil installed in /usr/bin/ 439 self.attach_run('test -f /root/gsutil/gsutil && ' 440 'ln -s /root/gsutil/gsutil /usr/bin/gsutil || true') 441 442 443 def modify_import_order(self): 444 """Swap the python import order of lib and local/lib. 445 446 In Moblab, the host's python modules located in 447 /usr/lib64/python2.7/site-packages is mounted to following folder inside 448 container: /usr/local/lib/python2.7/dist-packages/. The modules include 449 an old version of requests module, which is used in autotest 450 site-packages. For test, the module is only used in 451 dev_server/symbolicate_dump for requests.call and requests.codes.OK. 452 When pip is installed inside the container, it installs requests module 453 with version of 2.2.1 in /usr/lib/python2.7/dist-packages/. The version 454 is newer than the one used in autotest site-packages, but not the latest 455 either. 456 According to /usr/lib/python2.7/site.py, modules in /usr/local/lib are 457 imported before the ones in /usr/lib. That leads to pip to use the older 458 version of requests (0.11.2), and it will fail. On the other hand, 459 requests module 2.2.1 can't be installed in CrOS (refer to CL:265759), 460 and higher version of requests module can't work with pip. 461 The only fix to resolve this is to switch the import order, so modules 462 in /usr/lib can be imported before /usr/local/lib. 463 """ 464 site_module = '/usr/lib/python2.7/site.py' 465 self.attach_run("sed -i ':a;N;$!ba;s/\"local\/lib\",\\n/" 466 "\"lib_placeholder\",\\n/g' %s" % site_module) 467 self.attach_run("sed -i ':a;N;$!ba;s/\"lib\",\\n/" 468 "\"local\/lib\",\\n/g' %s" % site_module) 469 self.attach_run('sed -i "s/lib_placeholder/lib/g" %s' % 470 site_module) 471 472 473 def is_running(self): 474 """Returns whether or not this container is currently running.""" 475 self.refresh_status() 476 return self.state == 'RUNNING' 477 478 479 def set_hostname(self, hostname): 480 """Sets the hostname within the container. 481 482 This method can only be called on a running container. 483 484 @param hostname The new container hostname. 485 486 @raise ContainerError: If the container is not running. 487 """ 488 if not self.is_running(): 489 raise error.ContainerError( 490 'set_hostname can only be called on running containers.') 491 492 self.attach_run('hostname %s' % (hostname)) 493 self.attach_run(constants.APPEND_CMD_FMT % { 494 'content': '127.0.0.1 %s' % (hostname), 495 'file': '/etc/hosts'}) 496 497 498 def install_ssp(self, ssp_url): 499 """Downloads and installs the given server package. 500 501 @param ssp_url: The URL of the ssp to download and install. 502 """ 503 usr_local_path = os.path.join(self.rootfs, 'usr', 'local') 504 autotest_pkg_path = os.path.join(usr_local_path, 505 'autotest_server_package.tar.bz2') 506 # Changes within the container rootfs require sudo. 507 utils.run('sudo mkdir -p %s'% usr_local_path) 508 509 lxc.download_extract(ssp_url, autotest_pkg_path, usr_local_path) 510 511 512 def install_control_file(self, control_file): 513 """Installs the given control file. 514 515 The given file will be copied into the container. 516 517 @param control_file: Path to the control file to install. 518 """ 519 dst = os.path.join(constants.CONTROL_TEMP_PATH, 520 os.path.basename(control_file)) 521 self.copy(control_file, dst) 522 523 524 def copy(self, host_path, container_path): 525 """Copies files into the container. 526 527 @param host_path: Path to the source file/dir to be copied. 528 @param container_path: Path to the destination dir (in the container). 529 """ 530 dst_path = os.path.join(self.rootfs, 531 container_path.lstrip(os.path.sep)) 532 self._do_copy(src=host_path, dst=dst_path) 533 534 535 @property 536 def id(self): 537 """Returns the container ID.""" 538 return self._id 539 540 541 @id.setter 542 def id(self, new_id): 543 """Sets the container ID.""" 544 self._id = new_id; 545 # Persist the ID so other container objects can pick it up. 546 self._id.save(os.path.join(self.container_path, self.name)) 547 548 549 def _do_copy(self, src, dst): 550 """Copies files and directories on the host system. 551 552 @param src: The source file or directory. 553 @param dst: The destination file or directory. If the path to the 554 destination does not exist, it will be created. 555 """ 556 # Create the dst dir. mkdir -p will not fail if dst_dir exists. 557 dst_dir = os.path.dirname(dst) 558 # Make sure the source ends with `/.` if it's a directory. Otherwise 559 # command cp will not work. 560 if os.path.isdir(src) and os.path.split(src)[1] != '.': 561 src = os.path.join(src, '.') 562 utils.run("sudo sh -c 'mkdir -p \"%s\" && cp -RL \"%s\" \"%s\"'" % 563 (dst_dir, src, dst)) 564 565 def _set_lxc_config(self, key, value): 566 """Sets an LXC config value for this container. 567 568 Configuration changes made while a container is running don't take 569 effect until the container is restarted. Since this isn't a scenario 570 that should ever come up in our use cases, calling this method on a 571 running container will cause a ContainerError. 572 573 @param key: The LXC config key to set. 574 @param value: The value to use for the given key. 575 576 @raise error.ContainerError: If the container is already started. 577 """ 578 if self.is_running(): 579 raise error.ContainerError( 580 '_set_lxc_config(%s, %s) called on a running container.' % 581 (key, value)) 582 config_file = os.path.join(self.container_path, self.name, 'config') 583 config = '%s = %s' % (key, value) 584 utils.run( 585 constants.APPEND_CMD_FMT % {'content': config, 'file': config_file}) 586 587 588 def _get_lxc_config(self, key): 589 """Retrieves an LXC config value from the container. 590 591 @param key The key of the config value to retrieve. 592 """ 593 cmd = ('sudo lxc-info -P %s -n %s -c %s' % 594 (self.container_path, self.name, key)) 595 config = utils.run(cmd).stdout.strip().splitlines() 596 597 # Strip the decoration from line 1 of the output. 598 match = re.match('%s = (.*)' % key, config[0]) 599 if not match: 600 raise error.ContainerError( 601 'Config %s not found for container %s. (%s)' % 602 (key, self.name, ','.join(config))) 603 config[0] = match.group(1) 604 return config 605