Update to latest Python deploy scripts.

This commit is contained in:
Stephen Provine 2016-11-07 22:36:57 -08:00
Родитель e68980d3dd
Коммит 8d359b79c0
15 изменённых файлов: 302 добавлений и 101 удалений

Просмотреть файл

@ -68,6 +68,27 @@ class ACSClient(object):
private_key_file.seek(0)
return paramiko.RSAKey.from_private_key(private_key_file, self.acs_info.password)
def ensure_dcos_version(self):
"""
Ensures min DC/OS version is installed on the cluster
"""
min_dcos_version_str = '1.8.4'
min_dcos_version_tuple = map(int, (min_dcos_version_str.split('.')))
path = '/dcos-metadata/dcos-version.json'
version_json = self.get_request(path).json()
if not 'version' in version_json:
raise Exception('Could not determine DC/OS version from %s', path)
version_str = version_json['version']
logging.info('Found DC/OS version %s', version_str)
version_tuple = map(int, (version_str.split('.')))
if version_tuple < min_dcos_version_tuple:
err_msg = 'DC/OS version %s is not supported. Only DC/OS version %s or higher is supported' % (version_str, min_dcos_version_str)
logging.error(err_msg)
raise ValueError(err_msg)
return True
def _setup_tunnel_server(self):
"""
Gets the SSHTunnelForwarder instance and local_port

Просмотреть файл

@ -0,0 +1,39 @@
{
"id": "/exhibitor-data",
"cpus": 0.01,
"mem": 32,
"instances": 1,
"acceptedResourceRoles": [
"slave_public"
],
"container": {
"type": "DOCKER",
"docker": {
"image": "openresty/openresty:alpine",
"network": "BRIDGE",
"portMappings": [
{
"protocol": "tcp",
"hostPort": 0,
"containerPort": 80,
"labels": {
"VIP_0": "exhibitor-data:80"
}
}
]
}
},
"cmd": "cat << EOF > /usr/local/openresty/nginx/conf/nginx.conf && exec /usr/local/openresty/bin/openresty -g 'daemon off;'\nworker_processes 1;\nevents {\n worker_connections 1024;\n}\nhttp {\n upstream backend {\n server leader.mesos;\n }\n server {\n listen 80;\n location = / {\n proxy_pass http://backend/exhibitor/exhibitor/v1/cluster/status;\n }\n location ~ /_/(.*)$ {\n internal;\n proxy_pass http://backend/exhibitor/exhibitor/v1/explorer/node-data?key=/\\$1;\n }\n location ~ /(.*)$ {\n content_by_lua_block {\n local cjson = require \"cjson\"\n local resp = ngx.location.capture(\"/_/\" .. ngx.var[1])\n local bytes = cjson.decode(resp.body).bytes\n bytes = string.gsub(bytes, \" \", \"\")\n if string.len(bytes) == 0 then\n ngx.status = 404\n ngx.exit(404)\n end\n for i = 1, string.len(bytes), 2 do\n ngx.print(string.char(tonumber(string.sub(bytes, i, i + 1), 16)))\n end\n }\n }\n }\n}",
"healthChecks": [
{
"path": "/",
"protocol": "HTTP",
"portIndex": 0,
"gracePeriodSeconds": 300,
"intervalSeconds": 5,
"timeoutSeconds": 20,
"maxConsecutiveFailures": 3,
"ignoreHttp1xx": false
}
]
}

Просмотреть файл

@ -0,0 +1,34 @@
{
"id": "/external-nginx-lb",
"cpus": 0.1,
"mem": 128,
"instances": 1,
"acceptedResourceRoles": [
"slave_public"
],
"container": {
"type": "DOCKER",
"docker": {
"image": "nginx:alpine",
"network": "HOST"
}
},
"ports": [
80,
443
],
"requirePorts": true,
"cmd": "cat << EOF > /etc/nginx/nginx.conf && exec nginx -g 'daemon off;'\nworker_processes 1;\nevents {\n worker_connections 1024;\n}\nhttp {\n server {\n listen 80;\n resolver $(echo $(cat /etc/resolv.conf | grep ^nameserver\\ | cut -d ' ' -f 2));\n location / {\n proxy_pass http://\\$host.${GROUP:-external}.marathon.l4lb.thisdcos.directory\\$request_uri;\n }\n }\n}",
"healthChecks": [
{
"protocol": "TCP",
"gracePeriodSeconds": 60,
"intervalSeconds": 5,
"timeoutSeconds": 2,
"maxConsecutiveFailures": 2
}
],
"upgradeStrategy": {
"minimumHealthCapacity": 0
}
}

Просмотреть файл

@ -108,7 +108,8 @@ if __name__ == '__main__':
arguments.acs_port, arguments.acs_username, arguments.acs_password,
arguments.acs_private_key, arguments.group_name, arguments.group_qualifier,
arguments.group_version, arguments.registry_host, arguments.registry_username,
arguments.registry_password, arguments.minimum_health_capacity) as compose_parser:
arguments.registry_password, arguments.minimum_health_capacity,
check_dcos_version=True) as compose_parser:
compose_parser.deploy()
sys.exit(0)
except Exception as e:

Просмотреть файл

@ -11,13 +11,15 @@ import dockerregistry
import marathon
import portmappings
import serviceparser
from exhibitor import Exhibitor
from nginx import LoadBalancerApp
class DockerComposeParser(object):
def __init__(self, compose_file, master_url, acs_host, acs_port, acs_username,
acs_password, acs_private_key, group_name, group_qualifier, group_version,
registry_host, registry_username, registry_password,
minimum_health_capacity):
minimum_health_capacity, check_dcos_version=False):
self._ensure_docker_compose(compose_file)
with open(compose_file, 'r') as compose_stream:
@ -37,7 +39,11 @@ class DockerComposeParser(object):
self.minimum_health_capacity = minimum_health_capacity
self.acs_client = acsclient.ACSClient(self.acs_info)
if check_dcos_version:
self.acs_client.ensure_dcos_version()
self.marathon_helper = marathon.Marathon(self.acs_client)
self.exhibitor_helper = Exhibitor(self.marathon_helper)
self.nginx_helper = LoadBalancerApp(self.marathon_helper)
self.portmappings_helper = portmappings.PortMappings()
@ -111,6 +117,9 @@ class DockerComposeParser(object):
"""
group_name = self._get_group_id()
all_apps = {'id': group_name, 'apps': []}
self.nginx_helper.ensure_exists(self.compose_data)
docker_registry = dockerregistry.DockerRegistry(
self.registry_host, self.registry_username, self.registry_password,
self.marathon_helper)
@ -200,6 +209,9 @@ class DockerComposeParser(object):
except KeyError:
raise Exception('Could not find container/docker/portMappings key')
if port_mappings is None:
continue
# Go through port mappings and create VIPs
for port_mapping in port_mappings:
# Check if private IP is already created for this port mapping
@ -276,6 +288,18 @@ class DockerComposeParser(object):
self._add_host(marathon_app, app_id, private_ips)
def deploy(self):
"""
Deploys the services defined in docker-compose.yml file
"""
try:
self._deploy()
except Exception as exc:
group_id = self._get_group_id()
logging.exception('Exception occurred deploying "%s"', group_id)
logging.info('Removing "%s".', group_id)
self.marathon_helper.delete_group(group_id)
def _deploy(self):
"""
Deploys the services defined in docker-compose.yml file
"""

Просмотреть файл

@ -1,24 +1,17 @@
import json
import logging
import hexifier
import marathon
from exhibitor import Exhibitor
class DockerRegistry(object):
"""
Class for working with Docker registry
"""
EXHIBITOR_VIP = '10.1.0.0:80'
# TODO (peterj, 10/21/2016): 281451: Update exhibitor image once ACS uses DCOS 1.8
EXHIBITOR_DATA_IMAGE = 'mindaro/dcos-exhibitor-data:1.5.6'
EXHIBITOR_SERVICE_ID = '/exhibitor-data'
def __init__(self, registry_host, registry_username, registry_password, marathon_helper):
self.registry_host = registry_host
self.registry_username = registry_username
self.registry_password = registry_password
self.marathon_helper = marathon_helper
self.exhibitor_helper = Exhibitor(marathon_helper)
def get_registry_auth_url(self):
"""
@ -29,74 +22,10 @@ class DockerRegistry(object):
if not self.registry_host:
return None
self._ensure_exhibitor_service()
self.marathon_helper.ensure_exists(Exhibitor.APP_ID, Exhibitor.JSON_FILE)
auth_config_hexifier = hexifier.DockerAuthConfigHexifier(
self.registry_host, self.registry_username, self.registry_password)
hex_string = auth_config_hexifier.hexify()
return self._upload_auth_info(hex_string, auth_config_hexifier.get_auth_file_path())
def _upload_auth_info(self, hex_string, endpoint):
"""
Uploads the auth information (hex string) to Exhibitor
"""
# PUT the hex_string to exhibitor
self.marathon_helper.put_request(
'registries/{}'.format(endpoint),
put_data=hex_string,
endpoint='/exhibitor/exhibitor/v1/explorer/znode')
return 'http://{}/registries/{}'.format(
self.EXHIBITOR_VIP, endpoint)
def _get_exhibitor_service_json(self):
exhibitor_data_json = {
'id': self.EXHIBITOR_SERVICE_ID,
'cpus': 0.01,
'mem': 32,
'instances': 1,
'acceptedResourceRoles': [
'slave_public'
],
'container': {
'type': 'DOCKER',
'docker': {
'image': self.EXHIBITOR_DATA_IMAGE,
'network': 'BRIDGE',
'portMappings': [
{
'containerPort': 80,
'hostPort': 0,
'protocol': 'tcp',
'name': 'tcp80',
'labels': {
'VIP_0': self.EXHIBITOR_VIP
}
}]
}
},
'healthChecks': [
{
'path': '/',
'protocol': 'HTTP',
'portIndex': 0,
'gracePeriodSeconds': 300,
'intervalSeconds': 5,
'timeoutSeconds': 20,
'maxConsecutiveFailures': 3,
'ignoreHttp1xx': False
}]
}
return exhibitor_data_json
def _ensure_exhibitor_service(self):
"""
Checks exhibitor service is running and if is not
it will deploy it.
"""
exhibitor_json = self._get_exhibitor_service_json()
# Check if app exists and deploy it if it doesn't
app_exists = self.marathon_helper.app_exists(self.EXHIBITOR_SERVICE_ID)
if not app_exists:
logging.info('Exhibitor-data service not deployed - deploying it now')
self.marathon_helper.deploy_app(json.dumps(exhibitor_json))
endpoint = 'registries/{}'.format(auth_config_hexifier.get_auth_file_path())
return self.exhibitor_helper.upload(hex_string, endpoint)

Просмотреть файл

@ -0,0 +1,24 @@
class Exhibitor(object):
"""
Functionality for interacting with exhbitior
"""
APP_ID = '/exhibitor-data'
HOST_NAME = 'exhibitor-data.marathon.l4lb.thisdcos.directory'
JSON_FILE = 'conf/exhibitor-data.json'
def __init__(self, marathon_helper):
self.marathon_helper = marathon_helper
def upload(self, hex_string, endpoint):
"""
Uploads a hexified string to provided exhibitor endpoint
and returns the full URL to it
"""
self.marathon_helper.put_request(
endpoint,
put_data=hex_string,
endpoint='/exhibitor/exhibitor/v1/explorer/znode')
return 'http://{}/{}'.format(
Exhibitor.HOST_NAME, endpoint)

Просмотреть файл

@ -33,15 +33,23 @@ class DockerAuthConfigHexifier(object):
"""
return '{}/{}'.format(self.registry_host, self._get_auth_filename())
@classmethod
def hexify_file(cls, file_name):
"""
Creates a hex representation of a file and returns it as
a string
"""
with open(file_name, 'rb') as binary_file:
file_bytes = binary_file.read()
hex_string = binascii.hexlify(bytearray(file_bytes))
return hex_string
def hexify(self):
"""
Create a hex representation of the docker.tar.gz file
"""
file_path = self._create_temp_auth_file()
with open(file_path, 'rb') as binary_file:
file_bytes = binary_file.read()
hex_string = binascii.hexlify(bytearray(file_bytes))
return hex_string
return DockerAuthConfigHexifier.hexify_file(file_path)
def _get_auth_filename(self):
"""

Просмотреть файл

@ -1,6 +1,7 @@
import json
import time
import logging
import os
import time
class Marathon(object):
@ -72,6 +73,28 @@ class Marathon(object):
return True
return False
def ensure_exists(self, app_id, json_file):
"""
Checks if app with provided ID is deployed on Marathon and
deploys it if it is not
"""
logging.info('Check if app "%s" is deployed', app_id)
app_exists = self.app_exists(app_id)
if not app_exists:
logging.info('Deploying app "%s"', app_id)
json_contents = self._load_json(json_file)
self.deploy_app(json.dumps(json_contents))
def _load_json(self, file_path):
"""
Loads contents of a JSON file and returns it
"""
file_path = os.path.join(os.getcwd(), file_path)
with open(file_path) as json_file:
data = json.load(json_file)
return data
def deploy_app(self, app_json):
"""
Deploys an app to marathon

Просмотреть файл

@ -0,0 +1,35 @@
import os
from hexifier import DockerAuthConfigHexifier
from exhibitor import Exhibitor
class LoadBalancerApp(object):
"""
NGINX load balancer functionality
"""
APP_ID = '/external-nginx-lb'
JSON_FILE = 'conf/external-nginx-lb.json'
def __init__(self, marathon_helper):
self.marathon_helper = marathon_helper
def ensure_exists(self, compose_data):
"""
Checks if compose file has label that requires NGINX
to be install and ensures it is installed
"""
for _, service_info in compose_data['services'].items():
if 'labels' in service_info:
for label in service_info['labels']:
if label.startswith('com.microsoft.acs.dcos.marathon.vhost'):
self.marathon_helper.ensure_exists(Exhibitor.APP_ID, Exhibitor.JSON_FILE)
self._install()
def _install(self):
"""
Installs NGINX load balancer. Checks if NGINX is not installed yet
then deploys the nginx.conf template first, and deploys the NGINX app.
"""
if not self.marathon_helper.app_exists(LoadBalancerApp.APP_ID):
self.marathon_helper.ensure_exists(LoadBalancerApp.APP_ID, LoadBalancerApp.JSON_FILE)

Просмотреть файл

@ -210,7 +210,7 @@ class PortMappings(object):
port = str(port).strip()
external_vip = vhost + '.external' + ':' + port
for port_mapping in existing_port_mappings:
if str(port_mapping['containerPort']).strip() == port:
if str(port_mapping['containerPort']).strip() == str(port):
port_mapping['labels']['VIP_2'] = external_vip
vhost_added = True
if not vhost_added:
@ -221,22 +221,38 @@ class PortMappings(object):
port_mapping['labels']['VIP_2'] = external_vip
existing_port_mappings.append(port_mapping)
def _get_internal_port_mappings(self, service_data, ip_address, vip_name):
port_mappings = []
def _get_internal_port_mappings(self, service_data, ip_address,
vip_name, existing_port_mappings):
"""
Gets the internal ports from the service data and updates the
existing_port_mappings array
"""
internal_ports = self._parse_internal_ports(service_data)
for internal_port in internal_ports:
port_mapping = self._get_port_mapping_json()
vip_port = internal_port[0]
container_port = internal_port[1]
port_mapping['containerPort'] = int(container_port)
port_mapping['labels']['VIP_0'] = ip_address + ':' + str(container_port)
port_mapping['labels']['VIP_1'] = vip_name + '.internal' + ':' + str(vip_port)
port_mappings.append(port_mapping)
return port_mappings
existing_mapping = False
for existing_port_mapping in existing_port_mappings:
if str(existing_port_mapping['containerPort']).strip() == str(container_port):
# No need to add VIP_0 as it already exists
existing_port_mapping['labels']['VIP_1'] = \
vip_name + '.internal' + ':' + str(vip_port)
existing_mapping = True
break
# If we have a completely new mapping
if not existing_mapping:
port_mapping['containerPort'] = int(container_port)
port_mapping['labels']['VIP_0'] = ip_address + ':' + str(container_port)
port_mapping['labels']['VIP_1'] = vip_name + '.internal' + ':' + str(vip_port)
existing_port_mappings.append(port_mapping)
def _get_private_port_mappings(self, service_data, ip_address):
"""
Creates a list of port mappings with private ports
"""
port_mappings = []
private_ports = self._parse_private_ports(service_data)
@ -257,10 +273,8 @@ class PortMappings(object):
split = ip_address.split(':')
ip_address = split[0]
private_port_mappings = self._get_private_port_mappings(service_data, ip_address)
internal_port_mappings = self._get_internal_port_mappings(
service_data, ip_address, vip_name)
all_mappings = internal_port_mappings + private_port_mappings
self._set_external_port_mappings(service_data, ip_address, all_mappings)
return all_mappings
all_port_mappings = self._get_private_port_mappings(service_data, ip_address)
self._get_internal_port_mappings(
service_data, ip_address, vip_name, all_port_mappings)
self._set_external_port_mappings(service_data, ip_address, all_port_mappings)
return all_port_mappings

Просмотреть файл

@ -75,7 +75,11 @@ class Parser(object):
# If environment var does not have a value set
self.app_json['env'][env_pair] = ''
else:
self.app_json['env'][env_pair] = str(self.service_info[key][env_pair])
value = self.service_info[key][env_pair]
if value is None:
self.app_json['env'][env_pair] = ''
else:
self.app_json['env'][env_pair] = str(value)
def _parse_extra_hosts(self, key):
"""

Просмотреть файл

@ -0,0 +1,35 @@
import unittest
import requests
from mock import patch
import acsclient
import acsinfo
def mocked_requests_get(*args, **kwargs):
class MockResponse:
def __init__(self, json_data, status_code):
self.json_data = json_data
self.status_code = status_code
def json(self):
return self.json_data
if args[0].startswith('unsupported_version'):
return MockResponse({'version': '1.2.3'}, 200)
elif args[0].startswith('supported_version'):
return MockResponse({'version': '1.8.4'}, 200)
return MockResponse({}, 404)
class AcsClientTest(unittest.TestCase):
@patch('requests.get', side_effect=mocked_requests_get)
def test_ensure_dcos_version_unsupported(self, mock_get):
acs_info = acsinfo.AcsInfo('myhost', 2200, None, None, None, 'unsupported_version')
acs = acsclient.ACSClient(acs_info)
self.assertRaises(ValueError, acs.ensure_dcos_version)
@patch('requests.get', side_effect=mocked_requests_get)
def test_ensure_dcos_version_supported(self, mock_get):
acs_info = acsinfo.AcsInfo('myhost', 2200, None, None, None, 'supported_version')
acs = acsclient.ACSClient(acs_info)
self.assertTrue(acs.ensure_dcos_version())

Просмотреть файл

@ -158,7 +158,7 @@ class PortMappingsTest(unittest.TestCase):
p = portmappings.PortMappings()
expected = [{'labels': {'VIP_1': 'myvipname.internal:5000', 'VIP_0': '1.1.1.1:5000'}, 'protocol': 'tcp', 'containerPort': 5000, 'hostPort': 0}, {'labels': {'VIP_0': '1.1.1.1:3000'}, 'protocol': 'tcp', 'containerPort': 3000, 'hostPort': 0}]
service_data = {'ports': ["5000"], 'expose': ["3000"]}
self.assertEquals(p.get_port_mappings('1.1.1.1', service_data, 'myvipname'), expected)
self.assertEquals(sorted(p.get_port_mappings('1.1.1.1', service_data, 'myvipname')), sorted(expected))
def test_get_port_mappings_single_port_range(self):
p = portmappings.PortMappings()
@ -182,7 +182,7 @@ class PortMappingsTest(unittest.TestCase):
p = portmappings.PortMappings()
expected = [{'labels': {'VIP_1': 'myvipname.internal:5000', 'VIP_0': '1.1.1.1:6000'}, 'protocol': 'tcp', 'containerPort': 6000, 'hostPort': 0}, {'labels': {'VIP_1': 'myvipname.internal:5001', 'VIP_0': '1.1.1.1:6001'}, 'protocol': 'tcp', 'containerPort': 6001, 'hostPort': 0}, {'labels': {'VIP_1': 'myvipname.internal:5002', 'VIP_0': '1.1.1.1:6002'}, 'protocol': 'tcp', 'containerPort': 6002, 'hostPort': 0}, {'labels': {'VIP_0': '1.1.1.1:4000'}, 'protocol': 'tcp', 'containerPort': 4000, 'hostPort': 0}]
service_data = {'ports': ["5000-5002:6000-6002"], 'expose': ["4000"]}
self.assertEquals(p.get_port_mappings('1.1.1.1', service_data, 'myvipname'), expected)
self.assertEquals(sorted(p.get_port_mappings('1.1.1.1', service_data, 'myvipname')), sorted(expected))
def test_get_port_mappings_external_port(self):
p = portmappings.PortMappings()

Просмотреть файл

@ -100,6 +100,16 @@ class ServiceParserTests(unittest.TestCase):
p._parse_environment('environment')
self.assertEquals({}, p.app_json)
def test_parse_environment_null(self):
p = serviceparser.Parser('groupname', 'myservice', {'environment': ['SomeValue']})
p._parse_environment('environment')
self.assertEquals({'env': { 'SomeValue': ''}}, p.app_json)
def test_parse_environment_none_string(self):
p = serviceparser.Parser('groupname', 'myservice', {'environment': ['SomeValue=None']})
p._parse_environment('environment')
self.assertEquals({'env': { 'SomeValue': 'None'}}, p.app_json)
def test_parse_extra_hosts(self):
expected = {'container': {'docker': {'parameters': [{'key': 'add-host', 'value': 'somehost:162.242.195.82'}, {'key': 'add-host', 'value': 'otherhost:50.31.209.229'}]}}}
p = serviceparser.Parser('groupname', 'myservice', {'extra_hosts': ['somehost:162.242.195.82', 'otherhost:50.31.209.229']})