diff -Nru maas-0.1+bzr363+dfsg/contrib/preseeds/maas.preseed maas-0.1+bzr378+dfsg/contrib/preseeds/maas.preseed --- maas-0.1+bzr363+dfsg/contrib/preseeds/maas.preseed 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/contrib/preseeds/maas.preseed 2012-03-29 22:57:09.000000000 +0000 @@ -46,7 +46,7 @@ d-i passwd/make-user boolean true d-i passwd/user-fullname string ubuntu d-i passwd/username string ubuntu -d-i passwd/user-password-crypted password $6$.1eHH0iY$ArGzKX2YeQ3G6U.mlOO3A.NaL22Ewgz8Fi4qqz.Ns7EMKjEJRIW2Pm/TikDptZpuu7I92frytmk5YeL.9fRY4. +d-i passwd/user-password-crypted password ! d-i passwd/user-uid string d-i user-setup/allow-password-weak boolean false d-i user-setup/encrypt-home boolean false @@ -88,5 +88,6 @@ # Post scripts. Executes late command and disables PXE d-i preseed/late_command string true && \ + $SNIPPET('maas_sudoers') && \ $SNIPPET('maas_disable_pxe') && \ true diff -Nru maas-0.1+bzr363+dfsg/contrib/snippets/maas_client_packages maas-0.1+bzr378+dfsg/contrib/snippets/maas_client_packages --- maas-0.1+bzr363+dfsg/contrib/snippets/maas_client_packages 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/contrib/snippets/maas_client_packages 2012-03-29 22:57:09.000000000 +0000 @@ -1 +1 @@ -d-i pkgsel/include string cloud-init openssh-server python-software-properties vim +d-i pkgsel/include string cloud-init openssh-server python-software-properties vim avahi-daemon diff -Nru maas-0.1+bzr363+dfsg/contrib/snippets/maas_sudoers maas-0.1+bzr378+dfsg/contrib/snippets/maas_sudoers --- maas-0.1+bzr363+dfsg/contrib/snippets/maas_sudoers 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr378+dfsg/contrib/snippets/maas_sudoers 2012-03-29 22:57:09.000000000 +0000 @@ -0,0 +1 @@ +in-target sh -c 'f=$1; shift; echo $0 > $f && chmod 0440 $f $*' 'ubuntu ALL=(ALL) NOPASSWD: ALL' /etc/sudoers.d/maas \ diff -Nru maas-0.1+bzr363+dfsg/debian/changelog maas-0.1+bzr378+dfsg/debian/changelog --- maas-0.1+bzr363+dfsg/debian/changelog 2012-03-27 20:01:57.000000000 +0000 +++ maas-0.1+bzr378+dfsg/debian/changelog 2012-03-29 23:29:02.000000000 +0000 @@ -1,3 +1,12 @@ +maas (0.1+bzr378+dfsg-0ubuntu1) precise; urgency=low + + * maas.dirs: Create var/lib/maas/media/storage for juju storage. + * maas.postinst: + - Give correct permissions to above dir. + - stop apache2 before db upgrade, and restart after. + + -- Andres Rodriguez Thu, 29 Mar 2012 19:28:13 -0400 + maas (0.1+bzr363+dfsg-0ubuntu1) precise; urgency=low [ Dave Walker (Daviey) ] diff -Nru maas-0.1+bzr363+dfsg/debian/maas.dirs maas-0.1+bzr378+dfsg/debian/maas.dirs --- maas-0.1+bzr363+dfsg/debian/maas.dirs 2012-03-27 20:00:51.000000000 +0000 +++ maas-0.1+bzr378+dfsg/debian/maas.dirs 2012-03-29 23:29:02.000000000 +0000 @@ -1,2 +1,2 @@ -var/lib/maas/media/ +var/lib/maas/media/storage/ var/log/maas/oops diff -Nru maas-0.1+bzr363+dfsg/debian/maas.postinst maas-0.1+bzr378+dfsg/debian/maas.postinst --- maas-0.1+bzr363+dfsg/debian/maas.postinst 2012-03-27 20:00:51.000000000 +0000 +++ maas-0.1+bzr378+dfsg/debian/maas.postinst 2012-03-29 23:29:02.000000000 +0000 @@ -9,6 +9,14 @@ maas migrate metadataserver --noinput } +restart_apache2(){ + if [ -x /usr/sbin/invoke-rc.d ]; then + invoke-rc.d apache2 restart || true + else + /etc/init.d/apache2 restart || true + fi +} + if [ -f /usr/share/dbconfig-common/dpkg/postinst.pgsql ]; then . /usr/share/dbconfig-common/dpkg/postinst.pgsql fi @@ -16,6 +24,12 @@ if [ "$1" = "configure" ] && [ -z "$2" ]; then ######################################################### + ################ Folder Permissions #################### + ######################################################### + chown -R root:www-data /var/lib/maas/media/ + chmod -R 660 /var/lib/maas/media/ + + ######################################################### ################ Configure Apache2 #################### ######################################################### # handle apache configs @@ -29,11 +43,7 @@ a2enmod wsgi # Need to restart apache to pickup web configs - if [ -x /usr/sbin/invoke-rc.d ]; then - invoke-rc.d apache2 restart || true - else - /etc/init.d/apache2 restart || true - fi + restart_apache2 ######################################################### ########### Configure maas user for Cobbler ############# @@ -142,7 +152,11 @@ elif [ "$1" = "configure" ] && dpkg --compare-versions "$2" gt 0.1+bzr266+dfsg-0ubuntu1; then # If upgrading to any later package version, then upgrade db. + if [ -x /usr/sbin/invoke-rc.d ]; then + invoke-rc.d apache2 stop || true + fi maas_sync_migrate_db + restart_apache2 fi db_stop diff -Nru maas-0.1+bzr363+dfsg/docs/hacking.rst maas-0.1+bzr378+dfsg/docs/hacking.rst --- maas-0.1+bzr363+dfsg/docs/hacking.rst 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/docs/hacking.rst 2012-03-29 22:57:09.000000000 +0000 @@ -208,6 +208,67 @@ directory. +Database schema changes +======================= + +MAAS uses South_ to manage changes to the database schema. + +.. _South: http://south.aeracode.org + +Be sure to have a look at `South's documentation`_ before you make any change. + +.. _South's documentation: http://south.aeracode.org/docs/ + +Changing the schema +^^^^^^^^^^^^^^^^^^^ + +Once you've made a change to ``src//models.py`` you have to run +South's `schemamigration`_ command to create a migration file that will be +stored in ``src//migrations/``. + +.. _schemamigration: http://south.aeracode.org/docs/commands.html#schemamigration + +For instance if you're making changes to ``src/maasserver/models.py``, you +will need to run:: + + $ ./bin/maas schemamigration maasserver --auto description_of_the_change + +This will generate a migration module named +``src/maasserver/migrations/_description_of_the_change.py``. Don't +forget to add that file to the project with:: + + $ bzr add \ + src/maasserver/migrations/_description_of_the_change.py + +To apply that migration, run:: + + $ make syncdb + +Performing data migration +^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you need to perform data migration, very much in the same way, you will need +to run South's `datamigration`_ command. For instance, if you want to perform +changes to the ``maasserver`` application, run:: + + $ ./bin/maas datamigration maasserver --auto description_of_the_change + +.. _datamigration: http://south.aeracode.org/docs/commands.html#datamigration + +This will generate a migration module named +``src/maasserver/migrations/_description_of_the_change.py``. +You will need to edit that file and fill the ``forwards`` and ``backwards`` +methods where data should be actually migrated. Again, don't forget to +add that file to the project:: + + $ bzr add \ + src/maasserver/migrations/_description_of_the_change.py + +Once the methods have been written, apply that migration with:: + + $ make syncdb + + Documentation ============= @@ -233,7 +294,7 @@ To run them:: $ checkbox-gtk --config='checkbox/plugins/jobs_info/directories=\ - /path/to/local/checkbox/jobs/dir' --whitelist-file= + /path/to/local/checkbox/jobs/dir' --whitelist-file= or run from the root of the MAAS tree:: diff -Nru maas-0.1+bzr363+dfsg/docs/juju-quick-start.rst maas-0.1+bzr378+dfsg/docs/juju-quick-start.rst --- maas-0.1+bzr363+dfsg/docs/juju-quick-start.rst 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/docs/juju-quick-start.rst 2012-03-29 22:57:09.000000000 +0000 @@ -39,8 +39,10 @@ #. Go to your `MAAS preferences page`_, or go to your `MAAS home page`_ and choose *Preferences* from the drop-down menu that appears when clicking your username at the top-right of the page. + #. Optionally add a new MAAS key. Do this if you're setting up another environment within the same MAAS cluster. + .. _MAAS preferences page: http://localhost:5240/account/prefs/ .. _MAAS home page: http://localhost:5240/ @@ -79,7 +81,7 @@ As you've not bootstrapped you ought to see:: - $ juju environment not found: is the environment bootstrapped? + juju environment not found: is the environment bootstrapped? Bootstrap:: diff -Nru maas-0.1+bzr363+dfsg/HACKING.txt maas-0.1+bzr378+dfsg/HACKING.txt --- maas-0.1+bzr363+dfsg/HACKING.txt 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/HACKING.txt 2012-03-29 22:57:09.000000000 +0000 @@ -208,6 +208,67 @@ directory. +Database schema changes +======================= + +MAAS uses South_ to manage changes to the database schema. + +.. _South: http://south.aeracode.org + +Be sure to have a look at `South's documentation`_ before you make any change. + +.. _South's documentation: http://south.aeracode.org/docs/ + +Changing the schema +^^^^^^^^^^^^^^^^^^^ + +Once you've made a change to ``src//models.py`` you have to run +South's `schemamigration`_ command to create a migration file that will be +stored in ``src//migrations/``. + +.. _schemamigration: http://south.aeracode.org/docs/commands.html#schemamigration + +For instance if you're making changes to ``src/maasserver/models.py``, you +will need to run:: + + $ ./bin/maas schemamigration maasserver --auto description_of_the_change + +This will generate a migration module named +``src/maasserver/migrations/_description_of_the_change.py``. Don't +forget to add that file to the project with:: + + $ bzr add \ + src/maasserver/migrations/_description_of_the_change.py + +To apply that migration, run:: + + $ make syncdb + +Performing data migration +^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you need to perform data migration, very much in the same way, you will need +to run South's `datamigration`_ command. For instance, if you want to perform +changes to the ``maasserver`` application, run:: + + $ ./bin/maas datamigration maasserver --auto description_of_the_change + +.. _datamigration: http://south.aeracode.org/docs/commands.html#datamigration + +This will generate a migration module named +``src/maasserver/migrations/_description_of_the_change.py``. +You will need to edit that file and fill the ``forwards`` and ``backwards`` +methods where data should be actually migrated. Again, don't forget to +add that file to the project:: + + $ bzr add \ + src/maasserver/migrations/_description_of_the_change.py + +Once the methods have been written, apply that migration with:: + + $ make syncdb + + Documentation ============= @@ -233,7 +294,7 @@ To run them:: $ checkbox-gtk --config='checkbox/plugins/jobs_info/directories=\ - /path/to/local/checkbox/jobs/dir' --whitelist-file= + /path/to/local/checkbox/jobs/dir' --whitelist-file= or run from the root of the MAAS tree:: diff -Nru maas-0.1+bzr363+dfsg/src/maas/development.py maas-0.1+bzr378+dfsg/src/maas/development.py --- maas-0.1+bzr363+dfsg/src/maas/development.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maas/development.py 2012-03-29 22:57:09.000000000 +0000 @@ -10,6 +10,7 @@ __metaclass__ = type +import logging import os from maas import ( @@ -46,6 +47,9 @@ RABBITMQ_PUBLISH = False +# Silent South during tests. +logging.getLogger('south').setLevel(logging.WARNING) + DATABASES = { 'default': { # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' etc. diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/api.py maas-0.1+bzr378+dfsg/src/maasserver/api.py --- maas-0.1+bzr363+dfsg/src/maasserver/api.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/api.py 2012-03-29 22:57:09.000000000 +0000 @@ -1,7 +1,50 @@ # Copyright 2012 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). -"""API.""" +"""Restful MAAS API. + +This is the documentation for the API that lets you control and query MAAS. +The API is "Restful", which means that you access it through normal HTTP +requests. + + +API versions +------------ + +At any given time, MAAS may support multiple versions of its API. The version +number is included in the API's URL, e.g. /api/1.0/ + +For now, 1.0 is the only supported version. + + +HTTP methods and parameter-passing +---------------------------------- + +The following HTTP methods are available for accessing the API: + * GET (for information retrieval and queries), + * POST (for asking the system to do things), + * PUT (for updating objects), and + * DELETE (for deleting objects). + +All methods except DELETE may take parameters, but they are not all passed in +the same way. GET parameters are passed in the URL, as is normal with a GET: +"/item/?foo=bar" passes parameter "foo" with value "bar". + +POST and PUT are different. Your request should have MIME type +"multipart/form-data"; each part represents one parameter (for POST) or +attribute (for PUT). Each part is named after the parameter or attribute it +contains, and its contents are the conveyed value. + +All parameters are in text form. If you need to submit binary data to the +API, don't send it as any MIME binary format; instead, send it as a plain text +part containing base64-encoded data. + +Most resources offer a choice of GET or POST operations. In those cases these +methods will take one special parameter, called `op`, to indicate what it is +you want to do. + +For example, to list all nodes, you might GET "/api/1.0/nodes/?op=list". +""" from __future__ import ( print_function, @@ -11,6 +54,7 @@ __metaclass__ = type __all__ = [ "api_doc", + "api_doc_title", "extract_oauth_key", "generate_api_doc", "AccountHandler", @@ -26,6 +70,7 @@ import httplib import json import sys +from textwrap import dedent import types from django.core.exceptions import ValidationError @@ -665,7 +710,26 @@ return HttpResponse(json.dumps(value), content_type='application/json') -def generate_api_doc(add_title=False): +# Title section for the API documentation. Matches in style, format, +# etc. whatever generate_api_doc() produces, so that you can concatenate +# the two. +api_doc_title = dedent(""" + ======== + MAAS API + ======== + """.lstrip('\n')) + + +def generate_api_doc(): + """Generate ReST documentation for the REST API. + + This module's docstring forms the head of the documentation; details of + the API methods follow. + + :return: Documentation, in ReST, for the API. + :rtype: :class:`unicode` + """ + # Fetch all the API Handlers (objects with the class # HandlerMetaClass). module = sys.modules[__name__] @@ -683,21 +747,21 @@ docs = [generate_doc(handler) for handler in handlers] - messages = [] - if add_title: - messages.extend([ - '**********************\n', - 'MAAS API documentation\n', - '**********************\n', - '\n\n'] - ) + messages = [ + __doc__.strip(), + '', + '', + 'Operations', + '----------', + '', + ] for doc in docs: for method in doc.get_methods(): messages.append( - "%s %s\n %s\n\n" % ( + "%s %s\n %s\n" % ( method.http_name, doc.resource_uri_template, method.doc)) - return ''.join(messages) + return '\n'.join(messages) def reST_to_html_fragment(a_str): diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/forms.py maas-0.1+bzr378+dfsg/src/maasserver/forms.py --- maas-0.1+bzr363+dfsg/src/maasserver/forms.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/forms.py 2012-03-29 22:57:09.000000000 +0000 @@ -15,6 +15,7 @@ "NodeForm", "MACAddressForm", "MAASAndNetworkForm", + "SSHKeyForm", "UbuntuForm", "UIAdminNodeEditForm", "UINodeEditForm", @@ -42,6 +43,7 @@ Node, NODE_AFTER_COMMISSIONING_ACTION, NODE_AFTER_COMMISSIONING_ACTION_CHOICES, + SSHKey, UserProfile, ) @@ -112,7 +114,27 @@ model = MACAddress +class SSHKeyForm(ModelForm): + key = forms.CharField( + label="Public key", + widget=forms.Textarea(attrs={'rows': '5', 'cols': '30'}), + required=True) + + class Meta: + model = SSHKey + + def __init__(self, user, *args, **kwargs): + super(SSHKeyForm, self).__init__(*args, **kwargs) + self.user = user + + def save(self): + key = super(SSHKeyForm, self).save(commit=False) + key.user = self.user + return key.save() + + class MultipleMACAddressField(forms.MultiValueField): + def __init__(self, nb_macs=1, *args, **kwargs): fields = [MACAddressFormField() for i in range(nb_macs)] super(MultipleMACAddressField, self).__init__(fields, *args, **kwargs) @@ -265,8 +287,10 @@ class MAASAndNetworkForm(ConfigForm): """Settings page, MAAS and Network section.""" maas_name = forms.CharField(label="MAAS name") - provide_dhcp = forms.BooleanField( - label="Provide DHCP on this subnet", required=False) + enlistment_domain = forms.CharField( + label="Default domain for new nodes", required=False, help_text=( + "If 'local' is chosen, nodes must be using mDNS. Leave empty to " + "use hostnames without a domain for newly enlisted nodes.")) class CommissioningForm(ConfigForm): diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/management/commands/generate_api_doc.py maas-0.1+bzr378+dfsg/src/maasserver/management/commands/generate_api_doc.py --- maas-0.1+bzr363+dfsg/src/maasserver/management/commands/generate_api_doc.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/management/commands/generate_api_doc.py 2012-03-29 22:57:09.000000000 +0000 @@ -1,7 +1,10 @@ from django.core.management.base import BaseCommand -from maasserver.api import generate_api_doc +from maasserver.api import ( + api_doc_title, + generate_api_doc, + ) class Command(BaseCommand): def handle(self, *args, **options): - self.stdout.write(generate_api_doc(add_title=True)) + self.stdout.write('\n'.join([api_doc_title, generate_api_doc()])) diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/models.py maas-0.1+bzr378+dfsg/src/maasserver/models.py --- maas-0.1+bzr363+dfsg/src/maasserver/models.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/models.py 2012-03-29 22:57:09.000000000 +0000 @@ -13,6 +13,7 @@ "create_auth_token", "generate_node_system_id", "get_auth_tokens", + "get_html_display_for_key", "Config", "FileStorage", "NODE_STATUS", @@ -33,6 +34,7 @@ import os import re from socket import gethostname +from string import whitespace import time from uuid import uuid1 @@ -40,11 +42,13 @@ from django.contrib import admin from django.contrib.auth.backends import ModelBackend from django.contrib.auth.models import User +from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.core.files.storage import FileSystemStorage from django.db import models from django.db.models.signals import post_save from django.shortcuts import get_object_or_404 +from django.utils.safestring import mark_safe from maasserver.exceptions import ( CannotDeleteUserException, PermissionDenied, @@ -62,6 +66,10 @@ POWER_TYPE, POWER_TYPE_CHOICES, ) +from twisted.conch.ssh.keys import ( + BadKeyError, + Key, + ) # Special users internal to MAAS. SYSTEM_USERS = [ @@ -467,7 +475,12 @@ def set_mac_based_hostname(self, mac_address): mac_hostname = mac_address.replace(':', '').lower() - self.hostname = "node-%s" % mac_hostname + domain = Config.objects.get_config("enlistment_domain") + domain = domain.strip("." + whitespace) + if len(domain) > 0: + self.hostname = "node-%s.%s" % (mac_hostname, domain) + else: + self.hostname = "node-%s" % mac_hostname self.save() def get_effective_power_type(self): @@ -683,6 +696,59 @@ return SSHKey.objects.filter(user=user).values_list('key', flat=True) +def validate_ssh_public_key(value): + """Validate that the given value contains a valid SSH public key.""" + try: + key = Key.fromString(value) + if not key.isPublic(): + raise ValidationError( + "Invalid SSH public key (this key is a private key).") + except BadKeyError: + raise ValidationError("Invalid SSH public key.") + + +HELLIPSIS = '…' + + +def get_html_display_for_key(key, size): + """Return a compact HTML representation of this key with a boundary on + the size of the resulting string. + + A key typically looks like this: 'key_type key_string comment'. + What we want here is display the key_type and, if possible (i.e. if it + fits in the boundary that `size` gives us), the comment. If possible we + also want to display a truncated key_string. If the comment is too big + to fit in, we simply display a cropped version of the whole string. + + :param key: The key for which we want an HTML representation. + :type name: basestring + :param size: The maximum size of the representation. + :type size: int + :return: The HTML representation of this key. + :rtype: basestring + """ + key = key.strip() + key_parts = key.split(' ', 2) + + if len(key_parts) == 3: + key_type = key_parts[0] + key_string = key_parts[1] + comment = key_parts[2] + room_for_key = ( + size - (len(key_type) + len(comment) + len(HELLIPSIS) + 2)) + if room_for_key > 0: + return '%s %.*s%s %s' % ( + key_type, room_for_key, key_string, HELLIPSIS, comment) + + if len(key) > size: + return '%.*s%s' % (size - len(HELLIPSIS), key, HELLIPSIS) + else: + return key + + +MAX_KEY_DISPLAY = 50 + + class SSHKey(CommonInfo): """A `SSHKey` represents a user public SSH key. @@ -698,11 +764,20 @@ user = models.ForeignKey(User, null=False, editable=False) - key = models.TextField(null=False, editable=True) + key = models.TextField( + null=False, editable=True, validators=[validate_ssh_public_key]) def __unicode__(self): return self.key + def display_html(self): + """Return a compact HTML representation of this key. + + :return: The HTML representation of this key. + :rtype: basestring + """ + return mark_safe(get_html_display_for_key(self.key, MAX_KEY_DISPLAY)) + class FileStorageManager(models.Manager): """Manager for `FileStorage` objects. @@ -849,7 +924,7 @@ [['archive.ubuntu.com', 'archive.ubuntu.com']]), # Network section configuration. 'maas_name': gethostname(), - 'provide_dhcp': False, + 'enlistment_domain': b'local', ## /settings } diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/templates/maasserver/prefs_add_sshkey.html maas-0.1+bzr378+dfsg/src/maasserver/templates/maasserver/prefs_add_sshkey.html --- maas-0.1+bzr363+dfsg/src/maasserver/templates/maasserver/prefs_add_sshkey.html 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/templates/maasserver/prefs_add_sshkey.html 2012-03-29 22:57:09.000000000 +0000 @@ -0,0 +1,16 @@ +{% extends "maasserver/base.html" %} + +{% block title %}Add SSH key{% endblock %} +{% block page-title %}Add SSH key{% endblock %} + +{% block content %} +
+
    + {% for field in form %} + {% include "maasserver/form_field.html" %} + {% endfor %} +
+ +   Cancel +
+{% endblock %} diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/templates/maasserver/prefs_confirm_delete_sshkey.html maas-0.1+bzr378+dfsg/src/maasserver/templates/maasserver/prefs_confirm_delete_sshkey.html --- maas-0.1+bzr363+dfsg/src/maasserver/templates/maasserver/prefs_confirm_delete_sshkey.html 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/templates/maasserver/prefs_confirm_delete_sshkey.html 2012-03-29 22:57:09.000000000 +0000 @@ -0,0 +1,22 @@ +{% extends "maasserver/base.html" %} + +{% block title %}Delete SSH key{% endblock %} +{% block page-title %}Delete SSH key{% endblock %} + +{% block content %} +
+

+ Are you sure you want to delete the following key?
+

+

{{ key }}

+

This action is permanent and can not be undone.

+

+

+ + + Cancel +
+

+
+{% endblock %} + diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/templates/maasserver/prefs.html maas-0.1+bzr378+dfsg/src/maasserver/templates/maasserver/prefs.html --- maas-0.1+bzr363+dfsg/src/maasserver/templates/maasserver/prefs.html 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/templates/maasserver/prefs.html 2012-03-29 22:57:09.000000000 +0000 @@ -18,9 +18,10 @@ {% block content %}
-
+

Keys

MAAS keys

+
    {% for token in user.get_profile.get_authorisation_tokens %}
  • @@ -28,15 +29,36 @@ -
  • {% endfor %}

+

+
+
+

SSH keys

+
    + {% for key in user.sshkey_set.all %} +
  • + + + + {{ key.display_html }} +
  • + {% empty %} + No SSH key configured. + {% endfor %} +
+ + Add SSH key
+

User details

diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/testing/factory.py maas-0.1+bzr378+dfsg/src/maasserver/testing/factory.py --- maas-0.1+bzr363+dfsg/src/maasserver/testing/factory.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/testing/factory.py 2012-03-29 22:57:09.000000000 +0000 @@ -26,6 +26,7 @@ NODE_STATUS, SSHKey, ) +from maasserver.testing import get_data from maasserver.testing.enum import map_enum import maastesting.factory @@ -83,6 +84,12 @@ return User.objects.create_user( username=username, password=password, email=email) + def make_sshkey(self, user): + key_string = get_data('data/test_rsa.pub') + key = SSHKey(key=key_string, user=user) + key.save() + return key + def make_user_with_keys(self, n_keys=2, **kwargs): """Create a user with n `SSHKey`. diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/testing/__init__.py maas-0.1+bzr378+dfsg/src/maasserver/testing/__init__.py --- maas-0.1+bzr363+dfsg/src/maasserver/testing/__init__.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/testing/__init__.py 2012-03-29 22:57:09.000000000 +0000 @@ -10,11 +10,13 @@ __metaclass__ = type __all__ = [ + "get_data", "get_fake_provisioning_api_proxy", "reload_object", "reload_objects", ] +import os from uuid import uuid1 from provisioningserver.testing import fakeapi @@ -93,3 +95,13 @@ assert all(isinstance(obj, model_class) for obj in model_objects) return model_class.objects.filter( id__in=[obj.id for obj in model_objects]) + + +def get_data(filename): + """Utility method to read the content of files in + src/maasserver/tests. + + Usually used to read files in src/maasserver/tests/data.""" + path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), '..', 'tests', filename) + return file(path).read() diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/tests/data/test_dsa maas-0.1+bzr378+dfsg/src/maasserver/tests/data/test_dsa --- maas-0.1+bzr363+dfsg/src/maasserver/tests/data/test_dsa 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/tests/data/test_dsa 2012-03-29 22:57:09.000000000 +0000 @@ -0,0 +1,12 @@ +-----BEGIN DSA PRIVATE KEY----- +MIIBvAIBAAKBgQC5fDwjGkmt6QghiWia+JB9ED5a4Mhty5Gvv8uMcdXTriYabC3t +lffgil+0s9cqYQrNuiqjBXv3m2BgFA4U3VZROP04KQD+Bok43TtRRl02HYEMiaX6 +edG01FtMyiD/3cB7OXQi5+F2AMV80rLzkRlUBmzOj+r4d5Ynk5HlRExmQwIVAIBo +EpSY+9GojftVGPU8T0HaueR9AoGBAKVmDBdCFPgWFLRbsEUVBPcBBsQxI07bqFmA +ljC+2Z/nBTryvgtMfGCLUN8SIiN2v9AwqAzyGT7yJlwzK7NM1Lp3oJ3UvYOydIAh +xWPXXfq4GEoVB8AiPocvw/Q6dbpDZxg9G298ebxKEiusIayVgTOorO01uEeX78/8 +Q4a7SHMYAoGBAJbZsmuuWN2kb7lD27IzKcOgd07esoHPWZnv4qg7xhS1GdVr485v +73OW1rfpWU6PdohckXLg9ZaoWtVTwNKTfHxS3iug9/pseBWTHdpmxCM5ClsZJii6 +T4frR5NTOCGKLxOamTs///OXopZr5u3vT20NFlzFE95J86tGtxYPPivxAhQByRHQ +RXk6Jpjwa5kX+bYX1J3FIg== +-----END DSA PRIVATE KEY----- diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/tests/data/test_dsa.pub maas-0.1+bzr378+dfsg/src/maasserver/tests/data/test_dsa.pub --- maas-0.1+bzr363+dfsg/src/maasserver/tests/data/test_dsa.pub 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/tests/data/test_dsa.pub 2012-03-29 22:57:09.000000000 +0000 @@ -0,0 +1 @@ +ssh-dss AAAAB3NzaC1kc3MAAACBALl8PCMaSa3pCCGJaJr4kH0QPlrgyG3Lka+/y4xx1dOuJhpsLe2V9+CKX7Sz1yphCs26KqMFe/ebYGAUDhTdVlE4/TgpAP4GiTjdO1FGXTYdgQyJpfp50bTUW0zKIP/dwHs5dCLn4XYAxXzSsvORGVQGbM6P6vh3lieTkeVETGZDAAAAFQCAaBKUmPvRqI37VRj1PE9B2rnkfQAAAIEApWYMF0IU+BYUtFuwRRUE9wEGxDEjTtuoWYCWML7Zn+cFOvK+C0x8YItQ3xIiI3a/0DCoDPIZPvImXDMrs0zUunegndS9g7J0gCHFY9dd+rgYShUHwCI+hy/D9Dp1ukNnGD0bb3x5vEoSK6whrJWBM6is7TW4R5fvz/xDhrtIcxgAAACBAJbZsmuuWN2kb7lD27IzKcOgd07esoHPWZnv4qg7xhS1GdVr485v73OW1rfpWU6PdohckXLg9ZaoWtVTwNKTfHxS3iug9/pseBWTHdpmxCM5ClsZJii6T4frR5NTOCGKLxOamTs///OXopZr5u3vT20NFlzFE95J86tGtxYPPivx ubuntu@server-7476 diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/tests/data/test_rsa maas-0.1+bzr378+dfsg/src/maasserver/tests/data/test_rsa --- maas-0.1+bzr363+dfsg/src/maasserver/tests/data/test_rsa 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/tests/data/test_rsa 2012-03-29 22:57:09.000000000 +0000 @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA3a88w2TcMjFQbwU0+pAZ63z2b/wFG5MY+xuQmjE3cOsZCrYV +dthJ7Q95cWkMUSs0E3wnNnfwgtSuLrIQnEbNUukkNsydow6tVJjlJAzILzYeCVYS +WEinOprvIt7O6BkpgWnaOcL7ZaUszK7HDqX/EHv8Jk+Xx9digfYqSaqBTUDFW1XF +WqxP7+sKZ9xmQutmEW+Yms/fzPrfdkIbudFtrvin6LqrSgdpcCAwymTMMN/BMbXT +KLodhWacWqIeFHsuW0ad7TNyYJ1nF+n8lDqdoMSUTyJQ/a62YV+SJBJGTPDEG3T0 +0C4CH/fKBaYeURusNbdNgbyPZySjOypChHwR7QIDAQABAoIBAE5/DH8LqcTEHX0S +VO4cNHFkMEb68DwRXBkea5eNsdn0BUv7qaIJeDPO9OupjMj5CVmU7rWkxq8s6/hw +6NzNXUrsbvxQe8kPG2UHNqwLMp81BHG93oUQRNbFocOxLYaV0lKWzsUBO8+EK1bW +1HllYenOXTybll0W8TSfm9212E8n5YFYoJDFH9nEDiUqDcYLOKJc5njceZUr0KQ2 +eKTpaNEtS78crNAwvdu4UzfC/I4ezfoWreXao0epr3anfTXXYf2TrdthgG7FclNP +aOrCsQFXMrqVIc/FVXmbPKfhZOnOGglKVEUr0OmG3mfKan60636bGoiJJ2D6snKw +YCAk8mkCgYEA8TcYZAG7gsi/1yTDrIfcrm5zGGBQ371vNOsTOVXGD28mepwUUl3B +SucD7dAXlAyN/ikHly/wX/tVw3tF7N8nJx1wJ+KwGYgTVCgRBXexjiwz6NdyMbQX +F7kb6LQjL6P40ygYTjVuFgee2KXd11NuT5pW1wleF3cY3tKWx0TWgEcCgYEA60Wv +qz5yMg9+9A85Qis3VAw2DgJ3j12RYfSLhL0zkgZ6M1caZqMBuP7Q3HLlrEVJ+PJY +aIiidIz9VLARa9wetAfJUVDsi/aijVXknLdm18Rujymsf97Wa4xj+006VmjdEeXV +RoZGJ3l+j7yo2yfHxO1hHLLiXRXRQwOmUIGgSisCgYEAzYoI+o6PXS36ajUll0pd +vTTYVhkcUMp2jD0TMHPqRRSNUUTV/Clvn4eiTW5X6QuZos0LbsSmquLbfar5NpIg +JrBq9VGwhNDyx28sseAAKAl6YhnTcI7oboqJQYzdvqaWTDeKHnpgx9zOegU8N1Mc +WDBHdwzAZHZTduszF7GMpdkCgYAQ7SuNS2nV1i2RC4NYElnhrxs4eM73PokWHgzn +mOEb8WFbTjn1Bmc6UwLdyVpiwX1n7q+TnbjqX7ZeIGiwdN60nxbJxeOu0iixuGtB +JyS8A0LdA+eIL5UHmcsbqlu3GcZF4l4su75SWrhTSQRw9/S0Y0uoT+pfPhGXG60c +f6bzjwKBgQDe+l7pa+qkE0a8B11CgqKzmHOa/PRool7+0//WA5/H6jx0iAH5Ms2f +8bpVbWugyXOFXkFN+DOfzJgar5EQmAtpipx6OKx1DXQqabmUlm75HOkr1dxOshou +w39I3JOc1yHjlPJHjVyJqJcVOHYAFbEu5w+Sk0YELajvgU7FkfkonA== +-----END RSA PRIVATE KEY----- diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/tests/data/test_rsa.pub maas-0.1+bzr378+dfsg/src/maasserver/tests/data/test_rsa.pub --- maas-0.1+bzr363+dfsg/src/maasserver/tests/data/test_rsa.pub 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/tests/data/test_rsa.pub 2012-03-29 22:57:09.000000000 +0000 @@ -0,0 +1 @@ +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDdrzzDZNwyMVBvBTT6kBnrfPZv/AUbkxj7G5CaMTdw6xkKthV22EntD3lxaQxRKzQTfCc2d/CC1K4ushCcRs1S6SQ2zJ2jDq1UmOUkDMgvNh4JVhJYSKc6mu8i3s7oGSmBado5wvtlpSzMrscOpf8Qe/wmT5fH12KB9ipJqoFNQMVbVcVarE/v6wpn3GZC62YRb5iaz9/M+t92Qhu50W2u+KfouqtKB2lwIDDKZMww38ExtdMouh2FZpxaoh4Uey5bRp3tM3JgnWcX6fyUOp2gxJRPIlD9rrZhX5IkEkZM8MQbdPTQLgIf98oFph5RG6w1t02BvI9nJKM7KkKEfBHt ubuntu@server-7476 diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/tests/test_api.py maas-0.1+bzr378+dfsg/src/maasserver/tests/test_api.py --- maas-0.1+bzr363+dfsg/src/maasserver/tests/test_api.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/tests/test_api.py 2012-03-29 22:57:09.000000000 +0000 @@ -172,7 +172,7 @@ }) node = Node.objects.get( system_id=json.loads(response.content)['system_id']) - self.assertEqual('node-aabbccddeeff', node.hostname) + self.assertEqual('node-aabbccddeeff.local', node.hostname) def test_POST_returns_limited_fields(self): architecture = factory.getRandomChoice(ARCHITECTURE_CHOICES) diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/tests/test_commands.py maas-0.1+bzr378+dfsg/src/maasserver/tests/test_commands.py --- maas-0.1+bzr363+dfsg/src/maasserver/tests/test_commands.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/tests/test_commands.py 2012-03-29 22:57:09.000000000 +0000 @@ -45,7 +45,9 @@ result = stdout.getvalue() # Just check that the documentation looks all right. self.assertIn("POST /api/1.0/account/", result) - self.assertIn("MAAS API documentation", result) + self.assertIn("MAAS API", result) + # The documentation starts with a ReST title (not indented). + self.assertEqual('=', result[0]) def test_createadmin_requires_username(self): stderr = BytesIO() diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/tests/test_models.py maas-0.1+bzr378+dfsg/src/maasserver/tests/test_models.py --- maas-0.1+bzr363+dfsg/src/maasserver/tests/test_models.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/tests/test_models.py 2012-03-29 22:57:09.000000000 +0000 @@ -14,12 +14,14 @@ import codecs from io import BytesIO import os +import random import shutil from socket import gethostname from django.conf import settings from django.contrib.auth.models import User from django.core.exceptions import ValidationError +from django.utils.safestring import SafeUnicode from fixtures import TestWithFixtures from maasserver.exceptions import ( CannotDeleteUserException, @@ -33,6 +35,8 @@ GENERIC_CONSUMER, get_auth_tokens, get_default_config, + get_html_display_for_key, + HELLIPSIS, MACAddress, Node, NODE_STATUS, @@ -40,8 +44,10 @@ SSHKey, SYSTEM_USERS, UserProfile, + validate_ssh_public_key, ) from maasserver.provisioning import get_provisioning_api_proxy +from maasserver.testing import get_data from maasserver.testing.enum import map_enum from maasserver.testing.factory import factory from maasserver.testing.testcase import TestCase @@ -111,7 +117,34 @@ self.assertRaises( MACAddress.DoesNotExist, MACAddress.objects.get, id=mac.id) - def test_set_mac_based_hostname(self): + def test_set_mac_based_hostname_default_enlistment_domain(self): + # The enlistment domain defaults to `local`. + node = factory.make_node() + node.set_mac_based_hostname('AA:BB:CC:DD:EE:FF') + hostname = 'node-aabbccddeeff.local' + self.assertEqual(hostname, node.hostname) + + def test_set_mac_based_hostname_alt_enlistment_domain(self): + # A non-default enlistment domain can be specified. + Config.objects.set_config("enlistment_domain", "example.com") + node = factory.make_node() + node.set_mac_based_hostname('AA:BB:CC:DD:EE:FF') + hostname = 'node-aabbccddeeff.example.com' + self.assertEqual(hostname, node.hostname) + + def test_set_mac_based_hostname_cleaning_enlistment_domain(self): + # Leading and trailing dots and whitespace are cleaned from the + # configured enlistment domain before it's joined to the hostname. + Config.objects.set_config("enlistment_domain", " .example.com. ") + node = factory.make_node() + node.set_mac_based_hostname('AA:BB:CC:DD:EE:FF') + hostname = 'node-aabbccddeeff.example.com' + self.assertEqual(hostname, node.hostname) + + def test_set_mac_based_hostname_no_enlistment_domain(self): + # The enlistment domain can be set to the empty string and + # set_mac_based_hostname sets a hostname with no domain. + Config.objects.set_config("enlistment_domain", "") node = factory.make_node() node.set_mac_based_hostname('AA:BB:CC:DD:EE:FF') hostname = 'node-aabbccddeeff' @@ -520,8 +553,136 @@ self.assertTrue(set(SYSTEM_USERS).isdisjoint(usernames)) +class SSHKeyValidatorTest(TestCase): + + def test_validates_rsa_public_key(self): + key_string = get_data('data/test_rsa.pub') + validate_ssh_public_key(key_string) + # No ValidationError. + + def test_validates_dsa_public_key(self): + key_string = get_data('data/test_dsa.pub') + validate_ssh_public_key(key_string) + # No ValidationError. + + def test_does_not_validate_random_data(self): + key_string = factory.getRandomString() + self.assertRaises( + ValidationError, validate_ssh_public_key, key_string) + + def test_does_not_validate_rsa_private_key(self): + key_string = get_data('data/test_rsa') + self.assertRaises( + ValidationError, validate_ssh_public_key, key_string) + + def test_does_not_validate_dsa_private_key(self): + key_string = get_data('data/test_dsa') + self.assertRaises( + ValidationError, validate_ssh_public_key, key_string) + + +class GetHTMLDisplayForKeyTest(TestCase): + """Testing for the method `get_html_display_for_key`.""" + + def test_display_returns_unchanged_if_unknown_and_small(self): + # If the key does not look like a normal key (with three parts + # separated by spaces, it's returned unchanged if its size is <= + # size. + size = random.randint(101, 200) + key = factory.getRandomString(size - 100) + display = get_html_display_for_key(key, size) + self.assertTrue(len(display) < size) + self.assertEqual(key, display) + + def test_display_returns_cropped_if_unknown_and_large(self): + # If the key does not look like a normal key (with three parts + # separated by spaces, it's returned cropped if its size is > + # size. + size = random.randint(20, 100) # size cannot be < len(HELLIPSIS). + key = factory.getRandomString(size + 1) + display = get_html_display_for_key(key, size) + self.assertEqual(size, len(display)) + self.assertEqual( + '%.*s%s' % (size - len(HELLIPSIS), key, HELLIPSIS), display) + + def test_display_limits_size_with_large_comment(self): + # If the key has a large 'comment' part, the key is simply + # cropped and HELLIPSIS appended to it. + key_type = factory.getRandomString(10) + key_string = factory.getRandomString(10) + comment = factory.getRandomString(100, spaces=True) + key = '%s %s %s' % (key_type, key_string, comment) + display = get_html_display_for_key(key, 50) + self.assertEqual(50, len(display)) + self.assertEqual( + '%.*s%s' % (50 - len(HELLIPSIS), key, HELLIPSIS), display) + + def test_display_limits_size_with_large_key_type(self): + # If the key has a large 'key_type' part, the key is simply + # cropped and HELLIPSIS appended to it. + key_type = factory.getRandomString(100) + key_string = factory.getRandomString(10) + comment = factory.getRandomString(10, spaces=True) + key = '%s %s %s' % (key_type, key_string, comment) + display = get_html_display_for_key(key, 50) + self.assertEqual(50, len(display)) + self.assertEqual( + '%.*s%s' % (50 - len(HELLIPSIS), key, HELLIPSIS), display) + + def test_display_cropped_key(self): + # If the key has a small key_type, a small comment and a large + # key_string (which is the 'normal' case), the key_string part + # gets cropped. + key_type = factory.getRandomString(10) + key_string = factory.getRandomString(100) + comment = factory.getRandomString(10, spaces=True) + key = '%s %s %s' % (key_type, key_string, comment) + display = get_html_display_for_key(key, 50) + self.assertEqual(50, len(display)) + self.assertEqual( + '%s %.*s%s %s' % ( + key_type, + 50 - (len(key_type) + len(HELLIPSIS) + len(comment) + 2), + key_string, HELLIPSIS, comment), + display) + + +class SSHKeyTest(TestCase): + """Testing for the :class:`SSHKey`.""" + + def test_sshkey_validation_with_valid_key(self): + key_string = get_data('data/test_rsa.pub') + user = factory.make_user() + key = SSHKey(key=key_string, user=user) + key.full_clean() + # No ValidationError. + + def test_sshkey_validation_fails_if_key_is_invalid(self): + key_string = factory.getRandomString() + user = factory.make_user() + key = SSHKey(key=key_string, user=user) + self.assertRaises( + ValidationError, key.full_clean) + + def test_sshkey_display_with_real_life_key(self): + # With a real-life ssh-rsa key, the key_string part is cropped. + key_string = get_data('data/test_rsa.pub') + user = factory.make_user() + key = SSHKey(key=key_string, user=user) + display = key.display_html() + self.assertEqual( + 'ssh-rsa AAAAB3NzaC1yc2E… ubuntu@server-7476', display) + + def test_sshkey_display_is_safe(self): + key_string = get_data('data/test_rsa.pub') + user = factory.make_user() + key = SSHKey(key=key_string, user=user) + display = key.display_html() + self.assertIsInstance(display, SafeUnicode) + + class SSHKeyManagerTest(TestCase): - """Testing for the :class `SSHKeyManager` model manager.""" + """Testing for the :class:`SSHKeyManager` model manager.""" def test_get_keys_for_user_no_keys(self): user = factory.make_user() diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/tests/test_views.py maas-0.1+bzr378+dfsg/src/maasserver/tests/test_views.py --- maas-0.1+bzr363+dfsg/src/maasserver/tests/test_views.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/tests/test_views.py 2012-03-29 22:57:09.000000000 +0000 @@ -31,9 +31,13 @@ Config, NODE_AFTER_COMMISSIONING_ACTION, POWER_TYPE_CHOICES, + SSHKey, UserProfile, ) -from maasserver.testing import reload_object +from maasserver.testing import ( + get_data, + reload_object, + ) from maasserver.testing.factory import factory from maasserver.testing.testcase import ( LoggedInTestCase, @@ -363,6 +367,87 @@ # The password is SHA1ized, we just make sure that it has changed. self.assertNotEqual(old_pw, user.password) + def test_prefs_displays_message_when_no_public_keys_are_configured(self): + response = self.client.get('/account/prefs/') + self.assertIn("No SSH key configured.", response.content) + + def test_prefs_displays_add_ssh_key_button(self): + response = self.client.get('/account/prefs/') + add_key_link = reverse('prefs-add-sshkey') + self.assertIn(add_key_link, get_content_links(response)) + + def create_keys_for_user(self, user): + return [factory.make_sshkey(self.logged_in_user) for i in range(3)] + + def test_prefs_displays_compact_representation_of_users_keys(self): + keys = self.create_keys_for_user(self.logged_in_user) + response = self.client.get('/account/prefs/') + for key in keys: + self.assertIn(key.display_html(), response.content) + + def test_prefs_displays_link_to_delete_ssh_keys(self): + keys = self.create_keys_for_user(self.logged_in_user) + response = self.client.get('/account/prefs/') + links = get_content_links(response) + for key in keys: + del_key_link = reverse('prefs-delete-sshkey', args=[key.id]) + self.assertIn(del_key_link, links) + + +class KeyManagementTest(LoggedInTestCase): + + def test_add_key_GET(self): + # The 'Add key' page displays a form to add a key. + response = self.client.get(reverse('prefs-add-sshkey')) + doc = fromstring(response.content) + + self.assertEqual(1, len(doc.cssselect('textarea#id_key'))) + # The page features a form that submits to itself. + self.assertSequenceEqual( + ['.'], + [elem.get('action').strip() for elem in doc.cssselect( + '#content form')]) + + def test_add_key_POST_adds_key(self): + key_string = get_data('data/test_rsa.pub') + response = self.client.post( + reverse('prefs-add-sshkey'), {'key': key_string}) + + self.assertEqual(httplib.FOUND, response.status_code) + self.assertTrue(SSHKey.objects.filter(key=key_string).exists()) + + def test_delete_key_GET(self): + # The 'Delete key' page displays a confirmation page with a form. + key = factory.make_sshkey(self.logged_in_user) + del_link = reverse('prefs-delete-sshkey', args=[key.id]) + response = self.client.get(del_link) + doc = fromstring(response.content) + + self.assertIn( + "Are you sure you want to delete the following key?", + response.content) + # The page features a form that submits to itself. + self.assertSequenceEqual( + ['.'], + [elem.get('action').strip() for elem in doc.cssselect( + '#content form')]) + + def test_delete_key_GET_cannot_access_someoneelses_key(self): + key = factory.make_sshkey(factory.make_user()) + del_link = reverse('prefs-delete-sshkey', args=[key.id]) + response = self.client.get(del_link) + + self.assertEqual(httplib.NOT_FOUND, response.status_code) + + def test_delete_key_POST(self): + # A POST request deletes the key. + key = factory.make_sshkey(self.logged_in_user) + del_link = reverse('prefs-delete-sshkey', args=[key.id]) + response = self.client.post(del_link, {'post': 'yes'}) + + self.assertEqual(httplib.FOUND, response.status_code) + self.assertFalse(SSHKey.objects.filter(id=key.id).exists()) + class AdminLoggedInTestCase(LoggedInTestCase): @@ -515,20 +600,20 @@ def test_settings_maas_and_network_POST(self): new_name = factory.getRandomString() - new_provide_dhcp = factory.getRandomBoolean() + new_domain = factory.getRandomString() response = self.client.post( '/settings/', get_prefixed_form_data( prefix='maas_and_network', data={ 'maas_name': new_name, - 'provide_dhcp': new_provide_dhcp, + 'enlistment_domain': new_domain, })) self.assertEqual(httplib.FOUND, response.status_code) self.assertEqual(new_name, Config.objects.get_config('maas_name')) self.assertEqual( - new_provide_dhcp, Config.objects.get_config('provide_dhcp')) + new_domain, Config.objects.get_config('enlistment_domain')) def test_settings_commissioning_POST(self): new_after_commissioning = factory.getRandomEnum( diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/urls.py maas-0.1+bzr378+dfsg/src/maasserver/urls.py --- maas-0.1+bzr363+dfsg/src/maasserver/urls.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/urls.py 2012-03-29 22:57:09.000000000 +0000 @@ -41,6 +41,8 @@ proxy_to_longpoll, settings, settings_add_archive, + SSHKeyCreateView, + SSHKeyDeleteView, userprefsview, ) @@ -68,6 +70,12 @@ # URLs for logged-in users. urlpatterns += patterns('maasserver.views', url(r'^account/prefs/$', userprefsview, name='prefs'), + url( + r'^account/prefs/sshkey/add/$', SSHKeyCreateView.as_view(), + name='prefs-add-sshkey'), + url( + r'^account/prefs/sshkey/delete/(?P\d*)/$', + SSHKeyDeleteView.as_view(), name='prefs-delete-sshkey'), url(r'^accounts/logout/$', logout, name='logout'), url( r'^$', diff -Nru maas-0.1+bzr363+dfsg/src/maasserver/views.py maas-0.1+bzr378+dfsg/src/maasserver/views.py --- maas-0.1+bzr363+dfsg/src/maasserver/views.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maasserver/views.py 2012-03-29 22:57:09.000000000 +0000 @@ -10,10 +10,21 @@ __metaclass__ = type __all__ = [ + "AccountsAdd", + "AccountsDelete", + "AccountsEdit", + "AccountsView", + "combo_view", + "login", "logout", "NodeListView", "NodesCreateView", "NodeView", + "NodeEdit", + "settings", + "settings_add_archive", + "SSHKeyCreateView", + "SSHKeyDeleteView", ] from logging import getLogger @@ -64,6 +75,7 @@ MAASAndNetworkForm, NewUserCreationForm, ProfileForm, + SSHKeyForm, UbuntuForm, UIAdminNodeEditForm, UINodeEditForm, @@ -71,6 +83,7 @@ from maasserver.messages import messaging from maasserver.models import ( Node, + SSHKey, UserProfile, ) @@ -162,6 +175,41 @@ return reverse('index') +class SSHKeyCreateView(CreateView): + + form_class = SSHKeyForm + template_name = 'maasserver/prefs_add_sshkey.html' + + def get_form_kwargs(self): + kwargs = super(SSHKeyCreateView, self).get_form_kwargs() + kwargs['user'] = self.request.user + return kwargs + + def form_valid(self, form): + messages.info(self.request, "SSH key added.") + return super(SSHKeyCreateView, self).form_valid(form) + + def get_success_url(self): + return reverse('prefs') + + +class SSHKeyDeleteView(DeleteView): + + template_name = 'maasserver/prefs_confirm_delete_sshkey.html' + context_object_name = 'key' + + def get_object(self): + keyid = self.kwargs.get('keyid', None) + return get_object_or_404(SSHKey, user=self.request.user, id=keyid) + + def form_valid(self, form): + messages.info(self.request, "SSH key deleted.") + return super(SSHKeyDeleteView, self).form_valid(form) + + def get_success_url(self): + return reverse('prefs') + + def process_form(request, form_class, redirect_url, prefix, success_message=None, form_kwargs=None): """Utility method to process subforms (i.e. forms with a prefix). diff -Nru maas-0.1+bzr363+dfsg/src/maastesting/factory.py maas-0.1+bzr378+dfsg/src/maastesting/factory.py --- maas-0.1+bzr363+dfsg/src/maastesting/factory.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maastesting/factory.py 2012-03-29 22:57:09.000000000 +0000 @@ -28,11 +28,17 @@ random_letters = imap( random.choice, repeat(string.letters + string.digits)) + random_letters_with_spaces = imap( + random.choice, repeat(string.letters + string.digits + ' ')) + random_http_responses = imap( random.choice, repeat(tuple(httplib.responses))) - def getRandomString(self, size=10): - return "".join(islice(self.random_letters, size)) + def getRandomString(self, size=10, spaces=False): + if spaces: + return "".join(islice(self.random_letters_with_spaces, size)) + else: + return "".join(islice(self.random_letters, size)) def getRandomEmail(self, login_size=10): return "%s@example.com" % self.getRandomString(size=login_size) diff -Nru maas-0.1+bzr363+dfsg/src/maastesting/management/commands/reconcile.py maas-0.1+bzr378+dfsg/src/maastesting/management/commands/reconcile.py --- maas-0.1+bzr363+dfsg/src/maastesting/management/commands/reconcile.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maastesting/management/commands/reconcile.py 2012-03-29 22:57:09.000000000 +0000 @@ -34,13 +34,20 @@ ) +ARCHITECTURE_GUESSES = { + "i386": models.ARCHITECTURE.i386, + "amd64": models.ARCHITECTURE.amd64, + "x86_64": models.ARCHITECTURE.amd64, + } + + def guess_architecture_from_profile(profile_name): """ This attempts to obtain the architecture from a Cobbler profile name. The naming convention for profile names is "maas-${series}-${arch}". """ - for architecture, _ in models.ARCHITECTURE_CHOICES: - if architecture in profile_name: + for guess, architecture in ARCHITECTURE_GUESSES.items(): + if guess in profile_name: return architecture else: return None @@ -52,6 +59,7 @@ nodes_remote = papi.get_nodes() missing_local = set(nodes_remote).difference(nodes_local) + missing_local.discard("default") for name in missing_local: print("remote:", name) remote_node = nodes_remote[name] diff -Nru maas-0.1+bzr363+dfsg/src/maastesting/management/commands/tests/test_reconcile.py maas-0.1+bzr378+dfsg/src/maastesting/management/commands/tests/test_reconcile.py --- maas-0.1+bzr363+dfsg/src/maastesting/management/commands/tests/test_reconcile.py 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/maastesting/management/commands/tests/test_reconcile.py 2012-03-29 22:57:09.000000000 +0000 @@ -0,0 +1,27 @@ +# Copyright 2012 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Tests for `maastesting.management.commands.reconcile`.""" + +from __future__ import ( + print_function, + unicode_literals, + ) + +__metaclass__ = type +__all__ = [] + +from maastesting.testcase import TestCase +from maastesting.management.commands.reconcile import ( + guess_architecture_from_profile, + ) + + +class TestFunctions(TestCase): + + def test_guess_architecture_from_profile(self): + guess = guess_architecture_from_profile + self.assertEqual("i386", guess("a-i386-profile")) + self.assertEqual("amd64", guess("amd64-profile")) + self.assertEqual("amd64", guess("profile-for-x86_64")) + self.assertEqual(None, guess("profile-for-arm")) diff -Nru maas-0.1+bzr363+dfsg/src/metadataserver/fields.py maas-0.1+bzr378+dfsg/src/metadataserver/fields.py --- maas-0.1+bzr363+dfsg/src/metadataserver/fields.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/metadataserver/fields.py 2012-03-29 22:57:09.000000000 +0000 @@ -48,8 +48,9 @@ this constructor will refuse to render None as b'None'. :type initializer: bytes """ - assert isinstance(initializer, bytes), ( - "Not a binary string: '%s'" % repr(initializer)) + if not isinstance(initializer, bytes): + raise AssertionError( + "Not a binary string: '%s'" % repr(initializer)) super(Bin, self).__init__(initializer) diff -Nru maas-0.1+bzr363+dfsg/src/provisioningserver/cobblercatcher.py maas-0.1+bzr378+dfsg/src/provisioningserver/cobblercatcher.py --- maas-0.1+bzr363+dfsg/src/provisioningserver/cobblercatcher.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/provisioningserver/cobblercatcher.py 2012-03-29 22:57:09.000000000 +0000 @@ -83,8 +83,10 @@ :rtype: :class:`ProvisioningError` """ assert isinstance(fault, Fault) - assert not isinstance(fault, ProvisioningError), ( - "Fault went through double conversion.") + + if isinstance(fault, ProvisioningError): + raise AssertionError( + "Fault %r went through double conversion." % fault) err_str = extract_text(fault.faultString) if fault.faultCode != 1: diff -Nru maas-0.1+bzr363+dfsg/src/provisioningserver/cobblerclient.py maas-0.1+bzr378+dfsg/src/provisioningserver/cobblerclient.py --- maas-0.1+bzr363+dfsg/src/provisioningserver/cobblerclient.py 2012-03-27 17:12:06.000000000 +0000 +++ maas-0.1+bzr378+dfsg/src/provisioningserver/cobblerclient.py 2012-03-29 22:57:09.000000000 +0000 @@ -405,9 +405,10 @@ return attribute_name attribute_name = attribute_name.replace('-', '_') - assert attribute_name in attributes, ( - "Unknown attribute for %s: %s." - % (cls.object_type, attribute_name)) + if attribute_name not in attributes: + raise AssertionError( + "Unknown attribute for %s: %s." % ( + cls.object_type, attribute_name)) return attribute_name @classmethod