| # Copyright 2015 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Logic to generate a SandboxSpec from an appc pod manifest. |
| |
| https://github.com/appc/spec/blob/master/SPEC.md |
| """ |
| |
| from __future__ import print_function |
| |
| import collections |
| import copy |
| import json |
| import os |
| import re |
| |
| from chromite.lib import cros_logging as logging |
| from chromite.lib import json_lib |
| from chromite.lib import osutils |
| from chromite.lib import remote_access |
| from chromite.lib import user_db |
| |
| KEY_ANNOTATIONS_LIST = 'annotations' |
| KEY_ANNOTATION_NAME = 'name' |
| KEY_ANNOTATION_VALUE = 'value' |
| ENDPOINT_NAME_ANNOTATION_PREFIX = 'bruteus-endpoint-' |
| |
| KEY_APPS_LIST = 'apps' |
| KEY_APP_NAME = 'name' |
| KEY_APP_IMAGE = 'image' |
| KEY_APP_IMAGE_NAME = 'name' |
| KEY_APP_ISOLATORS = 'isolators' |
| |
| KEY_APP_SUB_APP = 'app' |
| KEY_SUB_APP_USER = 'user' |
| KEY_SUB_APP_GROUP = 'group' |
| KEY_SUB_APP_EXEC = 'exec' |
| KEY_SUB_APP_PORTS = 'ports' |
| |
| PORT_SPEC_COUNT = 'count' |
| PORT_SPEC_NAME = 'name' |
| PORT_SPEC_PORT = 'port' |
| PORT_SPEC_PROTOCOL = 'protocol' |
| PORT_SPEC_SOCKET_ACTIVATED = 'socketActivated' |
| |
| PROTOCOL_TCP = 'tcp' |
| PROTOCOL_UDP = 'udp' |
| VALID_PROTOCOLS = (PROTOCOL_TCP, PROTOCOL_UDP) |
| |
| ISOLATOR_KEY_NAME = 'name' |
| ISOLATOR_KEY_VALUE = 'value' |
| ISOLATOR_KEY_VALUE_SET = 'set' |
| |
| ISOLATOR_NAME_PREFIX = 'os/linux/capabilities-' |
| ISOLATOR_NAME_RETAIN_SET = 'os/linux/capabilities-retain-set' |
| |
| PortSpec = collections.namedtuple('PortSpec', ('allow_all', 'port_list')) |
| |
| |
| def IsValidAcName(name): |
| """Returns true if |name| adheres to appc's AC Name Type. |
| |
| This roughly means that a string looks like a protocol-less |
| URL (e.g. foo-foo/bar/bar). |
| |
| https://github.com/appc/spec/blob/master/SPEC.md#ac-name-type |
| |
| Args: |
| name: string to validate. |
| |
| Returns: |
| True iff |name| is a valid AC Name. |
| """ |
| return bool(re.match(r'^[a-z0-9]+([-./][a-z0-9]+)*$', name)) |
| |
| |
| class SandboxSpecWrapper(object): |
| """Wrapper that knows how to set fields in a protocol buffer. |
| |
| This makes mocking out our protocol buffer interface much simpler. |
| """ |
| |
| def __init__(self): |
| # In the context of unittests run from outside the chroot, this import |
| # will fail. Tests will mock out this entire class. |
| # pylint: disable=import-error |
| from generated import soma_sandbox_spec_pb2 |
| self.sandbox_spec = soma_sandbox_spec_pb2.SandboxSpec() |
| |
| def SetName(self, name): |
| """Set the name of the runnable brick.""" |
| self.sandbox_spec.name = name |
| self.sandbox_spec.overlay_path = '/bricks/%s' % name |
| |
| def AddExecutable(self, uid, gid, command_line, tcp_ports, udp_ports, |
| linux_caps): |
| """Add an executable to the wrapped SandboxSpec. |
| |
| Args: |
| uid: integer UID of the user to run this executable. |
| gid: integer GID of the group to run this executable. |
| command_line: list of strings to run. |
| tcp_ports: list of PortSpec tuples. |
| udp_ports: list of PortSpec tuples. |
| linux_caps: list of string names of capabilities (e.g. 'CAP_CHOWN'). |
| """ |
| executable = self.sandbox_spec.executables.add() |
| executable.uid = uid |
| executable.gid = gid |
| executable.command_line.extend(command_line) |
| for listen_ports, ports in ((executable.tcp_listen_ports, tcp_ports), |
| (executable.udp_listen_ports, udp_ports)): |
| if ports.allow_all: |
| listen_ports.allow_all = True |
| else: |
| listen_ports.allow_all = False |
| listen_ports.ports.extend(ports.port_list) |
| |
| # Map the names of caps to the appropriate protobuffer values. |
| caps = [self.sandbox_spec.LinuxCaps.Value('LINUX_' + cap_name) |
| for cap_name in linux_caps] |
| executable.capabilities.extend(caps) |
| |
| def AddEndpointName(self, endpoint_name): |
| """Adds the name of an endpoint that'll run inside this sandbox.""" |
| self.sandbox_spec.endpoint_names.append(endpoint_name) |
| |
| |
| def _GetPortList(desired_protocol, appc_port_list): |
| """Get the list of ports opened for |desired_protocol| from |appc_port_list|. |
| |
| Args: |
| desired_protocol: one of VALID_PROTOCOLS. |
| appc_port_list: list of port specifications from a appc pod manifest. |
| |
| Returns: |
| Instance of PortSpec. |
| """ |
| # The port specification is optional. |
| if appc_port_list is None: |
| return PortSpec(False, []) |
| |
| json_lib.AssertIsInstance(appc_port_list, list, 'port specification list') |
| |
| allow_all = False |
| port_list = [] |
| for port_dict in appc_port_list: |
| json_lib.AssertIsInstance(port_dict, dict, 'port specification') |
| port_dict = copy.deepcopy(port_dict) |
| |
| # By default, we open a single specified port. |
| port_dict.setdefault(PORT_SPEC_COUNT, 1) |
| # By default, don't set socket activated. |
| port_dict.setdefault(PORT_SPEC_SOCKET_ACTIVATED, False) |
| |
| # We don't actually use the port name, but it's handy for documentation |
| # and standard adherence to enforce its existence. |
| port_name = json_lib.PopValueOfType( |
| port_dict, PORT_SPEC_NAME, unicode, 'port name') |
| logging.debug('Validating appc specifcation of "%s"', port_name) |
| port = json_lib.PopValueOfType(port_dict, PORT_SPEC_PORT, int, 'port') |
| protocol = json_lib.PopValueOfType( |
| port_dict, PORT_SPEC_PROTOCOL, unicode, 'protocol') |
| |
| count = json_lib.PopValueOfType( |
| port_dict, PORT_SPEC_COUNT, int, 'port range count') |
| |
| # We also don't use the socketActivated flag, but we should tolerate safe |
| # values. |
| socket_activated = json_lib.PopValueOfType( |
| port_dict, PORT_SPEC_SOCKET_ACTIVATED, bool, 'socket activated flag') |
| |
| # Validate everything before acting on it. |
| if protocol not in VALID_PROTOCOLS: |
| raise ValueError('Port protocol must be in %r, not "%s"' % |
| (VALID_PROTOCOLS, protocol)) |
| if protocol != desired_protocol: |
| continue |
| |
| if socket_activated != False: |
| raise ValueError('No support for socketActivated==True in %s' % port_name) |
| |
| if port_dict: |
| raise ValueError('Unknown keys found in port spec %s: %r' % |
| (port_name, port_dict.keys())) |
| |
| if port == -1: |
| # Remember that we're going to return that all ports are opened, but |
| # continue validating all the remaining specifications. |
| allow_all = True |
| continue |
| |
| # Now we know it's not the wildcard port, and that we've never declared |
| # a wildcard for this protocol. |
| port = remote_access.NormalizePort(port) |
| |
| if count < 1: |
| raise ValueError('May only specify positive port ranges for %s' % |
| port_name) |
| if port + count >= 65536: |
| raise ValueError('Port range extends past max port number for %s' % |
| port_name) |
| |
| for effective_port in xrange(port, port + count): |
| port_list.append(effective_port) |
| |
| return PortSpec(allow_all, port_list) |
| |
| |
| def _ExtractLinuxCapNames(app_dict): |
| """Parses the set of Linux capabilities for an executable. |
| |
| Args: |
| app_dict: dictionary defining an executable. |
| |
| Returns: |
| List of names of Linux capabilities (e.g. ['CAP_CHOWN']). |
| """ |
| if KEY_APP_ISOLATORS not in app_dict: |
| return [] |
| |
| isolator_list = json_lib.GetValueOfType( |
| app_dict, KEY_APP_ISOLATORS, list, |
| 'list of isolators for application') |
| linux_cap_isolators = [] |
| |
| # Look for any isolators related to capability sets. |
| for isolator in isolator_list: |
| json_lib.AssertIsInstance(isolator, dict, 'isolator instance') |
| isolator_name = json_lib.GetValueOfType( |
| isolator, ISOLATOR_KEY_NAME, unicode, 'isolator name') |
| if not isolator_name.startswith(ISOLATOR_NAME_PREFIX): |
| continue |
| if isolator_name != ISOLATOR_NAME_RETAIN_SET: |
| raise ValueError('Capabilities may only be specified as %s' % |
| ISOLATOR_NAME_RETAIN_SET) |
| linux_cap_isolators.append(isolator) |
| |
| # We may have only a single isolator. |
| if len(linux_cap_isolators) > 1: |
| raise ValueError('Found two lists of Linux caps for an executable') |
| if not linux_cap_isolators: |
| return [] |
| |
| value = json_lib.GetValueOfType( |
| linux_cap_isolators[0], ISOLATOR_KEY_VALUE, dict, |
| 'Linux cap isolator value') |
| caps = json_lib.GetValueOfType( |
| value, ISOLATOR_KEY_VALUE_SET, list, 'Linux cap isolator set') |
| for cap in caps: |
| json_lib.AssertIsInstance(cap, unicode, 'Linux capability in set.') |
| |
| return caps |
| |
| |
| class SandboxSpecGenerator(object): |
| """Delegate that knows how to read appc manifests and write SandboxSpecs.""" |
| |
| def __init__(self, sysroot): |
| self._sysroot = sysroot |
| self._user_db = user_db.UserDB(sysroot) |
| |
| def _CheckAbsPathToExecutable(self, path_to_binary): |
| """Raises if there is no exectable at |path_to_binary|.""" |
| if not os.path.isabs(path_to_binary): |
| raise ValueError( |
| 'Brick executables must be specified by absolute path, not "%s".' % |
| path_to_binary) |
| return True |
| |
| def _FillInEndpointNamesFromAnnotations(self, wrapper, annotations): |
| """Fill in the SandboxSpec endpoint_names field from |annotations|. |
| |
| An appc pod specification can contain a list of (mostly) arbitrary |
| annotations that projects can use to add their own metadata fields. |
| |annotations| is a list of dicts that each contain a name and value field, |
| and this method looks for 'name' fields that are prefixed with |
| ENDPOINT_NAME_ANNOTATION_PREFIX and treats the associated 'value' as the |
| name of an endpoint that psyched will expect to be registered from within |
| this sandbox. |
| |
| Args: |
| wrapper: instance of SandboxSpecWrapper. |
| annotations: list of dicts, each with a name and value field. |
| """ |
| for annotation in annotations: |
| json_lib.AssertIsInstance(annotation, dict, 'a single annotation') |
| name = json_lib.GetValueOfType( |
| annotation, KEY_ANNOTATION_NAME, unicode, 'annotation name') |
| if not IsValidAcName(name): |
| raise ValueError('Annotation name "%s" contains illegal characters.' % |
| name) |
| if name.startswith(ENDPOINT_NAME_ANNOTATION_PREFIX): |
| endpoint_name = json_lib.GetValueOfType( |
| annotation, KEY_ANNOTATION_VALUE, unicode, 'endpoint name value') |
| if not IsValidAcName(name): |
| raise ValueError('Endpoint name "%s" contains illegal characters.' % |
| endpoint_name) |
| wrapper.AddEndpointName(endpoint_name) |
| |
| def _FillInExecutableFromApp(self, wrapper, app): |
| """Fill in the fields of a SandboxSpec.Executable object from |app|. |
| |
| Args: |
| wrapper: instance of SandboxSpecWrapper. |
| app: dictionary of information taken from the appc pod manifest. |
| """ |
| sub_app = json_lib.GetValueOfType( |
| app, KEY_APP_SUB_APP, dict, 'per app app dict') |
| user = json_lib.GetValueOfType( |
| sub_app, KEY_SUB_APP_USER, unicode, 'app dict user') |
| group = json_lib.GetValueOfType( |
| sub_app, KEY_SUB_APP_GROUP, unicode, 'app dict group') |
| |
| if not self._user_db.UserExists(user): |
| raise ValueError('Found invalid username "%s"' % user) |
| if not self._user_db.GroupExists(group): |
| raise ValueError('Found invalid groupname "%s"' % group) |
| |
| cmd = json_lib.GetValueOfType( |
| sub_app, KEY_SUB_APP_EXEC, list, 'app command line') |
| if not cmd: |
| raise ValueError('App command line must give the executable to run.') |
| self._CheckAbsPathToExecutable(cmd[0]) |
| for cmd_piece in cmd: |
| json_lib.AssertIsInstance(cmd_piece, unicode, 'app.exec fragment') |
| |
| port_list = sub_app.get(KEY_SUB_APP_PORTS, None) |
| wrapper.AddExecutable(self._user_db.ResolveUsername(user), |
| self._user_db.ResolveGroupname(group), |
| cmd, |
| _GetPortList(PROTOCOL_TCP, port_list), |
| _GetPortList(PROTOCOL_UDP, port_list), |
| _ExtractLinuxCapNames(sub_app)) |
| |
| def GetSandboxSpec(self, appc_contents, sandbox_spec_name): |
| """Create a SandboxSpec encoding the information in an appc pod manifest. |
| |
| Args: |
| appc_contents: string contents of an appc pod manifest |
| sandbox_spec_name: string unique name of this sandbox. |
| |
| Returns: |
| an instance of SandboxSpec. |
| """ |
| wrapper = SandboxSpecWrapper() |
| overlay_name = None |
| |
| app_list = json_lib.GetValueOfType( |
| appc_contents, KEY_APPS_LIST, list, 'app list') |
| for app in app_list: |
| json_lib.AssertIsInstance(app, dict, 'app') |
| |
| # Aid debugging of problems in specific apps. |
| app_name = json_lib.GetValueOfType( |
| app, KEY_APP_NAME, unicode, 'app name') |
| if not IsValidAcName(app_name): |
| raise ValueError('Application name "%s" contains illegal characters.' % |
| app_name) |
| logging.debug('Processing application "%s".', app_name) |
| |
| # Get the name of the image, check that it's consistent other image names. |
| image = json_lib.GetValueOfType( |
| app, KEY_APP_IMAGE, dict, 'image specification for app') |
| image_name = json_lib.GetValueOfType( |
| image, KEY_APP_IMAGE_NAME, unicode, 'image name') |
| if not IsValidAcName(image_name): |
| raise ValueError('Image name "%s" contains illegal characters.' % |
| image_name) |
| |
| if overlay_name and overlay_name != image_name: |
| raise ValueError( |
| 'All elements of "apps" must have the same image.name.') |
| overlay_name = image_name |
| |
| # Add the executable corresponding to this app to our SandboxSpec. |
| self._FillInExecutableFromApp(wrapper, app) |
| |
| if not overlay_name: |
| raise ValueError('Overlays must declare at least one app') |
| |
| annotation_list = json_lib.GetValueOfType( |
| appc_contents, KEY_ANNOTATIONS_LIST, list, 'list of all annotations') |
| self._FillInEndpointNamesFromAnnotations(wrapper, annotation_list) |
| |
| wrapper.SetName(sandbox_spec_name) |
| return wrapper.sandbox_spec |
| |
| def WriteSandboxSpec(self, appc_pod_manifest_path, output_path): |
| """Write a SandboxSpec corresponding to |appc_pod_manifest_path| to disk. |
| |
| Args: |
| appc_pod_manifest_path: path to an appc pod manifest file. |
| output_path: path to file to write serialized SandboxSpec. The |
| containing directory must exist, but the file may not. This is not |
| checked atomically. |
| """ |
| if os.path.isfile(output_path): |
| raise ValueError( |
| 'Refusing to write SandboxSpec to file %s which already exists!' % |
| output_path) |
| |
| appc_contents = json.loads(osutils.ReadFile(appc_pod_manifest_path)) |
| # Use the file name without extension as the the name of the sandbox spec. |
| sandbox_name = os.path.basename(appc_pod_manifest_path).rsplit('.', 1)[0] |
| spec = self.GetSandboxSpec(appc_contents, sandbox_name) |
| osutils.WriteFile(output_path, spec.SerializeToString()) |