diff --git a/Makefile b/Makefile index 1f2056bb8..2ff905e7d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,9 @@ -.PHONY: all binary test plugin ipam ut clean +.PHONY: all binary test plugin ipam ut clean update-version -SRCFILES=$(shell find calico_cni) +# The current CNI repo version. +CALICO_CNI_VERSION=v1.0.2-dev + +SRCFILES=$(shell find calico_cni) calico.py ipam.py LOCAL_IP_ENV?=$(shell ip route get 8.8.8.8 | head -1 | cut -d' ' -f8) default: all @@ -12,19 +15,26 @@ ipam: dist/calico-ipam # Builds the Calico CNI plugin binary. -dist/calico: $(SRCFILES) +dist/calico: $(SRCFILES) update-version docker run --rm \ -v `pwd`:/code \ calico/build:v0.11.0 \ pyinstaller calico.py -ayF # Makes the IPAM plugin. -dist/calico-ipam: $(SRCFILES) +dist/calico-ipam: $(SRCFILES) update-version docker run --rm \ -v `pwd`:/code \ calico/build:v0.11.0 \ pyinstaller ipam.py -ayF -n calico-ipam +# Updates the version information in __init__.py +update-version: + echo "# Auto-generated contents. Do not manually edit!" > calico_cni/__init__.py + echo "__version__ = '$(shell git describe --tags)'" >> calico_cni/__init__.py + echo "__commit__ = '$(shell git rev-parse HEAD)'" >> calico_cni/__init__.py + echo "__branch__ = '$(shell git rev-parse --abbrev-ref HEAD)'" >> calico_cni/__init__.py + # Run the unit tests. ut: docker run --rm -v `pwd`:/code \ diff --git a/calico.py b/calico.py index 2206de448..4ffa9077c 100755 --- a/calico.py +++ b/calico.py @@ -18,26 +18,39 @@ import os import sys -from subprocess import Popen, PIPE +from docopt import docopt +from subprocess import Popen, PIPE from netaddr import IPNetwork, AddrFormatError from pycalico import netns from pycalico.netns import Namespace, CalledProcessError -from pycalico.datastore import (DatastoreClient, ETCD_AUTHORITY_ENV, +from pycalico.datastore import (DatastoreClient, ETCD_AUTHORITY_ENV, ETCD_AUTHORITY_DEFAULT) from pycalico.datastore_errors import MultipleEndpointsMatch + +from calico_cni import __version__, __commit__, __branch__ from calico_cni.util import (configure_logging, parse_cni_args, print_cni_error, handle_datastore_error, CniError) from calico_cni.container_engines import get_container_engine from calico_cni.constants import * -from ipam import IpamPlugin from calico_cni.policy_drivers import ApplyProfileError, get_policy_driver +from ipam import IpamPlugin # Logging configuration. LOG_FILENAME = "cni.log" _log = logging.getLogger("calico_cni") +__doc__ = """ +Usage: calico [-vh] + +Description: + Calico CNI plugin. + +Options: + -h --help Print this message. + -v --version Print the plugin version +""" class CniPlugin(object): """ @@ -78,9 +91,9 @@ def __init__(self, network_config, env): Type of IPAM to use, e.g calico-ipam. """ - self.policy_driver = get_policy_driver(self.k8s_pod_name, - self.k8s_namespace, - self.network_config) + self.policy_driver = get_policy_driver(self.k8s_pod_name, + self.k8s_namespace, + self.network_config) """ Chooses the correct policy driver based on the given configuration """ @@ -99,7 +112,7 @@ def __init__(self, network_config, env): assert self.command in [CNI_CMD_DELETE, CNI_CMD_ADD], \ "Invalid CNI command %s" % self.command """ - The command to execute for this plugin instance. Required. + The command to execute for this plugin instance. Required. One of: - CNI_CMD_ADD - CNI_CMD_DELETE @@ -134,14 +147,14 @@ def __init__(self, network_config, env): self.orchestrator_id = "cni" """ Configure orchestrator specific settings. - workload_id: In Kubernetes, this is the pod's namespace and name. + workload_id: In Kubernetes, this is the pod's namespace and name. Otherwise, this is the container ID. orchestrator_id: Either "k8s" or "cni". """ def execute(self): """ - Execute the CNI plugin - uses the given CNI_COMMAND to determine + Execute the CNI plugin - uses the given CNI_COMMAND to determine which action to take. :return: None. @@ -152,7 +165,7 @@ def execute(self): self.delete() def add(self): - """"Handles CNI_CMD_ADD requests. + """"Handles CNI_CMD_ADD requests. Configures Calico networking and prints required json to stdout. @@ -163,7 +176,7 @@ def add(self): :return: None. """ - # If this container uses host networking, don't network it. + # If this container uses host networking, don't network it. # This should only be hit when running in Kubernetes mode with # docker - rkt doesn't call plugins when using host networking. if self.container_engine.uses_host_networking(self.container_id): @@ -171,22 +184,22 @@ def add(self): "with host networking.", self.container_id) sys.exit(0) - _log.info("Configuring network '%s' for container: %s", + _log.info("Configuring network '%s' for container: %s", self.network_name, self.container_id) _log.debug("Checking for existing Calico endpoint") endpoint = self._get_endpoint() if endpoint and not self.running_under_k8s: - # We've received a create for an existing container, likely on + # We've received a create for an existing container, likely on # a new CNI network. We don't need to configure the veth or - # assign IP addresses, we simply need to add to the new - # CNI network. Kubernetes handles this case + # assign IP addresses, we simply need to add to the new + # CNI network. Kubernetes handles this case # differently (see below). _log.info("Endpoint for container exists - add to new network") output = self._add_existing_endpoint(endpoint) elif endpoint and self.running_under_k8s: - # Running under Kubernetes and we've received a create for - # an existing workload. Kubernetes only supports a single CNI + # Running under Kubernetes and we've received a create for + # an existing workload. Kubernetes only supports a single CNI # network, which means that the old pod has been destroyed # under our feet and we need to set up networking on the new one. # We should also clean up any stale endpoint / IP assignment. @@ -225,10 +238,10 @@ def _add_new_endpoint(self): # Create the Calico endpoint object. endpoint = self._create_endpoint(ip_list) - + # Provision the veth for this endpoint. endpoint = self._provision_veth(endpoint) - + # Provision / apply profile on the created endpoint. try: self.policy_driver.apply_profile(endpoint) @@ -252,7 +265,7 @@ def _add_existing_endpoint(self, endpoint): We've already assigned an IP address and created the veth, we just need to apply a new profile to this endpoint. """ - # Get the already existing IP information for this Endpoint. + # Get the already existing IP information for this Endpoint. try: ip4 = next(iter(endpoint.ipv4_nets)) except StopIteration: @@ -278,9 +291,9 @@ def _add_existing_endpoint(self, endpoint): print_cni_error(ERR_CODE_GENERIC, e.message) sys.exit(ERR_CODE_GENERIC) - return {"ip4": {"ip": str(ip4.cidr)}, + return {"ip4": {"ip": str(ip4.cidr)}, "ip6": {"ip": str(ip6.cidr)}} - + def delete(self): """Handles CNI_CMD_DELETE requests. @@ -288,7 +301,7 @@ def delete(self): :return: None. """ - _log.info("Remove network '%s' from container: %s", + _log.info("Remove network '%s' from container: %s", self.network_name, self.container_id) # Step 1: Remove any IP assignments. @@ -326,7 +339,7 @@ def _assign_ips(self, env): rc, result = self._call_ipam_plugin(env) try: - # Load the response - either the assigned IP addresses or + # Load the response - either the assigned IP addresses or # a CNI error message. ipam_result = json.loads(result) except ValueError: @@ -396,7 +409,7 @@ def _call_ipam_plugin(self, env): Executes a CNI IPAM plugin. If `calico-ipam` is the provided IPAM type, then calls directly into ipam.py as a performance optimization. - For all other types of IPAM, searches the CNI_PATH for the + For all other types of IPAM, searches the CNI_PATH for the correct binary and executes it. :return: Tuple of return code, response from the IPAM plugin. @@ -404,14 +417,14 @@ def _call_ipam_plugin(self, env): if self.ipam_type == "calico-ipam": _log.info("Using Calico IPAM") try: - response = IpamPlugin(env, + response = IpamPlugin(env, self.network_config["ipam"]).execute() code = 0 except CniError as e: # We hit a CNI error - return the appropriate CNI formatted # error dictionary. - response = json.dumps({"code": e.code, - "msg": e.msg, + response = json.dumps({"code": e.code, + "msg": e.msg, "details": e.details}) code = e.code else: @@ -424,7 +437,7 @@ def _call_ipam_plugin(self, env): def _call_binary_ipam_plugin(self, env): """Calls through to the specified IPAM plugin binary. - + Utilizes the IPAM config as specified in the CNI network configuration file. A dictionary with the following form: { @@ -442,7 +455,7 @@ def _call_binary_ipam_plugin(self, env): (self.ipam_type, self.cni_path) print_cni_error(ERR_CODE_GENERIC, message) sys.exit(ERR_CODE_GENERIC) - + # Execute the plugin and return the result. _log.info("Using IPAM plugin at: %s", plugin_path) _log.debug("Passing in environment to IPAM plugin: \n%s", @@ -450,7 +463,7 @@ def _call_binary_ipam_plugin(self, env): p = Popen(plugin_path, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) stdout, stderr = p.communicate(json.dumps(self.network_config)) _log.debug("IPAM plugin return code: %s", p.returncode) - _log.debug("IPAM plugin output: \nstdout:\n%s\nstderr:\n%s", + _log.debug("IPAM plugin output: \nstdout:\n%s\nstderr:\n%s", stdout, stderr) return p.returncode, stdout @@ -460,7 +473,7 @@ def _create_endpoint(self, ip_list): :param ip_list - list of IP addresses that have been already allocated :return Calico endpoint object """ - _log.debug("Creating Calico endpoint with workload_id=%s", + _log.debug("Creating Calico endpoint with workload_id=%s", self.workload_id) try: endpoint = self._client.create_endpoint(HOSTNAME, @@ -468,7 +481,7 @@ def _create_endpoint(self, ip_list): self.workload_id, ip_list) except (AddrFormatError, KeyError) as e: - # AddrFormatError: Raised when an IP address type is not + # AddrFormatError: Raised when an IP address type is not # compatible with the node. # KeyError: Raised when BGP config for host is not found. _log.exception("Failed to create Calico endpoint.") @@ -482,7 +495,7 @@ def _create_endpoint(self, ip_list): def _remove_stale_endpoint(self, endpoint): """ - Removes the given endpoint from Calico. + Removes the given endpoint from Calico. Called when we discover a stale endpoint that is no longer in use. Note that this doesn't release IP allocations - that must be done using the designated IPAM plugin. @@ -506,7 +519,7 @@ def _remove_workload(self): workload_id=self.workload_id) except KeyError: # Attempt to remove the workload using the container ID as the - # workload ID. Earlier releases of the plugin used the + # workload ID. Earlier releases of the plugin used the # container ID for the workload ID rather than the Kubernetes pod # name and namespace. _log.debug("Could not find workload with workload ID %s.", @@ -559,7 +572,7 @@ def _remove_veth(self, endpoint): _log.info("Removing veth for endpoint: %s", endpoint.name) try: removed = netns.remove_veth(endpoint.name) - _log.debug("Successfully removed endpoint %s? %s", + _log.debug("Successfully removed endpoint %s? %s", endpoint.name, removed) except CalledProcessError: _log.warning("Unable to remove veth %s", endpoint.name) @@ -568,7 +581,7 @@ def _remove_veth(self, endpoint): def _get_endpoint(self): """Get endpoint matching self.workload_id. - If we cannot find an endpoint using self.workload_id, try + If we cannot find an endpoint using self.workload_id, try using self.container_id. Return None if no endpoint is found. @@ -585,9 +598,9 @@ def _get_endpoint(self): workload_id=self.workload_id ) except KeyError: - # Try to find using the container ID. In earlier version of the + # Try to find using the container ID. In earlier version of the # plugin, the container ID was used as the workload ID. - _log.debug("No endpoint found matching workload ID %s", + _log.debug("No endpoint found matching workload ID %s", self.workload_id) try: endpoint = self._client.get_endpoint( @@ -596,9 +609,9 @@ def _get_endpoint(self): workload_id=self.container_id ) except KeyError: - # We were unable to find an endpoint using either the + # We were unable to find an endpoint using either the # workload ID or the container ID. - _log.debug("No endpoint found matching container ID %s", + _log.debug("No endpoint found matching container ID %s", self.container_id) endpoint = None except MultipleEndpointsMatch: @@ -620,7 +633,7 @@ def _find_ipam_plugin(self): :rtype : str :return: plugin_path - absolute path of IPAM plugin binary """ - plugin_type = self.ipam_type + plugin_type = self.ipam_type plugin_path = "" for path in self.cni_path.split(":"): _log.debug("Looking for plugin %s in path %s", plugin_type, path) @@ -646,17 +659,17 @@ def main(): # Configure logging. configure_logging(_log, LOG_FILENAME, log_level=log_level) - _log.debug("Loaded network config:\n%s", + _log.debug("Loaded network config:\n%s", json.dumps(network_config, indent=2)) - # Get the etcd authority from the config file. Set the + # Get the etcd authority from the config file. Set the # environment variable. - etcd_authority = network_config.get(ETCD_AUTHORITY_KEY, + etcd_authority = network_config.get(ETCD_AUTHORITY_KEY, ETCD_AUTHORITY_DEFAULT) os.environ[ETCD_AUTHORITY_ENV] = etcd_authority _log.debug("Using ETCD_AUTHORITY=%s", etcd_authority) - # Get the CNI environment. + # Get the CNI environment. env = os.environ.copy() _log.debug("Loaded environment:\n%s", json.dumps(env, indent=2)) @@ -681,12 +694,22 @@ def main(): if __name__ == '__main__': # pragma: no cover + # Parse out the provided arguments. + command_args = docopt(__doc__) + + # If the version argument was given, print version and exit. + if command_args.get("--version"): + print(json.dumps({"Version": __version__, + "Commit": __commit__, + "Branch": __branch__}, indent=2)) + sys.exit(0) + try: main() except Exception as e: # Catch any unhandled exceptions in the main() function. Any errors # in CniPlugin.execute() are already handled. - print_cni_error(ERR_CODE_GENERIC, - "Unhandled Exception in main()", + print_cni_error(ERR_CODE_GENERIC, + "Unhandled Exception in main()", e.message) sys.exit(ERR_CODE_GENERIC) diff --git a/calico_cni/__init__.py b/calico_cni/__init__.py index e69de29bb..6209f8b21 100644 --- a/calico_cni/__init__.py +++ b/calico_cni/__init__.py @@ -0,0 +1,4 @@ +# Auto-generated contents. Do not manually edit! +__version__ = 'v1.0.2-9-g0e484fa' +__commit__ = '0e484fae8b0d0a60f7517b98dfbe9691bb583ceb' +__branch__ = 'version-flag' diff --git a/ipam.py b/ipam.py index d0a6298de..317e30d4d 100755 --- a/ipam.py +++ b/ipam.py @@ -17,13 +17,25 @@ import os import sys +from docopt import docopt from netaddr import IPNetwork from pycalico.ipam import IPAMClient -from calico_cni.util import (CniError, parse_cni_args, +from calico_cni import __version__, __commit__, __branch__ +from calico_cni.util import (CniError, parse_cni_args, configure_logging, print_cni_error) from calico_cni.constants import * +__doc__ = """ +Usage: calico-ipam [-vh] + +Description: + Calico CNI IPAM plugin. + +Options: + -h --help Print this message. + -v --version Print the plugin version +""" # Logging config. LOG_FILENAME = "ipam.log" @@ -79,10 +91,10 @@ def __init__(self, environment, ipam_config): def execute(self): """ - Assigns or releases IP addresses for the specified workload. + Assigns or releases IP addresses for the specified workload. May raise CniError. - + :return: CNI ipam dictionary for ADD, None for DEL. """ if self.command == "ADD": @@ -96,24 +108,24 @@ def execute(self): response["ip4"] = {"ip": str(ipv4.cidr)} if ipv6: response["ip6"] = {"ip": str(ipv6.cidr)} - + # Output the response and exit successfully. _log.debug("Returning response: %s", response) return json.dumps(response) else: # Release IPs using the workload_id as the handle. - _log.info("Releasing addresses on workload: %s", + _log.info("Releasing addresses on workload: %s", self.workload_id) try: self.datastore_client.release_ip_by_handle( handle_id=self.workload_id ) except KeyError: - _log.warning("No IPs assigned to workload: %s", + _log.warning("No IPs assigned to workload: %s", self.workload_id) try: # Try to release using the container ID. Earlier - # versions of IPAM used the container ID alone + # versions of IPAM used the container ID alone # as the handle. This allows us to be back-compatible. _log.debug("Try release using container ID") self.datastore_client.release_ip_by_handle( @@ -125,12 +137,12 @@ def execute(self): def _assign_address(self, handle_id): """ - Assigns an IPv4 and an IPv6 address. - + Assigns an IPv4 and an IPv6 address. + :return: A tuple of (IPv4, IPv6) address assigned. """ - ipv4 = None - ipv6 = None + ipv4 = None + ipv6 = None # Determine which addresses to assign. num_v4 = 1 if self.assign_ipv4 else 0 @@ -138,13 +150,13 @@ def _assign_address(self, handle_id): _log.info("Assigning %s IPv4 and %s IPv6 addresses", num_v4, num_v6) try: ipv4_addrs, ipv6_addrs = self.datastore_client.auto_assign_ips( - num_v4=num_v4, num_v6=num_v6, handle_id=handle_id, + num_v4=num_v4, num_v6=num_v6, handle_id=handle_id, attributes=None, ) _log.debug("Allocated ip4s: %s, ip6s: %s", ipv4_addrs, ipv6_addrs) except RuntimeError as e: _log.error("Cannot auto assign IPAddress: %s", e.message) - raise CniError(ERR_CODE_GENERIC, + raise CniError(ERR_CODE_GENERIC, msg="Failed to assign IP address", details=e.message) else: @@ -165,14 +177,14 @@ def _assign_address(self, handle_id): msg="No IPv6 addresses available in pool") _log.info("Assigned IPv4: %s, IPv6: %s", ipv4, ipv6) - return ipv4, ipv6 + return ipv4, ipv6 def _parse_environment(self, env): """ Validates the plugins environment and extracts the required values. """ _log.debug('Environment: %s', json.dumps(env, indent=2)) - + # Check the given environment contains the required fields. try: self.command = env[CNI_COMMAND_ENV] @@ -196,7 +208,7 @@ def _parse_environment(self, env): def _exit_on_error(code, message, details=""): """ - Return failure information to the calling plugin as specified in the + Return failure information to the calling plugin as specified in the CNI spec and exit. :param code: Error code to return (int) :param message: Short error message to return. @@ -215,10 +227,10 @@ def main(): # Get the log level from the config file, default to INFO. log_level = config.get(LOG_LEVEL_KEY, "INFO").upper() - # Setup logger. We log to file and to stderr based on the + # Setup logger. We log to file and to stderr based on the # log level provided in the network configuration file. - configure_logging(_log, LOG_FILENAME, - log_level=log_level, + configure_logging(_log, LOG_FILENAME, + log_level=log_level, stderr_level=logging.INFO) # Get copy of environment. @@ -228,7 +240,7 @@ def main(): # Execute IPAM. output = IpamPlugin(env, config["ipam"]).execute() except CniError as e: - # We caught a CNI error - print the result to stdout and + # We caught a CNI error - print the result to stdout and # exit. _exit_on_error(e.code, e.msg, e.details) except Exception as e: @@ -242,4 +254,14 @@ def main(): if __name__ == '__main__': # pragma: no cover + # Parse out the provided arguments. + command_args = docopt(__doc__) + + # If the version argument was given, print version and exit. + if command_args.get("--version"): + print(json.dumps({"Version": __version__, + "Commit": __commit__, + "Branch": __branch__}, indent=2)) + sys.exit(0) + main()