diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1794fc6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:2.7.11-alpine + +RUN apk update && apk add ca-certificates + +ADD . /sdk +WORKDIR sdk +RUN python setup.py install + diff --git a/MANIFEST.in b/MANIFEST.in index 84c7124..f989a21 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,4 @@ include LICENSE include requirements.txt +include test-requirements.txt include README.md diff --git a/Makefile b/Makefile deleted file mode 100644 index 0fd86d8..0000000 --- a/Makefile +++ /dev/null @@ -1,13 +0,0 @@ -test:prepare - venv/bin/python setup.py test - -clean: - rm -rf venv build dist *.egg-info - find . -name '*.pyc' -delete - -prepare:clean - set -ex - virtualenv venv - venv/bin/pip install mock - venv/bin/pip install -r requirements.txt - venv/bin/python setup.py install diff --git a/README.md b/README.md index 5a82ce2..aea721e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,19 @@ The authentication can be configured in the following ways: export DOCKERCLOUD_USER=username export DOCKERCLOUD_APIKEY=apikey +## Namespace + +To support teams and orgs, you can specify the namespace in the following ways: + +* Set it in the Python code: + + import dockercloud + dockercloud.namespace = "yourteam" + +* Set it in the environment variable: + + export DOCKERCLOUD_NAMESPACE=yourteam + ## Errors Errors in the HTTP API will be returned with status codes in the 4xx and 5xx ranges. diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..f4d914a --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,3 @@ +sut: + build: . + command: python setup.py test diff --git a/dockercloud/__init__.py b/dockercloud/__init__.py index 1607b62..8bb6aa5 100644 --- a/dockercloud/__init__.py +++ b/dockercloud/__init__.py @@ -25,7 +25,7 @@ from dockercloud.api.utils import Utils from dockercloud.api.events import Events from dockercloud.api.nodeaz import AZ -__version__ = '1.0.3' +__version__ = '1.0.9' dockercloud_auth = os.environ.get('DOCKERCLOUD_AUTH') basic_auth = auth.load_from_file("~/.docker/config.json") @@ -38,6 +38,8 @@ if os.environ.get('DOCKERCLOUD_USER') and os.environ.get('DOCKERCLOUD_APIKEY'): rest_host = os.environ.get("DOCKERCLOUD_REST_HOST") or 'https://cloud.docker.com/' stream_host = os.environ.get("DOCKERCLOUD_STREAM_HOST") or 'wss://ws.cloud.docker.com/' +namespace = os.environ.get('DOCKERCLOUD_NAMESPACE') + user_agent = None logging.basicConfig() diff --git a/dockercloud/api/action.py b/dockercloud/api/action.py index 71a3ed8..214827f 100644 --- a/dockercloud/api/action.py +++ b/dockercloud/api/action.py @@ -6,6 +6,7 @@ from .base import Immutable, StreamingLog class Action(Immutable): subsystem = 'audit' endpoint = "/action" + namespaced = False @classmethod def _pk_key(cls): diff --git a/dockercloud/api/auth.py b/dockercloud/api/auth.py index 5c44e9c..65d6668 100644 --- a/dockercloud/api/auth.py +++ b/dockercloud/api/auth.py @@ -3,12 +3,14 @@ from __future__ import absolute_import import base64 import json import os +import subprocess from requests.auth import HTTPBasicAuth import dockercloud from .http import send_request +HUB_INDEX = "https://index.docker.io/v1/" def authenticate(username, password): verify_credential(username, password) @@ -43,11 +45,29 @@ def load_from_file(f="~/.docker/config.json"): try: with open(os.path.expanduser(f)) as config_file: data = json.load(config_file) - - return data.get("auths", {}).get("https://index.docker.io/v1/", {}).get("auth", None) - except Exception: + except: return None + creds_store = data.get("credsStore", None) + if creds_store: + try: + cmd = "docker-credential-" + creds_store + p = subprocess.Popen([cmd, 'get'], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) + out = p.communicate(input=HUB_INDEX)[0] + except: + raise dockercloud.AuthError('error getting credentials - err: exec: "%s": executable file not found in $PATH, out: ``' % cmd) + + try: + credential = json.loads(out) + username = credential.get("Username") + password = credential.get("Secret") + return base64.b64encode("%s:%s" % (username, password)) + except: + return None + + else: + return data.get("auths", {}).get(HUB_INDEX, {}).get("auth", None) + def get_auth_header(): try: diff --git a/dockercloud/api/base.py b/dockercloud/api/base.py index b375ba4..f889f69 100644 --- a/dockercloud/api/base.py +++ b/dockercloud/api/base.py @@ -22,6 +22,7 @@ class BasicObject(object): class Restful(BasicObject): _detail_uri = None + namespaced = True def __init__(self, **kwargs): """Simply reflect all the values in kwargs""" @@ -58,7 +59,11 @@ class Restful(BasicObject): assert subsystem, "Subsystem not specified for %s" % self.__class__.__name__ for k, v in list(dict.items()): setattr(self, k, v) - self._detail_uri = "/".join(["api", subsystem, self._api_version, endpoint.strip("/"), self.pk]) + if self.namespaced and dockercloud.namespace: + self._detail_uri = "/".join(["api", subsystem, self._api_version, dockercloud.namespace, + endpoint.strip("/"), self.pk]) + else: + self._detail_uri = "/".join(["api", subsystem, self._api_version, endpoint.strip("/"), self.pk]) self.__setchanges__([]) @property @@ -126,7 +131,10 @@ class Immutable(Restful): subsystem = getattr(cls, 'subsystem', None) assert endpoint, "Endpoint not specified for %s" % cls.__name__ assert subsystem, "Subsystem not specified for %s" % cls.__name__ - detail_uri = "/".join(["api", subsystem, cls._api_version, endpoint.strip("/"), pk]) + if cls.namespaced and dockercloud.namespace: + detail_uri = "/".join(["api", subsystem, cls._api_version, dockercloud.namespace, endpoint.strip("/"), pk]) + else: + detail_uri = "/".join(["api", subsystem, cls._api_version, endpoint.strip("/"), pk]) json = send_request('GET', detail_uri) if json: instance = cls() @@ -141,7 +149,10 @@ class Immutable(Restful): assert endpoint, "Endpoint not specified for %s" % cls.__name__ assert subsystem, "Subsystem not specified for %s" % cls.__name__ - detail_uri = "/".join(["api", subsystem, cls._api_version, endpoint.strip("/")]) + if cls.namespaced and dockercloud.namespace: + detail_uri = "/".join(["api", subsystem, cls._api_version, dockercloud.namespace, endpoint.strip("/")]) + else: + detail_uri = "/".join(["api", subsystem, cls._api_version, endpoint.strip("/")]) objects = [] while True: if limit and len(objects) >= limit: @@ -219,7 +230,10 @@ class Mutable(Immutable): # Figure out whether we should do a create or update if not self._detail_uri: action = "POST" - path = "/".join(["api", subsystem, self._api_version, endpoint.lstrip("/")]) + if cls.namespaced and dockercloud.namespace: + path = "/".join(["api", subsystem, self._api_version, dockercloud.namespace, endpoint.lstrip("/")]) + else: + path = "/".join(["api", subsystem, self._api_version, endpoint.lstrip("/")]) else: action = "PATCH" path = self._detail_uri @@ -253,18 +267,14 @@ class Triggerable(BasicObject): class StreamingAPI(BasicObject): def __init__(self, url): - self._ws_init(url) - - def _ws_init(self, url): self.url = url - user_agent = 'python-dockercloud/%s' % dockercloud.__version__ if dockercloud.user_agent: user_agent = "%s %s" % (dockercloud.user_agent, user_agent) header = {'User-Agent': user_agent} header.update(dockercloud.auth.get_auth_header()) self.header = [": ".join([key, value]) for key, value in header.items()] - logger.info("websocket: %s %s" % (self.url, self.header)) + logger.info("Websocket: %s %s" % (self.url, self.header)) self.open_handler = None self.message_handler = None self.error_handler = None @@ -308,7 +318,7 @@ class StreamingAPI(BasicObject): on_message=self._on_message, on_error=self._on_error, on_close=self._on_close) - ws.run_forever(ping_interval=5, ping_timeout=5, *args, **kwargs) + ws.run_forever(ping_interval=10, ping_timeout=5, *args, **kwargs) class StreamingLog(StreamingAPI): @@ -316,7 +326,12 @@ class StreamingLog(StreamingAPI): endpoint = "%s/%s/logs/?follow=%s" % (resource, uuid, str(follow).lower()) if tail: endpoint = "%s&tail=%d" % (endpoint, tail) - url = "/".join([dockercloud.stream_host.rstrip("/"), "api", subsystem, self._api_version, endpoint.lstrip("/")]) + if dockercloud.namespace: + url = "/".join([dockercloud.stream_host.rstrip("/"), "api", subsystem, self._api_version, + dockercloud.namespace, endpoint.lstrip("/")]) + else: + url = "/".join([dockercloud.stream_host.rstrip("/"), "api", subsystem, self._api_version, + endpoint.lstrip("/")]) super(self.__class__, self).__init__(url) @staticmethod @@ -329,13 +344,17 @@ class StreamingLog(StreamingAPI): on_message=self._on_message, on_error=self._on_error, on_close=self._on_close) - ws.run_forever(ping_interval=5, ping_timeout=5, *args, **kwargs) + ws.run_forever(ping_interval=10, ping_timeout=5, *args, **kwargs) class Exec(StreamingAPI): def __init__(self, uuid, cmd='sh'): endpoint = "container/%s/exec/?command=%s" % (uuid, urllib.quote_plus(cmd)) - url = "/".join([dockercloud.stream_host.rstrip("/"), "api", "app", self._api_version, endpoint.lstrip("/")]) + if dockercloud.namespace: + url = "/".join([dockercloud.stream_host.rstrip("/"), "api", "app", self._api_version, + dockercloud.namespace, endpoint.lstrip("/")]) + else: + url = "/".join([dockercloud.stream_host.rstrip("/"), "api", "app", self._api_version, endpoint.lstrip("/")]) super(self.__class__, self).__init__(url) @staticmethod @@ -348,4 +367,4 @@ class Exec(StreamingAPI): on_message=self._on_message, on_error=self._on_error, on_close=self._on_close) - ws.run_forever(ping_interval=5, ping_timeout=5, *args, **kwargs) + ws.run_forever(ping_interval=10, ping_timeout=5, *args, **kwargs) diff --git a/dockercloud/api/events.py b/dockercloud/api/events.py index 9a3fcfc..4440f72 100644 --- a/dockercloud/api/events.py +++ b/dockercloud/api/events.py @@ -1,6 +1,7 @@ from __future__ import absolute_import import json +import logging import websocket @@ -8,35 +9,47 @@ import dockercloud from .base import StreamingAPI from .exceptions import AuthError +logger = logging.getLogger("python-dockercloud") + class Events(StreamingAPI): def __init__(self): endpoint = "events" - url = "/".join([dockercloud.stream_host.rstrip("/"), "api", "audit", self._api_version, endpoint.lstrip("/")]) + if dockercloud.namespace: + url = "/".join([dockercloud.stream_host.rstrip("/"), "api", "audit", self._api_version, + dockercloud.namespace, endpoint.lstrip("/")]) + else: + url = "/".join([dockercloud.stream_host.rstrip("/"), "api", "audit", self._api_version, + endpoint.lstrip("/")]) super(self.__class__, self).__init__(url) def _on_message(self, ws, message): + logger.info("Websocket Message: %s" % message) try: event = json.loads(message) except ValueError: return - - if event.get("type") == "error" and event.get("data", {}).get("errorMessage") == "UNAUTHORIZED": - self.auth_error = True - raise AuthError("Not authorized") if event.get("type") == "auth": return if self.message_handler: self.message_handler(message) + def _on_error(self, ws, e): + if isinstance(e, websocket._exceptions.WebSocketBadStatusException) and getattr(e, "status_code") == 401: + self.auth_error = True + + super(self.__class__, self)._on_error(ws, e) + def run_forever(self, *args, **kwargs): while True: if self.auth_error: - raise AuthError("Not authorized") + self.auth_error = False + raise AuthError("Not Authorized") + ws = websocket.WebSocketApp(self.url, header=self.header, on_open=self._on_open, on_message=self._on_message, on_error=self._on_error, on_close=self._on_close) - ws.run_forever(ping_interval=5, ping_timeout=5, *args, **kwargs) + ws.run_forever(ping_interval=10, ping_timeout=5, *args, **kwargs) diff --git a/dockercloud/api/nodeaz.py b/dockercloud/api/nodeaz.py index abc4d5f..25a153b 100644 --- a/dockercloud/api/nodeaz.py +++ b/dockercloud/api/nodeaz.py @@ -6,6 +6,7 @@ from .base import Immutable class AZ(Immutable): subsystem = "infra" endpoint = "/az" + namespaced = False @classmethod def _pk_key(cls): diff --git a/dockercloud/api/nodeprovider.py b/dockercloud/api/nodeprovider.py index 3d31f06..168bc72 100644 --- a/dockercloud/api/nodeprovider.py +++ b/dockercloud/api/nodeprovider.py @@ -6,6 +6,7 @@ from .base import Immutable class Provider(Immutable): subsystem = "infra" endpoint = "/provider" + namespaced = False @classmethod def _pk_key(cls): diff --git a/dockercloud/api/noderegion.py b/dockercloud/api/noderegion.py index 4d10bed..9a9c97a 100644 --- a/dockercloud/api/noderegion.py +++ b/dockercloud/api/noderegion.py @@ -6,6 +6,7 @@ from .base import Immutable class Region(Immutable): subsystem = "infra" endpoint = "/region" + namespaced = False @classmethod def _pk_key(cls): diff --git a/dockercloud/api/nodetype.py b/dockercloud/api/nodetype.py index 5274aaf..7416d63 100644 --- a/dockercloud/api/nodetype.py +++ b/dockercloud/api/nodetype.py @@ -6,6 +6,7 @@ from .base import Immutable class NodeType(Immutable): subsystem = "infra" endpoint = "/nodetype" + namespaced = False @classmethod def _pk_key(cls): diff --git a/hooks/push b/hooks/push new file mode 100755 index 0000000..f966504 --- /dev/null +++ b/hooks/push @@ -0,0 +1,2 @@ +#!/bin/sh +echo "Skipping push the image" diff --git a/requirements.txt b/requirements.txt index 72c0961..fe172c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -backports.ssl-match-hostname==3.4.0.2 future==0.15.0 requests==2.7.0 six==1.9.0 -websocket-client==0.32.0 +websocket-client==0.37.0 diff --git a/setup.py b/setup.py index a833d51..2ab4865 100644 --- a/setup.py +++ b/setup.py @@ -4,6 +4,13 @@ import re from setuptools import setup, find_packages +requirements =[ + "future >= 0.15.0, < 1", + "requests >= 2.5.2, < 3", + "six >= 1.3.0, < 2", + "websocket-client >= 0.32.0, < 1" +] + def read(*parts): path = os.path.join(os.path.dirname(__file__), *parts) @@ -19,15 +26,16 @@ def find_version(*file_paths): return version_match.group(1) raise RuntimeError('Unable to find version string.') +with open('./test-requirements.txt') as test_reqs_txt: + test_requirements = [line for line in test_reqs_txt] -with open('requirements.txt') as f: - install_requires = f.read().splitlines() setup( name="python-dockercloud", version=find_version('dockercloud', '__init__.py'), packages=find_packages(), - install_requires=install_requires, + install_requires=requirements, + tests_require=test_requirements, provides=['docker'], include_package_data=True, author="Docker, Inc.", diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..2e8cebf --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,3 @@ +mock==1.0.1 +coverage==4.0.3 +nose==1.3.7