diff -Nru maas-0.1+bzr338+dfsg/contrib/maas-http.conf maas-0.1+bzr363+dfsg/contrib/maas-http.conf --- maas-0.1+bzr338+dfsg/contrib/maas-http.conf 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/contrib/maas-http.conf 2012-03-27 17:12:06.000000000 +0000 @@ -1,4 +1,5 @@ WSGIScriptAlias /MAAS /usr/share/maas/wsgi.py +WSGIPassAuthorization On diff -Nru maas-0.1+bzr338+dfsg/debian/changelog maas-0.1+bzr363+dfsg/debian/changelog --- maas-0.1+bzr338+dfsg/debian/changelog 2012-03-22 19:59:20.000000000 +0000 +++ maas-0.1+bzr363+dfsg/debian/changelog 2012-03-27 20:01:57.000000000 +0000 @@ -1,3 +1,24 @@ +maas (0.1+bzr363+dfsg-0ubuntu1) precise; urgency=low + + [ Dave Walker (Daviey) ] + * debian/control: Add openssh-server as a Recommends, and wrap-and-sort. + + [ Andres Rodriguez ] + * debian/maas.postinst: + - Do not start apache with apache2ctl. Use invoke-rc.d instead to not + fail in the installer. + - For start of postgresql before creating the DB, otherwise it will + fail in the installer. + - Add check of invoke-rc.d for syslog. + - Add check of invoke-rc.d for rabbitmq-server; Add check for rabbitmqctl + - Add db_stop, in case invoke-rc.d fails. + * debian/control: Tight python-django-maas dependency. + * debian/postrm: Add check for rabbitmqctl. + * debian/maas.maas-txlongpoll.upstart: Create rabbitmq longpoll user/vhost + and set permissions if they don't exist. Start on rabbitmq-server-running. + + -- Andres Rodriguez Tue, 27 Mar 2012 14:49:56 -0400 + maas (0.1+bzr338+dfsg-0ubuntu1) precise; urgency=low [ Dave Walker (Daviey) ] diff -Nru maas-0.1+bzr338+dfsg/debian/control maas-0.1+bzr363+dfsg/debian/control --- maas-0.1+bzr338+dfsg/debian/control 2012-03-22 19:59:20.000000000 +0000 +++ maas-0.1+bzr363+dfsg/debian/control 2012-03-27 20:00:51.000000000 +0000 @@ -9,30 +9,29 @@ Package: maas Architecture: all -Depends: ${misc:Depends}, - ${python:Depends}, - apache2, +Depends: apache2, avahi-daemon, cobbler, + dbconfig-common, libapache2-mod-wsgi, postgresql-9.1, + pwgen, python-django, - python-django-maas (>= ${binary:Version}), + python-django-maas (= ${binary:Version}), python-django-piston, python-django-south, - pwgen, rabbitmq-server, rsyslog, squid-deb-proxy, - dbconfig-common + ${misc:Depends}, + ${python:Depends} +Recommends: openssh-server Description: The next step in the development of orchestra. It provides an easy to use UI to provision your Ubuntu servers. Package: python-django-maas Architecture: all -Depends: ${misc:Depends}, - ${python:Depends}, - python-avahi, +Depends: python-avahi, python-convoy, python-dbus, python-oops, @@ -45,6 +44,8 @@ python-twisted, python-txamqp, python-txlongpoll, - python-zope.interface + python-zope.interface, + ${misc:Depends}, + ${python:Depends} Description: The next step in the development of Orchestra. It provides an easy to use UI to provision your Ubuntu servers. diff -Nru maas-0.1+bzr338+dfsg/debian/maas.maas-txlongpoll.upstart maas-0.1+bzr363+dfsg/debian/maas.maas-txlongpoll.upstart --- maas-0.1+bzr338+dfsg/debian/maas.maas-txlongpoll.upstart 2012-03-22 19:59:20.000000000 +0000 +++ maas-0.1+bzr363+dfsg/debian/maas.maas-txlongpoll.upstart 2012-03-27 20:00:51.000000000 +0000 @@ -5,10 +5,23 @@ description "MAAS txlongpoll" author "Andres Rodriguez " -start on filesystem and net-device-up +start on filesystem and net-device-up and rabbitmq-server-running stop on runlevel [016] respawn +env longpoll_user="maas_longpoll" +env longpoll_pass="" +env longpoll_vhost="/maas_longpoll" + +pre-start script + if ! /usr/sbin/rabbitmqctl list_user_permissions $longpoll_user 1>/dev/null 2>&1; then + longpoll_pass=`/bin/grep "password" /etc/maas/txlongpoll.yaml | cut -d'"' -f2` + /usr/sbin/rabbitmqctl add_user "$longpoll_user" "$longpoll_pass" + /usr/sbin/rabbitmqctl add_vhost "$longpoll_vhost" + /usr/sbin/rabbitmqctl set_permissions -p "$longpoll_vhost" "$longpoll_user" ".*" ".*" ".*" + fi +end script + # To add options to your daemon, edit the line below: exec /usr/bin/twistd -n --pidfile=/run/maas-txlongpoll.pid --logfile=/dev/null txlongpoll --config-file=/etc/maas/txlongpoll.yaml diff -Nru maas-0.1+bzr338+dfsg/debian/maas.postinst maas-0.1+bzr363+dfsg/debian/maas.postinst --- maas-0.1+bzr338+dfsg/debian/maas.postinst 2012-03-22 19:59:20.000000000 +0000 +++ maas-0.1+bzr363+dfsg/debian/maas.postinst 2012-03-27 20:00:51.000000000 +0000 @@ -15,7 +15,10 @@ if [ "$1" = "configure" ] && [ -z "$2" ]; then - # handle apache + ######################################################### + ################ Configure Apache2 #################### + ######################################################### + # handle apache configs if [ -e /etc/maas/maas-http.conf -a \ ! -e /etc/apache2/conf.d/maas-http.conf ]; then ln -sf /etc/maas/maas-http.conf /etc/apache2/conf.d/maas-http.conf @@ -25,8 +28,16 @@ a2enmod expires a2enmod wsgi - # restart apache - apache2ctl restart || true + # 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 + + ######################################################### + ########### Configure maas user for Cobbler ############# + ######################################################### # Create 'maas' user and password to autoconfigure cblr_pass=`pwgen` @@ -39,22 +50,9 @@ sed -i "s/^\ \{1,\}password: [a-zA-Z0-9]\{1,\}$/ password: "$cblr_pass"/" /etc/maas/pserv.yaml fi - # Handle longpoll/rabbitmq publishing - longpoll_user="maas_longpoll" - longpoll_pass=`pwgen` - longpoll_vhost="/maas_longpoll" - rabbitmqctl add_user "$longpoll_user" "$longpoll_pass" || true - rabbitmqctl add_vhost "$longpoll_vhost" || true - rabbitmqctl set_permissions -p "$longpoll_vhost" "$longpoll_user" ".*" ".*" ".*" || true - - if grep -qs "^\ \{1,\}password: \"[a-zA-Z0-9]\{0,\}\"$" /etc/maas/txlongpoll.yaml; then - sed -i "s/^\ \{1,\}password: \"[a-zA-Z0-9]\{0,\}\"$/ password: \""$longpoll_pass"\"/" \ - /etc/maas/txlongpoll.yaml - fi - if grep -qs "^RABBITMQ_PASSWORD\ \= '[a-zA-Z0-9]\{0,\}'$" /etc/maas/maas_local_settings.py; then - sed -i "s/^RABBITMQ_PASSWORD\ \= '[a-zA-Z0-9]\{0,\}'$/RABBITMQ_PASSWORD = '"$longpoll_pass"'/" \ - /etc/maas/maas_local_settings.py - fi + ######################################################### + ########## Configure DEFAULT_MAAS_URL ################# + ######################################################### # Obtain IP address of default route and change DEFAULT_MAAS_URL. while read Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT; do @@ -72,6 +70,10 @@ fi fi + ######################################################### + ################ Configure Logging #################### + ######################################################### + # Give appropriate permissions if [ ! -f /var/log/maas/maas.log ]; then touch /var/log/maas/maas.log @@ -79,8 +81,54 @@ chown -R root:www-data /var/log/maas chmod 620 /var/log/maas/maas.log chmod -R 775 /var/log/maas/oops + + # Create log directory base + mkdir -p /var/log/maas/rsyslog + chown -R syslog:syslog /var/log/maas/rsyslog + # Make sure rsyslog reads our config + if [ -x /usr/sbin/invoke-rc.d ]; then + invoke-rc.d rsyslog restart + fi - # Configure database + ######################################################### + ########## Configure longpoll rabbitmq config ########### + ######################################################### + + # Handle longpoll/rabbitmq publishing + if [ -x /usr/sbin/invoke-rc.d ]; then + invoke-rc.d rabbitmq-server restart || true + else + /etc/init.d/rabbitmq-server restart || true + fi + longpoll_user="maas_longpoll" + longpoll_pass=`pwgen` + longpoll_vhost="/maas_longpoll" + if [ -x /usr/sbin/rabbitmqctl ]; then + /usr/sbin/rabbitmqctl add_user "$longpoll_user" "$longpoll_pass" || true + /usr/sbin/rabbitmqctl add_vhost "$longpoll_vhost" || true + /usr/sbin/rabbitmqctl set_permissions -p "$longpoll_vhost" "$longpoll_user" ".*" ".*" ".*" || true + fi + + if grep -qs "^\ \{1,\}password: \"[a-zA-Z0-9]\{0,\}\"$" /etc/maas/txlongpoll.yaml; then + sed -i "s/^\ \{1,\}password: \"[a-zA-Z0-9]\{0,\}\"$/ password: \""$longpoll_pass"\"/" \ + /etc/maas/txlongpoll.yaml + fi + if grep -qs "^RABBITMQ_PASSWORD\ \= '[a-zA-Z0-9]\{0,\}'$" /etc/maas/maas_local_settings.py; then + sed -i "s/^RABBITMQ_PASSWORD\ \= '[a-zA-Z0-9]\{0,\}'$/RABBITMQ_PASSWORD = '"$longpoll_pass"'/" \ + /etc/maas/maas_local_settings.py + fi + + ######################################################### + ################ Configure Database ################### + ######################################################### + + # Need to for postgresql start so it doesn't fail on the installer + if [ -x /usr/sbin/invoke-rc.d ]; then + invoke-rc.d --force postgresql restart || true + else + /etc/init.d/postgresql restart || true + fi + # Create the database dbc_go maas $@ if grep -qs "^\ \{1,\} 'PASSWORD': '[a-zA-Z0-9]\{0,\}',$" /etc/maas/maas_local_settings.py; then sed -i "s/^\ \{1,\} 'PASSWORD': '[a-zA-Z0-9]\{0,\}',$/ 'PASSWORD': '"$dbc_dbpass"',/" \ @@ -92,16 +140,13 @@ maas_sync_migrate_db fi - # Create log directory base - mkdir -p /var/log/maas/rsyslog - chown -R syslog:syslog /var/log/maas/rsyslog - # Make sure rsyslog reads our config - invoke-rc.d rsyslog restart - elif [ "$1" = "configure" ] && dpkg --compare-versions "$2" gt 0.1+bzr266+dfsg-0ubuntu1; then # If upgrading to any later package version, then upgrade db. maas_sync_migrate_db fi +db_stop + #DEBHELPER# + exit 0 diff -Nru maas-0.1+bzr338+dfsg/debian/maas.postrm maas-0.1+bzr363+dfsg/debian/maas.postrm --- maas-0.1+bzr338+dfsg/debian/maas.postrm 2012-03-22 19:59:20.000000000 +0000 +++ maas-0.1+bzr363+dfsg/debian/maas.postrm 2012-03-27 20:00:51.000000000 +0000 @@ -19,8 +19,10 @@ # Remove rabbitmq/longpoll longpoll_user="maas_longpoll" longpoll_vhost="/maas_longpoll" - rabbitmqctl delete_vhost "$longpoll_vhost" || true - rabbitmqctl delete_user "$longpoll_user" || true + if [ -x /usr/sbin/rabbitmqctl ]; then + rabbitmqctl delete_vhost "$longpoll_vhost" || true + rabbitmqctl delete_user "$longpoll_user" || true + fi esac #DEBHELPER# diff -Nru maas-0.1+bzr338+dfsg/docs/index.rst maas-0.1+bzr363+dfsg/docs/index.rst --- maas-0.1+bzr338+dfsg/docs/index.rst 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/docs/index.rst 2012-03-27 17:12:06.000000000 +0000 @@ -19,6 +19,8 @@ readme hacking + install + juju-quick-start api MAAS API diff -Nru maas-0.1+bzr338+dfsg/docs/install.rst maas-0.1+bzr363+dfsg/docs/install.rst --- maas-0.1+bzr338+dfsg/docs/install.rst 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr363+dfsg/docs/install.rst 2012-03-27 17:12:06.000000000 +0000 @@ -0,0 +1,90 @@ +*************** +Installing MAAS +*************** + +There are two main ways to install MAAS: + + * as part of a fresh Ubuntu install using the Ubuntu Server installer + * or from Ubuntu's archive on an existing Ubuntu install. + +This is a guide to installing MAAS from the Ubuntu archive. + +It assumes that you're working with: + + * a fresh Ubuntu 12.04 LTS install + * a machine dedicated to running MAAS + * control of the network your machine is connected to + * internet access or a local mirror of the Ubuntu archive. + +Installing MAAS from the archive +================================ + +Installing MAAS is straightforward. At the commandline, type:: + + $ sudo apt-get install maas + +From a fresh Ubuntu 12.04 LTS install, MAAS will pull down around 200 MB of packages. + +Creating a superuser account +---------------------------- + +Once MAAS is installed, you'll need to create your first administrator account. + +At the commandline, type:: + + $ maas createsuperuser + +Follow the prompts and MAAS will create an admin account that you can later use to log in. + +Configuring a DHCP server +========================= + +So that MAAS can PXE boot machines, you'll need a DHCP server. MAAS can work +with your existing DHCP set-up but for this guide we'll use dnsmasq. + +dnsmasq should already be installed. However, if it is not, enter the +following:: + + $ sudo apt-get install dnsmasq + +MAAS enlists nodes using a tool called Cobbler. Cobbler provides a +configuration file for dnsmasq: `/etc/cobbler/dnsmasq.template`. + +Make the following changes: + + * ``domain``: if applicable, specify your network's domain. + * ``dhcp-range``: specify the range from wluke haineshich dnsmasq should allocate IP + addresses to servers in your MAAS. + * ``dhcp-option=3,next_server``: replace **next_server** with the current + server's IP address. + +Save that file and now edit the Cobbler settings file: `/etc/cobbler/settings`. + +You need to change two settings: + + * ``manage_dns``: change the 0 to 1 + * ``manage_dhcp``: again, change the 0 to 1. + +Now restart dnsmasq:: + + $ sudo /etc/init.d/dnsmasq restart + + +Import the Ubuntu images +======================== + +MAAS will check for and download new Ubuntu images once a week. However, +you'll need to download them manually the first time:: + + $ sudo maas-import-isos + + +Next steps +========== + +Your MAAS is now ready for use. Visit the MAAS web interface in your browser +at `http://localhost/MAAS`_. + +.. _http://localhost/MAAS: http://localhost/MAAS + +Now, :doc:`let's prepare your Juju environment `. diff -Nru maas-0.1+bzr338+dfsg/docs/juju-quick-start.rst maas-0.1+bzr363+dfsg/docs/juju-quick-start.rst --- maas-0.1+bzr338+dfsg/docs/juju-quick-start.rst 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr363+dfsg/docs/juju-quick-start.rst 2012-03-27 17:12:06.000000000 +0000 @@ -0,0 +1,141 @@ +Juju Quick Start +================ + +These instructions will help you deploy your first charm with Juju to +a MAAS cluster. + +A few assumptions are made: + +- You have a MAAS cluster set-up, and you have at least 2 nodes + enlisted with it. + +- You're running MAAS on your local machine. If not you'll need to + adjust some of the URLs mentioned accordingly. + +- You're running Juju from the PPA (``ppa:juju/pkgs``) or from a + branch of ``lp:juju``. At the time of writing MAAS support had not + made it into the main Ubuntu archives. However, following the + release of Ubuntu Precise, all the necessary package revisions will + be in main. + + If you're using a branch, note that you'll need to set + ``PYTHONPATH`` carefully to ensure you use the code in the branch. + + +Your API key and environments.yaml +---------------------------------- + +You'll need an API key from MAAS so that the Juju client can access +it. Each user account in MAAS can have as many API keys as desired. +One hard and fast rule is that you'll need to use a different API key +for each Juju *environment* you set up within a single MAAS cluster. + + +Getting a key +^^^^^^^^^^^^^ + +To get the API key: + +#. 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/ + + +Creating environments.yaml +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Create or modify ``~/.juju/environments.yaml`` with the following content:: + + juju: environments + environments: + maas: + type: maas + maas-server: 'http://localhost:5240' + maas-oauth: '${maas-api-key}' + admin-secret: 'nothing' + +Substitute the API key from earlier into the ``${maas-api-key}`` +slot. You may need to modify the ``maas-server`` setting too; if +you're running from the maas package it should be something like +``http://hostname.example.com/MAAS``. + + +Now Juju +-------- + +:: + + $ juju status + +**Note**: if Juju complains that there are multiple environments and +no explicit default, add ``-e ${environment-name}`` after each +command, e.g.:: + + $ juju status -e maas + +As you've not bootstrapped you ought to see:: + + $ juju environment not found: is the environment bootstrapped? + +Bootstrap:: + + $ juju bootstrap + +This will return quickly, but the master node may take a *long* time +to come up. It has to completely install Ubuntu and Zookeeper and +reboot before it'll be available for use. It's probably worth either +trying a ``juju status`` once in a while to check on progress, or +following the install on the node directly. + + **Beware** of `bug 413415`_ - *console-setup hangs under chroot + debootstrap with a console login on ttyX* - when monitoring an + installation on the node. + +.. _bug 413415: + https://bugs.launchpad.net/ubuntu/+source/console-setup/+bug/413415 + +If you're using ``vdenv`` (included in ``lp:maas``) then ``virsh`` +makes it easy to follow on progress:: + + $ virsh list + Id Name State + ---------------------------------- + 1 zimmer running + 2 odev-node02 running + + $ gnome-terminal -e 'virsh console odev-node02' & + +.. + + ``zimmer`` is the machine on which MAAS is running. Here + ``odev-node02`` is the machine being bootstrapped as the Juju master + node. + +Once the master node has been installed a status command should come +up with something a bit more interesting: + + **XXX** `Bug 965101 + `_ - *MAAS provider + does not raise an exception when get_machines(...) does not find + the requested machines* - prevented capturing of output here. + +Now it's possible to deploy a charm:: + + $ juju deploy --repository /usr/share/doc/juju/examples local:mysql + $ juju status + +If you have another node free you can finish off the canonical and by +now familiar example:: + + $ juju deploy --repository /usr/share/doc/juju/examples local:wordpress + $ juju add-relation wordpress mysql + $ juju expose wordpress + $ juju status + +Note that each charm runs on its own host, so each deployment will +actually take as long as it took to bootstrap. Have a beer, drown your +sorrows in liquor, or, my preference, have another cup of tea. diff -Nru maas-0.1+bzr338+dfsg/INSTALL.txt maas-0.1+bzr363+dfsg/INSTALL.txt --- maas-0.1+bzr338+dfsg/INSTALL.txt 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr363+dfsg/INSTALL.txt 2012-03-27 17:12:06.000000000 +0000 @@ -0,0 +1,90 @@ +*************** +Installing MAAS +*************** + +There are two main ways to install MAAS: + + * as part of a fresh Ubuntu install using the Ubuntu Server installer + * or from Ubuntu's archive on an existing Ubuntu install. + +This is a guide to installing MAAS from the Ubuntu archive. + +It assumes that you're working with: + + * a fresh Ubuntu 12.04 LTS install + * a machine dedicated to running MAAS + * control of the network your machine is connected to + * internet access or a local mirror of the Ubuntu archive. + +Installing MAAS from the archive +================================ + +Installing MAAS is straightforward. At the commandline, type:: + + $ sudo apt-get install maas + +From a fresh Ubuntu 12.04 LTS install, MAAS will pull down around 200 MB of packages. + +Creating a superuser account +---------------------------- + +Once MAAS is installed, you'll need to create your first administrator account. + +At the commandline, type:: + + $ maas createsuperuser + +Follow the prompts and MAAS will create an admin account that you can later use to log in. + +Configuring a DHCP server +========================= + +So that MAAS can PXE boot machines, you'll need a DHCP server. MAAS can work +with your existing DHCP set-up but for this guide we'll use dnsmasq. + +dnsmasq should already be installed. However, if it is not, enter the +following:: + + $ sudo apt-get install dnsmasq + +MAAS enlists nodes using a tool called Cobbler. Cobbler provides a +configuration file for dnsmasq: `/etc/cobbler/dnsmasq.template`. + +Make the following changes: + + * ``domain``: if applicable, specify your network's domain. + * ``dhcp-range``: specify the range from wluke haineshich dnsmasq should allocate IP + addresses to servers in your MAAS. + * ``dhcp-option=3,next_server``: replace **next_server** with the current + server's IP address. + +Save that file and now edit the Cobbler settings file: `/etc/cobbler/settings`. + +You need to change two settings: + + * ``manage_dns``: change the 0 to 1 + * ``manage_dhcp``: again, change the 0 to 1. + +Now restart dnsmasq:: + + $ sudo /etc/init.d/dnsmasq restart + + +Import the Ubuntu images +======================== + +MAAS will check for and download new Ubuntu images once a week. However, +you'll need to download them manually the first time:: + + $ sudo maas-import-isos + + +Next steps +========== + +Your MAAS is now ready for use. Visit the MAAS web interface in your browser +at `http://localhost/MAAS`_. + +.. _http://localhost/MAAS: http://localhost/MAAS + +Now, :doc:`let's prepare your Juju environment `. diff -Nru maas-0.1+bzr338+dfsg/Makefile maas-0.1+bzr363+dfsg/Makefile --- maas-0.1+bzr338+dfsg/Makefile 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/Makefile 2012-03-27 17:12:06.000000000 +0000 @@ -52,13 +52,14 @@ bin/test.maas bin/test.pserv -lint: sources = setup.py src templates utilities +lint: sources = contrib setup.py src templates twisted utilities lint: bin/flake8 - @bin/flake8 $(sources) + @find $(sources) -name '*.py' ! -path '*/migrations/*' \ + -print0 | xargs -r0 bin/flake8 check: clean test -docs/api.rst: bin/maas src/maasserver/api.py +docs/api.rst: bin/maas src/maasserver/api.py syncdb bin/maas generate_api_doc > $@ sampledata: bin/maas syncdb diff -Nru maas-0.1+bzr338+dfsg/src/maas/demo.py maas-0.1+bzr363+dfsg/src/maas/demo.py --- maas-0.1+bzr338+dfsg/src/maas/demo.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maas/demo.py 2012-03-27 17:12:06.000000000 +0000 @@ -44,6 +44,8 @@ MAAS_CLI = os.path.join(os.getcwd(), 'bin', 'maas') +RABBITMQ_PUBLISH = True + LOGGING = { 'version': 1, 'disable_existing_loggers': False, diff -Nru maas-0.1+bzr338+dfsg/src/maas/development.py maas-0.1+bzr363+dfsg/src/maas/development.py --- maas-0.1+bzr338+dfsg/src/maas/development.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maas/development.py 2012-03-27 17:12:06.000000000 +0000 @@ -44,6 +44,8 @@ YUI_DEBUG = DEBUG STATIC_LOCAL_SERVE = True +RABBITMQ_PUBLISH = False + DATABASES = { 'default': { # 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' etc. diff -Nru maas-0.1+bzr338+dfsg/src/maas/settings.py maas-0.1+bzr363+dfsg/src/maas/settings.py --- maas-0.1+bzr338+dfsg/src/maas/settings.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maas/settings.py 2012-03-27 17:12:06.000000000 +0000 @@ -207,6 +207,8 @@ MIDDLEWARE_CLASSES = ( 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', + 'maasserver.middleware.APIErrorsMiddleware', + 'metadataserver.middleware.MetadataErrorsMiddleware', 'django.middleware.transaction.TransactionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfResponseMiddleware', @@ -214,8 +216,6 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'maasserver.middleware.AccessMiddleware', - 'maasserver.middleware.APIErrorsMiddleware', - 'metadataserver.middleware.MetadataErrorsMiddleware', ) ROOT_URLCONF = 'maas.urls' diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/api.py maas-0.1+bzr363+dfsg/src/maasserver/api.py --- maas-0.1+bzr338+dfsg/src/maasserver/api.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/api.py 2012-03-27 17:12:06.000000000 +0000 @@ -56,7 +56,6 @@ MACAddress, Node, NODE_STATUS, - NODE_STATUS_CHOICES_DICT, ) from piston.doc import generate_doc from piston.handler import ( @@ -350,7 +349,7 @@ else: raise NodeStateViolation( "Node cannot be released in its current state ('%s')." - % NODE_STATUS_CHOICES_DICT.get(node.status, "UNKNOWN")) + % node.display_status()) return node @@ -380,6 +379,21 @@ return ('nodes_handler', []) +def extract_constraints(request_params): + """Extract a dict of node allocation constraints from http parameters. + + :param request_params: Parameters submitted with the allocation request. + :type request_params: :class:`django.http.QueryDict` + :return: A mapping of applicable constraint names to their values. + :rtype: :class:`dict` + """ + name = request_params.get('name', None) + if name is None: + return {} + else: + return {'name': name} + + @api_operations class NodesHandler(BaseHandler): """Manage collection of Nodes.""" @@ -414,15 +428,19 @@ assert key is not None, ( "Invalid Authorization header on request.") token = Token.objects.get(key=key) - nodes = Node.objects.get_allocated_visible_nodes(token) + match_ids = request.GET.getlist('id') + if match_ids == []: + match_ids = None + nodes = Node.objects.get_allocated_visible_nodes(token, match_ids) return nodes.order_by('id') @api_exported('acquire', 'POST') def acquire(self, request): """Acquire an available node for deployment.""" - node = Node.objects.get_available_node_for_acquisition(request.user) + node = Node.objects.get_available_node_for_acquisition( + request.user, constraints=extract_constraints(request.data)) if node is None: - raise NodesNotAvailable("No node is available.") + raise NodesNotAvailable("No matching node is available.") auth_header = request.META.get("HTTP_AUTHORIZATION") assert auth_header is not None, ( "HTTP_AUTHORIZATION not set on request") @@ -502,7 +520,13 @@ return ('node_mac_handler', [node_system_id, mac_address]) -def get_file(request): +def get_file(handler, request): + """Get a named file from the file storage. + + :param filename: The exact name of the file you want to get. + :type filename: string + :return: The file is returned in the response content. + """ filename = request.GET.get("filename", None) if not filename: raise MAASAPIBadRequest("Filename not supplied") @@ -515,18 +539,20 @@ @api_operations class AnonFilesHandler(AnonymousBaseHandler): - """Anonymous file operations.""" - allowed_methods = ('GET',) + """Anonymous file operations. - @api_exported('get', 'GET') - def get(self, request): - """Get a named file from the file storage. + This is needed for Juju. The story goes something like this: - :param filename: The exact name of the file you want to get. - :type filename: string - :return: The file is returned in the response content. - """ - return get_file(request) + - The Juju provider will upload a file using an "unguessable" name. + + - The name of this file (or its URL) will be shared with all the agents in + the environment. They cannot modify the file, but they can access it + without credentials. + + """ + allowed_methods = ('GET',) + + get = api_exported('get', 'GET')(get_file) @api_operations @@ -535,15 +561,7 @@ allowed_methods = ('GET', 'POST',) anonymous = AnonFilesHandler - @api_exported('get', 'GET') - def get(self, request): - """Get a named file from the file storage. - - :param filename: The exact name of the file you want to get. - :type filename: string - :return: The file is returned in the response content. - """ - return get_file(request) + get = api_exported('get', 'GET')(get_file) @api_exported('add', 'POST') def add(self, request): diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/exceptions.py maas-0.1+bzr363+dfsg/src/maasserver/exceptions.py --- maas-0.1+bzr338+dfsg/src/maasserver/exceptions.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/exceptions.py 2012-03-27 17:12:06.000000000 +0000 @@ -30,8 +30,8 @@ """User can't be deleted.""" -class MissingProfileException(MAASException): - """System profile does not exist.""" +class NoRabbit(MAASException): + """Could not reach RabbitMQ.""" class MAASAPIException(Exception): diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/fields.py maas-0.1+bzr363+dfsg/src/maasserver/fields.py --- maas-0.1+bzr338+dfsg/src/maasserver/fields.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/fields.py 2012-03-27 17:12:06.000000000 +0000 @@ -31,7 +31,7 @@ from south.modelsinspector import add_introspection_rules -mac_re = re.compile(r'^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$') +mac_re = re.compile(r'^\s*([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}\s*$') mac_error_msg = "Enter a valid MAC address (e.g. AA:BB:CC:DD:EE:FF)." @@ -46,9 +46,10 @@ # See http://south.aeracode.org/docs/customfields.html#extending-introspection # for details. add_introspection_rules( - [], - ["^maasserver\.fields\.MACAddressField", - "^maasserver\.fields\.JSONObjectField"]) + [], [ + "^maasserver\.fields\.MACAddressField", + "^maasserver\.fields\.JSONObjectField", + ]) class MACAddressFormField(RegexField): diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/messages.py maas-0.1+bzr363+dfsg/src/maasserver/messages.py --- maas-0.1+bzr338+dfsg/src/maasserver/messages.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/messages.py 2012-03-27 17:12:06.000000000 +0000 @@ -20,6 +20,7 @@ ABCMeta, abstractmethod, ) +from logging import getLogger from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder @@ -27,6 +28,7 @@ post_delete, post_save, ) +from maasserver.exceptions import NoRabbit from maasserver.models import Node from maasserver.rabbit import RabbitMessaging @@ -62,16 +64,26 @@ def create_msg(self, event_name, instance): """Format a message from the given event_name and instance.""" + def publish_message(self, message): + """Attempt to publish `message` on the producer. + + If RabbitMQ is not available, log an error message but return + normally. + """ + try: + self.producer.publish(message) + except NoRabbit as e: + getLogger('maasserver').warn("Could not reach RabbitMQ: %s", e) + def update_obj(self, sender, instance, created, **kwargs): event_name = ( MESSENGER_EVENT.CREATED if created else MESSENGER_EVENT.UPDATED) - message = self.create_msg(event_name, instance) - self.producer.publish(message) + self.publish_message(self.create_msg(event_name, instance)) def delete_obj(self, sender, instance, **kwargs): message = self.create_msg(MESSENGER_EVENT.DELETED, instance) - self.producer.publish(message) + self.publish_message(message) def register(self): post_save.connect( diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/middleware.py maas-0.1+bzr363+dfsg/src/maasserver/middleware.py --- maas-0.1+bzr338+dfsg/src/maasserver/middleware.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/middleware.py 2012-03-27 17:12:06.000000000 +0000 @@ -19,6 +19,7 @@ ABCMeta, abstractproperty, ) +import httplib import json import logging import re @@ -73,7 +74,6 @@ reverse('metadata'), # API calls are protected by piston. settings.API_URL_REGEXP, - r'^/accounts/[\w]+/sshkeys/$', ] self.public_urls = re.compile("|".join(public_url_roots)) self.login_url = reverse('login') @@ -142,9 +142,11 @@ unicode(''.join(exception.messages)).encode(encoding), mimetype=b"text/plain; charset=%s" % encoding) else: - # Do not handle the exception, this will result in a - # "Internal Server Error" response. - return None + # Return an API-readable "Internal Server Error" response. + return HttpResponse( + content=unicode(exception).encode(encoding), + status=httplib.INTERNAL_SERVER_ERROR, + mimetype=b"text/plain; charset=%s" % encoding) class APIErrorsMiddleware(ExceptionMiddleware): diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/migrations/0002_add_token_to_node.py maas-0.1+bzr363+dfsg/src/maasserver/migrations/0002_add_token_to_node.py --- maas-0.1+bzr338+dfsg/src/maasserver/migrations/0002_add_token_to_node.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/migrations/0002_add_token_to_node.py 2012-03-27 17:12:06.000000000 +0000 @@ -4,9 +4,11 @@ # encoding: utf-8 import datetime + +from django.db import models from south.db import db from south.v2 import SchemaMigration -from django.db import models + class Migration(SchemaMigration): diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/migrations/0002_macaddress_unique.py maas-0.1+bzr363+dfsg/src/maasserver/migrations/0002_macaddress_unique.py --- maas-0.1+bzr338+dfsg/src/maasserver/migrations/0002_macaddress_unique.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/migrations/0002_macaddress_unique.py 2012-03-27 17:12:06.000000000 +0000 @@ -1,8 +1,28 @@ +# Copyright 2012 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Maasserver migration 0002_macaddress_unique.""" + +from __future__ import ( + print_function, + # This breaks South. + #unicode_literals, + ) + +__metaclass__ = type +__all__ = [] + +# flake8: noqa +# SKIP this file when reformatting. +# The rest of this file was generated by South. + # encoding: utf-8 import datetime + +from django.db import models from south.db import db from south.v2 import SchemaMigration -from django.db import models + class Migration(SchemaMigration): diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/migrations/0003_rename_sshkeys.py maas-0.1+bzr363+dfsg/src/maasserver/migrations/0003_rename_sshkeys.py --- maas-0.1+bzr338+dfsg/src/maasserver/migrations/0003_rename_sshkeys.py 1970-01-01 00:00:00.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/migrations/0003_rename_sshkeys.py 2012-03-27 17:12:06.000000000 +0000 @@ -0,0 +1,149 @@ +# encoding: utf-8 +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Deleting model 'SSHKeys' + db.delete_table('maasserver_sshkeys') + + # Adding model 'SSHKey' + db.create_table('maasserver_sshkey', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('created', self.gf('django.db.models.fields.DateField')()), + ('updated', self.gf('django.db.models.fields.DateTimeField')()), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('key', self.gf('django.db.models.fields.TextField')()), + )) + db.send_create_signal('maasserver', ['SSHKey']) + + + def backwards(self, orm): + + # Adding model 'SSHKeys' + db.create_table('maasserver_sshkeys', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('key', self.gf('django.db.models.fields.TextField')()), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['maasserver.UserProfile'])), + )) + db.send_create_signal('maasserver', ['SSHKeys']) + + # Deleting model 'SSHKey' + db.delete_table('maasserver_sshkey') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'maasserver.config': { + 'Meta': {'object_name': 'Config'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'value': ('maasserver.fields.JSONObjectField', [], {'null': 'True'}) + }, + 'maasserver.filestorage': { + 'Meta': {'object_name': 'FileStorage'}, + 'data': ('django.db.models.fields.files.FileField', [], {'max_length': '255'}), + 'filename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'maasserver.macaddress': { + 'Meta': {'object_name': 'MACAddress'}, + 'created': ('django.db.models.fields.DateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'mac_address': ('maasserver.fields.MACAddressField', [], {'unique': 'True'}), + 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['maasserver.Node']"}), + 'updated': ('django.db.models.fields.DateTimeField', [], {}) + }, + 'maasserver.node': { + 'Meta': {'object_name': 'Node'}, + 'after_commissioning_action': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'architecture': ('django.db.models.fields.CharField', [], {'default': "u'i386'", 'max_length': '10'}), + 'created': ('django.db.models.fields.DateField', [], {}), + 'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'power_type': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '10', 'blank': 'True'}), + 'status': ('django.db.models.fields.IntegerField', [], {'default': '4', 'max_length': '10'}), + 'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-0af2184e-7549-11e1-b99b-00242bbb6876'", 'unique': 'True', 'max_length': '41'}), + 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'null': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {}) + }, + 'maasserver.sshkey': { + 'Meta': {'object_name': 'SSHKey'}, + 'created': ('django.db.models.fields.DateField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.TextField', [], {}), + 'updated': ('django.db.models.fields.DateTimeField', [], {}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'maasserver.userprofile': { + 'Meta': {'object_name': 'UserProfile'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'piston.consumer': { + 'Meta': {'object_name': 'Consumer'}, + 'description': ('django.db.models.fields.TextField', [], {}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '16'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'consumers'", 'null': 'True', 'to': "orm['auth.User']"}) + }, + 'piston.token': { + 'Meta': {'object_name': 'Token'}, + 'callback': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}), + 'callback_confirmed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Consumer']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}), + 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}), + 'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1332549237L'}), + 'token_type': ('django.db.models.fields.IntegerField', [], {}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': "orm['auth.User']"}), + 'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'}) + } + } + + complete_apps = ['maasserver'] diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/models.py maas-0.1+bzr363+dfsg/src/maasserver/models.py --- maas-0.1+bzr338+dfsg/src/maasserver/models.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/models.py 2012-03-27 17:12:06.000000000 +0000 @@ -18,10 +18,14 @@ "NODE_STATUS", "Node", "MACAddress", + "SSHKey", "UserProfile", ] -from collections import defaultdict +from collections import ( + defaultdict, + OrderedDict, + ) import copy import datetime from errno import ENOENT @@ -119,6 +123,8 @@ RETIRED = 7 +# Django choices for NODE_STATUS: sequence of tuples (key, UI +# representation). NODE_STATUS_CHOICES = ( (NODE_STATUS.DECLARED, "Declared"), (NODE_STATUS.COMMISSIONING, "Commissioning"), @@ -131,7 +137,7 @@ ) -NODE_STATUS_CHOICES_DICT = dict(NODE_STATUS_CHOICES) +NODE_STATUS_CHOICES_DICT = OrderedDict(NODE_STATUS_CHOICES) class NODE_AFTER_COMMISSIONING_ACTION: @@ -177,20 +183,16 @@ ) +def get_papi(): + """Return a provisioning server API proxy.""" + # Avoid circular imports. + from maasserver.provisioning import get_provisioning_api_proxy + return get_provisioning_api_proxy() + + class NodeManager(models.Manager): """A utility to manage the collection of Nodes.""" - # Twisted XMLRPC proxy for talking to the provisioning API. Created - # on demand. - provisioning_proxy = None - - def _set_provisioning_proxy(self): - """Set up the provisioning-API proxy if needed.""" - # Avoid circular imports. - from maasserver.provisioning import get_provisioning_api_proxy - if self.provisioning_proxy is None: - self.provisioning_proxy = get_provisioning_api_proxy() - def filter_by_ids(self, query, ids=None): """Filter `query` result set by system_id values. @@ -230,19 +232,25 @@ models.Q(owner__isnull=True) | models.Q(owner=user)) return self.filter_by_ids(visible_nodes, ids) - def get_allocated_visible_nodes(self, token): + def get_allocated_visible_nodes(self, token, ids): """Fetch Nodes that were allocated to the User_/oauth token. :param user: The user whose nodes to fetch :type user: User_ :param token: The OAuth token associated with the Nodes. :type token: piston.models.Token. + :param ids: Optional set of IDs to filter by. If given, nodes whose + system_ids are not in `ids` will be ignored. + :type param_ids: Sequence .. _User: https:// docs.djangoproject.com/en/dev/topics/auth/ #django.contrib.auth.models.User """ - nodes = self.filter(token=token) + if ids is None: + nodes = self.filter(token=token) + else: + nodes = self.filter(token=token, system_id__in=ids) return nodes def get_editable_nodes(self, user, ids=None): @@ -282,15 +290,26 @@ else: raise PermissionDenied - def get_available_node_for_acquisition(self, for_user): + def get_available_node_for_acquisition(self, for_user, constraints=None): """Find a `Node` to be acquired by the given user. :param for_user: The user who is to acquire the node. - :return: A `Node`, or None if none are available. + :type for_user: :class:`django.contrib.auth.models.User` + :param constraints: Optional selection constraints. If given, only + nodes matching these constraints are considered. + :type constraints: :class:`dict` + :return: A matching `Node`, or None if none are available. """ + if constraints is None: + constraints = {} available_nodes = ( self.get_visible_nodes(for_user) .filter(status=NODE_STATUS.READY)) + + if constraints.get('name'): + available_nodes = available_nodes.filter( + system_id=constraints['name']) + available_nodes = list(available_nodes[:1]) if len(available_nodes) == 0: return None @@ -310,9 +329,8 @@ :return: Those Nodes for which shutdown was actually requested. :rtype: list """ - self._set_provisioning_proxy() nodes = self.get_editable_nodes(by_user, ids=ids) - self.provisioning_proxy.stop_nodes([node.system_id for node in nodes]) + get_papi().stop_nodes([node.system_id for node in nodes]) return nodes def start_nodes(self, ids, by_user, user_data=None): @@ -333,13 +351,11 @@ :rtype: list """ from metadataserver.models import NodeUserData - self._set_provisioning_proxy() nodes = self.get_editable_nodes(by_user, ids=ids) if user_data is not None: for node in nodes: NodeUserData.objects.set_user_data(node, user_data) - self.provisioning_proxy.start_nodes( - [node.system_id for node in nodes]) + get_papi().start_nodes([node.system_id for node in nodes]) return nodes @@ -401,7 +417,20 @@ return self.system_id def display_status(self): - return NODE_STATUS_CHOICES_DICT[self.status] + """Return status text as displayed to the user. + + The UI representation is taken from NODE_STATUS_CHOICES_DICT and may + interpolate the variable "owner" to reflect the username of the node's + current owner, if any. + """ + status_text = NODE_STATUS_CHOICES_DICT[self.status] + if self.status == NODE_STATUS.ALLOCATED: + # The User is represented as its username in interpolation. + # Don't just say self.owner.username here, or there will be + # trouble with unowned nodes! + return "%s to %s" % (status_text, self.owner) + else: + return status_text def add_mac_address(self, mac_address): """Add a new MAC Address to this `Node`. @@ -646,15 +675,30 @@ User._meta.get_field('email')._unique = True -class SSHKeys(models.Model): - """A simple SSH public keystore that can be retrieved, a user - can have multiple keys. +class SSHKeyManager(models.Manager): + """A utility to manage the colletion of `SSHKey`s.""" + + def get_keys_for_user(self, user): + """Return the text of the ssh keys associated with a user.""" + return SSHKey.objects.filter(user=user).values_list('key', flat=True) + + +class SSHKey(CommonInfo): + """A `SSHKey` represents a user public SSH key. + + Users will be able to access `Node`s using any of their registered keys. :ivar user: The user which owns the key. :ivar key: The ssh public key. """ - user = models.ForeignKey(UserProfile) - key = models.TextField() + class Meta: + verbose_name_plural = "SSH keys" + + objects = SSHKeyManager() + + user = models.ForeignKey(User, null=False, editable=False) + + key = models.TextField(null=False, editable=True) def __unicode__(self): return self.key @@ -920,7 +964,7 @@ admin.site.register(FileStorage) admin.site.register(MACAddress) admin.site.register(Node) -admin.site.register(SSHKeys) +admin.site.register(SSHKey) class MAASAuthorizationBackend(ModelBackend): diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/provisioning.py maas-0.1+bzr363+dfsg/src/maasserver/provisioning.py --- maas-0.1+bzr338+dfsg/src/maasserver/provisioning.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/provisioning.py 2012-03-27 17:12:06.000000000 +0000 @@ -11,6 +11,7 @@ __metaclass__ = type __all__ = [ 'get_provisioning_api_proxy', + 'ProvisioningProxy', ] from logging import getLogger @@ -25,7 +26,7 @@ post_save, ) from django.dispatch import receiver -from maasserver.exceptions import MissingProfileException +from maasserver.exceptions import MAASAPIException from maasserver.models import ( Config, MACAddress, @@ -33,6 +34,117 @@ ) from provisioningserver.enum import PSERV_FAULT +# Presentation templates for various provisioning faults. +PRESENTATIONS = { + PSERV_FAULT.NO_COBBLER: """ + The provisioning server was unable to reach the Cobbler service: + %(fault_string)s + + Check pserv.log, and restart MaaS if needed. + """, + PSERV_FAULT.COBBLER_AUTH_FAILED: """ + The provisioning server failed to authenticate with the Cobbler + service: %(fault_string)s. + + This may mean that Cobbler's authentication configuration has + changed. Check /var/log/cobbler/ and pserv.log. + """, + PSERV_FAULT.COBBLER_AUTH_ERROR: """ + The Cobbler server no longer accepts the provisioning server's + authentication token. This should not happen; it may indicate + that the server is under unsustainable load. + """, + PSERV_FAULT.NO_SUCH_PROFILE: """ + System profile does not exist: %(fault_string)s. + + Has the maas-import-isos script been run? This will run + automatically from time to time, but if it is failing, an + administrator may need to run it manually. + """, + PSERV_FAULT.GENERIC_COBBLER_ERROR: """ + The provisioning service encountered a problem with the Cobbler + server, fault code %(fault_code)s: %(fault_string)s + + If the error message is not clear, you may need to check the + Cobbler logs in /var/log/cobbler/ or pserv.log. + """, + 8002: """ + Unable to reach provisioning server (%(fault_string)s). + + Check pserv.log and your PSERV_URL setting, and restart MaaS if + needed. + """, +} + + +def present_user_friendly_fault(fault): + """Return a more user-friendly exception to represent `fault`. + + :param fault: An exception raised by, or received across, xmlrpc. + :type fault: :class:`xmlrpclib.Fault` + :return: A more user-friendly exception, if one can be produced. + Otherwise, this returns None and the original exception should be + re-raised. (This is left to the caller in order to minimize + erosion of the backtrace). + :rtype: :class:`MAASAPIException`, or None. + """ + params = { + 'fault_code': fault.faultCode, + 'fault_string': fault.faultString, + } + user_friendly_text = PRESENTATIONS.get(fault.faultCode) + if user_friendly_text is None: + return None + else: + return MAASAPIException(dedent( + user_friendly_text.lstrip('\n') % params)) + + +class ProvisioningCaller: + """Wrapper for an XMLRPC call. + + Runs xmlrpc exceptions through `present_user_friendly_fault` for better + presentation to the user. + """ + + def __init__(self, method): + self.method = method + + def __call__(self, *args, **kwargs): + try: + return self.method(*args, **kwargs) + except xmlrpclib.Fault as e: + friendly_fault = present_user_friendly_fault(e) + if friendly_fault is None: + raise + else: + raise friendly_fault + + +class ProvisioningProxy: + """Proxy for calling the provisioning service. + + This wraps an XMLRPC :class:`ServerProxy`, but translates exceptions + coming in from, or across, the xmlrpc mechanism into more helpful ones + for the user. + """ + + def __init__(self, xmlrpc_proxy): + self.proxy = xmlrpc_proxy + + def patch(self, method, replacement): + setattr(self.proxy, method, replacement) + + def __getattr__(self, attribute_name): + """Return a wrapped version of the requested method.""" + attribute = getattr(self.proxy, attribute_name) + if getattr(attribute, '__call__', None) is None: + # This is a regular attribute. Return it as-is. + return attribute + else: + # This attribute is callable. Wrap it in a caller. + return ProvisioningCaller(attribute) + def get_provisioning_api_proxy(): """Return a proxy to the Provisioning API. @@ -44,7 +156,7 @@ if settings.USE_REAL_PSERV: # Use a real provisioning server. This requires PSERV_URL to be # set. - return xmlrpclib.ServerProxy( + xmlrpc_proxy = xmlrpclib.ServerProxy( settings.PSERV_URL, allow_none=True, use_datetime=True) else: # Create a fake. The code that provides the testing fake is not @@ -59,12 +171,19 @@ "USE_REAL_PSERV to False, on an installed MAAS. " "Don't do either.") raise - return get_fake_provisioning_api_proxy() + xmlrpc_proxy = get_fake_provisioning_api_proxy() + + return ProvisioningProxy(xmlrpc_proxy) def get_metadata_server_url(): """Return the URL where nodes can reach the metadata service.""" - return urljoin(Config.objects.get_config('maas_url'), "metadata/") + maas_url = Config.objects.get_config('maas_url') + if settings.FORCE_SCRIPT_NAME is None: + path = "metadata/" + else: + path = "%s/metadata/" % settings.FORCE_SCRIPT_NAME + return urljoin(maas_url, path) def compose_metadata(node): @@ -123,18 +242,7 @@ profile = select_profile_for_node(instance, papi) power_type = instance.get_effective_power_type() metadata = compose_metadata(instance) - try: - papi.add_node(instance.system_id, profile, power_type, metadata) - except xmlrpclib.Fault as e: - if e.faultCode == PSERV_FAULT.NO_SUCH_PROFILE: - raise MissingProfileException(dedent(""" - System profile %s does not exist. Has the maas-import-isos - script been run? This will run automatically from time to - time, but if it is failing, an administrator may need to run - it manually. - """ % profile).lstrip('\n')) - else: - raise + papi.add_node(instance.system_id, profile, power_type, metadata) def set_node_mac_addresses(node): diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/rabbit.py maas-0.1+bzr363+dfsg/src/maasserver/rabbit.py --- maas-0.1+bzr338+dfsg/src/maasserver/rabbit.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/rabbit.py 2012-03-27 17:12:06.000000000 +0000 @@ -17,20 +17,29 @@ ] +from errno import ECONNREFUSED +import socket import threading from amqplib import client_0_8 as amqp from django.conf import settings +from maasserver.exceptions import NoRabbit def connect(): """Connect to AMQP.""" - return amqp.Connection( - host=settings.RABBITMQ_HOST, - userid=settings.RABBITMQ_USERID, - password=settings.RABBITMQ_PASSWORD, - virtual_host=settings.RABBITMQ_VIRTUAL_HOST, - insist=False) + try: + return amqp.Connection( + host=settings.RABBITMQ_HOST, + userid=settings.RABBITMQ_USERID, + password=settings.RABBITMQ_PASSWORD, + virtual_host=settings.RABBITMQ_VIRTUAL_HOST, + insist=False) + except socket.error as e: + if e.errno == ECONNREFUSED: + raise NoRabbit(e.message) + else: + raise class RabbitSession(threading.local): diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/static/css/forms.css maas-0.1+bzr363+dfsg/src/maasserver/static/css/forms.css --- maas-0.1+bzr338+dfsg/src/maasserver/static/css/forms.css 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/static/css/forms.css 2012-03-27 17:12:06.000000000 +0000 @@ -12,7 +12,8 @@ text-align: right; } .form-errors, -form .errors { +form .errors, +form .field-error { color: #DE3E25; } label { diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/static/js/node_add.js maas-0.1+bzr363+dfsg/src/maasserver/static/js/node_add.js --- maas-0.1+bzr338+dfsg/src/maasserver/static/js/node_add.js 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/static/js/node_add.js 2012-03-27 17:12:06.000000000 +0000 @@ -87,6 +87,14 @@ this.get('srcNode').all('div.field-error').remove(); }, + /* Display validation errors on their respective fields. + * + * The "errors" argument is an object. If a field has validation errors, + * this object will map the field's name to a list of error strings. Each + * field's errors will be shown with the label for that field. + * + * @method displayFieldErrors + */ displayFieldErrors: function(errors) { this.cleanFormErrors(); var key; @@ -114,7 +122,7 @@ .set('name', 'op') .set('value', 'new'); var global_error = Y.Node.create('

') - .addClass('form-global-errors'); + .addClass('form-errors'); var addnodeform = Y.Node.create('

') .set('method', 'post') .append(global_error) @@ -145,8 +153,7 @@ error_node = error; } - this.get( - 'srcNode').one('.form-global-errors').empty().append(error_node); + this.get('srcNode').one('.form-errors').empty().append(error_node); }, /** @@ -227,15 +234,22 @@ self.hidePanel(); }, failure: function(id, out) { + Y.log("Adding a node failed. Response object follows.") Y.log(out); if (out.status === 400) { try { - // Validation error: display the errors in the - // form. - self.displayFieldErrors(JSON.parse(out.response)); + /* Validation error: display the errors in the + * form next to their respective fields. + */ + self.displayFieldErrors( + JSON.parse(out.responseText)); } catch (e) { - self.displayFormError("Unable to create Node."); + Y.log( + "Exception while decoding error JSON: " + + e.message); + self.displayFormError( + "Unable to create Node: " + out.responseText); } } else if (out.status === 401) { @@ -244,7 +258,8 @@ } else { // Unexpected error. - self.displayFormError("Unable to create Node."); + self.displayFormError( + "Unable to create Node: " + out.responseText); } }, end: Y.bind(self.hideSpinner, self) diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/static/js/testing/testing.js maas-0.1+bzr363+dfsg/src/maasserver/static/js/testing/testing.js --- maas-0.1+bzr338+dfsg/src/maasserver/static/js/testing/testing.js 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/static/js/testing/testing.js 2012-03-27 17:12:06.000000000 +0000 @@ -8,6 +8,29 @@ var module = Y.namespace('maas.testing'); + +/** + * Create a fake http response. + */ +function make_fake_response(response_text, status_code) { + var out = {}; + // status_code defaults to undefined, since it's not always set. + if (Y.Lang.isValue(status_code)) { + out.status = status_code; + } + out.responseText = response_text; + + /* We probably shouldn't rely on the response attribute: according to + * http://yuilibrary.com/yui/docs/io/#the-response-object it doesn't + * always have to be populated. We do get a guarantee for responseText + * or responseXML. + */ + out.response = response_text; + + return out; +} + + module.TestCase = Y.Base.create('ioMockableTestCase', Y.Test.Case, [], { _setUp: function() { @@ -92,14 +115,54 @@ return handle; }, - mockSuccess: function(response, module) { - var mockXhr = {}; + /** + * Set up mockIO to feign successful I/O completion. Returns an array + * where calls will be recorded. + * + * @method mockSuccess + * @param response_text The response text to fake. It will be available + * as request.responseText in the request passed to the success + * handler. + * @param module The module to be instrumented. + * @param status_code Optional HTTP status code. This defaults to + * undefined, since the attribute may not always be available. + */ + mockSuccess: function(response_text, module, status_code) { + var log = []; + var mockXhr = new Y.Base(); mockXhr.send = function(url, cfg) { - var out = {}; - out.response = response; - cfg.on.success('4', out); + log.push([url, cfg]); + var response = make_fake_response(response_text, status_code); + var arbitrary_txn_id = '4'; + cfg.on.success(arbitrary_txn_id, response); }; this.mockIO(mockXhr, module); + return log; + }, + + /** + * Set up mockIO to feign I/O failure. Returns an array + * where calls will be recorded. + * + * @method mockFailure + * @param response_text The response text to fake. It will be available + * as request.responseText in the request passed to the failure + * handler. + * @param module The module to be instrumented. + * @param status_code Optional HTTP status code. This defaults to + * undefined, since the attribute may not always be available. + */ + mockFailure: function(response_text, module, status_code) { + var log = []; + var mockXhr = new Y.Base(); + mockXhr.send = function(url, cfg) { + log.push([url, cfg]); + var response = make_fake_response(response_text, status_code); + var arbitrary_txn_id = '4'; + cfg.on.failure(arbitrary_txn_id, response); + }; + this.mockIO(mockXhr, module); + return log; } }); diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/static/js/tests/test_longpoll.js maas-0.1+bzr363+dfsg/src/maasserver/static/js/tests/test_longpoll.js --- maas-0.1+bzr338+dfsg/src/maasserver/static/js/tests/test_longpoll.js 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/static/js/tests/test_longpoll.js 2012-03-27 17:12:06.000000000 +0000 @@ -34,7 +34,7 @@ setUp: function() { var old_repoll = longpoll._repoll; longpoll._repoll = false; - this.addCleanup(function() {longpoll._repoll = old_repoll}); + this.addCleanup(function() {longpoll._repoll = old_repoll; }); }, tearDown: function() { @@ -63,14 +63,10 @@ Y.on(longpoll.longpoll_fail_event, function() { fired = true; }); - // Monkeypatch io to simulate failure. var manager = longpoll.getLongPollManager(); - var mockXhr = new Y.Base(); - mockXhr.send = function(uri, cfg) { - cfg.on.failure(); - }; - this.mockIO(mockXhr, longpoll); - var manager = longpoll.setupLongPollManager('key', '/longpoll/'); + // Simulate failure. + this.mockFailure('unused', longpoll); + longpoll.setupLongPollManager('key', '/longpoll/'); Y.Assert.isTrue(fired, "Failure event not fired."); }, @@ -113,14 +109,10 @@ shortdelay_event_fired = true; }); var manager = longpoll.getLongPollManager(); - // Monkeypatch io to simulate failure. - var mockXhr = new Y.Base(); - mockXhr.send = function(uri, cfg) { - cfg.on.failure(); - }; - this.mockIO(mockXhr, longpoll); + // Simulate failure. + this.mockFailure('unused', longpoll); Y.Assert.areEqual(0, manager._failed_attempts); - var manager = longpoll.setupLongPollManager('key', '/longpoll/'); + longpoll.setupLongPollManager('key', '/longpoll/'); Y.Assert.areEqual(1, manager._failed_attempts); var i, delay; for (i=0; i", module, 400); + submit_add_node(); + var error_message = find_global_errors().get('innerHTML'); + Y.Assert.areEqual(-1, error_message.search("")); + Y.Assert.areNotEqual(-1, error_message.search("<huh>")); } })); diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/static/js/tests/test_node_views.js maas-0.1+bzr363+dfsg/src/maasserver/static/js/tests/test_node_views.js --- maas-0.1+bzr338+dfsg/src/maasserver/static/js/tests/test_node_views.js 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/static/js/tests/test_node_views.js 2012-03-27 17:12:06.000000000 +0000 @@ -233,12 +233,16 @@ 1, view.chart.get('added_nodes'), 'The old chart status number should also be updated'); + /* XXX: Bug: 963090 This is timing dependant and causes spurious + failures from time to time. + this.wait(function() { Y.Assert.areEqual( Y.one('#nodes-number').get('text'), '12', 'The total number of nodes should not have been updated'); }, 500); + */ }, testUpdateNodeDeleting: function() { diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/static/js/tests/test_prefs.js maas-0.1+bzr363+dfsg/src/maasserver/static/js/tests/test_prefs.js --- maas-0.1+bzr338+dfsg/src/maasserver/static/js/tests/test_prefs.js 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/static/js/tests/test_prefs.js 2012-03-27 17:12:06.000000000 +0000 @@ -57,21 +57,16 @@ testDeleteTokenCall: function() { // A click on the delete link calls the API to delete a token. - var mockXhr = new Y.Base(); - var fired = false; - mockXhr.send = function(url, cfg) { - fired = true; - Y.Assert.areEqual(MAAS_config.uris.account_handler, url); - Y.Assert.areEqual( - "op=delete_authorisation_token&token_key=tokenkey1", - cfg.data); - }; - this.mockIO(mockXhr, module); + var log = this.logIO(module); var widget = this.createWidget(); widget.render(); var link = widget.get('srcNode').one('.delete-link'); link.simulate('click'); - Y.Assert.isTrue(fired); + var request_info = log.pop(); + Y.Assert.areEqual(MAAS_config.uris.account_handler, request_info[0]); + Y.Assert.areEqual( + "op=delete_authorisation_token&token_key=tokenkey1", + request_info[1].data); }, testDeleteTokenCallsAPI: function() { @@ -86,11 +81,7 @@ testDeleteTokenFail404DisplaysErrorMessage: function() { // If the API call to delete a token fails with a 404 error, // an error saying that the key has already been deleted is displayed. - var mockXhr = new Y.Base(); - mockXhr.send = function(url, cfg) { - cfg.on.failure(3, {status: 404}); - }; - this.mockIO(mockXhr, module); + this.mockFailure('unused', module, 404); var widget = this.createWidget(); widget.render(); var link = widget.get('srcNode').one('.delete-link'); @@ -102,11 +93,7 @@ testDeleteTokenFailDisplaysErrorMessage: function() { // If the API call to delete a token fails, an error is displayed. - var mockXhr = new Y.Base(); - mockXhr.send = function(url, cfg) { - cfg.on.failure(3, {status: 500}); - }; - this.mockIO(mockXhr, module); + this.mockFailure('unused', module, 500); var widget = this.createWidget(); widget.render(); var link = widget.get('srcNode').one('.delete-link'); @@ -119,19 +106,13 @@ testDeleteTokenDisplay: function() { // When the token is successfully deleted by the API, the // corresponding row is deleted. - var mockXhr = new Y.Base(); - var fired = false; - mockXhr.send = function(url, cfg) { - fired = true; - cfg.on.success(3); - }; - this.mockIO(mockXhr, module); + var log = this.mockSuccess('unused', module); var widget = this.createWidget(); widget.render(); var link = widget.get('srcNode').one('.delete-link'); Y.Assert.isNotNull(Y.one('#tokenkey1')); link.simulate('click'); - Y.Assert.isTrue(fired); + Y.Assert.areEqual(1, log.length); Y.Assert.isNull(Y.one('#tokenkey1')); Y.Assert.isNotNull(Y.one('#tokenkey2')); Y.Assert.areEqual(1, widget.get('nb_tokens')); @@ -159,37 +140,26 @@ testCreateTokenCall: function() { // A click on the "create a new token" link calls the API to // create a token. - var mockXhr = new Y.Base(); - var fired = false; - mockXhr.send = function(url, cfg) { - fired = true; - Y.Assert.areEqual(MAAS_config.uris.account_handler, url); - Y.Assert.areEqual( - "op=create_authorisation_token", - cfg.data); - }; - this.mockIO(mockXhr, module); + var log = this.logIO(module); var widget = this.createWidget(); widget.render(); var create_link = widget.get('srcNode').one('#create_token'); create_link.simulate('click'); - Y.Assert.isTrue(fired); + var request_infos = log.pop(); + Y.Assert.areEqual(MAAS_config.uris.account_handler, request_infos[0]); + Y.Assert.areEqual( + "op=create_authorisation_token", + request_infos[1].data); }, testCreateTokenFail: function() { // If the API call to create a token fails, an error is displayed. - var mockXhr = new Y.Base(); - var fired = false; - mockXhr.send = function(url, cfg) { - fired = true; - cfg.on.failure(3); - }; - this.mockIO(mockXhr, module); + var log = this.mockFailure('unused', module); var widget = this.createWidget(); widget.render(); var create_link = widget.get('srcNode').one('#create_token'); create_link.simulate('click'); - Y.Assert.isTrue(fired); + Y.Assert.areEqual(1, log.length); Y.Assert.areEqual( 'Unable to create a new token.', widget.get('srcNode').one('#create_error').get('text')); @@ -198,23 +168,17 @@ testCreateTokenDisplay: function() { // When a new token is successfully created by the API, a new // corresponding row is added. - var mockXhr = new Y.Base(); - var fired = false; - mockXhr.send = function(url, cfg) { - fired = true; - var response = { - consumer_key: 'consumer_key', - token_key: 'token_key', - token_secret: 'token_secret' - }; - cfg.on.success(3, {response: Y.JSON.stringify(response)}); + var response = { + consumer_key: 'consumer_key', + token_key: 'token_key', + token_secret: 'token_secret' }; - this.mockIO(mockXhr, module); + var log = this.mockSuccess(Y.JSON.stringify(response), module); var widget = this.createWidget(); widget.render(); var create_link = widget.get('srcNode').one('#create_token'); create_link.simulate('click'); - Y.Assert.isTrue(fired); + Y.Assert.areEqual(1, log.length); Y.Assert.areEqual(3, widget.get('nb_tokens')); Y.Assert.isNotNull(Y.one('#token_key')); } diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/static/js/tests/test_utils.js maas-0.1+bzr363+dfsg/src/maasserver/static/js/tests/test_utils.js --- maas-0.1+bzr338+dfsg/src/maasserver/static/js/tests/test_utils.js 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/static/js/tests/test_utils.js 2012-03-27 17:12:06.000000000 +0000 @@ -207,18 +207,13 @@ var widget = this.createWidget(); widget._editing = true; widget.set('title', "SampleTitle"); - var mockXhr = new Y.Base(); - var fired = false; - mockXhr.send = function(url, cfg) { - fired = true; - Y.Assert.areEqual(MAAS_config.uris.maas_handler, url); - Y.Assert.areEqual( - "op=set_config&name=maas_name&value=SampleTitle", - cfg.data); - }; - this.mockIO(mockXhr, module); + var log = this.logIO(module); widget.titleEditEnd(); - Y.Assert.isTrue(fired); + var req_args = log.pop(); + Y.Assert.areEqual(MAAS_config.uris.maas_handler, req_args[0]); + Y.Assert.areEqual( + 'op=set_config&name=maas_name&value=SampleTitle', + req_args[1].data); Y.Assert.isFalse(widget._editing); Y.Assert.areEqual( "SampleTitle MAAS", widget.get('title')); diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/testing/factory.py maas-0.1+bzr363+dfsg/src/maasserver/testing/factory.py --- maas-0.1+bzr338+dfsg/src/maasserver/testing/factory.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/testing/factory.py 2012-03-27 17:12:06.000000000 +0000 @@ -24,6 +24,7 @@ MACAddress, Node, NODE_STATUS, + SSHKey, ) from maasserver.testing.enum import map_enum import maastesting.factory @@ -82,6 +83,21 @@ return User.objects.create_user( username=username, password=password, email=email) + def make_user_with_keys(self, n_keys=2, **kwargs): + """Create a user with n `SSHKey`. + + Additional keyword arguments are passed to `make_user()`. + + Keys will have a comment of the form: -key- where i + is the key index. + """ + user = self.make_user(**kwargs) + for i in range(n_keys): + SSHKey( + user=user, + key='ssh-rsa KEY %s-key-%d' % (user.username, i)).save() + return user + def make_admin(self, username=None, password=None, email=None): admin = self.make_user( username=username, password=password, email=email) diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/testing/tests/test_module.py maas-0.1+bzr363+dfsg/src/maasserver/testing/tests/test_module.py --- maas-0.1+bzr338+dfsg/src/maasserver/testing/tests/test_module.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/testing/tests/test_module.py 2012-03-27 17:12:06.000000000 +0000 @@ -40,8 +40,9 @@ # TestCase.setUp() patches in a fake provisioning API so that we can # observe what the signal handlers are doing. papi_fake = provisioning.get_provisioning_api_proxy() + self.assertIsInstance(papi_fake, provisioning.ProvisioningProxy) self.assertIsInstance( - papi_fake, fakeapi.FakeSynchronousProvisioningAPI) + papi_fake.proxy, fakeapi.FakeSynchronousProvisioningAPI) # The fake has some limited, automatically generated, sample # data. This is required for many tests to run. First there is a # sample distro. diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/tests/test_api.py maas-0.1+bzr363+dfsg/src/maasserver/tests/test_api.py --- maas-0.1+bzr338+dfsg/src/maasserver/tests/test_api.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/tests/test_api.py 2012-03-27 17:12:06.000000000 +0000 @@ -15,11 +15,18 @@ import httplib import json import os +import random import shutil from django.conf import settings from django.db.models.signals import post_save -from maasserver.api import extract_oauth_key +from django.http import QueryDict +from fixtures import Fixture +from maasserver import api +from maasserver.api import ( + extract_constraints, + extract_oauth_key, + ) from maasserver.models import ( ARCHITECTURE_CHOICES, Config, @@ -40,6 +47,7 @@ LoggedInTestCase, TestCase, ) +from maastesting.testcase import TransactionTestCase from metadataserver.models import ( NodeKey, NodeUserData, @@ -70,6 +78,20 @@ def test_extract_oauth_key_returns_None_without_oauth_key(self): self.assertIs(None, extract_oauth_key('')) + def test_extract_constraints_ignores_unknown_parameters(self): + unknown_parameter = "%s=%s" % ( + factory.getRandomString(), + factory.getRandomString(), + ) + self.assertEqual( + {}, extract_constraints(QueryDict(unknown_parameter))) + + def test_extract_constraints_extracts_name(self): + name = factory.getRandomString() + self.assertEqual( + {'name': name}, + extract_constraints(QueryDict('name=%s' % name))) + class AnonymousEnlistmentAPITest(APIv10TestMixin, TestCase): # Nodes can be enlisted anonymously. @@ -212,8 +234,10 @@ mac = 'aa:bb:cc:dd:ee:ff' factory.make_mac_address(mac) architecture = factory.getRandomChoice(ARCHITECTURE_CHOICES) + def node_created(sender, instance, created, **kwargs): - self.assertFalse(True) + self.fail("post_save should not have been called") + post_save.connect(node_created, sender=Node) self.client.post( self.get_uri('nodes/'), @@ -296,8 +320,12 @@ self.assertEqual(httplib.FORBIDDEN, response.status_code) +class AnonAPITestCase(APIv10TestMixin, TestCase): + """Base class for anonymous API tests.""" + + class APITestCase(APIv10TestMixin, TestCase): - """Extension to `TestCase`: log in first. + """Base class for logged-in API tests. :ivar logged_in_user: A user who is currently logged in and can access the API. @@ -707,7 +735,7 @@ token=second_token) user_2 = factory.make_user() - user_2_token = create_auth_token(user_2) + create_auth_token(user_2) factory.make_node( owner=self.logged_in_user, status=NODE_STATUS.ALLOCATED, token=second_token) @@ -725,12 +753,32 @@ self.assertItemsEqual( [node_1.system_id], extract_system_ids(parsed_result)) + def test_GET_list_allocated_filters_by_id(self): + # list_allocated takes an optional list of 'id' parameters to + # filter returned results. + current_token = get_auth_tokens(self.logged_in_user)[0] + nodes = [] + for i in range(3): + nodes.append(factory.make_node( + status=NODE_STATUS.ALLOCATED, + owner=self.logged_in_user, token=current_token)) + + required_node_ids = [nodes[0].system_id, nodes[1].system_id] + response = self.client.get(self.get_uri('nodes/'), { + 'op': 'list_allocated', + 'id': required_node_ids, + }) + self.assertEqual(httplib.OK, response.status_code) + parsed_result = json.loads(response.content) + self.assertItemsEqual( + required_node_ids, extract_system_ids(parsed_result)) + def test_POST_acquire_returns_available_node(self): # The "acquire" operation returns an available node. available_status = NODE_STATUS.READY node = factory.make_node(status=available_status, owner=None) response = self.client.post(self.get_uri('nodes/'), {'op': 'acquire'}) - self.assertEqual(200, response.status_code) + self.assertEqual(httplib.OK, response.status_code) parsed_result = json.loads(response.content) self.assertEqual(node.system_id, parsed_result['system_id']) @@ -749,6 +797,90 @@ # Fails with Conflict error: resource can't satisfy request. self.assertEqual(httplib.CONFLICT, response.status_code) + def test_POST_ignores_already_allocated_node(self): + factory.make_node( + status=NODE_STATUS.ALLOCATED, owner=factory.make_user()) + response = self.client.post(self.get_uri('nodes/'), {'op': 'acquire'}) + self.assertEqual(httplib.CONFLICT, response.status_code) + + def test_POST_acquire_chooses_candidate_matching_constraint(self): + # If "acquire" is passed a constraint, it will go for a node + # matching that constraint even if there's tons of other nodes + # available. + # (Creating lots of nodes here to minimize the chances of this + # passing by accident). + available_nodes = [ + factory.make_node(status=NODE_STATUS.READY, owner=None) + for counter in range(20)] + desired_node = random.choice(available_nodes) + response = self.client.post(self.get_uri('nodes/'), { + 'op': 'acquire', + 'name': desired_node.system_id, + }) + self.assertEqual(httplib.OK, response.status_code) + parsed_result = json.loads(response.content) + self.assertEqual(desired_node.system_id, parsed_result['system_id']) + + def test_POST_acquire_would_rather_fail_than_disobey_constraint(self): + # If "acquire" is passed a constraint, it won't return a node + # that does not meet that constraint. Even if it means that it + # can't meet the request. + factory.make_node(status=NODE_STATUS.READY, owner=None) + desired_node = factory.make_node( + status=NODE_STATUS.ALLOCATED, owner=factory.make_user()) + response = self.client.post(self.get_uri('nodes/'), { + 'op': 'acquire', + 'name': desired_node.system_id, + }) + self.assertEqual(httplib.CONFLICT, response.status_code) + + def test_POST_acquire_ignores_unknown_constraint(self): + node = factory.make_node(status=NODE_STATUS.READY, owner=None) + response = self.client.post(self.get_uri('nodes/'), { + 'op': 'acquire', + factory.getRandomString(): factory.getRandomString(), + }) + self.assertEqual(httplib.OK, response.status_code) + parsed_result = json.loads(response.content) + self.assertEqual(node.system_id, parsed_result['system_id']) + + def test_POST_acquire_allocates_node_by_name(self): + # Positive test for name constraint. + # If a name constraint is given, "acquire" attempts to allocate + # a node of that name. + node = factory.make_node(status=NODE_STATUS.READY, owner=None) + system_id = node.system_id + response = self.client.post(self.get_uri('nodes/'), { + 'op': 'acquire', + 'name': system_id, + }) + self.assertEqual(httplib.OK, response.status_code) + self.assertEqual(system_id, json.loads(response.content)['system_id']) + + def test_POST_acquire_constrains_by_name(self): + # Negative test for name constraint. + # If a name constraint is given, "acquire" will only consider a + # node with that name. + factory.make_node(status=NODE_STATUS.READY, owner=None) + response = self.client.post(self.get_uri('nodes/'), { + 'op': 'acquire', + 'name': factory.getRandomString(), + }) + self.assertEqual(httplib.CONFLICT, response.status_code) + + def test_POST_acquire_treats_unknown_name_as_resource_conflict(self): + # A name constraint naming an unknown node produces a resource + # conflict: most likely the node existed but has changed or + # disappeared. + # Certainly it's not a 404, since the resource named in the URL + # is "nodes/," which does exist. + factory.make_node(status=NODE_STATUS.READY, owner=None) + response = self.client.post(self.get_uri('nodes/'), { + 'op': 'acquire', + 'name': factory.getRandomString(), + }) + self.assertEqual(httplib.CONFLICT, response.status_code) + def test_POST_acquire_sets_a_token(self): # "acquire" should set the Token being used in the request on # the Node that is allocated. @@ -887,7 +1019,7 @@ def test_macs_DELETE_not_found(self): # When deleting a MAC Address, the api returns a 'Not Found' (404) # error if no existing MAC Address is found. - node = factory.make_node() + node = factory.make_node() response = self.client.delete( self.get_uri('nodes/%s/macs/%s/') % ( node.system_id, '00-aa-22-cc-44-dd')) @@ -897,7 +1029,7 @@ def test_macs_DELETE_bad_request(self): # When deleting a MAC Address, the api returns a 'Bad Request' (400) # error if the provided MAC Address is not valid. - node = factory.make_node() + node = factory.make_node() response = self.client.delete( self.get_uri('nodes/%s/macs/%s/') % ( node.system_id, 'invalid-mac')) @@ -940,7 +1072,28 @@ self.assertEqual(httplib.BAD_REQUEST, response.status_code) -class FileStorageTestMixin: +class MediaRootFixture(Fixture): + """Create and clear-down a `settings.MEDIA_ROOT` directory. + + The directory must not previously exist. + """ + + def setUp(self): + super(MediaRootFixture, self).setUp() + self.path = settings.MEDIA_ROOT + if os.path.exists(self.path): + raise AssertionError("See media/README") + self.addCleanup(shutil.rmtree, self.path, ignore_errors=True) + os.mkdir(self.path) + + +class FileStorageAPITestMixin: + + def setUp(self): + super(FileStorageAPITestMixin, self).setUp() + media_root = self.useFixture(MediaRootFixture()).path + self.tmpdir = os.path.join(media_root, "testing") + os.mkdir(self.tmpdir) def make_file(self, name="foo", contents="test file contents"): """Make a temp file named `name` with contents `contents`. @@ -973,17 +1126,7 @@ return self.client.get(self.get_uri('files/'), params) -class AnonymousFileStorageAPITest(APIv10TestMixin, FileStorageTestMixin, - TestCase): - - def setUp(self): - super(AnonymousFileStorageAPITest, self).setUp() - media_root = settings.MEDIA_ROOT - self.assertFalse(os.path.exists(media_root), "See media/README") - self.addCleanup(shutil.rmtree, media_root, ignore_errors=True) - os.mkdir(media_root) - self.tmpdir = os.path.join(media_root, "testing") - os.mkdir(self.tmpdir) +class AnonymousFileStorageAPITest(FileStorageAPITestMixin, AnonAPITestCase): def test_get_works_anonymously(self): factory.make_file_storage(filename="foofilers", data=b"give me rope") @@ -993,16 +1136,7 @@ self.assertEqual(b"give me rope", response.content) -class FileStorageAPITest(APITestCase, FileStorageTestMixin): - - def setUp(self): - super(FileStorageAPITest, self).setUp() - media_root = settings.MEDIA_ROOT - self.assertFalse(os.path.exists(media_root), "See media/README") - self.addCleanup(shutil.rmtree, media_root, ignore_errors=True) - os.mkdir(media_root) - self.tmpdir = os.path.join(media_root, "testing") - os.mkdir(self.tmpdir) +class FileStorageAPITest(FileStorageAPITestMixin, APITestCase): def test_add_file_succeeds(self): filepath = self.make_file() @@ -1202,3 +1336,45 @@ self.assertEqual(httplib.OK, response.status_code) stored_value = Config.objects.get_config(name) self.assertEqual(stored_value, value) + + +class APIErrorsTest(APIv10TestMixin, TransactionTestCase): + + def test_internal_error_generate_proper_api_response(self): + error_message = factory.getRandomString() + + # Monkey patch api.create_node to have it raise a RuntimeError. + def raise_exception(request): + raise RuntimeError(error_message) + self.patch(api, 'create_node', raise_exception) + response = self.client.post(self.get_uri('nodes/'), {'op': 'new'}) + + self.assertEqual( + (httplib.INTERNAL_SERVER_ERROR, error_message), + (response.status_code, response.content)) + + def test_Node_post_save_error_rollbacks_transaction(self): + # If post_save raises an exception after a Node is added, the + # whole transaction is rolledback. + error_message = factory.getRandomString() + + def raise_exception(*args, **kwargs): + raise RuntimeError(error_message) + post_save.connect(raise_exception, sender=Node) + self.addCleanup(post_save.disconnect, raise_exception, sender=Node) + + architecture = factory.getRandomChoice(ARCHITECTURE_CHOICES) + hostname = factory.getRandomString() + response = self.client.post(self.get_uri('nodes/'), { + 'op': 'new', + 'hostname': hostname, + 'architecture': architecture, + 'after_commissioning_action': '2', + 'mac_addresses': ['aa:bb:cc:dd:ee:ff'], + }) + + self.assertEqual( + (httplib.INTERNAL_SERVER_ERROR, error_message), + (response.status_code, response.content)) + self.assertRaises( + Node.DoesNotExist, Node.objects.get, hostname=hostname) diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/tests/test_commands.py maas-0.1+bzr363+dfsg/src/maasserver/tests/test_commands.py --- maas-0.1+bzr338+dfsg/src/maasserver/tests/test_commands.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/tests/test_commands.py 2012-03-27 17:12:06.000000000 +0000 @@ -11,6 +11,7 @@ __metaclass__ = type __all__ = [] +from codecs import getwriter from io import BytesIO import os @@ -37,6 +38,15 @@ # The test is that we get here without errors. pass + def test_generate_api_doc(self): + out = BytesIO() + stdout = getwriter("UTF-8")(out) + call_command('generate_api_doc', stdout=stdout) + 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) + def test_createadmin_requires_username(self): stderr = BytesIO() self.assertRaises( diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/tests/test_fields.py maas-0.1+bzr363+dfsg/src/maasserver/tests/test_fields.py --- maas-0.1+bzr338+dfsg/src/maasserver/tests/test_fields.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/tests/test_fields.py 2012-03-27 17:12:06.000000000 +0000 @@ -25,7 +25,7 @@ class TestMACAddressField(TestCase): def test_mac_address_is_stored_normalized_and_loaded(self): - stored_mac = factory.make_mac_address('AA-bb-CC-dd-EE-Ff') + stored_mac = factory.make_mac_address(' AA-bb-CC-dd-EE-Ff ') stored_mac.save() loaded_mac = MACAddress.objects.get(id=stored_mac.id) self.assertEqual('aa:bb:cc:dd:ee:ff', loaded_mac.mac_address) @@ -45,6 +45,11 @@ # No error. pass + def test_accepts_leading_and_trailing_whitespace(self): + validate_mac(' AA:BB:CC:DD:EE:FF ') + # No error. + pass + def test_rejects_short_mac(self): self.assertRaises(ValidationError, validate_mac, '00:11:22:33:44') diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/tests/test_messages.py maas-0.1+bzr363+dfsg/src/maasserver/tests/test_messages.py --- maas-0.1+bzr338+dfsg/src/maasserver/tests/test_messages.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/tests/test_messages.py 2012-03-27 17:12:06.000000000 +0000 @@ -12,7 +12,9 @@ __all__ = [] import json +import socket +from maasserver.exceptions import NoRabbit from maasserver.messages import ( MAASMessenger, MESSENGER_EVENT, @@ -97,6 +99,41 @@ self.assertEqual( [[MESSENGER_EVENT.DELETED, obj]], producer.messages) + def test_publish_message_publishes_message(self): + event = factory.getRandomString() + instance = {factory.getRandomString(): factory.getRandomString()} + messenger = TestMessenger(MessagesTestModel, FakeProducer()) + messenger.publish_message(messenger.create_msg(event, instance)) + self.assertEqual([[event, instance]], messenger.producer.messages) + + def test_publish_message_swallows_missing_rabbit(self): + event = factory.getRandomString() + instance = {factory.getRandomString(): factory.getRandomString()} + + def fail_for_lack_of_rabbit(*args, **kwargs): + raise NoRabbit("I'm pretending not to have a RabbitMQ.") + + messenger = TestMessenger(MessagesTestModel, FakeProducer()) + messenger.producer.publish = fail_for_lack_of_rabbit + + messenger.publish_message(messenger.create_msg(event, instance)) + self.assertEqual([], messenger.producer.messages) + + def test_publish_message_propagates_exceptions(self): + event = factory.getRandomString() + instance = {factory.getRandomString(): factory.getRandomString()} + + def fail_despite_having_a_rabbit(*args, **kwargs): + raise socket.error("I have a rabbit but I fail anyway.") + + messenger = TestMessenger(MessagesTestModel, FakeProducer()) + messenger.producer.publish = fail_despite_having_a_rabbit + + self.assertRaises( + socket.error, + messenger.publish_message, messenger.create_msg(event, instance)) + self.assertEqual([], messenger.producer.messages) + class MAASMessengerTest(TestModelTestCase): diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/tests/test_middleware.py maas-0.1+bzr363+dfsg/src/maasserver/tests/test_middleware.py --- maas-0.1+bzr338+dfsg/src/maasserver/tests/test_middleware.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/tests/test_middleware.py 2012-03-27 17:12:06.000000000 +0000 @@ -72,20 +72,24 @@ exception = MAASAPINotFound("Huh?") self.assertIsNone(middleware.process_exception(request, exception)) - def test_ignores_unknown_exception(self): - # An unknown exception is not processed by the middleware - # (returns None). - self.assertIsNone( - self.process_exception(ValueError("Error occurred!"))) + def test_unknown_exception_generates_internal_server_error(self): + # An unknown exception generates an internal server error with the + # exception message. + error_message = factory.getRandomString() + response = self.process_exception(RuntimeError(error_message)) + self.assertEqual( + (httplib.INTERNAL_SERVER_ERROR, error_message), + (response.status_code, response.content)) def test_reports_MAASAPIException_with_appropriate_api_error(self): class MyException(MAASAPIException): api_error = httplib.UNAUTHORIZED - exception = MyException("Error occurred!") + error_message = factory.getRandomString() + exception = MyException(error_message) response = self.process_exception(exception) self.assertEqual( - (httplib.UNAUTHORIZED, "Error occurred!"), + (httplib.UNAUTHORIZED, error_message), (response.status_code, response.content)) def test_renders_MAASAPIException_as_unicode(self): @@ -99,13 +103,14 @@ (response.status_code, response.content.decode('utf-8'))) def test_reports_ValidationError_as_Bad_Request(self): - response = self.process_exception(ValidationError("Validation Error")) + error_message = factory.getRandomString() + response = self.process_exception(ValidationError(error_message)) self.assertEqual( - (httplib.BAD_REQUEST, "Validation Error"), + (httplib.BAD_REQUEST, error_message), (response.status_code, response.content)) def test_returns_ValidationError_message_dict_as_json(self): - exception = ValidationError("Error") + exception = ValidationError(factory.getRandomString()) exception_dict = {'hostname': 'invalid'} setattr(exception, 'message_dict', exception_dict) response = self.process_exception(exception) @@ -118,16 +123,17 @@ def test_handles_error_on_API(self): middleware = APIErrorsMiddleware() non_api_request = fake_request("/api/1.0/hello") - exception = MAASAPINotFound("Have you looked under the couch?") + error_message = factory.getRandomString() + exception = MAASAPINotFound(error_message) response = middleware.process_exception(non_api_request, exception) self.assertEqual( - (httplib.NOT_FOUND, "Have you looked under the couch?"), + (httplib.NOT_FOUND, error_message), (response.status_code, response.content)) def test_ignores_error_outside_API(self): middleware = APIErrorsMiddleware() non_api_request = fake_request("/middleware/api/hello") - exception = MAASAPINotFound("Have you looked under the couch?") + exception = MAASAPINotFound(factory.getRandomString()) self.assertIsNone( middleware.process_exception(non_api_request, exception)) diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/tests/test_models.py maas-0.1+bzr363+dfsg/src/maasserver/tests/test_models.py --- maas-0.1+bzr338+dfsg/src/maasserver/tests/test_models.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/tests/test_models.py 2012-03-27 17:12:06.000000000 +0000 @@ -37,9 +37,11 @@ Node, NODE_STATUS, NODE_STATUS_CHOICES_DICT, + SSHKey, SYSTEM_USERS, UserProfile, ) +from maasserver.provisioning import get_provisioning_api_proxy from maasserver.testing.enum import map_enum from maasserver.testing.factory import factory from maasserver.testing.testcase import TestCase @@ -68,10 +70,17 @@ self.assertEqual(len(node.system_id), 41) self.assertTrue(node.system_id.startswith('node-')) - def test_display_status(self): + def test_display_status_shows_default_status(self): node = factory.make_node() self.assertEqual( - NODE_STATUS_CHOICES_DICT[NODE_STATUS.DEFAULT_STATUS], + NODE_STATUS_CHOICES_DICT[node.status], + node.display_status()) + + def test_display_status_for_allocated_node_shows_owner(self): + node = factory.make_node( + owner=factory.make_user(), status=NODE_STATUS.ALLOCATED) + self.assertEqual( + "Allocated to %s" % node.owner.username, node.display_status()) def test_add_node_with_token(self): @@ -284,15 +293,37 @@ self.assertEqual( None, Node.objects.get_available_node_for_acquisition(user)) + def test_get_available_node_combines_constraint_with_availability(self): + user = factory.make_user() + node = self.make_node(factory.make_user()) + self.assertEqual( + None, + Node.objects.get_available_node_for_acquisition( + user, {'name': node.system_id})) + + def test_get_available_node_constrains_by_name(self): + user = factory.make_user() + nodes = [self.make_node() for counter in range(3)] + self.assertEqual( + nodes[1], + Node.objects.get_available_node_for_acquisition( + user, {'name': nodes[1].system_id})) + + def test_get_available_node_returns_None_if_name_is_unknown(self): + user = factory.make_user() + self.assertEqual( + None, + Node.objects.get_available_node_for_acquisition( + user, {'name': factory.getRandomString()})) + def test_stop_nodes_stops_nodes(self): user = factory.make_user() node = self.make_node(user) output = Node.objects.stop_nodes([node.system_id], user) self.assertItemsEqual([node], output) - self.assertEqual( - 'stop', - Node.objects.provisioning_proxy.power_status[node.system_id]) + power_status = get_provisioning_api_proxy().power_status + self.assertEqual('stop', power_status[node.system_id]) def test_stop_nodes_ignores_uneditable_nodes(self): nodes = [self.make_node(factory.make_user()) for counter in range(3)] @@ -308,9 +339,8 @@ output = Node.objects.start_nodes([node.system_id], user) self.assertItemsEqual([node], output) - self.assertEqual( - 'start', - Node.objects.provisioning_proxy.power_status[node.system_id]) + power_status = get_provisioning_api_proxy().power_status + self.assertEqual('start', power_status[node.system_id]) def test_start_nodes_ignores_uneditable_nodes(self): nodes = [self.make_node(factory.make_user()) for counter in range(3)] @@ -490,6 +520,26 @@ self.assertTrue(set(SYSTEM_USERS).isdisjoint(usernames)) +class SSHKeyManagerTest(TestCase): + """Testing for the :class `SSHKeyManager` model manager.""" + + def test_get_keys_for_user_no_keys(self): + user = factory.make_user() + keys = SSHKey.objects.get_keys_for_user(user) + self.assertItemsEqual([], keys) + + def test_get_keys_for_user_with_keys(self): + user1 = factory.make_user_with_keys(n_keys=3, username='user1') + # user2 + factory.make_user_with_keys(n_keys=2) + keys = SSHKey.objects.get_keys_for_user(user1) + self.assertItemsEqual([ + 'ssh-rsa KEY user1-key-0', + 'ssh-rsa KEY user1-key-1', + 'ssh-rsa KEY user1-key-2', + ], keys) + + class FileStorageTest(TestCase): """Testing of the :class:`FileStorage` model.""" diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/tests/test_provisioning.py maas-0.1+bzr363+dfsg/src/maasserver/tests/test_provisioning.py --- maas-0.1+bzr338+dfsg/src/maasserver/tests/test_provisioning.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/tests/test_provisioning.py 2012-03-27 17:12:06.000000000 +0000 @@ -14,8 +14,9 @@ from urlparse import parse_qs from xmlrpclib import Fault +from django.conf import settings from maasserver import provisioning -from maasserver.exceptions import MissingProfileException +from maasserver.exceptions import MAASAPIException from maasserver.models import ( ARCHITECTURE, Config, @@ -27,6 +28,8 @@ compose_metadata, get_metadata_server_url, name_arch_in_cobbler_style, + present_user_friendly_fault, + PRESENTATIONS, select_profile_for_node, ) from maasserver.testing.enum import map_enum @@ -125,22 +128,19 @@ def raise_missing_profile(*args, **kwargs): raise Fault(PSERV_FAULT.NO_SUCH_PROFILE, "Unknown profile.") - self.papi.add_node = raise_missing_profile - expectation = ExpectedException( - MissingProfileException, value_re='.*maas-import-isos.*') - with expectation: + self.papi.patch('add_node', raise_missing_profile) + with ExpectedException(MAASAPIException): node = factory.make_node(architecture='amd32k') provisioning.provision_post_save_Node( sender=Node, instance=node, created=True) def test_provision_post_save_Node_returns_other_pserv_faults(self): - error_text = factory.getRandomString() def raise_fault(*args, **kwargs): - raise Fault(PSERV_FAULT.NO_COBBLER, error_text) + raise Fault(PSERV_FAULT.NO_COBBLER, factory.getRandomString()) - self.papi.add_node = raise_fault - with ExpectedException(Fault, ".*%s.*" % error_text): + self.papi.patch('add_node', raise_fault) + with ExpectedException(MAASAPIException): node = factory.make_node(architecture='amd32k') provisioning.provision_post_save_Node( sender=Node, instance=node, created=True) @@ -218,6 +218,13 @@ % Config.objects.get_config('maas_url').rstrip('/'), get_metadata_server_url()) + def test_metadata_server_url_includes_script_name(self): + self.patch(settings, "FORCE_SCRIPT_NAME", "/MAAS") + self.assertEqual( + "%s/MAAS/metadata/" + % Config.objects.get_config('maas_url').rstrip('/'), + get_metadata_server_url()) + def test_compose_metadata_includes_metadata_url(self): node = factory.make_node() self.assertEqual( @@ -235,6 +242,85 @@ }, parse_qs(metadata['maas-metadata-credentials'])) + def test_papi_xmlrpc_faults_are_reported_helpfully(self): + + def raise_fault(*args, **kwargs): + raise Fault(8002, factory.getRandomString()) + + self.papi.patch('add_node', raise_fault) + + with ExpectedException(MAASAPIException, ".*provisioning server.*"): + self.papi.add_node('node', 'profile', 'power', {}) + + def test_provisioning_errors_are_reported_helpfully(self): + + def raise_provisioning_error(*args, **kwargs): + raise Fault(PSERV_FAULT.NO_COBBLER, factory.getRandomString()) + + self.papi.patch('add_node', raise_provisioning_error) + + with ExpectedException(MAASAPIException, ".*Cobbler.*"): + self.papi.add_node('node', 'profile', 'power', {}) + + def test_present_user_friendly_fault_describes_pserv_fault(self): + self.assertIn( + "provisioning server", + present_user_friendly_fault(Fault(8002, 'error')).message) + + def test_present_user_friendly_fault_covers_all_pserv_faults(self): + all_pserv_faults = set(map_enum(PSERV_FAULT).values()) + presentable_pserv_faults = set(PRESENTATIONS.keys()) + self.assertItemsEqual([], all_pserv_faults - presentable_pserv_faults) + + def test_present_user_friendly_fault_rerepresents_all_pserv_faults(self): + fault_string = factory.getRandomString() + for fault_code in map_enum(PSERV_FAULT).values(): + original_fault = Fault(fault_code, fault_string) + new_fault = present_user_friendly_fault(original_fault) + self.assertNotEqual(fault_string, new_fault.message) + + def test_present_user_friendly_fault_describes_cobbler_fault(self): + friendly_fault = present_user_friendly_fault( + Fault(PSERV_FAULT.NO_COBBLER, factory.getRandomString())) + friendly_text = friendly_fault.message + self.assertIn("unable to reach", friendly_text) + self.assertIn("Cobbler", friendly_text) + + def test_present_user_friendly_fault_describes_cobbler_auth_fail(self): + friendly_fault = present_user_friendly_fault( + Fault(PSERV_FAULT.COBBLER_AUTH_FAILED, factory.getRandomString())) + friendly_text = friendly_fault.message + self.assertIn("failed to authenticate", friendly_text) + self.assertIn("Cobbler", friendly_text) + + def test_present_user_friendly_fault_describes_cobbler_auth_error(self): + friendly_fault = present_user_friendly_fault( + Fault(PSERV_FAULT.COBBLER_AUTH_ERROR, factory.getRandomString())) + friendly_text = friendly_fault.message + self.assertIn("authentication token", friendly_text) + self.assertIn("Cobbler", friendly_text) + + def test_present_user_friendly_fault_describes_missing_profile(self): + profile = factory.getRandomString() + friendly_fault = present_user_friendly_fault( + Fault( + PSERV_FAULT.NO_SUCH_PROFILE, + "invalid profile name: %s" % profile)) + friendly_text = friendly_fault.message + self.assertIn(profile, friendly_text) + self.assertIn("maas-import-isos", friendly_text) + + def test_present_user_friendly_fault_describes_generic_cobbler_fail(self): + error_text = factory.getRandomString() + friendly_fault = present_user_friendly_fault( + Fault(PSERV_FAULT.GENERIC_COBBLER_ERROR, error_text)) + friendly_text = friendly_fault.message + self.assertIn("Cobbler", friendly_text) + self.assertIn(error_text, friendly_text) + + def test_present_user_friendly_fault_returns_None_for_other_fault(self): + self.assertIsNone(present_user_friendly_fault(Fault(9999, "!!!"))) + class TestProvisioningWithFake(ProvisioningTests, TestCase): """Tests for the Provisioning API using a fake.""" diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/tests/test_rabbit.py maas-0.1+bzr363+dfsg/src/maasserver/tests/test_rabbit.py --- maas-0.1+bzr338+dfsg/src/maasserver/tests/test_rabbit.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/tests/test_rabbit.py 2012-03-27 17:12:06.000000000 +0000 @@ -12,10 +12,12 @@ __all__ = [] +import socket import time from amqplib import client_0_8 as amqp -from fixtures import MonkeyPatch +from django.conf import settings +from maasserver.exceptions import NoRabbit from maasserver.rabbit import ( RabbitBase, RabbitExchange, @@ -24,28 +26,26 @@ RabbitSession, ) from maasserver.testing.factory import factory +from maastesting.rabbit import ( + get_rabbit, + uses_rabbit_fixture, + ) from maastesting.testcase import TestCase -from rabbitfixture.server import RabbitServer - - -class RabbitTestCase(TestCase): +from testtools.testcase import ExpectedException - def setUp(self): - super(RabbitTestCase, self).setUp() - self.rabbit_server = self.useFixture(RabbitServer()) - self.rabbit_env = self.rabbit_server.runner.environment - patch = MonkeyPatch( - "maasserver.rabbit.connect", self.rabbit_env.get_connection) - self.useFixture(patch) - def get_command_output(self, command): - # Returns the output of the given rabbit command. - return self.rabbit_env.rabbitctl(str(command))[0] +def run_rabbit_command(command): + """Run a Rabbit command through rabbitctl, and return its output.""" + if isinstance(command, unicode): + command = command.encode('ascii') + rabbit_env = get_rabbit().runner.environment + return rabbit_env.rabbitctl(command)[0] -class TestRabbitSession(RabbitTestCase): +class TestRabbitSession(TestCase): - def test_session_connection(self): + @uses_rabbit_fixture + def test_connection_gets_connection(self): session = RabbitSession() # Referencing the connection property causes a connection to be # created. @@ -54,14 +54,33 @@ # The same connection is returned every time. self.assertIs(connection, session.connection) - def test_session_disconnect(self): + def test_connection_raises_NoRabbit_if_cannot_connect(self): + # Attempt to connect to a RabbitMQ on the local "discard" + # service. The connection will be refused. + self.patch(settings, 'RABBITMQ_HOST', 'localhost:9') + session = RabbitSession() + with ExpectedException(NoRabbit): + session.connection + + def test_connection_propagates_exceptions(self): + + def fail(*args, **kwargs): + raise socket.error("Connection not refused, but failed anyway.") + + self.patch(amqp, 'Connection', fail) + session = RabbitSession() + with ExpectedException(socket.error): + session.connection + + def test_disconnect(self): session = RabbitSession() session.disconnect() self.assertIsNone(session._connection) -class TestRabbitMessaging(RabbitTestCase): +class TestRabbitMessaging(TestCase): + @uses_rabbit_fixture def test_messaging_getExchange(self): exchange_name = factory.getRandomString() messaging = RabbitMessaging(exchange_name) @@ -70,6 +89,7 @@ self.assertEqual(messaging._session, exchange._session) self.assertEqual(exchange_name, exchange.exchange_name) + @uses_rabbit_fixture def test_messaging_getQueue(self): exchange_name = factory.getRandomString() messaging = RabbitMessaging(exchange_name) @@ -79,7 +99,7 @@ self.assertEqual(exchange_name, queue.exchange_name) -class TestRabbitBase(RabbitTestCase): +class TestRabbitBase(TestCase): def test_rabbitbase_contains_session(self): exchange_name = factory.getRandomString() @@ -91,6 +111,7 @@ rabbitbase = RabbitBase(RabbitSession(), exchange_name) self.assertEqual(exchange_name, rabbitbase.exchange_name) + @uses_rabbit_fixture def test_base_channel(self): rabbitbase = RabbitBase(RabbitSession(), factory.getRandomString()) # Referencing the channel property causes an open channel to be @@ -101,16 +122,15 @@ # The same channel is returned every time. self.assertIs(channel, rabbitbase.channel) + @uses_rabbit_fixture def test_base_channel_creates_exchange(self): exchange_name = factory.getRandomString() rabbitbase = RabbitBase(RabbitSession(), exchange_name) rabbitbase.channel - self.assertIn( - exchange_name, - self.get_command_output('list_exchanges')) + self.assertIn(exchange_name, run_rabbit_command('list_exchanges')) -class TestRabbitExchange(RabbitTestCase): +class TestRabbitExchange(TestCase): def basic_get(self, channel, queue_name, timeout): endtime = time.time() + timeout @@ -123,6 +143,7 @@ else: return message + @uses_rabbit_fixture def test_exchange_publish(self): exchange_name = factory.getRandomString() message_content = factory.getRandomString() @@ -136,8 +157,9 @@ self.assertEqual(message_content, message.body) -class TestRabbitQueue(RabbitTestCase): +class TestRabbitQueue(TestCase): + @uses_rabbit_fixture def test_rabbit_queue_binds_queue(self): exchange_name = factory.getRandomString() message_content = factory.getRandomString() diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/tests/test_views.py maas-0.1+bzr363+dfsg/src/maasserver/tests/test_views.py --- maas-0.1+bzr338+dfsg/src/maasserver/tests/test_views.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/tests/test_views.py 2012-03-27 17:12:06.000000000 +0000 @@ -22,13 +22,15 @@ from django.contrib.auth.models import User from django.core.urlresolvers import reverse from lxml.html import fromstring -from maasserver import views -from maasserver.messages import get_messaging +from maasserver import ( + messages, + views, + ) +from maasserver.exceptions import NoRabbit from maasserver.models import ( Config, NODE_AFTER_COMMISSIONING_ACTION, POWER_TYPE_CHOICES, - SSHKeys, UserProfile, ) from maasserver.testing import reload_object @@ -251,12 +253,23 @@ def test_get_longpoll_context_empty_if_rabbitmq_publish_is_none(self): self.patch(settings, 'RABBITMQ_PUBLISH', None) - self.patch(views, 'messaging', get_messaging()) + self.patch(views, 'messaging', messages.get_messaging()) + self.assertEqual({}, get_longpoll_context()) + + def test_get_longpoll_context_returns_empty_if_rabbit_not_running(self): + + class FakeMessaging: + """Fake :class:`RabbitMessaging`: fail with `NoRabbit`.""" + + def getQueue(self, *args, **kwargs): + raise NoRabbit("Pretending not to have a rabbit.") + + self.patch(messages, 'messaging', FakeMessaging()) self.assertEqual({}, get_longpoll_context()) def test_get_longpoll_context_empty_if_longpoll_url_is_None(self): self.patch(settings, 'LONGPOLL_PATH', None) - self.patch(views, 'messaging', get_messaging()) + self.patch(views, 'messaging', messages.get_messaging()) self.assertEqual({}, get_longpoll_context()) @uses_rabbit_fixture @@ -264,7 +277,7 @@ longpoll = factory.getRandomString() self.patch(settings, 'LONGPOLL_PATH', longpoll) self.patch(settings, 'RABBITMQ_PUBLISH', True) - self.patch(views, 'messaging', get_messaging()) + self.patch(views, 'messaging', messages.get_messaging()) context = get_longpoll_context() self.assertItemsEqual( ['LONGPOLL_PATH', 'longpoll_queue'], list(context)) @@ -667,26 +680,3 @@ content_text = doc.cssselect('#content')[0].text_content() self.assertIn(user.username, content_text) self.assertIn(user.email, content_text) - - -class SSHKeyServerTest(TestCase): - - def setUp(self): - super(SSHKeyServerTest, self).setUp() - self.user = factory.make_user() - self.sshkey = SSHKeys.objects.create( - user=self.user.get_profile(), - key=("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAYQDmQLTto0BUB2+Ayj9rwuE", - "iwd/IyY9YU7qUzqgJBqRp+3FDhZYQqI6aG9sLmPccP+gka1Ia5wlJODpXeu", - "cQVqPsKW9Moj/XP1spIuYh6ZrhHElyPB7aPjqoTtpX1+lx6mJU=", - "maas@example") - ) - - def test_get_user_sshkey(self): - response = self.client.get('/accounts/%s/sshkeys/' % self.user) - self.assertIn(str(self.sshkey.key), response.content) - - def test_get_null_sshkey(self): - response = self.client.get('/accounts/nulluser/sshkeys/') - self.assertEqual(response.status_code, 200) - self.assertEqual('\n'.encode('utf-8'), response.content) diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/urls.py maas-0.1+bzr363+dfsg/src/maasserver/urls.py --- maas-0.1+bzr338+dfsg/src/maasserver/urls.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/urls.py 2012-03-27 17:12:06.000000000 +0000 @@ -32,11 +32,10 @@ AccountsEdit, AccountsView, combo_view, - KeystoreView, login, logout, - NodeListView, NodeEdit, + NodeListView, NodesCreateView, NodeView, proxy_to_longpoll, @@ -64,7 +63,6 @@ url( r'^favicon\.ico$', redirect_to, {'url': '/static/img/favicon.ico'}, name='favicon'), - url(r'^accounts/(?P\w+)/sshkeys/$', KeystoreView), ) # URLs for logged-in users. diff -Nru maas-0.1+bzr338+dfsg/src/maasserver/views.py maas-0.1+bzr363+dfsg/src/maasserver/views.py --- maas-0.1+bzr338+dfsg/src/maasserver/views.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maasserver/views.py 2012-03-27 17:12:06.000000000 +0000 @@ -16,6 +16,7 @@ "NodeView", ] +from logging import getLogger import mimetypes import os import urllib2 @@ -25,7 +26,6 @@ parse_qs, ) from django.conf import settings as django_settings -from django.core.exceptions import PermissionDenied from django.contrib import messages from django.contrib.auth.forms import PasswordChangeForm as PasswordForm from django.contrib.auth.models import User @@ -33,6 +33,7 @@ login as dj_login, logout as dj_logout, ) +from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse from django.http import ( HttpResponse, @@ -52,7 +53,10 @@ ListView, UpdateView, ) -from maasserver.exceptions import CannotDeleteUserException +from maasserver.exceptions import ( + CannotDeleteUserException, + NoRabbit, + ) from maasserver.forms import ( AddArchiveForm, CommissioningForm, @@ -61,13 +65,12 @@ NewUserCreationForm, ProfileForm, UbuntuForm, - UINodeEditForm, UIAdminNodeEditForm, + UINodeEditForm, ) from maasserver.messages import messaging from maasserver.models import ( Node, - SSHKeys, UserProfile, ) @@ -125,10 +128,15 @@ def get_longpoll_context(): if messaging is not None and django_settings.LONGPOLL_PATH is not None: - return { - 'longpoll_queue': messaging.getQueue().name, - 'LONGPOLL_PATH': django_settings.LONGPOLL_PATH, - } + try: + return { + 'longpoll_queue': messaging.getQueue().name, + 'LONGPOLL_PATH': django_settings.LONGPOLL_PATH, + } + except NoRabbit as e: + getLogger('maasserver').warn( + "Could not connect to RabbitMQ: %s", e) + return {} else: return {} @@ -154,13 +162,6 @@ return reverse('index') -def KeystoreView(request, userid): - keys = SSHKeys.objects.filter(user__user__username=userid) - return render_to_response( - 'maasserver/sshkeys.txt', {'keys': keys}, mimetype="text/plain", - context_instance=RequestContext(request)) - - 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+bzr338+dfsg/src/maastesting/testcase.py maas-0.1+bzr363+dfsg/src/maastesting/testcase.py --- maas-0.1+bzr338+dfsg/src/maastesting/testcase.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/maastesting/testcase.py 2012-03-27 17:12:06.000000000 +0000 @@ -12,6 +12,7 @@ __all__ = [ 'TestCase', 'TestModelTestCase', + 'TransactionTestCase', ] import unittest @@ -24,12 +25,7 @@ import testtools -class TestCase(testtools.TestCase, django.test.TestCase): - """`TestCase` for Metal as a Service. - - Supports test resources and fixtures. - """ - +class TestCaseBase(testtools.TestCase): # testresources.ResourcedTestCase does something similar to this class # (with respect to setUpResources and tearDownResources) but it explicitly # up-calls to unittest.TestCase instead of using super() even though it is @@ -39,7 +35,7 @@ resources = () def setUp(self): - super(TestCase, self).setUp() + super(TestCaseBase, self).setUp() self.setUpResources() def setUpResources(self): @@ -48,7 +44,7 @@ def tearDown(self): self.tearDownResources() - super(TestCase, self).tearDown() + super(TestCaseBase, self).tearDown() def tearDownResources(self): testresources.tearDownResources( @@ -59,6 +55,23 @@ assertItemsEqual = unittest.TestCase.assertItemsEqual +class TestCase(TestCaseBase, django.test.TestCase): + """`TestCase` for Metal as a Service. + + Supports test resources and fixtures. + """ + + +class TransactionTestCase(TestCaseBase, django.test.TransactionTestCase): + """`TransactionTestCase` for Metal as a Service. + + A version of TestCase that supports transactions. + + The basic Django TestCase class uses transactions to speed up tests + so this class should be used when tests involve transactions. + """ + + class TestModelTestCase(TestCase): """A custom test case that adds support for test-only models. diff -Nru maas-0.1+bzr338+dfsg/src/metadataserver/api.py maas-0.1+bzr363+dfsg/src/metadataserver/api.py --- maas-0.1+bzr338+dfsg/src/metadataserver/api.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/metadataserver/api.py 2012-03-27 17:12:06.000000000 +0000 @@ -23,6 +23,7 @@ PermissionDenied, Unauthorized, ) +from maasserver.models import SSHKey from metadataserver.models import ( NodeKey, NodeUserData, @@ -99,7 +100,7 @@ class MetaDataHandler(VersionIndexHandler): """Meta-data listing for a given version.""" - fields = ('instance-id', 'local-hostname',) + fields = ('instance-id', 'local-hostname', 'public-keys') def get_attribute_producer(self, item): """Return a callable to deliver a given metadata item. @@ -119,18 +120,26 @@ producers = { 'local-hostname': self.local_hostname, 'instance-id': self.instance_id, + 'public-keys': self.public_keys, } return producers[field] def read(self, request, version, item=None): - if item is None or len(item) == 0: - # Requesting the list of attributes, not any particular - # attribute. - return make_list_response(sorted(self.fields)) - check_version(version) node = get_node_for_request(request) + + # Requesting the list of attributes, not any particular + # attribute. + if item is None or len(item) == 0: + fields = list(self.fields) + # Add public-keys to the list of attributes, if the + # node has registered SSH keys. + keys = SSHKey.objects.get_keys_for_user(user=node.owner) + if not keys: + fields.remove('public-keys') + return make_list_response(sorted(fields)) + producer = self.get_attribute_producer(item) return producer(node, version, item) @@ -142,6 +151,13 @@ """Produce instance-id attribute.""" return make_text_response(node.system_id) + def public_keys(self, node, version, item): + """ Produce public-keys attribute.""" + keys = SSHKey.objects.get_keys_for_user(user=node.owner) + if not keys: + raise MAASAPINotFound("No registered public keys") + return make_list_response(keys) + class UserDataHandler(MetadataViewHandler): """User-data blob for a given version.""" diff -Nru maas-0.1+bzr338+dfsg/src/metadataserver/tests/test_api.py maas-0.1+bzr363+dfsg/src/metadataserver/tests/test_api.py --- maas-0.1+bzr338+dfsg/src/metadataserver/tests/test_api.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/metadataserver/tests/test_api.py 2012-03-27 17:12:06.000000000 +0000 @@ -13,11 +13,11 @@ from collections import namedtuple import httplib +from textwrap import dedent from maasserver.exceptions import Unauthorized from maasserver.testing.factory import factory from maasserver.testing.oauthclient import OAuthAuthenticatedClient -from maastesting.rabbit import uses_rabbit_fixture from maastesting.testcase import TestCase from metadataserver.api import ( check_version, @@ -63,7 +63,6 @@ def test_check_version_reports_unknown_version(self): self.assertRaises(UnknownMetadataVersion, check_version, '1.0') - @uses_rabbit_fixture def test_get_node_for_request_finds_node(self): node = factory.make_node() token = NodeKey.objects.get_token_for_node(node) @@ -108,12 +107,10 @@ def test_no_anonymous_access(self): self.assertEqual(httplib.UNAUTHORIZED, self.get('/').status_code) - @uses_rabbit_fixture def test_metadata_index_shows_latest(self): client = self.make_node_client() self.assertIn('latest', self.get('/', client).content) - @uses_rabbit_fixture def test_metadata_index_shows_only_known_versions(self): client = self.make_node_client() for item in self.get('/', client).content.splitlines(): @@ -121,19 +118,16 @@ # The test is that we get here without exception. pass - @uses_rabbit_fixture def test_version_index_shows_meta_data(self): client = self.make_node_client() items = self.get('/latest/', client).content.splitlines() self.assertIn('meta-data', items) - @uses_rabbit_fixture def test_version_index_does_not_show_user_data_if_not_available(self): client = self.make_node_client() items = self.get('/latest/', client).content.splitlines() self.assertNotIn('user-data', items) - @uses_rabbit_fixture def test_version_index_shows_user_data_if_available(self): node = factory.make_node() NodeUserData.objects.set_user_data(node, b"User data for node") @@ -141,22 +135,22 @@ items = self.get('/latest/', client).content.splitlines() self.assertIn('user-data', items) - @uses_rabbit_fixture def test_meta_data_view_lists_fields(self): - client = self.make_node_client() + # Some fields only are returned if there is data related to them. + user = factory.make_user_with_keys(n_keys=2, username='my-user') + node = factory.make_node(owner=user) + client = self.make_node_client(node=node) response = self.get('/latest/meta-data/', client) self.assertIn('text/plain', response['Content-Type']) self.assertItemsEqual( MetaDataHandler.fields, response.content.split()) - @uses_rabbit_fixture def test_meta_data_view_is_sorted(self): client = self.make_node_client() response = self.get('/latest/meta-data/', client) attributes = response.content.split() self.assertEqual(sorted(attributes), attributes) - @uses_rabbit_fixture def test_meta_data_unknown_item_is_not_found(self): client = self.make_node_client() response = self.get('/latest/meta-data/UNKNOWN-ITEM-HA-HA-HA', client) @@ -167,7 +161,6 @@ producers = map(handler.get_attribute_producer, handler.fields) self.assertNotIn(None, producers) - @uses_rabbit_fixture def test_meta_data_local_hostname_returns_hostname(self): hostname = factory.getRandomString() client = self.make_node_client(factory.make_node(hostname=hostname)) @@ -177,7 +170,6 @@ (response.status_code, response.content.decode('ascii'))) self.assertIn('text/plain', response['Content-Type']) - @uses_rabbit_fixture def test_meta_data_instance_id_returns_system_id(self): node = factory.make_node() client = self.make_node_client(node) @@ -187,7 +179,6 @@ (response.status_code, response.content.decode('ascii'))) self.assertIn('text/plain', response['Content-Type']) - @uses_rabbit_fixture def test_user_data_view_returns_binary_data(self): data = b"\x00\xff\xff\xfe\xff" node = factory.make_node() @@ -199,7 +190,36 @@ self.assertEqual( (httplib.OK, data), (response.status_code, response.content)) - @uses_rabbit_fixture def test_user_data_for_node_without_user_data_returns_not_found(self): response = self.get('/latest/user-data', self.make_node_client()) self.assertEqual(httplib.NOT_FOUND, response.status_code) + + def test_public_keys_not_listed_for_node_without_public_keys(self): + response = self.get('/latest/meta-data/', self.make_node_client()) + self.assertNotIn( + 'public-keys', response.content.decode('ascii').split('\n')) + + def test_public_keys_listed_for_node_with_public_keys(self): + user = factory.make_user_with_keys(n_keys=2, username='my-user') + node = factory.make_node(owner=user) + response = self.get( + '/latest/meta-data/', self.make_node_client(node=node)) + self.assertIn( + 'public-keys', response.content.decode('ascii').split('\n')) + + def test_public_keys_for_node_without_public_keys_returns_not_found(self): + response = self.get( + '/latest/meta-data/public-keys', self.make_node_client()) + self.assertEqual(httplib.NOT_FOUND, response.status_code) + + def test_public_keys_for_node_returns_list_of_keys(self): + user = factory.make_user_with_keys(n_keys=2, username='my-user') + node = factory.make_node(owner=user) + response = self.get( + '/latest/meta-data/public-keys', self.make_node_client(node=node)) + self.assertEqual(httplib.OK, response.status_code) + self.assertEquals(dedent("""\ + ssh-rsa KEY my-user-key-0 + ssh-rsa KEY my-user-key-1"""), + response.content.decode('ascii')) + self.assertIn('text/plain', response['Content-Type']) diff -Nru maas-0.1+bzr338+dfsg/src/metadataserver/tests/test_models.py maas-0.1+bzr363+dfsg/src/metadataserver/tests/test_models.py --- maas-0.1+bzr338+dfsg/src/metadataserver/tests/test_models.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/metadataserver/tests/test_models.py 2012-03-27 17:12:06.000000000 +0000 @@ -12,7 +12,6 @@ __all__ = [] from maasserver.testing.factory import factory -from maastesting.rabbit import use_rabbit_fixture from maastesting.testcase import TestCase from metadataserver.models import ( NodeKey, @@ -25,7 +24,6 @@ def setUp(self): super(TestNodeKeyManager, self).setUp() - use_rabbit_fixture(self) def test_get_token_for_node_registers_node_key(self): node = factory.make_node() @@ -75,7 +73,6 @@ def setUp(self): super(TestNodeUserDataManager, self).setUp() - use_rabbit_fixture(self) def test_set_user_data_creates_new_nodeuserdata_if_needed(self): node = factory.make_node() diff -Nru maas-0.1+bzr338+dfsg/src/provisioningserver/testing/amqpclient.py maas-0.1+bzr363+dfsg/src/provisioningserver/testing/amqpclient.py --- maas-0.1+bzr338+dfsg/src/provisioningserver/testing/amqpclient.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/src/provisioningserver/testing/amqpclient.py 2012-03-27 17:12:06.000000000 +0000 @@ -1,4 +1,4 @@ -# Copyright 2005-2011 Canonical Ltd. This software is licensed under the +# Copyright 2005-2012 Canonical Ltd. This software is licensed under the # GNU Affero General Public License version 3 (see the file LICENSE). """Tests for `provisioningserver.amqpclient`.""" diff -Nru maas-0.1+bzr338+dfsg/templates/script.py maas-0.1+bzr363+dfsg/templates/script.py --- maas-0.1+bzr338+dfsg/templates/script.py 2012-03-22 19:49:55.000000000 +0000 +++ maas-0.1+bzr363+dfsg/templates/script.py 2012-03-27 17:12:06.000000000 +0000 @@ -13,7 +13,6 @@ import argparse - # See http://docs.python.org/release/2.7/library/argparse.html. argument_parser = argparse.ArgumentParser(description=__doc__)