import json import re import calendar from uuid import uuid4 from app import app from util.registry.streamlayerformat import StreamLayerMerger from image.common import TarImageFormatter ACNAME_REGEX = re.compile(r'[^a-z-]+') class AppCImageFormatter(TarImageFormatter): """ Image formatter which produces an tarball according to the AppC specification. """ def stream_generator(self, namespace, repository, tag, repo_image, synthetic_image_id, get_image_iterator, get_layer_iterator): image_mtime = 0 created = next(get_image_iterator()).v1_metadata.created if created is not None: image_mtime = calendar.timegm(created.utctimetuple()) # ACI Format (.tar): # manifest - The JSON manifest # rootfs - The root file system # Yield the manifest. manifest = self._build_manifest(namespace, repository, tag, repo_image, synthetic_image_id) yield self.tar_file('manifest', manifest, mtime=image_mtime) # Yield the merged layer dtaa. yield self.tar_folder('rootfs', mtime=image_mtime) layer_merger = StreamLayerMerger(get_layer_iterator, path_prefix='rootfs/') for entry in layer_merger.get_generator(): yield entry @staticmethod def _build_isolators(docker_config): """ Builds ACI isolator config from the docker config. """ def _isolate_memory(memory): return { "name": "memory/limit", "value": { "request": str(memory) + 'B', } } def _isolate_swap(memory): return { "name": "memory/swap", "value": { "request": str(memory) + 'B', } } def _isolate_cpu(cpu): return { "name": "cpu/shares", "value": { "request": str(cpu), } } def _isolate_capabilities(capabilities_set_value): capabilities_set = re.split(r'[\s,]', capabilities_set_value) return { "name": "os/linux/capabilities-retain-set", "value": { "set": capabilities_set, } } mappers = { 'Memory': _isolate_memory, 'MemorySwap': _isolate_swap, 'CpuShares': _isolate_cpu, 'Cpuset': _isolate_capabilities } isolators = [] for config_key in mappers: value = docker_config.get(config_key) if value: isolators.append(mappers[config_key](value)) return isolators @staticmethod def _get_docker_config_value(docker_config, key, default_value): # Try the key itself. result = docker_config.get(key) if result is not None: return result or default_value # The the lowercase version of the key. result = docker_config.get(key.lower()) if result is not None: return result or default_value return default_value @staticmethod def _build_ports(docker_config): """ Builds the ports definitions for the ACI. Formats: port/tcp port/udp port """ ports = [] for docker_port in AppCImageFormatter._get_docker_config_value(docker_config, 'Ports', []): protocol = 'tcp' port_number = -1 if '/' in docker_port: (port_number, protocol) = docker_port.split('/') else: port_number = docker_port try: port_number = int(port_number) ports.append({ "name": "port-%s" % port_number, "port": port_number, "protocol": protocol, }) except ValueError: pass return ports @staticmethod def _ac_name(value): sanitized = ACNAME_REGEX.sub('-', value.lower()).strip('-') if sanitized == '': return str(uuid4()) return sanitized @staticmethod def _build_volumes(docker_config): """ Builds the volumes definitions for the ACI. """ volumes = [] def get_name(docker_volume_path): return "volume-%s" % AppCImageFormatter._ac_name(docker_volume_path) for docker_volume_path in AppCImageFormatter._get_docker_config_value(docker_config, 'Volumes', []): if not docker_volume_path: continue volumes.append({ "name": get_name(docker_volume_path), "path": docker_volume_path, "readOnly": False, }) return volumes @staticmethod def _build_manifest(namespace, repository, tag, repo_image, synthetic_image_id): """ Builds an ACI manifest of an existing repository image. """ docker_layer_data = repo_image.compat_metadata config = docker_layer_data.get('config', {}) source_url = "%s://%s/%s/%s:%s" % (app.config['PREFERRED_URL_SCHEME'], app.config['SERVER_HOSTNAME'], namespace, repository, tag) # ACI requires that the execution command be absolutely referenced. Therefore, if we find # a relative command, we give it as an argument to /bin/sh to resolve and execute for us. entrypoint = config.get('Entrypoint', []) or [] exec_path = entrypoint + (config.get('Cmd', []) or []) if exec_path and not exec_path[0].startswith('/'): exec_path = ['/bin/sh', '-c', '""%s""' % ' '.join(exec_path)] # TODO(jschorr): ACI doesn't support : in the name, so remove any ports. hostname = app.config['SERVER_HOSTNAME'] hostname = hostname.split(':', 1)[0] # Calculate the environment variables. docker_env_vars = config.get('Env', []) or [] env_vars = [] for var in docker_env_vars: pieces = var.split('=') if len(pieces) != 2: continue env_vars.append(pieces) manifest = { "acKind": "ImageManifest", "acVersion": "0.6.1", "name": '%s/%s/%s' % (hostname.lower(), namespace.lower(), repository.lower()), "labels": [ { "name": "version", "value": tag, }, { "name": "arch", "value": docker_layer_data.get('architecture', 'amd64') }, { "name": "os", "value": docker_layer_data.get('os', 'linux') } ], "app": { "exec": exec_path, # Below, `or 'root'` is required to replace empty string from Dockerfiles. "user": config.get('User', '') or 'root', "group": config.get('Group', '') or 'root', "eventHandlers": [], "workingDirectory": config.get('WorkingDir', '') or '/', "environment": [{"name": key, "value": value} for (key, value) in env_vars], "isolators": AppCImageFormatter._build_isolators(config), "mountPoints": AppCImageFormatter._build_volumes(config), "ports": AppCImageFormatter._build_ports(config), "annotations": [ {"name": "created", "value": docker_layer_data.get('created', '')}, {"name": "homepage", "value": source_url}, {"name": "quay.io/derived-image", "value": synthetic_image_id}, ] }, } return json.dumps(manifest)