Add ability to associate drone sets with jobs. This restricts a job to
running on a specified set of drones.

Signed-off-by: James Ren <[email protected]>


git-svn-id: http://test.kernel.org/svn/autotest/trunk@4439 592f7852-d20e-0410-864c-8624ca9c26a4
diff --git a/frontend/afe/admin.py b/frontend/afe/admin.py
index df5d9a7..0b92670 100644
--- a/frontend/afe/admin.py
+++ b/frontend/afe/admin.py
@@ -136,6 +136,14 @@
 admin.site.register(models.AclGroup, AclGroupAdmin)
 
 
+class DroneSetAdmin(SiteAdmin):
+    filter_horizontal = ('drones',)
+
+admin.site.register(models.DroneSet, DroneSetAdmin)
+
+admin.site.register(models.Drone)
+
+
 if settings.FULL_ADMIN:
     class JobAdmin(SiteAdmin):
         list_display = ('id', 'owner', 'name', 'control_type')
diff --git a/frontend/afe/doctests/001_rpc_test.txt b/frontend/afe/doctests/001_rpc_test.txt
index 8b0f1d7..7e33a86 100644
--- a/frontend/afe/doctests/001_rpc_test.txt
+++ b/frontend/afe/doctests/001_rpc_test.txt
@@ -30,6 +30,10 @@
 >>> from autotest_lib.client.common_lib import logging_manager
 >>> logging_manager.logger.setLevel(100)
 
+>>> drone_set = models.DroneSet.default_drone_set_name()
+>>> if drone_set:
+...     _ = models.DroneSet.objects.create(name=drone_set)
+
 # basic interface test
 ######################
 
@@ -184,6 +188,7 @@
 ...           'access_level': 1,
 ...           'reboot_before': 'If dirty',
 ...           'reboot_after': 'Always',
+...           'drone_set': None,
 ...           'show_experimental': False}]
 True
 >>> rpc_interface.delete_user('showard')
@@ -538,7 +543,8 @@
 ...         'email_list': '',
 ...         'reboot_before': 'If dirty',
 ...         'reboot_after': 'Always',
-...         'parse_failed_repair': True}
+...         'parse_failed_repair': True,
+...         'drone_set': drone_set}
 True
 
 # get_host_queue_entries returns a lot of data, so let's only check a couple
diff --git a/frontend/afe/frontend_test_utils.py b/frontend/afe/frontend_test_utils.py
index 637c286..5aba4f4 100644
--- a/frontend/afe/frontend_test_utils.py
+++ b/frontend/afe/frontend_test_utils.py
@@ -8,6 +8,10 @@
 class FrontendTestMixin(object):
     def _fill_in_test_data(self):
         """Populate the test database with some hosts and labels."""
+        if models.DroneSet.drone_sets_enabled():
+            models.DroneSet.objects.create(
+                    name=models.DroneSet.default_drone_set_name())
+
         acl_group = models.AclGroup.objects.create(name='my_acl')
         acl_group.users.add(models.User.current_user())
 
@@ -72,7 +76,8 @@
 
 
     def _create_job(self, hosts=[], metahosts=[], priority=0, active=False,
-                    synchronous=False, atomic_group=None, hostless=False):
+                    synchronous=False, atomic_group=None, hostless=False,
+                    drone_set=None):
         """
         Create a job row in the test database.
 
@@ -93,6 +98,10 @@
 
         @returns A Django frontend.afe.models.Job instance.
         """
+        if not drone_set:
+            drone_set = (models.DroneSet.default_drone_set_name()
+                         and models.DroneSet.get_default())
+
         assert not (atomic_group and active)  # TODO(gps): support this
         synch_count = synchronous and 2 or 1
         created_on = datetime.datetime(2008, 1, 1)
@@ -102,7 +111,8 @@
         job = models.Job.objects.create(
             name='test', owner='autotest_system', priority=priority,
             synch_count=synch_count, created_on=created_on,
-            reboot_before=model_attributes.RebootBefore.NEVER)
+            reboot_before=model_attributes.RebootBefore.NEVER,
+            drone_set=drone_set)
         for host_id in hosts:
             models.HostQueueEntry.objects.create(job=job, host_id=host_id,
                                                  status=status,
@@ -126,11 +136,12 @@
 
 
     def _create_job_simple(self, hosts, use_metahost=False,
-                          priority=0, active=False):
+                          priority=0, active=False, drone_set=None):
         """An alternative interface to _create_job"""
         args = {'hosts' : [], 'metahosts' : []}
         if use_metahost:
             args['metahosts'] = hosts
         else:
             args['hosts'] = hosts
-        return self._create_job(priority=priority, active=active, **args)
+        return self._create_job(priority=priority, active=active,
+                                drone_set=drone_set, **args)
diff --git a/frontend/afe/models.py b/frontend/afe/models.py
index eceb1f5..3370442 100644
--- a/frontend/afe/models.py
+++ b/frontend/afe/models.py
@@ -116,6 +116,119 @@
         return unicode(self.name)
 
 
+class Drone(dbmodels.Model, model_logic.ModelExtensions):
+    """
+    A scheduler drone
+
+    hostname: the drone's hostname
+    """
+    hostname = dbmodels.CharField(max_length=255, unique=True)
+
+    name_field = 'hostname'
+    objects = model_logic.ExtendedManager()
+
+
+    def save(self, *args, **kwargs):
+        if not User.current_user().is_superuser():
+            raise Exception('Only superusers may edit drones')
+        super(Drone, self).save(*args, **kwargs)
+
+
+    def delete(self):
+        if not User.current_user().is_superuser():
+            raise Exception('Only superusers may delete drones')
+        super(Drone, self).delete()
+
+
+    class Meta:
+        db_table = 'afe_drones'
+
+    def __unicode__(self):
+        return unicode(self.hostname)
+
+
+class DroneSet(dbmodels.Model, model_logic.ModelExtensions):
+    """
+    A set of scheduler drones
+
+    These will be used by the scheduler to decide what drones a job is allowed
+    to run on.
+
+    name: the drone set's name
+    drones: the drones that are part of the set
+    """
+    DRONE_SETS_ENABLED = global_config.global_config.get_config_value(
+            'SCHEDULER', 'drone_sets_enabled', type=bool, default=False)
+    DEFAULT_DRONE_SET_NAME = global_config.global_config.get_config_value(
+            'SCHEDULER', 'default_drone_set_name', default=None)
+
+    name = dbmodels.CharField(max_length=255, unique=True)
+    drones = dbmodels.ManyToManyField(Drone, db_table='afe_drone_sets_drones')
+
+    name_field = 'name'
+    objects = model_logic.ExtendedManager()
+
+
+    def save(self, *args, **kwargs):
+        if not User.current_user().is_superuser():
+            raise Exception('Only superusers may edit drone sets')
+        super(DroneSet, self).save(*args, **kwargs)
+
+
+    def delete(self):
+        if not User.current_user().is_superuser():
+            raise Exception('Only superusers may delete drone sets')
+        super(DroneSet, self).delete()
+
+
+    @classmethod
+    def drone_sets_enabled(cls):
+        return cls.DRONE_SETS_ENABLED
+
+
+    @classmethod
+    def default_drone_set_name(cls):
+        return cls.DEFAULT_DRONE_SET_NAME
+
+
+    @classmethod
+    def get_default(cls):
+        return cls.smart_get(cls.DEFAULT_DRONE_SET_NAME)
+
+
+    @classmethod
+    def resolve_name(cls, drone_set_name):
+        """
+        Returns the name of one of these, if not None, in order of preference:
+        1) the drone set given,
+        2) the current user's default drone set, or
+        3) the global default drone set
+
+        or returns None if drone sets are disabled
+        """
+        if not cls.drone_sets_enabled():
+            return None
+
+        user = User.current_user()
+        user_drone_set_name = user.drone_set and user.drone_set.name
+
+        return drone_set_name or user_drone_set_name or cls.get_default().name
+
+
+    def get_drone_hostnames(self):
+        """
+        Gets the hostnames of all drones in this drone set
+        """
+        return set(self.drones.all().values_list('hostname', flat=True))
+
+
+    class Meta:
+        db_table = 'afe_drone_sets'
+
+    def __unicode__(self):
+        return unicode(self.name)
+
+
 class User(dbmodels.Model, model_logic.ModelExtensions):
     """\
     Required:
@@ -140,6 +253,7 @@
     reboot_after = dbmodels.SmallIntegerField(
         choices=model_attributes.RebootAfter.choices(), blank=True,
         default=DEFAULT_REBOOT_AFTER)
+    drone_set = dbmodels.ForeignKey(DroneSet, null=True, blank=True)
     show_experimental = dbmodels.BooleanField(default=False)
 
     name_field = 'login'
@@ -644,6 +758,7 @@
     reboot_after: Never, If all tests passed, or Always
     parse_failed_repair: if True, a failed repair launched by this job will have
     its results parsed as part of the job.
+    drone_set: The set of drones to run this job on
     """
     DEFAULT_TIMEOUT = global_config.global_config.get_config_value(
         'AUTOTEST_WEB', 'job_timeout_default', default=240)
@@ -682,6 +797,7 @@
     parse_failed_repair = dbmodels.BooleanField(
         default=DEFAULT_PARSE_FAILED_REPAIR)
     max_runtime_hrs = dbmodels.IntegerField(default=DEFAULT_MAX_RUNTIME_HRS)
+    drone_set = dbmodels.ForeignKey(DroneSet, null=True, blank=True)
 
 
     # custom manager
@@ -706,6 +822,8 @@
         if options.get('reboot_after') is None:
             options['reboot_after'] = user.get_reboot_after_display()
 
+        drone_set = DroneSet.resolve_name(options.get('drone_set'))
+
         job = cls.add_object(
             owner=owner,
             name=options['name'],
@@ -720,7 +838,8 @@
             reboot_before=options.get('reboot_before'),
             reboot_after=options.get('reboot_after'),
             parse_failed_repair=options.get('parse_failed_repair'),
-            created_on=datetime.now())
+            created_on=datetime.now(),
+            drone_set=drone_set)
 
         job.dependency_labels = options['dependencies']
 
@@ -995,7 +1114,7 @@
     host = dbmodels.ForeignKey(Host, blank=False, null=False)
     task = dbmodels.CharField(max_length=64, choices=Task.choices(),
                               blank=False, null=False)
-    requested_by = dbmodels.ForeignKey(User, blank=True, null=True)
+    requested_by = dbmodels.ForeignKey(User)
     time_requested = dbmodels.DateTimeField(auto_now_add=True, blank=False,
                                             null=False)
     is_active = dbmodels.BooleanField(default=False, blank=False, null=False)
diff --git a/frontend/afe/models_test.py b/frontend/afe/models_test.py
index 244aa47..c01c9cd 100755
--- a/frontend/afe/models_test.py
+++ b/frontend/afe/models_test.py
@@ -77,7 +77,8 @@
 
     def _create_task(self):
         return models.SpecialTask.objects.create(
-                host=self.hosts[0], task=models.SpecialTask.Task.VERIFY)
+                host=self.hosts[0], task=models.SpecialTask.Task.VERIFY,
+                requested_by=models.User.current_user())
 
 
     def test_execution_path(self):
diff --git a/frontend/afe/resources.py b/frontend/afe/resources.py
index b47c5f5..eaeeea0 100644
--- a/frontend/afe/resources.py
+++ b/frontend/afe/resources.py
@@ -643,9 +643,11 @@
         rep = super(Job, self).full_representation()
         queue_entries = QueueEntryCollection(self._request)
         queue_entries.set_query_parameters(job=self.instance.id)
+        drone_set = self.instance.drone_set and self.instance.drone_set.name
         rep.update({'email_list': self.instance.email_list,
                     'parse_failed_repair':
                         bool(self.instance.parse_failed_repair),
+                    'drone_set': drone_set,
                     'execution_info':
                         ExecutionInfo.execution_info_from_job(self.instance),
                     'queue_entries': queue_entries.link(),
@@ -686,6 +688,7 @@
                 reboot_before=execution_info.get('cleanup_before_job'),
                 reboot_after=execution_info.get('cleanup_after_job'),
                 parse_failed_repair=input_dict.get('parse_failed_repair', None),
+                drone_set=input_dict.get('drone_set', None),
                 keyvals=input_dict.get('keyvals', None))
 
         host_objects, metahost_label_objects, atomic_group = [], [], None
diff --git a/frontend/afe/resources_test.py b/frontend/afe/resources_test.py
index e3551b4..9120335 100644
--- a/frontend/afe/resources_test.py
+++ b/frontend/afe/resources_test.py
@@ -350,6 +350,7 @@
                 'execution_info': {'control_file': self.CONTROL_FILE_CONTENTS,
                                    'is_server': True},
                 'owner': owner,
+                'drone_set': models.DroneSet.default_drone_set_name(),
                 'queue_entries':
                 [{'host': {'href': self.URI_PREFIX + '/hosts/host1'}},
                  {'host': {'href': self.URI_PREFIX + '/hosts/host2'}}]}
diff --git a/frontend/afe/rpc_interface.py b/frontend/afe/rpc_interface.py
index ab2596a..fc60a75 100644
--- a/frontend/afe/rpc_interface.py
+++ b/frontend/afe/rpc_interface.py
@@ -403,7 +403,7 @@
                timeout=None, max_runtime_hrs=None, run_verify=True,
                email_list='', dependencies=(), reboot_before=None,
                reboot_after=None, parse_failed_repair=None, hostless=False,
-               keyvals=None):
+               keyvals=None, drone_set=None):
     """\
     Create and enqueue a job.
 
@@ -432,6 +432,7 @@
     one host will be chosen from that label to run the job on.
     @param one_time_hosts List of hosts not in the database to run the job on.
     @param atomic_group_name The name of an atomic group to schedule the job on.
+    @param drone_set The name of the drone set to run this test on.
 
 
     @returns The created Job id number.
@@ -529,7 +530,8 @@
                    reboot_before=reboot_before,
                    reboot_after=reboot_after,
                    parse_failed_repair=parse_failed_repair,
-                   keyvals=keyvals)
+                   keyvals=keyvals,
+                   drone_set=drone_set)
     return rpc_utils.create_new_job(owner=owner,
                                     options=options,
                                     host_objects=host_objects,
@@ -656,6 +658,7 @@
     else:
         info['atomic_group_name'] = None
     info['hostless'] = job_info['hostless']
+    info['drone_set'] = job.drone_set and job.drone_set.name
 
     return rpc_utils.prepare_for_serialization(info)
 
@@ -802,6 +805,11 @@
     """
 
     job_fields = models.Job.get_field_dict()
+    default_drone_set_name = models.DroneSet.default_drone_set_name()
+    drone_sets = ([default_drone_set_name] +
+                  sorted(drone_set.name for drone_set in
+                         models.DroneSet.objects.exclude(
+                                 name=default_drone_set_name)))
 
     result = {}
     result['priorities'] = models.Job.Priority.choices()
@@ -824,6 +832,8 @@
     result['reboot_before_options'] = model_attributes.RebootBefore.names
     result['reboot_after_options'] = model_attributes.RebootAfter.names
     result['motd'] = rpc_utils.get_motd()
+    result['drone_sets_enabled'] = models.DroneSet.drone_sets_enabled()
+    result['drone_sets'] = drone_sets
 
     result['status_dictionary'] = {"Aborted": "Aborted",
                                    "Verifying": "Verifying Host",
diff --git a/frontend/afe/rpc_interface_unittest.py b/frontend/afe/rpc_interface_unittest.py
index 9ba13cb..900f46c 100755
--- a/frontend/afe/rpc_interface_unittest.py
+++ b/frontend/afe/rpc_interface_unittest.py
@@ -212,13 +212,14 @@
         self.task1 = models.SpecialTask.objects.create(
                 host=host, task=models.SpecialTask.Task.VERIFY,
                 time_started=datetime.datetime(2009, 1, 1), # ran before job 1
-                is_complete=True)
+                is_complete=True, requested_by=models.User.current_user())
         self.task2 = models.SpecialTask.objects.create(
                 host=host, task=models.SpecialTask.Task.VERIFY,
                 queue_entry=entry2, # ran with job 2
-                is_active=True)
+                is_active=True, requested_by=models.User.current_user())
         self.task3 = models.SpecialTask.objects.create(
-                host=host, task=models.SpecialTask.Task.VERIFY) # not yet run
+                host=host, task=models.SpecialTask.Task.VERIFY,
+                requested_by=models.User.current_user()) # not yet run
 
 
     def test_get_special_tasks(self):
diff --git a/frontend/client/src/autotest/afe/AfeUtils.java b/frontend/client/src/autotest/afe/AfeUtils.java
index 691c549..f5124db 100644
--- a/frontend/client/src/autotest/afe/AfeUtils.java
+++ b/frontend/client/src/autotest/afe/AfeUtils.java
@@ -16,6 +16,9 @@
 import com.google.gwt.json.client.JSONObject;
 import com.google.gwt.json.client.JSONString;
 import com.google.gwt.json.client.JSONValue;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.Element;
+import com.google.gwt.user.client.ui.ListBox;
 
 import java.util.ArrayList;
 import java.util.Collection;
@@ -312,6 +315,28 @@
             chooser.addChoice(Utils.jsonToString(jsonOption));
         }
     }
+    
+    public static void popualateListBox(ListBox box, String staticDataKey) {
+        JSONArray options = staticData.getData(staticDataKey).isArray();
+        for (JSONString jsonOption : new JSONArrayList<JSONString>(options)) {
+            box.addItem(Utils.jsonToString(jsonOption));
+        }
+    }
+    
+    public static void setSelectedItem(ListBox box, String item) {
+        box.setSelectedIndex(0);
+        for (int i = 0; i < box.getItemCount(); i++) {
+            if (box.getItemText(i).equals(item)) {
+                box.setSelectedIndex(i);
+                break;
+            }
+        }
+    }
+    
+    public static void removeElement(String id) {
+        Element element = DOM.getElementById(id);
+        element.getParentElement().removeChild(element);
+    }
 
     public static int parsePositiveIntegerInput(String input, String fieldName) {
         final int parsedInt;
diff --git a/frontend/client/src/autotest/afe/CreateJobView.java b/frontend/client/src/autotest/afe/CreateJobView.java
index 74249d5..4b43e86 100644
--- a/frontend/client/src/autotest/afe/CreateJobView.java
+++ b/frontend/client/src/autotest/afe/CreateJobView.java
@@ -179,6 +179,7 @@
         new CheckBoxPanel<CheckBox>(TEST_COLUMNS);
     private CheckBox runNonProfiledIteration =
         new CheckBox("Run each test without profilers first");
+    private ListBox droneSet = new ListBox();
     protected TextArea controlFile = new TextArea();
     protected DisclosurePanel controlFilePanel = new DisclosurePanel();
     protected ControlTypeSelect controlTypeSelect;
@@ -238,6 +239,9 @@
         if (hostless.getValue()) {
             hostSelector.setEnabled(false);
         }
+        if (cloneObject.get("drone_set").isNull() == null) {
+            AfeUtils.setSelectedItem(droneSet, Utils.jsonToString(cloneObject.get("drone_set")));
+        }
 
         controlTypeSelect.setControlType(
                 jobObject.get("control_type").isString().stringValue());
@@ -455,8 +459,9 @@
     @Override
     public void initialize() {
         super.initialize();
+        
         populatePriorities(staticData.getData("priorities").isArray());
-
+        
         BlurHandler kernelBlurHandler = new BlurHandler() {
             public void onBlur(BlurEvent event) {
                 generateControlFile(false);
@@ -617,6 +622,13 @@
         addWidget(createTemplateJobButton, "create_template_job");
         addWidget(resetButton, "create_reset");
         
+        if (staticData.getData("drone_sets_enabled").isBoolean().booleanValue()) {
+            AfeUtils.popualateListBox(droneSet, "drone_sets");
+            addWidget(droneSet, "create_drone_set");
+        } else {
+            AfeUtils.removeElement("create_drone_set_wrapper");
+        }
+        
         testSelector.setListener(this);
     }
 
@@ -693,6 +705,11 @@
                 args.put("parse_failed_repair",
                          JSONBoolean.getInstance(parseFailedRepair.getValue()));
                 args.put("hostless", JSONBoolean.getInstance(hostless.getValue()));
+                
+                if (staticData.getData("drone_sets_enabled").isBoolean().booleanValue()) {
+                    args.put("drone_set",
+                            new JSONString(droneSet.getItemText(droneSet.getSelectedIndex())));
+                }
 
                 HostSelector.HostSelection hosts = hostSelector.getSelectedHosts();
                 args.put("hosts", Utils.stringsToJSON(hosts.hosts));
@@ -773,10 +790,17 @@
         String defaultOption = Utils.jsonToString(user.get(name));
         chooser.setDefaultChoice(defaultOption);
     }
-
+    
+    private void selectPreferredDroneSet() {
+        JSONObject user = staticData.getData("current_user").isObject();
+        String preference = Utils.jsonToString(user.get("drone_set"));
+        AfeUtils.setSelectedItem(droneSet, preference);
+    }
+    
     public void onPreferencesChanged() {
         setRebootSelectorDefault(rebootBefore, "reboot_before");
         setRebootSelectorDefault(rebootAfter, "reboot_after");
+        selectPreferredDroneSet();
         testSelector.reset();
     }
 }
diff --git a/frontend/client/src/autotest/afe/JobDetailView.java b/frontend/client/src/autotest/afe/JobDetailView.java
index c7c804b..7e37585 100644
--- a/frontend/client/src/autotest/afe/JobDetailView.java
+++ b/frontend/client/src/autotest/afe/JobDetailView.java
@@ -76,6 +76,8 @@
     private Label controlFile = new Label();
     private DisclosurePanel controlFilePanel = new DisclosurePanel("");
     
+    protected StaticDataRepository staticData = StaticDataRepository.getRepository();
+    
     public JobDetailView(JobDetailListener listener) {
         this.listener = listener;
     }
@@ -114,6 +116,10 @@
                 showField(jobObject, "synch_count", "view_synch_count");
                 showField(jobObject, "dependencies", "view_dependencies");
                 
+                if (staticData.getData("drone_sets_enabled").isBoolean().booleanValue()) {
+                    showField(jobObject, "drone_set", "view_drone_set");
+                }
+                
                 String header = Utils.jsonToString(jobObject.get("control_type")) + " control file";
                 controlFilePanel.getHeaderTextAccessor().setText(header);
                 controlFile.setText(Utils.jsonToString(jobObject.get("control_file")));
@@ -208,6 +214,10 @@
         controlFile.addStyleName("code");
         controlFilePanel.setContent(controlFile);
         addWidget(controlFilePanel, "view_control_file");
+        
+        if (!staticData.getData("drone_sets_enabled").isBoolean().booleanValue()) {
+            AfeUtils.removeElement("view_drone_set_wrapper");
+        }
     }
 
     
diff --git a/frontend/client/src/autotest/afe/UserPreferencesView.java b/frontend/client/src/autotest/afe/UserPreferencesView.java
index 217819e..057905d 100644
--- a/frontend/client/src/autotest/afe/UserPreferencesView.java
+++ b/frontend/client/src/autotest/afe/UserPreferencesView.java
@@ -18,6 +18,7 @@
 import com.google.gwt.user.client.ui.CheckBox;
 import com.google.gwt.user.client.ui.FlexTable;
 import com.google.gwt.user.client.ui.HTMLTable;
+import com.google.gwt.user.client.ui.ListBox;
 import com.google.gwt.user.client.ui.Panel;
 import com.google.gwt.user.client.ui.VerticalPanel;
 import com.google.gwt.user.client.ui.Widget;
@@ -35,6 +36,7 @@
     
     private RadioChooser rebootBefore = new RadioChooser();
     private RadioChooser rebootAfter = new RadioChooser();
+    private ListBox droneSet = new ListBox();
     private CheckBox showExperimental = new CheckBox();
     private Button saveButton = new Button("Save preferences");
     private HTMLTable preferencesTable = new FlexTable();
@@ -60,6 +62,11 @@
         addOption("Reboot before", rebootBefore);
         addOption("Reboot after", rebootAfter);
         addOption("Show experimental tests", showExperimental);
+        if (staticData.getData("drone_sets_enabled").isBoolean().booleanValue()) {
+            AfeUtils.popualateListBox(droneSet, "drone_sets");
+            addOption("Drone set", droneSet);
+        }
+
         container.add(preferencesTable);
         container.add(saveButton);
         addWidget(container, "user_preferences_table");
@@ -81,6 +88,8 @@
     private void updateValues() {
         rebootBefore.setSelectedChoice(getValue("reboot_before"));
         rebootAfter.setSelectedChoice(getValue("reboot_after"));
+        AfeUtils.setSelectedItem(droneSet, getValue("drone_set"));
+
         showExperimental.setValue(user.get("show_experimental").isBoolean().booleanValue());
     }
     
@@ -98,6 +107,7 @@
         values.put("id", user.get("id"));
         values.put("reboot_before", new JSONString(rebootBefore.getSelectedChoice()));
         values.put("reboot_after", new JSONString(rebootAfter.getSelectedChoice()));
+        values.put("drone_set", new JSONString(droneSet.getItemText(droneSet.getSelectedIndex())));
         values.put("show_experimental", JSONBoolean.getInstance(showExperimental.getValue()));
         proxy.rpcCall("modify_user", values, new JsonRpcCallback() {
             @Override
diff --git a/frontend/client/src/autotest/public/AfeClient.html b/frontend/client/src/autotest/public/AfeClient.html
index 303157f..f5f2a9c 100644
--- a/frontend/client/src/autotest/public/AfeClient.html
+++ b/frontend/client/src/autotest/public/AfeClient.html
@@ -72,6 +72,12 @@
           <span class="field-name">Reboot options:</span>
           <span id="view_reboot_before"></span> before job,
           <span id="view_reboot_after"></span> after job<br>
+
+          <span id="view_drone_set_wrapper">
+            <span class="field-name">Drone set:</span>
+            <span id="view_drone_set"></span><br>
+          </span>
+
           <span class="field-name">Include failed repair results:</span>
           <span id="view_parse_failed_repair"></span><br>
           <span class="field-name">Dependencies:</span>
@@ -132,6 +138,12 @@
               <td id="create_parse_failed_repair"></td><td></td></tr>
           <tr><td class="field-name">Hostless:</td>
               <td id="create_hostless"></td><td></td></tr>
+
+          <tr id="create_drone_set_wrapper">
+            <td class="field-name">Drone set:</td>
+            <td id="create_drone_set" colspan="2"></td>
+          </tr>
+
           <tr><td class="field-name">Tests:</td>
               <td id="create_tests" colspan="2"></td></tr>
           <tr><td class="field-name">Profilers:</td>
diff --git a/frontend/migrations/058_drone_management.py b/frontend/migrations/058_drone_management.py
new file mode 100644
index 0000000..183e921
--- /dev/null
+++ b/frontend/migrations/058_drone_management.py
@@ -0,0 +1,85 @@
+UP_SQL = """
+CREATE TABLE afe_drones (
+  id INT AUTO_INCREMENT NOT NULL PRIMARY KEY,
+  hostname VARCHAR(255) NOT NULL
+) ENGINE=InnoDB;
+
+ALTER TABLE afe_drones
+ADD CONSTRAINT afe_drones_unique
+UNIQUE KEY (hostname);
+
+
+CREATE TABLE afe_drone_sets (
+  id INT AUTO_INCREMENT NOT NULL PRIMARY KEY,
+  name VARCHAR(255) NOT NULL
+) ENGINE=InnoDB;
+
+ALTER TABLE afe_drone_sets
+ADD CONSTRAINT afe_drone_sets_unique
+UNIQUE KEY (name);
+
+
+CREATE TABLE afe_drone_sets_drones (
+  id INT AUTO_INCREMENT NOT NULL PRIMARY KEY,
+  droneset_id INT NOT NULL,
+  drone_id INT NOT NULL
+) ENGINE=InnoDB;
+
+ALTER TABLE afe_drone_sets_drones
+ADD CONSTRAINT afe_drone_sets_drones_droneset_ibfk
+FOREIGN KEY (droneset_id) REFERENCES afe_drone_sets (id);
+
+ALTER TABLE afe_drone_sets_drones
+ADD CONSTRAINT afe_drone_sets_drones_drone_ibfk
+FOREIGN KEY (drone_id) REFERENCES afe_drones (id);
+
+ALTER TABLE afe_drone_sets_drones
+ADD CONSTRAINT afe_drone_sets_drones_unique
+UNIQUE KEY (droneset_id, drone_id);
+
+
+ALTER TABLE afe_jobs
+ADD COLUMN drone_set_id INT;
+
+ALTER TABLE afe_jobs
+ADD CONSTRAINT afe_jobs_drone_set_ibfk
+FOREIGN KEY (drone_set_id) REFERENCES afe_drone_sets (id);
+
+
+ALTER TABLE afe_users
+ADD COLUMN drone_set_id INT;
+
+ALTER TABLE afe_users
+ADD CONSTRAINT afe_users_drone_set_ibfk
+FOREIGN KEY (drone_set_id) REFERENCES afe_drone_sets (id);
+
+
+UPDATE afe_special_tasks SET requested_by_id = (
+  SELECT id FROM afe_users WHERE login = 'autotest_system')
+WHERE requested_by_id IS NULL;
+
+ALTER TABLE afe_special_tasks
+MODIFY COLUMN requested_by_id INT NOT NULL;
+"""
+
+
+DOWN_SQL = """
+ALTER TABLE afe_special_tasks
+MODIFY COLUMN requested_by_id INT DEFAULT NULL;
+
+ALTER TABLE afe_users
+DROP FOREIGN KEY afe_users_drone_set_ibfk;
+
+ALTER TABLE afe_users
+DROP COLUMN drone_set_id;
+
+ALTER TABLE afe_jobs
+DROP FOREIGN KEY afe_jobs_drone_set_ibfk;
+
+ALTER TABLE afe_jobs
+DROP COLUMN drone_set_id;
+
+DROP TABLE IF EXISTS afe_drone_sets_drones;
+DROP TABLE IF EXISTS afe_drone_sets;
+DROP TABLE IF EXISTS afe_drones;
+"""