github.com/uber/kraken@v0.1.4/test/python/components.py (about) 1 # Copyright (c) 2016-2019 Uber Technologies, Inc. 2 # 3 # Licensed under the Apache License, Version 2.0 (the "License"); 4 # you may not use this file except in compliance with the License. 5 # You may obtain a copy of the License at 6 # 7 # http://www.apache.org/licenses/LICENSE-2.0 8 # 9 # Unless required by applicable law or agreed to in writing, software 10 # distributed under the License is distributed on an "AS IS" BASIS, 11 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 # See the License for the specific language governing permissions and 13 # limitations under the License. 14 from __future__ import absolute_import 15 16 import os 17 import platform 18 import random 19 import subprocess 20 import time 21 import urllib 22 from contextlib import contextmanager 23 from io import BytesIO 24 from os.path import abspath 25 from Queue import Queue 26 from socket import socket 27 from threading import Thread 28 29 import requests 30 31 from uploader import Uploader 32 from utils import ( 33 PortReservation, 34 dev_tag, 35 find_free_port, 36 format_insecure_curl, 37 tls_opts, 38 ) 39 40 41 def get_docker_bridge(): 42 system = platform.system() 43 if system == 'Darwin': 44 return 'host.docker.internal' 45 elif system == 'Linux': 46 return '172.17.0.1' 47 else: 48 raise Exception('unknown system: ' + system) 49 50 51 def print_logs(container): 52 title = ' {name} logs '.format(name=container.name) 53 left_border = '<' * 20 54 right_border = '>' * 20 55 fill = ('<' * (len(title) / 2)) + ('>' * (len(title) / 2)) 56 print '{l}{title}{r}'.format(l=left_border, title=title, r=right_border) 57 print container.logs() 58 print '{l}{fill}{r}'.format(l=left_border, fill=fill, r=right_border) 59 60 61 def yaml_list(l): 62 return '[' + ','.join(map(lambda x: "'" + str(x) + "'", l)) + ']' 63 64 65 def pull(source, image): 66 cmd = [ 67 'tools/bin/puller/puller', '-source', source, '-image', image, 68 ] 69 assert subprocess.call(cmd, stderr=subprocess.STDOUT) == 0 70 71 72 class HealthCheck(object): 73 74 def __init__(self, cmd, interval=1, min_consecutive_successes=1, timeout=10): 75 self.cmd = cmd 76 self.interval = interval 77 self.min_consecutive_successes = min_consecutive_successes 78 self.timeout = timeout 79 80 def run(self, container): 81 start_time = time.time() 82 successes = 0 83 msg = '' 84 while time.time() - start_time < self.timeout: 85 try: 86 # We can't use container.exec_run since it doesn't expose exit code. 87 subprocess.check_output( 88 'docker exec {name} {cmd}'.format(name=container.name, cmd=self.cmd), 89 shell=True) 90 successes += 1 91 if successes >= self.min_consecutive_successes: 92 return 93 except Exception as e: 94 msg = str(e) 95 successes = 0 96 time.sleep(self.interval) 97 98 raise RuntimeError('Health check failure: {msg}'.format(msg=msg)) 99 100 101 class DockerContainer(object): 102 103 def __init__(self, name, image, command=None, ports=None, volumes=None, user=None): 104 self.name = name 105 self.image = image 106 107 self.command = [] 108 if command: 109 self.command = command 110 111 self.ports = [] 112 if ports: 113 for i, o in ports.iteritems(): 114 self.ports.extend(['-p', '{o}:{i}'.format(i=i, o=o)]) 115 116 self.volumes = [] 117 if volumes: 118 for o, i in volumes.iteritems(): 119 bind = i['bind'] 120 mode = i['mode'] 121 self.volumes.extend(['-v', '{o}:{bind}:{mode}'.format(o=o, bind=bind, mode=mode)]) 122 123 self.user = ['-u', user] if user else [] 124 125 def run(self): 126 cmd = [ 127 'docker', 'run', 128 '-d', 129 '--name=' + self.name, 130 ] 131 cmd.extend(self.ports) 132 cmd.extend(self.volumes) 133 cmd.extend(self.user) 134 cmd.append(self.image) 135 cmd.extend(self.command) 136 assert subprocess.call(cmd) == 0 137 138 def logs(self): 139 subprocess.call(['docker', 'logs', self.name]) 140 141 def remove(self, force=False): 142 cmd = [ 143 'docker', 'rm', 144 ] 145 if force: 146 cmd.append('-f') 147 cmd.append(self.name) 148 assert subprocess.call(cmd) == 0 149 150 151 def new_docker_container(name, image, command=None, environment=None, ports=None, 152 volumes=None, health_check=None, user=None): 153 """ 154 Creates and starts a detached Docker container. If health_check is specified, 155 ensures the container is healthy before returning. 156 """ 157 if command: 158 # Set umask so jenkins user can delete files created by non-jenkins user. 159 command = ['bash', '-c', 'umask 0000 && {command}'.format(command=' '.join(command))] 160 161 c = DockerContainer( 162 name=name, 163 image=image, 164 command=command, 165 ports=ports, 166 volumes=volumes, 167 user=user) 168 c.run() 169 print 'Starting container {}'.format(c.name) 170 try: 171 if health_check: 172 health_check.run(c) 173 else: 174 print 'No health checks supplied for {name}'.format(name=c.name) 175 except: 176 print_logs(c) 177 raise 178 return c 179 180 181 def populate_config_template(kname, filename, **kwargs): 182 """ 183 Populates a test config template with kwargs for Kraken name `kname` 184 and writes the result to the config directory of `kname` with filename. 185 """ 186 template = abspath('config/{kname}/test.template'.format(kname=kname)) 187 yaml = abspath('config/{kname}/{filename}'.format(kname=kname, filename=filename)) 188 189 with open(template) as f: 190 config = f.read().format(**kwargs) 191 192 with open(yaml, 'w') as f: 193 f.write(config) 194 195 196 def init_cache(cname): 197 """ 198 Wipes and initializes a cache dir for container name `cname`. 199 """ 200 cache = abspath('.tmptest/test-kraken-integration/{cname}/cache'.format(cname=cname)) 201 if os.path.exists(cache): 202 subprocess.check_call(['rm', '-rf', cache]) 203 os.makedirs(cache) 204 os.chmod(cache, 0777) 205 return cache 206 207 208 def create_volumes(kname, cname, local_cache=True): 209 """ 210 Creates volume bindings for Kraken name `kname` and container name `cname`. 211 """ 212 volumes = {} 213 214 # Mount configuration directory. This is necessary for components which 215 # populate templates and need to mount the populated template into the 216 # container. 217 config = abspath('config/{kname}'.format(kname=kname)) 218 volumes[config] = { 219 'bind': '/etc/kraken/config/{kname}'.format(kname=kname), 220 'mode': 'ro', 221 } 222 223 if local_cache: 224 # Mount local cache. Allows components to simulate unavailability whilst 225 # retaining their state on disk. 226 cache = init_cache(cname) 227 volumes[cache] = { 228 'bind': '/var/cache/kraken/kraken-{kname}/'.format(kname=kname), 229 'mode': 'rw', 230 } 231 232 return volumes 233 234 235 class Component(object): 236 """ 237 Base class for all containerized Kraken components. Each subclass implements 238 the container property for exposing its underlying Docker container, and Component 239 provides utilities acting upon said container. 240 """ 241 def new_container(self): 242 """ 243 Initializes a new container. All subclasses must implement this method. 244 """ 245 raise NotImplementedError 246 247 def start(self): 248 self.container = self.new_container() 249 250 def stop(self, wipe_disk=False): 251 self.container.remove(force=True) 252 if wipe_disk: 253 cache = init_cache(self.container.name) 254 255 def restart(self, wipe_disk=False): 256 self.stop(wipe_disk=wipe_disk) 257 # When a container is removed, there is a race condition 258 # when starting the container with the same command right away, 259 # which causes the start command to fail. 260 # Sleep for one second to make sure that the container is really 261 # removed from docker. 262 time.sleep(1) 263 self.start() 264 265 def print_logs(self): 266 print_logs(self.container) 267 268 def teardown(self): 269 try: 270 self.print_logs() 271 self.stop() 272 except Exception as e: 273 print 'Teardown {name} failed: {e}'.format(name=self.container.name, e=e) 274 275 276 class Redis(Component): 277 278 def __init__(self, zone): 279 self.zone = zone 280 self.port = find_free_port() 281 self.start() 282 283 def new_container(self): 284 return new_docker_container( 285 name='kraken-redis-{zone}'.format(zone=self.zone), 286 image='redis:latest', 287 ports={6379: self.port}, 288 health_check=HealthCheck('redis-cli ping')) 289 290 @property 291 def addr(self): 292 return '{}:{}'.format(get_docker_bridge(), self.port) 293 294 295 class Tracker(Component): 296 297 def __init__(self, zone, redis, origin_cluster): 298 self.zone = zone 299 self.redis = redis 300 self.origin_cluster = origin_cluster 301 self.port = find_free_port() 302 self.config_file = 'test-{zone}.yaml'.format(zone=zone) 303 self.name = 'kraken-tracker-{zone}'.format(zone=zone) 304 305 populate_config_template( 306 'tracker', 307 self.config_file, 308 redis=self.redis.addr, 309 origins=yaml_list([o.addr for o in self.origin_cluster.origins])) 310 311 self.volumes = create_volumes('tracker', self.name) 312 313 self.start() 314 315 def new_container(self): 316 return new_docker_container( 317 name=self.name, 318 image=dev_tag('kraken-tracker'), 319 environment={}, 320 ports={self.port: self.port}, 321 volumes=self.volumes, 322 command=[ 323 '/usr/bin/kraken-tracker', 324 '--config=/etc/kraken/config/tracker/{config}'.format(config=self.config_file), 325 '--port={port}'.format(port=self.port)], 326 health_check=HealthCheck(format_insecure_curl('localhost:{port}/health'.format(port=self.port)))) 327 328 @property 329 def addr(self): 330 return '{}:{}'.format(get_docker_bridge(), self.port) 331 332 333 class Origin(Component): 334 335 class Instance(object): 336 337 def __init__(self, name): 338 self.name = name 339 self.hostname = get_docker_bridge() 340 self.port_rez = PortReservation() 341 self.peer_port = find_free_port() 342 343 @property 344 def port(self): 345 return self.port_rez.get() 346 347 @property 348 def addr(self): 349 return '{}:{}'.format(self.hostname, self.port) 350 351 def __init__(self, zone, instances, name, testfs): 352 self.zone = zone 353 self.instance = instances[name] 354 self.testfs = testfs 355 self.config_file = 'test-{zone}.yaml'.format(zone=zone) 356 self.name = '{name}-{zone}'.format(name=self.instance.name, zone=zone) 357 358 populate_config_template( 359 'origin', 360 self.config_file, 361 origins=yaml_list([i.addr for i in instances.values()]), 362 testfs=self.testfs.addr) 363 364 self.volumes = create_volumes('origin', self.name) 365 366 self.start() 367 368 def new_container(self): 369 self.instance.port_rez.release() 370 return new_docker_container( 371 name=self.name, 372 image=dev_tag('kraken-origin'), 373 volumes=self.volumes, 374 environment={}, 375 ports={ 376 self.instance.port: self.instance.port, 377 self.instance.peer_port: self.instance.peer_port, 378 }, 379 command=[ 380 '/usr/bin/kraken-origin', 381 '--config=/etc/kraken/config/origin/{config}'.format(config=self.config_file), 382 '--blobserver-port={port}'.format(port=self.instance.port), 383 '--blobserver-hostname={hostname}'.format(hostname=self.instance.hostname), 384 '--peer-ip={ip}'.format(ip=get_docker_bridge()), 385 '--peer-port={port}'.format(port=self.instance.peer_port), 386 ], 387 health_check=HealthCheck(format_insecure_curl('https://localhost:{}/health'.format(self.instance.port)))) 388 389 @property 390 def addr(self): 391 return self.instance.addr 392 393 394 class OriginCluster(object): 395 396 def __init__(self, origins): 397 self.origins = origins 398 399 def get_location(self, name): 400 url = 'https://localhost:{port}/blobs/sha256:{name}/locations'.format( 401 port=random.choice(self.origins).instance.port, name=name) 402 res = requests.get(url, **tls_opts()) 403 res.raise_for_status() 404 addr = random.choice(res.headers['Origin-Locations'].split(',')) 405 # Origin addresses are configured under the bridge network, but we 406 # need to speak via localhost. 407 addr = addr.replace(get_docker_bridge(), 'localhost') 408 return addr 409 410 def upload(self, name, blob): 411 addr = self.get_location(name) 412 Uploader(addr).upload(name, blob) 413 414 def __iter__(self): 415 return iter(self.origins) 416 417 418 class Agent(Component): 419 420 def __init__(self, zone, id, tracker, build_indexes, with_docker_socket=False): 421 self.zone = zone 422 self.id = id 423 self.tracker = tracker 424 self.build_indexes = build_indexes 425 self.torrent_client_port = find_free_port() 426 self.registry_port = find_free_port() 427 self.port = find_free_port() 428 self.config_file = 'test-{zone}.yaml'.format(zone=zone) 429 self.name = 'kraken-agent-{id}-{zone}'.format(id=id, zone=zone) 430 self.with_docker_socket = with_docker_socket 431 432 populate_config_template( 433 'agent', 434 self.config_file, 435 trackers=yaml_list([self.tracker.addr]), 436 build_indexes=yaml_list([bi.addr for bi in self.build_indexes])) 437 438 if self.with_docker_socket: 439 # In aditional to the need to mount docker socket, also avoid using 440 # local cache volume, otherwise the process would run as root and 441 # create local cache files that's hard to clean outside of the 442 # container. 443 self.volumes = create_volumes('agent', self.name, local_cache=False) 444 self.volumes['/var/run/docker.sock'] = { 445 'bind': '/var/run/docker.sock', 446 'mode': 'rw', 447 } 448 else: 449 self.volumes = create_volumes('agent', self.name) 450 451 self.start() 452 453 def new_container(self): 454 # Root user is needed for accessing docker socket. 455 user = 'root' if self.with_docker_socket else None 456 return new_docker_container( 457 name=self.name, 458 image=dev_tag('kraken-agent'), 459 environment={}, 460 ports={ 461 self.torrent_client_port: self.torrent_client_port, 462 self.registry_port: self.registry_port, 463 self.port: self.port, 464 }, 465 volumes=self.volumes, 466 command=[ 467 '/usr/bin/kraken-agent', 468 '--config=/etc/kraken/config/agent/{config}'.format(config=self.config_file), 469 '--peer-ip={}'.format(get_docker_bridge()), 470 '--peer-port={port}'.format(port=self.torrent_client_port), 471 '--agent-server-port={port}'.format(port=self.port), 472 '--agent-registry-port={port}'.format(port=self.registry_port), 473 ], 474 health_check=HealthCheck('curl localhost:{port}/health'.format(port=self.port)), 475 user=user) 476 477 @property 478 def registry(self): 479 return '127.0.0.1:{port}'.format(port=self.registry_port) 480 481 def download(self, name, expected): 482 url = 'http://localhost:{port}/namespace/testfs/blobs/{name}'.format( 483 port=self.port, name=name) 484 s = requests.session() 485 s.keep_alive = False 486 res = s.get(url, stream=True, timeout=60) 487 res.raise_for_status() 488 assert res.content == expected 489 490 def pull(self, image): 491 return pull(self.registry, image) 492 493 def preload(self, image): 494 url = 'http://127.0.0.1:{port}/preload/tags/{image}'.format( 495 port=self.port, image=urllib.quote(image, safe='')) 496 s = requests.session() 497 s.keep_alive = False 498 res = s.get(url, stream=True, timeout=60) 499 res.raise_for_status() 500 501 502 class AgentFactory(object): 503 504 def __init__(self, zone, tracker, build_indexes): 505 self.zone = zone 506 self.tracker = tracker 507 self.build_indexes = build_indexes 508 509 @contextmanager 510 def create(self, n=1, with_docker_socket=False): 511 agents = [Agent(self.zone, i, self.tracker, self.build_indexes, with_docker_socket) for i in range(n)] 512 try: 513 if len(agents) == 1: 514 yield agents[0] 515 else: 516 yield agents 517 finally: 518 for agent in agents: 519 agent.teardown() 520 521 522 class Proxy(Component): 523 524 def __init__(self, zone, origin_cluster, build_indexes): 525 self.zone = zone 526 self.origin_cluster = origin_cluster 527 self.build_indexes = build_indexes 528 self.port = find_free_port() 529 self.config_file = 'test-{zone}.yaml'.format(zone=zone) 530 self.name = 'kraken-proxy-{zone}'.format(zone=zone) 531 532 populate_config_template( 533 'proxy', 534 self.config_file, 535 build_indexes=yaml_list([bi.addr for bi in self.build_indexes]), 536 origins=yaml_list([o.addr for o in self.origin_cluster.origins])) 537 538 self.volumes = create_volumes('proxy', self.name) 539 540 self.start() 541 542 def new_container(self): 543 return new_docker_container( 544 name=self.name, 545 image=dev_tag('kraken-proxy'), 546 ports={self.port: self.port}, 547 environment={}, 548 command=[ 549 '/usr/bin/kraken-proxy', 550 '--config=/etc/kraken/config/proxy/{config}'.format(config=self.config_file), 551 '--port={port}'.format(port=self.port), 552 ], 553 volumes=self.volumes, 554 health_check=HealthCheck('curl localhost:{port}/v2/'.format(port=self.port))) 555 556 @property 557 def registry(self): 558 return '127.0.0.1:{port}'.format(port=self.port) 559 560 def push(self, image): 561 proxy_image = '{reg}/{img}'.format(reg=self.registry, img=image) 562 for command in [ 563 ['docker', 'tag', image, proxy_image], 564 ['docker', 'push', proxy_image], 565 ]: 566 subprocess.check_call(command) 567 568 def push_as(self, image, new_tag): 569 repo = image.split(':')[0] 570 proxy_image = '{reg}/{repo}:{tag}'.format(reg=self.registry, repo=repo, tag=new_tag) 571 for command in [ 572 ['docker', 'tag', image, proxy_image], 573 ['docker', 'push', proxy_image], 574 ]: 575 subprocess.check_call(command) 576 577 def list(self, repo): 578 url = 'http://{reg}/v2/{repo}/tags/list'.format(reg=self.registry, repo=repo) 579 res = requests.get(url) 580 res.raise_for_status() 581 return res.json()['tags'] 582 583 def catalog(self): 584 url = 'http://{reg}/v2/_catalog'.format(reg=self.registry) 585 res = requests.get(url) 586 res.raise_for_status() 587 return res.json()['repositories'] 588 589 def pull(self, image): 590 pull(self.registry, image) 591 592 593 class BuildIndex(Component): 594 595 class Instance(object): 596 597 def __init__(self, name): 598 self.name = name 599 self.hostname = get_docker_bridge() 600 self.port_rez = PortReservation() 601 602 @property 603 def port(self): 604 return self.port_rez.get() 605 606 @property 607 def addr(self): 608 return '{}:{}'.format(self.hostname, self.port) 609 610 def __init__(self, zone, instances, name, origin_cluster, testfs, remote_instances=None): 611 self.zone = zone 612 self.instance = instances[name] 613 self.origin_cluster = origin_cluster 614 self.testfs = testfs 615 self.config_file = 'test-{zone}.yaml'.format(zone=zone) 616 self.name = '{name}-{zone}'.format(name=self.instance.name, zone=zone) 617 618 remotes = "remotes:\n{remotes}".format(remotes='\n'.join(" {addr}: [.*]".format(addr=i.addr) for i in (remote_instances or []))) 619 620 populate_config_template( 621 'build-index', 622 self.config_file, 623 testfs=testfs.addr, 624 origins=yaml_list([o.addr for o in self.origin_cluster.origins]), 625 cluster=yaml_list([i.addr for i in instances.values()]), 626 remotes=remotes) 627 628 self.volumes = create_volumes('build-index', self.name) 629 630 self.start() 631 632 def new_container(self): 633 self.instance.port_rez.release() 634 return new_docker_container( 635 name=self.name, 636 image=dev_tag('kraken-build-index'), 637 ports={self.port: self.port}, 638 environment={}, 639 command=[ 640 '/usr/bin/kraken-build-index', 641 '--config=/etc/kraken/config/build-index/{config}'.format(config=self.config_file), 642 '--port={port}'.format(port=self.port), 643 ], 644 volumes=self.volumes, 645 health_check=HealthCheck(format_insecure_curl( 646 'https://localhost:{}/health'.format(self.port)))) 647 648 @property 649 def port(self): 650 return self.instance.port 651 652 @property 653 def addr(self): 654 return self.instance.addr 655 656 def list_repo(self, repo): 657 url = 'https://localhost:{port}/repositories/{repo}/tags'.format( 658 port=self.port, 659 repo=urllib.quote(repo, safe='')) 660 res = requests.get(url, **tls_opts()) 661 res.raise_for_status() 662 return res.json()['result'] 663 664 665 class TestFS(Component): 666 667 def __init__(self, zone): 668 self.zone = zone 669 self.port = find_free_port() 670 self.start() 671 672 def new_container(self): 673 return new_docker_container( 674 name='kraken-testfs-{zone}'.format(zone=self.zone), 675 image=dev_tag('kraken-testfs'), 676 ports={self.port: self.port}, 677 command=[ 678 '/usr/bin/kraken-testfs', 679 '--port={port}'.format(port=self.port), 680 ], 681 health_check=HealthCheck('curl localhost:{port}/health'.format(port=self.port))) 682 683 def upload(self, name, blob): 684 url = 'http://localhost:{port}/files/blobs/{name}'.format(port=self.port, name=name) 685 res = requests.post(url, data=BytesIO(blob)) 686 res.raise_for_status() 687 688 @property 689 def addr(self): 690 return '{}:{}'.format(get_docker_bridge(), self.port) 691 692 693 class Cluster(object): 694 695 def __init__( 696 self, 697 zone, 698 local_build_index_instances, 699 remote_build_index_instances=None): 700 """ 701 Initializes a full Kraken cluster. 702 703 Note, only use a full cluster if you need to test multiple clusters. Otherwise, 704 the default fixtures should suffice. 705 """ 706 self.zone = zone 707 self.components = [] 708 709 self.testfs = self._register(TestFS(zone)) 710 711 origin_instances = { 712 name: Origin.Instance(name) 713 for name in ('kraken-origin-01', 'kraken-origin-02', 'kraken-origin-03') 714 } 715 self.origin_cluster = OriginCluster([ 716 self._register(Origin(zone, origin_instances, name, self.testfs)) 717 for name in origin_instances 718 ]) 719 720 self.redis = self._register(Redis(zone)) 721 722 self.tracker = self._register(Tracker(zone, self.redis, self.origin_cluster)) 723 724 self.build_indexes = [] 725 for name in local_build_index_instances: 726 self.build_indexes.append(self._register( 727 BuildIndex( 728 zone, local_build_index_instances, name, self.origin_cluster, self.testfs, 729 remote_build_index_instances))) 730 731 # TODO(codyg): Some tests rely on the fact that proxy and agents point 732 # to the first build-index. 733 self.proxy = self._register(Proxy(zone, self.origin_cluster, self.build_indexes)) 734 735 self.agent_factory = AgentFactory(zone, self.tracker, self.build_indexes) 736 737 def _register(self, component): 738 self.components.append(component) 739 return component 740 741 def teardown(self): 742 for c in self.components: 743 c.teardown()