diff --git a/gcloud/connection.py b/gcloud/connection.py index 35855e89b445..fb9773441f0f 100644 --- a/gcloud/connection.py +++ b/gcloud/connection.py @@ -1,4 +1,8 @@ import httplib2 +import json +import urllib + +from gcloud import exceptions class Connection(object): @@ -42,3 +46,83 @@ def http(self): self._http = self._credentials.authorize(self._http) return self._http + +class JsonConnection(Connection): + + API_BASE_URL = 'https://www.googleapis.com' + """The base of the API call URL.""" + + _EMPTY = object() + """A pointer to represent an empty value for default arguments.""" + + def __init__(self, project=None, *args, **kwargs): + + super(JsonConnection, self).__init__(*args, **kwargs) + + self.project = project + + def build_api_url(self, path, query_params=None, api_base_url=None, + api_version=None): + + url = self.API_URL_TEMPLATE.format( + api_base_url=(api_base_url or self.API_BASE_URL), + api_version=(api_version or self.API_VERSION), + path=path) + + query_params = query_params or {} + query_params.update({'project': self.project}) + url += '?' + urllib.urlencode(query_params) + + return url + + def make_request(self, method, url, data=None, content_type=None, + headers=None): + + headers = headers or {} + headers['Accept-Encoding'] = 'gzip' + + if data: + content_length = len(str(data)) + else: + content_length = 0 + + headers['Content-Length'] = content_length + + if content_type: + headers['Content-Type'] = content_type + + return self.http.request(uri=url, method=method, headers=headers, + body=data) + + def api_request(self, method, path=None, query_params=None, + data=None, content_type=None, + api_base_url=None, api_version=None, + expect_json=True): + + url = self.build_api_url(path=path, query_params=query_params, + api_base_url=api_base_url, + api_version=api_version) + + # Making the executive decision that any dictionary + # data will be sent properly as JSON. + if data and isinstance(data, dict): + data = json.dumps(data) + content_type = 'application/json' + + response, content = self.make_request( + method=method, url=url, data=data, content_type=content_type) + + # TODO: Add better error handling. + if response.status == 404: + raise exceptions.NotFoundError(response, content) + elif not 200 <= response.status < 300: + raise exceptions.ConnectionError(response, content) + + if content and expect_json: + # TODO: Better checking on this header for JSON. + content_type = response.get('content-type', '') + if not content_type.startswith('application/json'): + raise TypeError('Expected JSON, got %s' % content_type) + return json.loads(content) + + return content diff --git a/gcloud/dns/__init__.py b/gcloud/dns/__init__.py new file mode 100644 index 000000000000..83b11cfd4022 --- /dev/null +++ b/gcloud/dns/__init__.py @@ -0,0 +1,92 @@ +"""Shortcut methods for getting set up with Google Cloud DNS. + +You'll typically use these to get started with the API: + +>>> import gcloud.dns +>>> zone = gcloud.dns.get_zone('zone-name-here', + 'long-email@googleapis.com', + '/path/to/private.key') + +The main concepts with this API are: + +- :class:`gcloud.dns.connection.Connection` + which represents a connection between your machine + and the Cloud DNS API. + +- :class:`gcloud.dns.zone.Zone` + which represents a particular zone. +""" + + +__version__ = '0.1' + +# TODO: Allow specific scopes and authorization levels. +SCOPE = ('https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/ndev.clouddns.readonly', + 'https://www.googleapis.com/auth/ndev.clouddns.readwrite') +"""The scope required for authenticating as a Cloud DNS consumer.""" + + +def get_connection(project, client_email, private_key_path): + """Shortcut method to establish a connection to Cloud DNS. + + Use this if you are going to access several zones + with the same set of credentials: + + >>> from gcloud import dns + >>> connection = dns.get_connection(project, email, key_path) + >>> zone1 = connection.get_zone('zone1') + >>> zone2 = connection.get_zone('zone2') + + :type project: string + :param project: The name of the project to connect to. + + :type client_email: string + :param client_email: The e-mail attached to the service account. + + :type private_key_path: string + :param private_key_path: The path to a private key file (this file was + given to you when you created the service + account). + + :rtype: :class:`gcloud.dns.connection.Connection` + :returns: A connection defined with the proper credentials. + """ + + from gcloud.credentials import Credentials + from gcloud.dns.connection import Connection + + credentials = Credentials.get_for_service_account( + client_email, private_key_path, scope=SCOPE) + return Connection(project=project, credentials=credentials) + + +def get_zone(zone, project, client_email, private_key_path): + """Shortcut method to establish a connection to a particular zone. + + You'll generally use this as the first call to working with the API: + + >>> from gcloud import dns + >>> zone = dns.get_zone(zone, project, email, key_path) + + :type zone: string + :param zone: The id of the zone you want to use. + This is akin to a disk name on a file system. + + :type project: string + :param project: The name of the project to connect to. + + :type client_email: string + :param client_email: The e-mail attached to the service account. + + :type private_key_path: string + :param private_key_path: The path to a private key file (this file was + given to you when you created the service + account). + + :rtype: :class:`gcloud.dns.zone.Zone` + :returns: A zone with a connection using the provided credentials. + """ + + connection = get_connection(project, client_email, private_key_path) + return connection.get_zone(zone) diff --git a/gcloud/dns/change.py b/gcloud/dns/change.py new file mode 100644 index 000000000000..243ce3b965a1 --- /dev/null +++ b/gcloud/dns/change.py @@ -0,0 +1,21 @@ +class Change(object): + """A class representing a Change on Cloud DNS. + + :type additions: list + :param name: A list of records slated to be added to a zone. + + :type deletions: list + :param data: A list of records slated to be deleted to a zone. + """ + + def __init__(self, additions=None, deletions=None): + self.additions = additions + self.deletions = deletions + + def to_dict(self): + """Format the change into a dict compatible with Cloud DNS. + + :rtype: dict + :returns: A Cloud DNS dict representation of a change. + """ + return {'additions': self.additions, 'deletions': self.deletions} diff --git a/gcloud/dns/connection.py b/gcloud/dns/connection.py new file mode 100644 index 000000000000..11bc608262f5 --- /dev/null +++ b/gcloud/dns/connection.py @@ -0,0 +1,145 @@ +from gcloud import connection +from gcloud.dns.record import Record +from gcloud.dns.zone import Zone + + +class Connection(connection.JsonConnection): + """A connection to Google Cloud DNS via the JSON REST API. + + See :class:`gcloud.connection.JsonConnection` for a full list of parameters. + :class:`Connection` differs only in needing a project name + (which you specify when creating a project in the Cloud Console). + """ + + API_VERSION = 'v1beta1' + """The version of the API, used in building the API call's URL.""" + + API_URL_TEMPLATE = ('{api_base_url}/dns/{api_version}/projects/{path}') + """A template used to craft the URL pointing toward a particular API call.""" + + _EMPTY = object() + """A pointer to represent an empty value for default arguments.""" + + def __init__(self, project=None, *args, **kwargs): + """ + :type project: string + :param project: The project name to connect to. + """ + + super(Connection, self).__init__(*args, **kwargs) + + self.project = project + + def new_zone(self, zone): + """Factory method for creating a new (unsaved) zone object. + + :type zone: string or :class:`gcloud.dns.zone.Zone` + :param zone: A name of a zone or an existing Zone object. + """ + + if isinstance(zone, Zone): + return zone + + # Support Python 2 and 3. + try: + string_type = basestring + except NameError: + string_type = str + + if isinstance(zone, string_type): + return Zone(connection=self, name=zone) + + def create_zone(self, zone, dns_name, description): + """Create a new zone. + + :type zone: string or :class:`gcloud.dns.zone.Zone` + :param zone: The zone name (or zone object) to create. + + :rtype: :class:`gcloud.dns.zone.Zone` + :returns: The newly created zone. + """ + + zone = self.new_zone(zone) + response = self.api_request(method='POST', path=zone.path, + data={'name': zone.name, 'dnsName': dns_name, + 'description': description}) + return Zone.from_dict(response, connection=self) + + def delete_zone(self, zone, force=False): + """Delete a zone. + + You can use this method to delete a zone by name, + or to delete a zone object:: + + >>> from gcloud import dns + >>> connection = dns.get_connection(project, email, key_path) + >>> connection.delete_zone('my-zone') + True + + You can also delete pass in the zone object:: + + >>> zone = connection.get_zone('other-zone') + >>> connection.delete_zone(zone) + True + + :type zone: string or :class:`gcloud.dns.zone.Zone` + :param zone: The zone name (or zone object) to create. + + :type force: bool + :param full: If True, deletes the zones's recordss then deletes it. + + :rtype: bool + :returns: True if the zone was deleted. + """ + + zone = self.new_zone(zone) + + if force: + rrsets = self.get_records(zone) + for rrset in rrsets['rrsets']: + record = Record.from_dict(rrset) + if record.type != 'NS' and record.type != 'SOA': + zone.remove_record(record) + zone.save() + + self.api_request(method='DELETE', path=zone.path + zone.name) + return True + + def get_zone(self, zone): + """Get a zone by name. + + :type zone: string + :param zone: The name of the zone to get. + + :rtype: :class:`gcloud.dns.zone.Zone` + :returns: The zone matching the name provided. + """ + + zone = self.new_zone(zone) + response = self.api_request(method='GET', path=zone.path) + return Zone.from_dict(response['managedZones'][0], connection=self) + + def get_records(self, zone): + """Get a list of resource records on a zone. + + :type zone: string or :class:`gcloud.dns.zone.Zone` + :param zone: The zone name (or zone object) to get records from. + """ + + zone = self.new_zone(zone) + return self.api_request(method='GET', path=zone.path + zone.name + + '/rrsets') + + def save_change(self, zone, change): + """Save a set of changes to a zone. + + :type zone: string or :class:`gcloud.dns.zone.Zone` + :param zone: The zone name (or zone object) to save to. + + :type change: dict + :param dict: A dict with the addition and deletion lists of records. + """ + + zone = self.new_zone(zone) + return self.api_request(method='POST', path=zone.path + zone.name + + '/changes', data=change) diff --git a/gcloud/dns/demo/__init__.py b/gcloud/dns/demo/__init__.py new file mode 100644 index 000000000000..6b050674353b --- /dev/null +++ b/gcloud/dns/demo/__init__.py @@ -0,0 +1,19 @@ +import os +from gcloud import dns + + +__all__ = ['get_connection', 'get_zone' 'CLIENT_EMAIL', 'PRIVATE_KEY_PATH', + 'PROJECT'] + + +CLIENT_EMAIL = '524635209885-rda26ks46309o10e0nc8rb7d33rn0hlm@developer.gserviceaccount.com' +PRIVATE_KEY_PATH = os.path.join(os.path.dirname(__file__), 'demo.p12') +PROJECT = 'gceremote' + + +def get_connection(): + return dns.get_connection(PROJECT, CLIENT_EMAIL, PRIVATE_KEY_PATH) + + +def get_zone(zone): + return dns.get_zone(zone, PROJECT, CLIENT_EMAIL, PRIVATE_KEY_PATH) diff --git a/gcloud/dns/demo/__main__.py b/gcloud/dns/demo/__main__.py new file mode 100644 index 000000000000..8074eae216b9 --- /dev/null +++ b/gcloud/dns/demo/__main__.py @@ -0,0 +1,5 @@ +from gcloud import demo +from gcloud import dns + + +demo.DemoRunner.from_module(dns).run() diff --git a/gcloud/dns/demo/demo.p12 b/gcloud/dns/demo/demo.p12 new file mode 100644 index 000000000000..0f5966647b38 Binary files /dev/null and b/gcloud/dns/demo/demo.p12 differ diff --git a/gcloud/dns/demo/demo.py b/gcloud/dns/demo/demo.py new file mode 100644 index 000000000000..e6eca9d89621 --- /dev/null +++ b/gcloud/dns/demo/demo.py @@ -0,0 +1,26 @@ +# Welcome to the gCloud DNS Demo! (hit enter) + +# We're going to walk through some of the basics..., +# Don't worry though. You don't need to do anything, just keep hitting enter... + +# Let's start by importing the demo module and getting a connection: +from gcloud.dns import demo +connection = demo.get_connection() + +# Lets create a zone. +zone = connection.create_zone('zone', 'zone.com.', 'My zone.') + +# Lets see what records the zone has... +print connection.get_records('zone') + +# Lets add a A record to the zone. +zone.add_a('zone.com.', ['1.1.1.1'], 9000) + +# Lets commit the changes of the zone with... +zone.save() + +# Lets see what records the zone has... +print connection.get_records('zone') + +# Finally lets clean up and delete our test zone. +zone.delete(force=True) diff --git a/gcloud/dns/exceptions.py b/gcloud/dns/exceptions.py new file mode 100644 index 000000000000..dff61f3cf740 --- /dev/null +++ b/gcloud/dns/exceptions.py @@ -0,0 +1,6 @@ +from gcloud.exceptions import Error +# TODO: Make these super useful. + + +class DNSError(Error): + pass diff --git a/gcloud/dns/record.py b/gcloud/dns/record.py new file mode 100644 index 000000000000..857bcb62f5aa --- /dev/null +++ b/gcloud/dns/record.py @@ -0,0 +1,65 @@ +class Record(object): + """A class representing a Resource Record Set on Cloud DNS. + + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + + :type type: string + :param string: The type of DNS record. + """ + + def __init__(self, name=None, data=[], ttl=None, type=None): + self.name = name + self.data = data + self.ttl = ttl + self.type = type + + @classmethod + def from_dict(cls, record_dict): + """Construct a new record from a dictionary of data from Cloud DNS. + + :type record_dict: dict + :param record_dict: The dictionary of data to construct a record from. + + :rtype: :class:`Record` + :returns: A record constructed from the data provided. + """ + + return cls(name=record_dict['name'], data=record_dict['rrdatas'], + ttl=record_dict['ttl'], type=record_dict['type']) + + def __str__(self): + """Format the record when printed. + + :rtype: string + :returns: A formated record string. + """ + + record = ('{name} {ttl} IN {type} {data}') + return record.format(name=self.name, ttl=self.ttl, type=self.type, + data=self.data) + + def add_data(self, data): + """Add to the list of resource record data for the record. + + :type data: string + :param data: The textual representation of a resourse record. + """ + + self.data.append(data) + + def to_dict(self): + """Format the record into a dict compatible with Cloud DNS. + + :rtype: dict + :returns: A Cloud DNS dict representation of a record. + """ + + return {'name': self.name, 'rrdatas': self.data, 'ttl': self.ttl, + 'type': self.type} diff --git a/gcloud/dns/zone.py b/gcloud/dns/zone.py new file mode 100644 index 000000000000..bb9924da81a8 --- /dev/null +++ b/gcloud/dns/zone.py @@ -0,0 +1,260 @@ +from gcloud.dns.change import Change +from gcloud.dns.record import Record + + +class Zone(object): + """A class representing a Managed Zone on Cloud DNS. + + :type connection: :class:`gcloud.dns.connection.Connection` + :param connection: The connection to use when sending requests. + + :type creation_time: string + :param connection_time: Time that this zone was created on the server. + + :type description: string + :param data: A description of the zone. + + :type dns_name: string + :param data: The DNS name of the zone. + + :type id: unsigned long + :param data: Unique identifier defined by the server. + + :type kind: string + :param data: Identifies what kind of resource. + + :type name_servers: list + :param name_servers: List of virtual name servers of the zone. + """ + + def __init__(self, connection=None, creation_time=None, + description=None, dns_name=None, id=None, kind=None, name=None, + name_servers=None): + self.additions = [] + self.connection = connection + self.creation_time = creation_time + self.deletions = [] + self.description = description + self.dns_name = dns_name + self.id = id + self.kind = kind + self.name = name + self.name_servers = name_servers + + @classmethod + def from_dict(cls, zone_dict, connection=None): + """Construct a new zone from a dictionary of data from Cloud DNS. + + :type zone_dict: dict + :param zone_dict: The dictionary of data to construct a record from. + + :rtype: :class:`Zone` + :returns: A zone constructed from the data provided. + """ + + return cls(connection=connection, + creation_time=zone_dict['creationTime'], + description=zone_dict['description'], + dns_name=zone_dict['dnsName'], id=zone_dict['id'], + kind=zone_dict['kind'], name=zone_dict['name'], + name_servers=zone_dict['nameServers']) + + @property + def path(self): + """The URL path to this zone.""" + + if not self.connection.project: + raise ValueError('Cannot determine path without project name.') + + return self.connection.project + '/managedZones/' + + def delete(self, force=False): + """Delete this zone. + + The zone **must** be empty in order to delete it. + + If you want to delete a non-empty zone you can pass + in a force parameter set to true. + This will iterate through the zones's records and delete the related + records, before deleting the zone. + + :type force: bool + :param full: If True, deletes the zones's records then deletes it. + """ + + return self.connection.delete_zone(self.name, force=force) + + def save(self): + """Commit all the additions and deletions of records on this zone. + """ + + change = Change(additions=self.additions, deletions=self.deletions) + self.connection.save_change(self.name, change.to_dict()) + self.additions = [] + self.deletions = [] + return True + + def add_record(self, record): + """Add a record to the dict of records to be added to the zone. + + :type record: dict or :class:`Record` + :param record: A dict representation of a record to be added. + """ + + if isinstance(record, Record): + record = record.to_dict() + + if isinstance(record, dict): + self.additions.append(record) + + # Throw type error here. + + def remove_record(self, record): + """Add a record to the dict of records to be deleted to the zone. + + :type record: dict or :class:`Record` + :param record: A dict representation of a record to be deleted. + """ + + if isinstance(record, Record): + record = record.to_dict() + + if isinstance(record, dict): + self.deletions.append(record) + + # Throw type error here. + + def add_a(self, name, data, ttl): + """ Shortcut method to add a A record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'A') + self.add_record(record) + + def add_aaaa(self, name, data, ttl): + """ Shortcut method to add a AAAA record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'AAAA') + self.add_record(record) + + def add_cname(self, name, data, ttl): + """ Shortcut method to add a CNAME record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'CNAME') + self.add_record(record) + + def add_mx(self, name, data, ttl): + """ Shortcut method to add a MX record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'MX') + self.add_record(record) + + def add_ns(self, name, data, ttl): + """ Shortcut method to add a NS record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'NS') + self.add_record(record) + + def add_ptr(self, name, data, ttl): + """ Shortcut method to add a PTR record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'PTR') + self.add_record(record) + + def add_soa(self, name, data, ttl): + """ Shortcut method to add a SOA record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'SOA') + self.add_record(record) + + def add_spf(self, name, data, ttl): + """ Shortcut method to add a SRV record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'SRV') + self.add_record(record) + + def add_txt(self, name, data, ttl): + """ Shortcut method to add a TXT record to be added to the zone. + :type name: string + :param name: The name of the record, for example 'www.example.com.'. + + :type data: list + :param data: A list of the textual representation of Resource Records. + + :type ttl: int + :param ttl: The record's time to live. + """ + + record = Record(name, data, ttl, 'TXT') + self.add_record(record) diff --git a/gcloud/exceptions.py b/gcloud/exceptions.py new file mode 100644 index 000000000000..b05d5586307b --- /dev/null +++ b/gcloud/exceptions.py @@ -0,0 +1,18 @@ +# TODO: Make these super useful. + + +class Error(Exception): + pass + + +class ConnectionError(Error): + + def __init__(self, response, content): + message = str(response) + content + super(ConnectionError, self).__init__(message) + + +class NotFoundError(Error): + + def __init__(self, response, content): + self.message = 'GET %s returned a 404.' % (response.url)