├── calico_mesos ├── tests │ ├── __init__.py │ ├── utils │ │ ├── utils.py │ │ └── __init__.py │ └── unit │ │ ├── __init__.py │ │ └── mesos_test.py ├── nose.cfg └── calico_mesos.py ├── .gitignore ├── circle.yml ├── README.md ├── Makefile └── LICENSE /calico_mesos/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /calico_mesos/tests/utils/utils.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /calico_mesos/tests/unit/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | dist 3 | .coverage 4 | *.created 5 | -------------------------------------------------------------------------------- /calico_mesos/tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'dano' 2 | -------------------------------------------------------------------------------- /calico_mesos/nose.cfg: -------------------------------------------------------------------------------- 1 | [nosetests] 2 | with-coverage=1 3 | cover-erase=1 4 | cover-package=calico_mesos -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | general: 2 | artifacts: 3 | - "dist" 4 | machine: 5 | services: 6 | - docker 7 | 8 | test: 9 | override: 10 | - make ut-circle 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![CircleCI branch](https://img.shields.io/circleci/project/projectcalico/calico-mesos/master.svg?label=calico_mesos)](https://circleci.com/gh/projectcalico/calico-mesos/tree/master) 3 | [![Slack Status](https://slack.projectcalico.org/badge.svg)](https://slack.projectcalico.org) 4 | [![IRC Channel](https://img.shields.io/badge/irc-%23calico-blue.svg)](https://kiwiirc.com/client/irc.freenode.net/#calico) 5 | 6 | 7 | # Calico's Net-Modules Plugin 8 | 9 | This repository is home to Calico's [Net-Modules](https://github.com/mesosphere/net-modules) compliant plugin. 10 | 11 | ## Deprecation Warning! 12 | 13 | Note: Mesos has deprecated support for the Net-Modules networking interface in favor of the standardized 14 | [Container Networking Interface (CNI)](https://github.com/containernetworking/cni). 15 | 16 | Calico provides IP-per-container and fine-grain isolation 17 | in Mesos v1.0.0+ with its standard CNI plugin. Learn how to get started with the [Calico for Mesos CNI Install Guide](http://docs.projectcalico.org/master/getting-started/mesos/installation/unified). 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: binary 2 | BUILD_DIR=build_calico_mesos 3 | BUILD_FILES=$(BUILD_DIR)/Dockerfile $(BUILD_DIR)/requirements.txt 4 | CALICO_MESOS_FILES=calico_mesos/calico_mesos.py 5 | 6 | default: help 7 | calico_mesos: dist/calico_mesos ## Create the calico_mesos plugin binary 8 | 9 | ## Create the calico_mesos plugin binary 10 | dist/calico_mesos: $(CALICO_MESOS_FILES) 11 | mkdir -p -m 777 dist/ 12 | 13 | # Build the mesos plugin 14 | docker run --rm \ 15 | -v `pwd`/calico_mesos/:/code/calico_mesos \ 16 | -v `pwd`/dist/:/code/dist \ 17 | calico/build \ 18 | pyinstaller calico_mesos/calico_mesos.py -ayF 19 | 20 | ## Run the UTs in a container 21 | ut: 22 | # Use the `root` user, since code coverage requires the /code directory to 23 | # be writable. It may not be writable for the `user` account inside the 24 | # container. 25 | docker run --rm -v `pwd`/calico_mesos:/code -u root \ 26 | calico/test \ 27 | nosetests tests/unit -c nose.cfg 28 | 29 | ut-circle: calico_mesos 30 | docker run \ 31 | -v `pwd`/calico_mesos:/code \ 32 | -v $(CIRCLE_TEST_REPORTS):/circle_output \ 33 | -e COVERALLS_REPO_TOKEN=$(COVERALLS_REPO_TOKEN) \ 34 | calico/test sh -c \ 35 | 'nosetests tests/unit -c nose.cfg \ 36 | --with-xunit --xunit-file=/circle_output/output.xml; RC=$$?;\ 37 | [[ ! -z "$$COVERALLS_REPO_TOKEN" ]] && coveralls || true; exit $$RC' 38 | 39 | ## Clean everything (including stray volumes) 40 | clean: 41 | find . -name '*.created' -exec rm -f {} + 42 | find . -name '*.pyc' -exec rm -f {} + 43 | -rm -rf dist 44 | 45 | help: # Some kind of magic from https://gist.github.com/rcmachado/af3db315e31383502660 46 | $(info Available targets) 47 | @awk '/^[a-zA-Z\-\_0-9]+:/ { \ 48 | nb = sub( /^## /, "", helpMsg ); \ 49 | if(nb == 0) { \ 50 | helpMsg = $$0; \ 51 | nb = sub( /^[^:]*:.* ## /, "", helpMsg ); \ 52 | } \ 53 | if (nb) \ 54 | printf "\033[1;31m%-" width "s\033[0m %s\n", $$1, helpMsg; \ 55 | } \ 56 | { helpMsg = $$0 }' \ 57 | width=$$(grep -o '^[a-zA-Z_0-9]\+:' $(MAKEFILE_LIST) | wc -L) \ 58 | $(MAKEFILE_LIST) 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | -------------------------------------------------------------------------------- /calico_mesos/tests/unit/mesos_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Tigera, 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 | import unittest 15 | from mock import patch, MagicMock 16 | from mock import Mock, ANY 17 | import json 18 | from netaddr import IPAddress, IPNetwork 19 | from nose_parameterized import parameterized 20 | from pycalico.datastore_datatypes import Rule, Endpoint, Profile 21 | from pycalico.block import AlreadyAssignedError 22 | import calico_mesos 23 | import socket 24 | from calico_mesos import IsolatorException 25 | from calico_mesos import ERROR_MISSING_COMMAND, \ 26 | ERROR_MISSING_CONTAINER_ID, \ 27 | ERROR_MISSING_HOSTNAME, \ 28 | ERROR_MISSING_PID, \ 29 | ERROR_UNKNOWN_COMMAND, \ 30 | ERROR_MISSING_ARGS 31 | 32 | HOSTNAME = socket.gethostname() 33 | 34 | class TestIsolate(unittest.TestCase): 35 | @parameterized.expand([ 36 | ({"hostname": "metaman", 37 | "ipv4_addrs": ["192.168.1.1"], 38 | "ipv6_addrs": ["abcd::"], 39 | "pid": 3789}, 40 | ERROR_MISSING_CONTAINER_ID), 41 | 42 | ({"container_id": "abcdef12345", 43 | "ipv4_addrs": ["192.168.1.1"], 44 | "ipv6_addrs": ["abcd::"], 45 | "pid": 3789}, 46 | ERROR_MISSING_HOSTNAME), 47 | 48 | ({"container_id": "abcdef12345", 49 | "hostname": "metaman", 50 | "ipv4_addrs": ["192.168.1.1"], 51 | "ipv6_addrs": ["abcd::"]}, 52 | ERROR_MISSING_PID), 53 | 54 | ({"container_id": "abcdef12345", 55 | "hostname": "metaman", 56 | "ipv4_addrs": ["1.1.1.1.1"], 57 | "pid": 3789}, 58 | "IP address could not be parsed: %s" % "1.1.1.1.1"), 59 | 60 | ({"container_id": "abcdef12345", 61 | "hostname": "metaman", 62 | "ipv6_addrs": ["fe80::fe80::"], 63 | "pid": 3789}, 64 | "IP address could not be parsed: %s" % "fe80::fe80::"), 65 | 66 | ({"container_id": "abcdef12345", 67 | "hostname": "metaman", 68 | "ipv4_addrs": ["fe80::"], 69 | "pid": 3789}, 70 | "IPv6 address must not be placed in IPv4 address field."), 71 | 72 | ({"container_id": "abcdef12345", 73 | "hostname": "metaman", 74 | "ipv6_addrs": ["192.168.1.1"], 75 | "pid": 3789}, 76 | "IPv4 address must not be placed in IPv6 address field.") 77 | ]) 78 | @patch('calico_mesos._isolate') 79 | def test_error_messages_with_invalid_params(self, args, error, m_isolate): 80 | with self.assertRaises(IsolatorException) as e: 81 | calico_mesos.isolate(args) 82 | self.assertFalse(m_isolate.called) 83 | self.assertEqual(e.exception.message, error) 84 | 85 | @parameterized.expand([ 86 | ({"container_id": "abcdef12345", 87 | "hostname": "metaman", 88 | "ipv4_addrs": ["192.168.1.1"], 89 | "pid": 3789},), 90 | 91 | ({"container_id": "abcdef12345", 92 | "hostname": "metaman", 93 | "ipv6_addrs": ["abcd::"], 94 | "pid": 3789},), 95 | 96 | ({"container_id": "abcdef12345", 97 | "hostname": "metaman", 98 | "ipv4_addrs": ["192.168.1.1"], 99 | "ipv6_addrs": ["abcd::"], 100 | "pid": 3789},) 101 | ]) 102 | @patch('calico_mesos._isolate') 103 | def test_isolate_executes_with_valid_params(self, args, m_isolate): 104 | result = calico_mesos.isolate(args) 105 | self.assertEqual(result, None) 106 | self.assertTrue(m_isolate.called) 107 | 108 | 109 | class TestAllocate(unittest.TestCase): 110 | @parameterized.expand([ 111 | ({"hostname": "metaman", 112 | "num_ipv4": 1, 113 | "num_ipv6": 1}, 114 | "Missing uid"), 115 | 116 | ({"num_ipv4": 1, 117 | "num_ipv6": 1, 118 | "uid": "abc-def-gh"}, 119 | ERROR_MISSING_HOSTNAME), 120 | 121 | ({"hostname": "metaman", 122 | "num_ipv4": 1, 123 | "uid": "abc-def-gh"}, 124 | "Missing num_ipv6"), 125 | 126 | ({"hostname": "metaman", 127 | "num_ipv6": 1, 128 | "uid": "abc-def-gh"}, 129 | "Missing num_ipv4"), 130 | ]) 131 | @patch('calico_mesos._allocate') 132 | def test_error_messages_with_invalid_params(self, args, error, m_allocate): 133 | with self.assertRaises(IsolatorException) as e: 134 | calico_mesos.allocate(args) 135 | self.assertFalse(m_allocate.called) 136 | self.assertEqual(e.exception.message, error) 137 | 138 | @parameterized.expand([ 139 | ({"hostname": "metaman", 140 | "num_ipv4": 1, 141 | "num_ipv6": 1, 142 | "uid": "abc-def-gh"},), 143 | ]) 144 | @patch('calico_mesos._allocate') 145 | def test_allocate_executes_with_valid_params(self, args, m_allocate): 146 | m_allocate.return_value = {"ipv4": ["192.168.1.1"], 147 | "ipv6": "dead:beef::", 148 | "error": None} 149 | result = calico_mesos.allocate(args) 150 | self.assertTrue(m_allocate.called) 151 | self.assertEqual(result, '{"error": null, "ipv4": ["192.168.1.1"], "ipv6": "dead:beef::"}') 152 | 153 | @parameterized.expand([ 154 | ({"hostname": "metaman", 155 | "num_ipv4": 1, 156 | "num_ipv6": 1, 157 | "uid": "abc-def-gh", 158 | "labels": {"ipv4_addrs": "['192.168.3.1']"}},), 159 | ]) 160 | @patch('calico_mesos._reserve') 161 | @patch('calico_mesos._allocate') 162 | def test_allocate_executes_with_static_addr(self, args, m_allocate, m_reserve): 163 | m_allocate.return_value = {"ipv4": [], 164 | "ipv6": "dead:beef::", 165 | "error": None} 166 | result = calico_mesos.allocate(args) 167 | self.assertEqual(result, '{"error": null, "ipv4": ["192.168.3.1"], "ipv6": "dead:beef::"}') 168 | 169 | 170 | class TestReserve(unittest.TestCase): 171 | @parameterized.expand([ 172 | ({"hostname": "metaman", 173 | "ipv4_addrs": ["192.168.1.1"], 174 | "ipv6_addrs": ["dead::beef"]}, 175 | "Missing uid"), 176 | 177 | ({"ipv4_addrs": ["192.168.1.1"], 178 | "ipv6_addrs": ["dead::beef"], 179 | "uid": "abc-def-gh"}, 180 | ERROR_MISSING_HOSTNAME), 181 | ]) 182 | @patch('calico_mesos._reserve') 183 | def test_error_messages_with_invalid_params(self, args, error, m_reserve): 184 | with self.assertRaises(IsolatorException) as e: 185 | calico_mesos.reserve(args) 186 | self.assertFalse(m_reserve.called) 187 | self.assertEqual(e.exception.message, error) 188 | 189 | @parameterized.expand([ 190 | ({"hostname": "metaman", 191 | "ipv4_addrs": ["192.168.1.1"], 192 | "ipv6_addrs": ["dead::beef"], 193 | "uid": "abc-def-gh"},), 194 | ]) 195 | @patch('calico_mesos._reserve') 196 | def test_reserve_executes_with_valid_params(self, args, m_reserve): 197 | result = calico_mesos.reserve(args) 198 | self.assertTrue(m_reserve.called) 199 | self.assertEqual(result, m_reserve()) 200 | 201 | @patch('calico_mesos.datastore', autospec=True) 202 | def test_reserve_is_functional(self, m_datastore): 203 | hostname = "metaman" 204 | ipv4_addrs = ["192.168.1.1", "192.168.1.2"] 205 | ipv6_addrs = ["dead::beef"] 206 | uid = "abc-def-gh" 207 | result = calico_mesos._reserve(hostname, uid, ipv4_addrs, ipv6_addrs) 208 | self.assertIsNone(result) 209 | for ip_addr in ipv4_addrs + ipv6_addrs: 210 | # TODO: workaround until hostname can be passed in 211 | m_datastore.assign_ip.assert_any_call(ip_addr, uid, {}, 212 | host=HOSTNAME) 213 | 214 | @parameterized.expand([ 215 | [ValueError], 216 | [RuntimeError], 217 | [AlreadyAssignedError] 218 | ]) 219 | @patch('calico_mesos.datastore') 220 | def test_reserve_rolls_back(self, exception, m_datastore): 221 | hostname = "metaman" 222 | ipv4_addrs = [IPAddress("192.168.1.1"), IPAddress("192.168.1.2")] 223 | ipv6_addrs = [IPAddress("dead::beef")] 224 | uid = "abc-def-gh" 225 | 226 | def side_effect(address, handle_id, attributes, host=None): 227 | if address == IPAddress("192.168.1.2"): 228 | # Arbitrarily throw an error when the second address is passed in 229 | raise exception 230 | 231 | m_assign_ip = MagicMock(side_effect=side_effect) 232 | m_datastore.assign_ip = m_assign_ip 233 | 234 | # Test that error for second IP was ack'd 235 | with self.assertRaises(IsolatorException) as e: 236 | calico_mesos._reserve(hostname, uid, ipv4_addrs, ipv6_addrs) 237 | self.assertEqual(e.exception.message, "IP '192.168.1.2' already in use.") 238 | 239 | # Test that only the first IP was released 240 | m_datastore.release_ips.assert_called_once_with({IPAddress("192.168.1.1")}) 241 | 242 | 243 | class TestRelease(unittest.TestCase): 244 | @parameterized.expand([ 245 | ({}, 246 | "Must supply either uid or ips."), 247 | 248 | ({"ips": ["192.168.0.1"], 249 | "uid": "abc-def-gh"}, 250 | "Supply either uid or ips, not both."), 251 | 252 | ({"ips": ["192.9.168.0.1"]}, 253 | "IP address could not be parsed: 192.9.168.0.1"), 254 | 255 | ({"uid": 12345}, 256 | "uid must be a string"), 257 | ]) 258 | @patch('calico_mesos._release_ips') 259 | @patch('calico_mesos._release_uid') 260 | def test_error_messages_with_invalid_params(self, args, error, m_release_ips, m_release_uid): 261 | with self.assertRaises(IsolatorException) as e: 262 | calico_mesos.release(args) 263 | self.assertEqual(e.exception.message, error) 264 | 265 | @parameterized.expand([ 266 | ({"ips": ["192.168.0.1"]},), 267 | 268 | ({"ips": ["192.168.0.1", "192.168.0.2"]},), 269 | ]) 270 | @patch('calico_mesos._release_ips') 271 | def test_release_ips(self, args, m_release): 272 | result = calico_mesos.release(args) 273 | 274 | m_release.assert_called_with(set([IPAddress(ip) for ip in args["ips"]])) 275 | self.assertEqual(result, m_release()) 276 | 277 | @parameterized.expand([ 278 | ({"uid": "abc-def-gh"},), 279 | ]) 280 | @patch('calico_mesos._release_uid') 281 | def test_release_uid(self, args, m_release): 282 | result = calico_mesos.release(args) 283 | 284 | m_release.assert_called_with(args["uid"]) 285 | self.assertEqual(result, m_release()) 286 | 287 | 288 | class TestDispatch(unittest.TestCase): 289 | @parameterized.expand([ 290 | # Missing command 291 | ({"args": {}},), 292 | 293 | # Invalid command 294 | ({"args": {}, 295 | "command": "not-a-real-command"},), 296 | 297 | ({"command": "isolate"},) 298 | ]) 299 | @patch('sys.stdin') 300 | def test_dispatch_catches_bad_commands(self, args, m_stdin): 301 | m_stdin.read.return_value = json.dumps(args) 302 | self.assertRaises(IsolatorException, calico_mesos.calico_mesos) 303 | 304 | @patch('sys.stdin') 305 | def test_dispatch_catches_invalid_json(self, m_stdin): 306 | m_stdin.read.return_value = '{"command: invalidjson' 307 | self.assertRaises(IsolatorException, calico_mesos.calico_mesos) 308 | 309 | @patch('calico_mesos.isolate') 310 | @patch('sys.stdin') 311 | def test_distpach_calls_isolate(self, m_stdin, m_isolate): 312 | # Load stdin.read to return input string 313 | input = {"args": {}, 314 | "command": "isolate"} 315 | m_stdin.read.return_value = json.dumps(input) 316 | 317 | # Call function 318 | calico_mesos.calico_mesos() 319 | m_isolate.assert_called_with(input["args"]) 320 | 321 | @patch('calico_mesos.cleanup') 322 | @patch('sys.stdin') 323 | def test_distpach_calls_cleanup(self, m_stdin, m_cleanup): 324 | # Load stdin.read to return input string 325 | input = {"args": {}, 326 | "command": "cleanup"} 327 | m_stdin.read.return_value = json.dumps(input) 328 | 329 | # Call function 330 | calico_mesos.calico_mesos() 331 | m_cleanup.assert_called_with(input["args"]) 332 | 333 | @patch('calico_mesos.allocate') 334 | @patch('sys.stdin') 335 | def test_distpach_calls_allocate(self, m_stdin, m_allocate): 336 | # Load stdin.read to return input string 337 | input = {"args": {}, 338 | "command": "allocate"} 339 | m_stdin.read.return_value = json.dumps(input) 340 | 341 | # Call function 342 | calico_mesos.calico_mesos() 343 | m_allocate.assert_called_with(input["args"]) 344 | 345 | @patch('calico_mesos.release') 346 | @patch('sys.stdin') 347 | def test_distpach_calls_release(self, m_stdin, m_release): 348 | # Load stdin.read to return input string 349 | input = {"args": {}, 350 | "command": "release"} 351 | m_stdin.read.return_value = json.dumps(input) 352 | 353 | # Call function 354 | calico_mesos.calico_mesos() 355 | m_release.assert_called_with(input["args"]) 356 | 357 | 358 | class TestDefaultProfile(unittest.TestCase): 359 | HOST_IP_NET = "172.16.0.0/16" 360 | 361 | @patch('calico_mesos._get_host_ip_net', return_value=HOST_IP_NET) 362 | @patch('calico_mesos.datastore', autospec=True) 363 | def test_correct_rules_for_host_profile(self, m_datastore, m_get_host_ip_net): 364 | new_profile = Mock(spec=Profile) 365 | m_datastore.get_profile.return_value = new_profile 366 | 367 | calico_mesos._create_profile_for_host_communication("default") 368 | new_rules = new_profile.rules 369 | self.assertIn(Rule(action="allow", src_net=self.HOST_IP_NET), new_rules.inbound_rules) 370 | self.assertIn(Rule(action="allow", dst_net=self.HOST_IP_NET), new_rules.outbound_rules) 371 | self.assertEqual(len(new_rules.inbound_rules) + len(new_rules.outbound_rules), 2) 372 | 373 | @patch('calico_mesos.datastore', autospec=True) 374 | def test_correct_rules_for_netgroup_profile(self, m_datastore): 375 | new_profile = Mock(spec=Profile) 376 | m_datastore.get_profile.return_value = new_profile 377 | 378 | calico_mesos._create_profile_for_netgroup("prof_a") 379 | new_rules = new_profile.rules 380 | self.assertIn(Rule(action="allow", src_tag="prof_a"), new_rules.inbound_rules) 381 | self.assertIn(Rule(action="allow"), new_rules.outbound_rules) 382 | self.assertEqual(len(new_rules.inbound_rules) + len(new_rules.outbound_rules), 2) 383 | 384 | @patch('calico_mesos.datastore', autospec=True) 385 | def test_correct_rules_for_public_profile(self, m_datastore): 386 | new_profile = Mock(spec=Profile) 387 | m_datastore.get_profile.return_value = new_profile 388 | 389 | calico_mesos._create_profile_for_public_communication("public") 390 | new_rules = new_profile.rules 391 | 392 | self.assertIn(Rule(action="allow"), new_rules.inbound_rules) 393 | self.assertIn(Rule(action="allow"), new_rules.outbound_rules) 394 | self.assertEqual(len(new_rules.inbound_rules) + len(new_rules.outbound_rules), 2) 395 | 396 | 397 | @patch('calico_mesos.datastore', autospec=True) 398 | def test_profiles_are_created(self, m_datastore): 399 | created_endpoint = Mock(spec=Endpoint) 400 | m_datastore.create_endpoint.return_value = created_endpoint 401 | m_datastore.profile_exists.return_value = False 402 | 403 | profiles = ["public", "prof_a"] 404 | calico_mesos._isolate("testhostname", 1234, "container-id-1234", ["192.168.0.0"], [], profiles, None) 405 | 406 | self.assertIn("public", created_endpoint.profile_ids) 407 | self.assertIn("prof_a", created_endpoint.profile_ids) 408 | self.assertIn("default_testhostname", created_endpoint.profile_ids) 409 | self.assertEqual(len(created_endpoint.profile_ids), 3) 410 | 411 | 412 | class TestCleanup(unittest.TestCase): 413 | @parameterized.expand([ 414 | ({"container_id": "abcdef-12345"}, 415 | ERROR_MISSING_HOSTNAME), 416 | 417 | ({"hostname": "metaman"}, 418 | ERROR_MISSING_CONTAINER_ID), 419 | ]) 420 | @patch('calico_mesos._cleanup') 421 | def test_error_messages_with_invalid_params(self, args, error, m_cleanup): 422 | with self.assertRaises(IsolatorException) as e: 423 | calico_mesos.cleanup(args) 424 | self.assertEqual(e.exception.message, error) 425 | 426 | @patch('calico_mesos._cleanup') 427 | def test_cleanup(self, m_cleanup): 428 | args = {"hostname": "metaman", "container_id": "abcdef-12345"} 429 | calico_mesos.cleanup(args) 430 | m_cleanup.assert_called_with(args["hostname"], args["container_id"]) 431 | 432 | @patch('calico_mesos.datastore', autospec=True) 433 | def test__cleanup(self, m_datastore): 434 | ep = Endpoint("test_host", 435 | "mesos", 436 | "test_workload", 437 | "test_endpoint", 438 | "active", 439 | "aa:bb:cc:dd:ee:ff") 440 | ipv4_addrs = {IPAddress(ip) for ip in ["192.168.1.1", "192.168.5.4"]} 441 | ipv6_addrs = {IPAddress("2001:4567::1:1")} 442 | ep.ipv4_nets = {IPNetwork(ip) for ip in ipv4_addrs} 443 | ep.ipv6_nets = {IPNetwork(ip) for ip in ipv6_addrs} 444 | 445 | m_datastore.get_endpoint.return_value = ep 446 | 447 | calico_mesos._cleanup("test_host", "test_workload") 448 | 449 | m_datastore.release_ips.assert_called_once_with(ipv4_addrs | 450 | ipv6_addrs) 451 | m_datastore.remove_endpoint.assert_called_once_with(ep) 452 | m_datastore.remove_workload.assert_called_once_with( 453 | hostname=ANY, 454 | orchestrator_id=ANY, 455 | workload_id="test_workload") 456 | 457 | 458 | class TestGetHostIPNet(unittest.TestCase): 459 | IP_ADDR1_OUTPUT = """ 460 | 1: lo: mtu 65536 qdisc noqueue state UNKNOWN group default 461 | inet 127.0.0.1/8 scope host lo 462 | valid_lft forever preferred_lft forever 463 | 2: eth0: mtu 1500 qdisc pfifo_fast state UP group default qlen 1000 464 | inet 10.0.2.15/24 brd 10.0.2.255 scope global eth0 465 | valid_lft forever preferred_lft forever 466 | 3: eth1: mtu 1500 qdisc pfifo_fast state UP group default qlen 1000 467 | inet 192.168.10.6/24 brd 192.168.10.255 scope global eth1 468 | valid_lft forever preferred_lft forever 469 | 5: docker0: mtu 1500 qdisc noqueue state DOWN group default 470 | inet 172.17.0.1/16 scope global docker0 471 | valid_lft forever preferred_lft forever 472 | """ 473 | IP_ADDR1_NET = IPNetwork("10.0.2.15/32") 474 | 475 | @patch('calico_mesos.check_output') 476 | def test_get_host_ip_net_mainline(self, m_check_output): 477 | m_check_output.return_value = self.IP_ADDR1_OUTPUT 478 | 479 | ip_net = calico_mesos._get_host_ip_net() 480 | self.assertEqual(self.IP_ADDR1_NET, ip_net) 481 | 482 | -------------------------------------------------------------------------------- /calico_mesos/calico_mesos.py: -------------------------------------------------------------------------------- 1 | # Copyright 2015 Tigera, 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 | import sys 15 | import os 16 | import errno 17 | from pycalico import netns 18 | from pycalico.ipam import IPAMClient 19 | from pycalico.datastore import Rules, Rule 20 | from pycalico.block import AlreadyAssignedError 21 | from pycalico.datastore_errors import DataStoreError 22 | from netaddr import IPAddress, AddrFormatError 23 | import json 24 | import logging 25 | import logging.handlers 26 | import traceback 27 | import re 28 | from subprocess import check_output, CalledProcessError 29 | from netaddr import IPNetwork 30 | import socket 31 | 32 | LOGFILE = "/var/log/calico/isolator.log" 33 | ORCHESTRATOR_ID = "mesos" 34 | 35 | ERROR_MISSING_COMMAND = "Missing command" 36 | ERROR_MISSING_CONTAINER_ID = "Missing container_id" 37 | ERROR_MISSING_HOSTNAME = "Missing hostname" 38 | ERROR_MISSING_PID = "Missing pid" 39 | ERROR_UNKNOWN_COMMAND = "Unknown command: %s" 40 | ERROR_MISSING_ARGS = "Missing args" 41 | 42 | datastore = IPAMClient() 43 | _log = logging.getLogger("CALICOMESOS") 44 | 45 | HOSTNAME = socket.gethostname() 46 | 47 | 48 | def calico_mesos(): 49 | """ 50 | Module function which parses JSON from stdin and calls the appropriate 51 | plugin function. Input JSON data looks like the following: 52 | { 53 | "command": "allocate|isolate|reserve|cleanup", 54 | "args": {} 55 | } 56 | 57 | Args will vary depending on which function is called. See the docstring 58 | of each of the listed command functions for accepted args. 59 | 60 | :return: 61 | """ 62 | stdin_raw_data = sys.stdin.read() 63 | _log.info("Received request: %s" % stdin_raw_data) 64 | 65 | # Convert input data to JSON object 66 | try: 67 | stdin_json = json.loads(stdin_raw_data) 68 | except ValueError as e: 69 | raise IsolatorException(str(e)) 70 | 71 | # Extract command 72 | try: 73 | command = stdin_json['command'] 74 | except KeyError: 75 | raise IsolatorException(ERROR_MISSING_COMMAND) 76 | 77 | # Extract args 78 | try: 79 | args = stdin_json['args'] 80 | except KeyError: 81 | raise IsolatorException(ERROR_MISSING_ARGS) 82 | 83 | # Labels are passed in as JSONified protobufs, which look like the following: 84 | # { 85 | # "args": { 86 | # ... 87 | # "labels": [ 88 | # { "key": "mykey1", "value": "myvalue1" }, 89 | # { "key": "mykey2", "value": "myvalue2" } 90 | # ] 91 | # } 92 | # } 93 | # 94 | # Update them to a more pythonic representation: 95 | # { 96 | # "args": { 97 | # ... 98 | # "labels": { 99 | # "mykey1": "myvalue1", 100 | # "mykey2": "myvalue2" 101 | # } 102 | # } 103 | # } 104 | labels = args.get("labels") 105 | if labels: 106 | args['labels'] = {label['key']: label['value'] for label in labels} 107 | _log.info("Fixed request to be: %s" % str(args)) 108 | 109 | # Call command with args 110 | _log.debug("Executing %s" % command) 111 | if command == 'isolate': 112 | return isolate(args) 113 | elif command == 'cleanup': 114 | return cleanup(args) 115 | elif command == 'allocate': 116 | return allocate(args) 117 | elif command == 'reserve': 118 | return reserve(args) 119 | elif command == 'release': 120 | return release(args) 121 | else: 122 | raise IsolatorException(ERROR_UNKNOWN_COMMAND % command) 123 | 124 | 125 | def _setup_logging(logfile): 126 | # Ensure directory exists. 127 | try: 128 | os.makedirs(os.path.dirname(LOGFILE)) 129 | except OSError as oserr: 130 | if oserr.errno != errno.EEXIST: 131 | raise 132 | 133 | _log.setLevel(logging.DEBUG) 134 | formatter = logging.Formatter('%(asctime)s %(process)d [%(levelname)s] ' 135 | '%(name)s %(lineno)d: %(message)s') 136 | handler = logging.handlers.TimedRotatingFileHandler(logfile, 137 | when='D', 138 | backupCount=10) 139 | handler.setLevel(logging.DEBUG) 140 | handler.setFormatter(formatter) 141 | _log.addHandler(handler) 142 | 143 | 144 | def _validate_ip_addrs(ip_addrs, ip_version=None): 145 | if not isinstance(ip_addrs, list): 146 | raise IsolatorException( 147 | "IP addresses must be provided as JSON list, not: %s" % 148 | type(ip_addrs)) 149 | validated_ip_addrs = [] 150 | for ip_addr in ip_addrs: 151 | try: 152 | ip = IPAddress(ip_addr) 153 | except AddrFormatError: 154 | raise IsolatorException("IP address could not be parsed: %s" % 155 | ip_addr) 156 | 157 | if ip_version and ip.version != ip_version: 158 | raise IsolatorException("IPv%d address must not be placed in IPv%d" 159 | " address field." % 160 | (ip.version, ip_version)) 161 | else: 162 | validated_ip_addrs.append(ip) 163 | return validated_ip_addrs 164 | 165 | 166 | def _create_profile_for_host_communication(profile_name): 167 | """ 168 | Create a profile which allows traffic to and from the host. 169 | """ 170 | _log.info("Autocreating profile %s", profile_name) 171 | datastore.create_profile(profile_name) 172 | prof = datastore.get_profile(profile_name) 173 | 174 | host_net = str(_get_host_ip_net()) 175 | _log.info("adding accept rule for %s" % host_net) 176 | allow_from_slave = Rule(action="allow", src_net=host_net) 177 | allow_to_slave = Rule(action="allow", dst_net=host_net) 178 | prof.rules = Rules(id=profile_name, 179 | inbound_rules=[allow_from_slave], 180 | outbound_rules=[allow_to_slave]) 181 | datastore.profile_update_rules(prof) 182 | 183 | 184 | def _create_profile_for_netgroup(profile_name): 185 | """ 186 | Create a profile which allows traffic from other Endpoints in the same 187 | profile. 188 | """ 189 | _log.info("Autocreating profile %s", profile_name) 190 | datastore.create_profile(profile_name) 191 | prof = datastore.get_profile(profile_name) 192 | allow_from_profile = Rule(action="allow", src_tag=profile_name) 193 | allow_to_all = Rule(action="allow") 194 | prof.rules = Rules(id=profile_name, 195 | inbound_rules=[allow_from_profile], 196 | outbound_rules=[allow_to_all]) 197 | datastore.profile_update_rules(prof) 198 | 199 | 200 | def _create_profile_for_public_communication(profile_name): 201 | """ 202 | Create a public profile which allows open traffic from all. 203 | """ 204 | _log.info("Creating public profile: %s", profile_name) 205 | datastore.create_profile(profile_name) 206 | prof = datastore.get_profile(profile_name) 207 | allow_all = Rule(action="allow") 208 | prof.rules = Rules(id=profile_name, 209 | inbound_rules=[allow_all], 210 | outbound_rules=[allow_all]) 211 | datastore.profile_update_rules(prof) 212 | 213 | 214 | def _get_host_ip_net(): 215 | """ 216 | Gets the IP Address of the host. 217 | 218 | Ignores Loopback and docker0 Addresses. 219 | """ 220 | IP_SUBNET_RE = re.compile(r'inet ((?:\d+\.){3}\d+\/\d+)') 221 | INTERFACE_SPLIT_RE = re.compile(r'(\d+:.*(?:\n\s+.*)+)') 222 | IFACE_RE = re.compile(r'^\d+: (\S+):') 223 | 224 | # Call `ip addr`. 225 | try: 226 | ip_addr_output = check_output(["ip", "-4", "addr"]) 227 | except CalledProcessError, OSError: 228 | raise IsolatorException("Could not read host IP") 229 | 230 | # Separate interface blocks from ip addr output and iterate. 231 | for iface_block in INTERFACE_SPLIT_RE.findall(ip_addr_output): 232 | # Exclude certain interfaces. 233 | match = IFACE_RE.match(iface_block) 234 | if match and match.group(1) not in ["docker0", "lo"]: 235 | # Iterate through Addresses on interface. 236 | for address in IP_SUBNET_RE.findall(iface_block): 237 | ip_net = IPNetwork(address) 238 | if not ip_net.ip.is_loopback(): 239 | # Select just the host IP address, not the entire subnet 240 | # it belongs, since we only want this host to communicate 241 | # with the executor. 242 | return IPNetwork(ip_net.ip) 243 | raise IsolatorException("Couldn't determine host's IP Address.") 244 | 245 | 246 | def isolate(args): 247 | """ 248 | Toplevel function which validates and sanitizes json args into variables 249 | which can be passed to _isolate. 250 | 251 | "args": { 252 | "hostname": "slave-H3A-1", # Required 253 | "container_id": "ba11f1de-fc4d-46fd-9f15-424f4ef05a3a", # Required 254 | "ipv4_addrs": ["192.168.23.4"], # Not Required 255 | "ipv6_addrs": ["2001:3ac3:f90b:1111::1"], # Not Required 256 | "netgroups": ["prod", "frontend"], # Required. 257 | "labels": { # Optional. 258 | "rack": "3A", 259 | "pop": "houston" 260 | } 261 | """ 262 | hostname = args.get("hostname") 263 | container_id = args.get("container_id") 264 | pid = args.get("pid") 265 | ipv4_addrs = args.get("ipv4_addrs", []) 266 | ipv6_addrs = args.get("ipv6_addrs", []) 267 | netgroups = args.get("netgroups", []) 268 | labels = args.get("labels") 269 | 270 | # Validate Container ID 271 | if not container_id: 272 | raise IsolatorException(ERROR_MISSING_CONTAINER_ID) 273 | if not hostname: 274 | raise IsolatorException(ERROR_MISSING_HOSTNAME) 275 | if not pid: 276 | raise IsolatorException(ERROR_MISSING_PID) 277 | 278 | # Validate IPv4 Addresses 279 | ipv4_addrs_validated = _validate_ip_addrs(ipv4_addrs, 4) 280 | 281 | # Validate IPv6 Addresses 282 | ipv6_addrs_validated = _validate_ip_addrs(ipv6_addrs, 6) 283 | 284 | if not ipv4_addrs_validated + ipv6_addrs_validated: 285 | raise IsolatorException("Must provide at least one IPv4 or IPv6 address.") 286 | 287 | # Validate that netgroups are present 288 | if not isinstance(netgroups, list): 289 | raise IsolatorException("Must provide list of netgroups.") 290 | 291 | _isolate(hostname, pid, container_id, ipv4_addrs_validated, ipv6_addrs_validated, netgroups, labels) 292 | _log.debug("Request completed.") 293 | 294 | 295 | def _isolate(hostname, ns_pid, container_id, ipv4_addrs, ipv6_addrs, profiles, labels): 296 | """ 297 | Configure networking for a container. 298 | 299 | This function performs the following steps: 300 | 1.) Create endpoint in memory 301 | 2.) Fill endpoint with data 302 | 3.) Configure network to match the filled endpoint's specifications 303 | 4.) Write endpoint to etcd 304 | 305 | :param hostname: Hostname of the slave which the container is running on 306 | :param container_id: The container's ID 307 | :param ipv4_addrs: List of desired IPv4 addresses to be assigned to the endpoint 308 | :param ipv6_addrs: List of desired IPv6 addresses to be assigned to the endpoint 309 | :param profiles: List of desired profiles to be assigned to the endpoint 310 | :param labels: TODO 311 | :return: None 312 | """ 313 | _log.info("Preparing network for Container with ID %s", container_id) 314 | _log.info("IP: %s, Profile %s", ipv4_addrs, profiles) 315 | 316 | # Exit if the endpoint has already been configured 317 | if len(datastore.get_endpoints(hostname=HOSTNAME, 318 | orchestrator_id=ORCHESTRATOR_ID, 319 | workload_id=container_id)) == 1: 320 | raise IsolatorException("This container has already been configured " 321 | "with Calico Networking.") 322 | 323 | # Create the endpoint 324 | ep = datastore.create_endpoint(hostname=HOSTNAME, 325 | orchestrator_id=ORCHESTRATOR_ID, 326 | workload_id=container_id, 327 | ip_list=ipv4_addrs) 328 | 329 | # Create any profiles in etcd that do not already exist 330 | assigned_profiles = [] 331 | _log.info("Assigning Profiles: %s" % profiles) 332 | # First remove any keyword profile names 333 | try: 334 | profiles.remove("public") 335 | except ValueError: 336 | pass 337 | else: 338 | _log.info("Assigning Public Profile") 339 | if not datastore.profile_exists("public"): 340 | _create_profile_for_public_communication("public") 341 | assigned_profiles.append("public") 342 | 343 | # Assign remaining netgroup profiles 344 | for profile in profiles: 345 | if not datastore.profile_exists(profile): 346 | _log.info("Assigning Netgroup Profile: %s" % profile) 347 | _create_profile_for_netgroup(profile) 348 | assigned_profiles.append(profile) 349 | 350 | # Insert the host-communication profile 351 | default_profile_name = "default_%s" % hostname 352 | _log.info("Assigning Default Host Profile: %s" % default_profile_name) 353 | if not datastore.profile_exists(default_profile_name): 354 | _create_profile_for_host_communication(default_profile_name) 355 | assigned_profiles.insert(0, default_profile_name) 356 | 357 | # Call through to complete the network setup matching this endpoint 358 | ep.profile_ids = assigned_profiles 359 | try: 360 | ep.mac = ep.provision_veth(netns.PidNamespace(ns_pid), "eth0") 361 | except netns.NamespaceError as e: 362 | raise IsolatorException(e.message) 363 | 364 | datastore.set_endpoint(ep) 365 | _log.info("Finished networking for container %s", container_id) 366 | 367 | 368 | def cleanup(args): 369 | hostname = args.get("hostname") 370 | container_id = args.get("container_id") 371 | 372 | if not container_id: 373 | raise IsolatorException(ERROR_MISSING_CONTAINER_ID) 374 | if not hostname: 375 | raise IsolatorException(ERROR_MISSING_HOSTNAME) 376 | 377 | _cleanup(hostname, container_id) 378 | 379 | 380 | def _cleanup(hostname, container_id): 381 | _log.info("Cleaning executor with Container ID %s.", container_id) 382 | 383 | try: 384 | endpoint = datastore.get_endpoint(hostname=HOSTNAME, 385 | orchestrator_id=ORCHESTRATOR_ID, 386 | workload_id=container_id) 387 | except KeyError: 388 | raise IsolatorException("No endpoint found with container-id: %s" % container_id) 389 | 390 | # Release IP addresses. 391 | ips = {net.ip for net in endpoint.ipv4_nets | endpoint.ipv6_nets} 392 | _log.info("%s | Release IPs %s", container_id, ips) 393 | datastore.release_ips(ips) 394 | 395 | # Remove the endpoint 396 | _log.info("Removing veth for endpoint %s", endpoint.endpoint_id) 397 | datastore.remove_endpoint(endpoint) 398 | 399 | # Remove the container from the datastore. 400 | datastore.remove_workload(hostname=HOSTNAME, 401 | orchestrator_id=ORCHESTRATOR_ID, 402 | workload_id=container_id) 403 | _log.info("Cleanup complete for container %s", container_id) 404 | 405 | 406 | def reserve(args): 407 | """ 408 | Toplevel function which validates and sanitizes dictionary of args 409 | which can be passed to _reserve. Calico's reserve does not make use of 410 | netgroups or labels, so they are ignored. 411 | 412 | "args": { 413 | "hostname": "slave-0-1", # Required 414 | # At least one of "ipv4_addrs" and "ipv6_addrs" must be present. 415 | "ipv4_addrs": ["192.168.23.4"], 416 | "ipv6_addrs": ["2001:3ac3:f90b:1111::1", "2001:3ac3:f90b:1111::2"], 417 | "uid": "0cd47986-24ad-4c00-b9d3-5db9e5c02028", 418 | "netgroups": ["prod", "frontend"], # Optional. 419 | "labels": { # Optional. 420 | "rack": "3A", 421 | "pop": "houston" 422 | } 423 | } 424 | """ 425 | hostname = args.get("hostname") 426 | ipv4_addrs = args.get("ipv4_addrs", []) 427 | ipv6_addrs = args.get("ipv6_addrs", []) 428 | uid = args.get("uid") 429 | 430 | # Validations 431 | if not uid: 432 | raise IsolatorException("Missing uid") 433 | try: 434 | # Convert to string since libcalico requires uids to be strings 435 | uid = str(uid) 436 | except ValueError: 437 | raise IsolatorException("Invalid UID: %s" % uid) 438 | 439 | if hostname is None: 440 | raise IsolatorException(ERROR_MISSING_HOSTNAME) 441 | 442 | # Validate IP addresses 443 | ipv4_addrs_validated = _validate_ip_addrs(ipv4_addrs, 4) 444 | ipv6_addrs_validated = _validate_ip_addrs(ipv6_addrs, 6) 445 | 446 | if not ipv4_addrs_validated + ipv6_addrs_validated: 447 | raise IsolatorException("Must provide at least one IPv4 or IPv6 address.") 448 | 449 | return _reserve(hostname, uid, ipv4_addrs_validated, ipv6_addrs_validated) 450 | 451 | 452 | def _reserve(hostname, uid, ipv4_addrs, ipv6_addrs): 453 | """ 454 | Reserve an IP from the IPAM. 455 | :param hostname: The host agent which is reserving this IP 456 | :param uid: A unique ID, which is indexed by the IPAM module and can be 457 | used to release all addresses with the uid. 458 | :param ipv4_addrs: List of IPAddress objects representing requested IPv4 459 | addresses. 460 | :param ipv6_addrs: List of IPAddress objects representing requested IPv6 461 | addresses. 462 | :return: 463 | """ 464 | _log.info("Reserving. hostname: %s, uid: %s, ipv4_addrs: %s, ipv6_addrs: %s" % \ 465 | (HOSTNAME, uid, ipv4_addrs, ipv6_addrs)) 466 | assigned_ips = [] 467 | try: 468 | for ip_addr in ipv4_addrs + ipv6_addrs: 469 | datastore.assign_ip(ip_addr, uid, {}, host=HOSTNAME) 470 | assigned_ips.append(ip_addr) 471 | # Keep track of succesfully assigned ip_addrs in case we need to rollback 472 | except (RuntimeError, ValueError, AlreadyAssignedError): 473 | failed_addr = ip_addr 474 | _log.error("Couldn't reserve %s. Attempting rollback." % (ip_addr)) 475 | # Rollback assigned ip_addrs 476 | datastore.release_ips(set(assigned_ips)) 477 | raise IsolatorException("IP '%s' already in use." % failed_addr) 478 | 479 | def allocate(args): 480 | """ 481 | Toplevel function which validates and sanitizes json args into variables 482 | which can be passed to _allocate. 483 | 484 | args = { 485 | "hostname": "slave-0-1", # Required 486 | "num_ipv4": 1, # Required. 487 | "num_ipv6": 2, # Required. 488 | "uid": "0cd47986-24ad-4c00-b9d3-5db9e5c02028", # Required 489 | "netgroups": ["prod", "frontend"], # Optional. 490 | "labels": { # Optional. 491 | "rack": "3A", 492 | "pop": "houston" 493 | } 494 | } 495 | 496 | """ 497 | hostname = args.get("hostname") 498 | uid = args.get("uid") 499 | num_ipv4 = args.get("num_ipv4") 500 | num_ipv6 = args.get("num_ipv6") 501 | netgroups = args.get("netgroups") 502 | labels = args.get("labels", {}) 503 | 504 | # Validations 505 | if not uid: 506 | raise IsolatorException("Missing uid") 507 | try: 508 | # Convert to string since libcalico requires uids to be strings 509 | uid = str(uid) 510 | except ValueError: 511 | raise IsolatorException("Invalid UID: %s" % uid) 512 | 513 | if hostname is None: 514 | raise IsolatorException(ERROR_MISSING_HOSTNAME) 515 | if num_ipv4 is None: 516 | raise IsolatorException("Missing num_ipv4") 517 | if num_ipv6 is None: 518 | raise IsolatorException("Missing num_ipv6") 519 | 520 | if not isinstance(num_ipv4, (int, long)): 521 | try: 522 | num_ipv4 = int(num_ipv4) 523 | except TypeError: 524 | raise IsolatorException("num_ipv4 must be an integer") 525 | 526 | if not isinstance(num_ipv6, (int, long)): 527 | try: 528 | num_ipv6 = int(num_ipv6) 529 | except TypeError: 530 | raise IsolatorException("num_ipv6 must be an integer") 531 | 532 | # Check if the user has requested a specific IP via label 533 | # Note: this will be deprecated once marathon provides the ipv4_addrs 534 | # field which will trigger netmodules' 'reserve'. 535 | # This implementation replaces the requested IPAM IP with the static IP. 536 | # i.e. If a user requests 1 IP, and specifies one specific IP in the 537 | # ipv4_addr label, they will receive 1 IP back - the one they specified. 538 | ipv4_addrs = [] 539 | if labels.has_key("ipv4_addrs"): 540 | try: 541 | ipv4_addrs = eval(labels['ipv4_addrs']) 542 | except SyntaxError: 543 | raise IsolatorException("Calico detected a malformed ipv4_addrs " 544 | "field. Ensure you've specified a string representation " 545 | "of a list of strings.") 546 | # 'reserve' will sanitize the IP Addresses for us 547 | reserve({"hostname": hostname, 548 | "ipv4_addrs": ipv4_addrs, 549 | "ipv6_addrs": [], 550 | "uid": uid, 551 | "netgroups": netgroups, 552 | "labels": labels}) 553 | 554 | # Decrement how many IPAM'd IPs they will be getting by how many they 555 | # requested by label. 556 | num_ipv4 = max(num_ipv4 - len(ipv4_addrs), 0) 557 | 558 | result = _allocate(num_ipv4, num_ipv6, hostname, uid) 559 | result['ipv4'] += ipv4_addrs 560 | return json.dumps(result) 561 | 562 | def _allocate(num_ipv4, num_ipv6, hostname, uid): 563 | """ 564 | Allocate IP addresses from the data store. 565 | :param num_ipv4: Number of IPv4 addresses to request. 566 | :param num_ipv6: Number of IPv6 addresses to request. 567 | :param hostname: The hostname of this host. 568 | :param uid: A unique ID, which is indexed by the IPAM module and can be 569 | used to release all addresses with the uid. 570 | :return: Dictionary of the result in the following format: 571 | { 572 | "ipv4": ["192.168.23.4"], 573 | "ipv6": ["2001:3ac3:f90b:1111::1", "2001:3ac3:f90b:1111::2"], 574 | "error": None # Not None indicates error and contains error message. 575 | } 576 | """ 577 | result = datastore.auto_assign_ips(num_ipv4, num_ipv6, uid, {}, 578 | host=HOSTNAME) 579 | ipv4_strs = [str(ip) for ip in result[0]] 580 | ipv6_strs = [str(ip) for ip in result[1]] 581 | return {"ipv4": ipv4_strs, 582 | "ipv6": ipv6_strs, 583 | "error": None} 584 | 585 | 586 | def release(args): 587 | """ 588 | Toplevel function which validates and sanitizes json args into variables 589 | which can be passed to _release_uid or _release_ips. 590 | 591 | args: { 592 | "uid": "0cd47986-24ad-4c00-b9d3-5db9e5c02028", 593 | # OR 594 | "ips": ["192.168.23.4", "2001:3ac3:f90b:1111::1"] # OK to mix 6 & 4 595 | } 596 | 597 | Must include a uid or ips, but not both. If a uid is passed, release all 598 | addresses with that uid. 599 | 600 | If a list of ips is passed, release those IPs. 601 | """ 602 | uid = args.get("uid") 603 | ips = args.get("ips") 604 | 605 | if uid is None: 606 | if ips is None: 607 | raise IsolatorException("Must supply either uid or ips.") 608 | else: 609 | ips_validated = _validate_ip_addrs(ips) 610 | return _release_ips(set(ips_validated)) 611 | 612 | else: 613 | # uid supplied. 614 | if ips is not None: 615 | raise IsolatorException("Supply either uid or ips, not both.") 616 | else: 617 | if not isinstance(uid, (str, unicode)): 618 | raise IsolatorException("uid must be a string") 619 | # uid validated. 620 | return _release_uid(uid) 621 | 622 | 623 | def _release_ips(ips): 624 | """ 625 | Release the given IPs using the data store. 626 | 627 | :param ips: Set of IPAddress objects to release. 628 | :return: None 629 | """ 630 | # release_ips returns a set of addresses that were already not allocated 631 | # when this function was called. But, Mesos doesn't consume that 632 | # information, so we ignore it. 633 | _ = datastore.release_ips(ips) 634 | 635 | 636 | def _release_uid(uid): 637 | """ 638 | Release all IP addresses with the given unique ID using the data store. 639 | :param uid: The unique ID used to allocate the IPs. 640 | :return: None 641 | """ 642 | _ = datastore.release_ip_by_handle(uid) 643 | 644 | 645 | def _error_message(msg=None): 646 | """ 647 | Helper function to convert error messages into the JSON format. 648 | """ 649 | return json.dumps({"error": msg}) 650 | 651 | 652 | class IsolatorException(Exception): 653 | pass 654 | 655 | 656 | 657 | if __name__ == '__main__': 658 | _setup_logging(LOGFILE) 659 | try: 660 | response = calico_mesos() 661 | except IsolatorException as e: 662 | _log.error(e) 663 | sys.stdout.write(_error_message(str(e))) 664 | sys.exit(1) 665 | except DatastoreError as e: 666 | # Encountered an etcd error 667 | _log.error(e) 668 | # Try to give a more helpful error message depending on whether or not 669 | # the user has set ETCD_AUTHORITY 670 | try: 671 | etcd_authority = os.environ['ETCD_AUTHORITY'] 672 | error_message = "Failed to communicate with etcd at '%s'. " \ 673 | "Ensure that it is up and running or change ETCD_AUTHORITY" \ 674 | " environment variable used by the meoss-agent process." % \ 675 | etcd_authority 676 | except KeyError: 677 | error_message = "Failed to communicate with etcd. ETCD_AUTHORITY " \ 678 | "is not set for this agent process." 679 | sys.stdout.write(_error_message(error_message)) 680 | sys.exit(1) 681 | except Exception as e: 682 | _log.error(e) 683 | sys.stdout.write(_error_message("Unhandled error %s\n%s" % 684 | (str(e), traceback.format_exc()))) 685 | sys.exit(1) 686 | else: 687 | if response == None: 688 | response = _error_message(None) 689 | _log.info("Request completed with response: %s" % response) 690 | sys.stdout.write(response) 691 | sys.exit(0) 692 | --------------------------------------------------------------------------------