1#
2# BitBake Toaster Implementation
3#
4# Copyright (C) 2016        Intel Corporation
5#
6# SPDX-License-Identifier: GPL-2.0-only
7#
8# Please run flake8 on this file before sending patches
9
10import os
11import re
12import logging
13import json
14import glob
15from collections import Counter
16
17from orm.models import Project, ProjectTarget, Build, Layer_Version
18from orm.models import LayerVersionDependency, LayerSource, ProjectLayer
19from orm.models import Recipe, CustomImageRecipe, CustomImagePackage
20from orm.models import Layer, Target, Package, Package_Dependency
21from orm.models import ProjectVariable
22from bldcontrol.models import BuildRequest, BuildEnvironment
23from bldcontrol import bbcontroller
24
25from django.http import HttpResponse, JsonResponse
26from django.views.generic import View
27from django.urls import reverse
28from django.db.models import Q, F
29from django.db import Error
30from toastergui.templatetags.projecttags import filtered_filesizeformat
31
32# development/debugging support
33verbose = 2
34def _log(msg):
35    if 1 == verbose:
36        print(msg)
37    elif 2 == verbose:
38        f1=open('/tmp/toaster.log', 'a')
39        f1.write("|" + msg + "|\n" )
40        f1.close()
41
42logger = logging.getLogger("toaster")
43
44
45def error_response(error):
46    return JsonResponse({"error": error})
47
48
49class XhrBuildRequest(View):
50
51    def get(self, request, *args, **kwargs):
52        return HttpResponse()
53
54    @staticmethod
55    def cancel_build(br):
56        """Cancel a build request"""
57        try:
58            bbctrl = bbcontroller.BitbakeController(br.environment)
59            bbctrl.forceShutDown()
60        except:
61            # We catch a bunch of exceptions here because
62            # this is where the server has not had time to start up
63            # and the build request or build is in transit between
64            # processes.
65            # We can safely just set the build as cancelled
66            # already as it never got started
67            build = br.build
68            build.outcome = Build.CANCELLED
69            build.save()
70
71        # We now hand over to the buildinfohelper to update the
72        # build state once we've finished cancelling
73        br.state = BuildRequest.REQ_CANCELLING
74        br.save()
75
76    def post(self, request, *args, **kwargs):
77        """
78          Build control
79
80          Entry point: /xhr_buildrequest/<project_id>
81          Method: POST
82
83          Args:
84              id: id of build to change
85              buildCancel = build_request_id ...
86              buildDelete = id ...
87              targets = recipe_name ...
88
89          Returns:
90              {"error": "ok"}
91            or
92              {"error": <error message>}
93        """
94
95        project = Project.objects.get(pk=kwargs['pid'])
96
97        if 'buildCancel' in request.POST:
98            for i in request.POST['buildCancel'].strip().split(" "):
99                try:
100                    br = BuildRequest.objects.get(project=project, pk=i)
101                    self.cancel_build(br)
102                except BuildRequest.DoesNotExist:
103                    return error_response('No such build request id %s' % i)
104
105            return error_response('ok')
106
107        if 'buildDelete' in request.POST:
108            for i in request.POST['buildDelete'].strip().split(" "):
109                try:
110                    BuildRequest.objects.select_for_update().get(
111                        project=project,
112                        pk=i,
113                        state__lte=BuildRequest.REQ_DELETED).delete()
114
115                except BuildRequest.DoesNotExist:
116                    pass
117            return error_response("ok")
118
119        if 'targets' in request.POST:
120            ProjectTarget.objects.filter(project=project).delete()
121            s = str(request.POST['targets'])
122            for t in re.sub(r'[;%|"]', '', s).split(" "):
123                if ":" in t:
124                    target, task = t.split(":")
125                else:
126                    target = t
127                    task = ""
128                ProjectTarget.objects.create(project=project,
129                                             target=target,
130                                             task=task)
131            project.schedule_build()
132
133            return error_response('ok')
134
135        response = HttpResponse()
136        response.status_code = 500
137        return response
138
139
140class XhrProjectUpdate(View):
141
142    def get(self, request, *args, **kwargs):
143        return HttpResponse()
144
145    def post(self, request, *args, **kwargs):
146        """
147          Project Update
148
149          Entry point: /xhr_projectupdate/<project_id>
150          Method: POST
151
152          Args:
153              pid: pid of project to update
154
155          Returns:
156              {"error": "ok"}
157            or
158              {"error": <error message>}
159        """
160
161        project = Project.objects.get(pk=kwargs['pid'])
162        logger.debug("ProjectUpdateCallback:project.pk=%d,project.builddir=%s" % (project.pk,project.builddir))
163
164        if 'do_update' in request.POST:
165
166            # Extract any default image recipe
167            if 'default_image' in request.POST:
168                project.set_variable(Project.PROJECT_SPECIFIC_DEFAULTIMAGE,str(request.POST['default_image']))
169            else:
170                project.set_variable(Project.PROJECT_SPECIFIC_DEFAULTIMAGE,'')
171
172            logger.debug("ProjectUpdateCallback:Chain to the build request")
173
174            # Chain to the build request
175            xhrBuildRequest = XhrBuildRequest()
176            return xhrBuildRequest.post(request, *args, **kwargs)
177
178        logger.warning("ERROR:XhrProjectUpdate")
179        response = HttpResponse()
180        response.status_code = 500
181        return response
182
183class XhrSetDefaultImageUrl(View):
184
185    def get(self, request, *args, **kwargs):
186        return HttpResponse()
187
188    def post(self, request, *args, **kwargs):
189        """
190          Project Update
191
192          Entry point: /xhr_setdefaultimage/<project_id>
193          Method: POST
194
195          Args:
196              pid: pid of project to update default image
197
198          Returns:
199              {"error": "ok"}
200            or
201              {"error": <error message>}
202        """
203
204        project = Project.objects.get(pk=kwargs['pid'])
205        logger.debug("XhrSetDefaultImageUrl:project.pk=%d" % (project.pk))
206
207        # set any default image recipe
208        if 'targets' in request.POST:
209            default_target = str(request.POST['targets'])
210            project.set_variable(Project.PROJECT_SPECIFIC_DEFAULTIMAGE,default_target)
211            logger.debug("XhrSetDefaultImageUrl,project.pk=%d,project.builddir=%s" % (project.pk,project.builddir))
212            return error_response('ok')
213
214        logger.warning("ERROR:XhrSetDefaultImageUrl")
215        response = HttpResponse()
216        response.status_code = 500
217        return response
218
219
220#
221# Layer Management
222#
223# Rules for 'local_source_dir' layers
224#  * Layers must have a unique name in the Layers table
225#  * A 'local_source_dir' layer is supposed to be shared
226#    by all projects that use it, so that it can have the
227#    same logical name
228#  * Each project that uses a layer will have its own
229#    LayerVersion and Project Layer for it
230#  * During the Project delete process, when the last
231#    LayerVersion for a 'local_source_dir' layer is deleted
232#    then the Layer record is deleted to remove orphans
233#
234
235def scan_layer_content(layer,layer_version):
236    # if this is a local layer directory, we can immediately scan its content
237    if os.path.isdir(layer.local_source_dir):
238        try:
239            # recipes-*/*/*.bb
240            recipes_list = glob.glob(os.path.join(layer.local_source_dir, 'recipes-*/*/*.bb'))
241            for recipe in recipes_list:
242                for recipe in recipes_list.split('\n'):
243                    recipe_path = recipe[recipe.rfind('recipes-'):]
244                    recipe_name = recipe[recipe.rfind('/')+1:].replace('.bb','')
245                    recipe_ver = recipe_name.rfind('_')
246                    if recipe_ver > 0:
247                        recipe_name = recipe_name[0:recipe_ver]
248                    if recipe_name:
249                        ro, created = Recipe.objects.get_or_create(
250                            layer_version=layer_version,
251                            name=recipe_name
252                        )
253                        if created:
254                            ro.file_path = recipe_path
255                            ro.summary = 'Recipe %s from layer %s' % (recipe_name,layer.name)
256                            ro.description = ro.summary
257                        ro.save()
258
259        except Exception as e:
260            logger.warning("ERROR:scan_layer_content: %s" % e)
261    else:
262        logger.warning("ERROR: wrong path given")
263        raise KeyError("local_source_dir")
264
265class XhrLayer(View):
266    """ Delete, Get, Add and Update Layer information
267
268        Methods: GET POST DELETE PUT
269    """
270
271    def get(self, request, *args, **kwargs):
272        """
273        Get layer information
274
275        Method: GET
276        Entry point: /xhr_layer/<project id>/<layerversion_id>
277        """
278
279        try:
280            layer_version = Layer_Version.objects.get(
281                pk=kwargs['layerversion_id'])
282
283            project = Project.objects.get(pk=kwargs['pid'])
284
285            project_layers = ProjectLayer.objects.filter(
286                project=project).values_list("layercommit_id",
287                                             flat=True)
288
289            ret = {
290                'error': 'ok',
291                'id': layer_version.pk,
292                'name': layer_version.layer.name,
293                'layerdetailurl':
294                layer_version.get_detailspage_url(project.pk),
295                'vcs_ref': layer_version.get_vcs_reference(),
296                'vcs_url': layer_version.layer.vcs_url,
297                'local_source_dir': layer_version.layer.local_source_dir,
298                'layerdeps': {
299                    "list": [
300                        {
301                            "id": dep.id,
302                            "name": dep.layer.name,
303                            "layerdetailurl":
304                            dep.get_detailspage_url(project.pk),
305                            "vcs_url": dep.layer.vcs_url,
306                            "vcs_reference": dep.get_vcs_reference()
307                        }
308                        for dep in layer_version.get_alldeps(project.id)]
309                },
310                'projectlayers': list(project_layers)
311            }
312
313            return JsonResponse(ret)
314        except Layer_Version.DoesNotExist:
315            error_response("No such layer")
316
317    def post(self, request, *args, **kwargs):
318        """
319          Update a layer
320
321          Method: POST
322          Entry point: /xhr_layer/<layerversion_id>
323
324          Args:
325              vcs_url, dirpath, commit, up_branch, summary, description,
326              local_source_dir
327
328              add_dep = append a layerversion_id as a dependency
329              rm_dep = remove a layerversion_id as a depedency
330          Returns:
331              {"error": "ok"}
332            or
333              {"error": <error message>}
334        """
335
336        try:
337            # We currently only allow Imported layers to be edited
338            layer_version = Layer_Version.objects.get(
339                id=kwargs['layerversion_id'],
340                project=kwargs['pid'],
341                layer_source=LayerSource.TYPE_IMPORTED)
342
343        except Layer_Version.DoesNotExist:
344            return error_response("Cannot find imported layer to update")
345
346        if "vcs_url" in request.POST:
347            layer_version.layer.vcs_url = request.POST["vcs_url"]
348        if "dirpath" in request.POST:
349            layer_version.dirpath = request.POST["dirpath"]
350        if "commit" in request.POST:
351            layer_version.commit = request.POST["commit"]
352            layer_version.branch = request.POST["commit"]
353        if "summary" in request.POST:
354            layer_version.layer.summary = request.POST["summary"]
355        if "description" in request.POST:
356            layer_version.layer.description = request.POST["description"]
357        if "local_source_dir" in request.POST:
358            layer_version.layer.local_source_dir = \
359                request.POST["local_source_dir"]
360
361        if "add_dep" in request.POST:
362            lvd = LayerVersionDependency(
363                layer_version=layer_version,
364                depends_on_id=request.POST["add_dep"])
365            lvd.save()
366
367        if "rm_dep" in request.POST:
368            rm_dep = LayerVersionDependency.objects.get(
369                layer_version=layer_version,
370                depends_on_id=request.POST["rm_dep"])
371            rm_dep.delete()
372
373        try:
374            layer_version.layer.save()
375            layer_version.save()
376        except Exception as e:
377            return error_response("Could not update layer version entry: %s"
378                                  % e)
379
380        return error_response("ok")
381
382    def put(self, request, *args, **kwargs):
383        """ Add a new layer
384
385        Method: PUT
386        Entry point: /xhr_layer/<project id>/
387        Args:
388            project_id, name,
389            [vcs_url, dir_path, git_ref], [local_source_dir], [layer_deps
390            (csv)]
391
392        """
393
394        try:
395            project = Project.objects.get(pk=kwargs['pid'])
396
397            layer_data = json.loads(request.body.decode('utf-8'))
398
399            # We require a unique layer name as otherwise the lists of layers
400            # becomes very confusing
401            existing_layers = \
402                project.get_all_compatible_layer_versions().values_list(
403                    "layer__name",
404                    flat=True)
405
406            add_to_project = False
407            layer_deps_added = []
408            if 'add_to_project' in layer_data:
409                add_to_project = True
410
411            if layer_data['name'] in existing_layers:
412                return JsonResponse({"error": "layer-name-exists"})
413
414            if ('local_source_dir' in layer_data):
415                # Local layer can be shared across projects. They have no 'release'
416                # and are not included in get_all_compatible_layer_versions() above
417                layer,created = Layer.objects.get_or_create(name=layer_data['name'])
418                _log("Local Layer created=%s" % created)
419            else:
420                layer = Layer.objects.create(name=layer_data['name'])
421
422            layer_version = Layer_Version.objects.create(
423                layer=layer,
424                project=project,
425                layer_source=LayerSource.TYPE_IMPORTED)
426
427            # Local layer
428            if ('local_source_dir' in layer_data): ### and layer.local_source_dir:
429                layer.local_source_dir = layer_data['local_source_dir']
430            # git layer
431            elif 'vcs_url' in layer_data:
432                layer.vcs_url = layer_data['vcs_url']
433                layer_version.dirpath = layer_data['dir_path']
434                layer_version.commit = layer_data['git_ref']
435                layer_version.branch = layer_data['git_ref']
436
437            layer.save()
438            layer_version.save()
439
440            if add_to_project:
441                ProjectLayer.objects.get_or_create(
442                    layercommit=layer_version, project=project)
443
444            # Add the layer dependencies
445            if 'layer_deps' in layer_data:
446                for layer_dep_id in layer_data['layer_deps'].split(","):
447                    layer_dep = Layer_Version.objects.get(pk=layer_dep_id)
448                    LayerVersionDependency.objects.get_or_create(
449                        layer_version=layer_version, depends_on=layer_dep)
450
451                    # Add layer deps to the project if specified
452                    if add_to_project:
453                        created, pl = ProjectLayer.objects.get_or_create(
454                            layercommit=layer_dep, project=project)
455                        layer_deps_added.append(
456                            {'name': layer_dep.layer.name,
457                             'layerdetailurl':
458                             layer_dep.get_detailspage_url(project.pk)})
459
460            # Only scan_layer_content if layer is local
461            if layer_data.get('local_source_dir', None):
462                # Scan the layer's content and update components
463                scan_layer_content(layer,layer_version)
464
465        except Layer_Version.DoesNotExist:
466            return error_response("layer-dep-not-found")
467        except Project.DoesNotExist:
468            return error_response("project-not-found")
469        except KeyError as e:
470            _log("KeyError: %s" % e)
471            return error_response(f"incorrect-parameters")
472
473        return JsonResponse({'error': "ok",
474                             'imported_layer': {
475                                 'name': layer.name,
476                                 'layerdetailurl':
477                                 layer_version.get_detailspage_url()},
478                             'deps_added': layer_deps_added})
479
480    def delete(self, request, *args, **kwargs):
481        """ Delete an imported layer
482
483        Method: DELETE
484        Entry point: /xhr_layer/<projed id>/<layerversion_id>
485
486        """
487        try:
488            # We currently only allow Imported layers to be deleted
489            layer_version = Layer_Version.objects.get(
490                id=kwargs['layerversion_id'],
491                project=kwargs['pid'],
492                layer_source=LayerSource.TYPE_IMPORTED)
493        except Layer_Version.DoesNotExist:
494            return error_response("Cannot find imported layer to delete")
495
496        try:
497            ProjectLayer.objects.get(project=kwargs['pid'],
498                                     layercommit=layer_version).delete()
499        except ProjectLayer.DoesNotExist:
500            pass
501
502        layer_version.layer.delete()
503        layer_version.delete()
504
505        return JsonResponse({
506            "error": "ok",
507            "gotoUrl": reverse('projectlayers', args=(kwargs['pid'],))
508        })
509
510
511class XhrCustomRecipe(View):
512    """ Create a custom image recipe """
513
514    def post(self, request, *args, **kwargs):
515        """
516        Custom image recipe REST API
517
518        Entry point: /xhr_customrecipe/
519        Method: POST
520
521        Args:
522            name: name of custom recipe to create
523            project: target project id of orm.models.Project
524            base: base recipe id of orm.models.Recipe
525
526        Returns:
527            {"error": "ok",
528             "url": <url of the created recipe>}
529            or
530            {"error": <error message>}
531        """
532        # check if request has all required parameters
533        for param in ('name', 'project', 'base'):
534            if param not in request.POST:
535                return error_response("Missing parameter '%s'" % param)
536
537        # get project and baserecipe objects
538        params = {}
539        for name, model in [("project", Project),
540                            ("base", Recipe)]:
541            value = request.POST[name]
542            try:
543                params[name] = model.objects.get(id=value)
544            except model.DoesNotExist:
545                return error_response("Invalid %s id %s" % (name, value))
546
547        # create custom recipe
548        try:
549
550            # Only allowed chars in name are a-z, 0-9 and -
551            if re.search(r'[^a-z|0-9|-]', request.POST["name"]):
552                return error_response("invalid-name")
553
554            custom_images = CustomImageRecipe.objects.all()
555
556            # Are there any recipes with this name already in our project?
557            existing_image_recipes_in_project = custom_images.filter(
558                name=request.POST["name"], project=params["project"])
559
560            if existing_image_recipes_in_project.count() > 0:
561                return error_response("image-already-exists")
562
563            # Are there any recipes with this name which aren't custom
564            # image recipes?
565            custom_image_ids = custom_images.values_list('id', flat=True)
566            existing_non_image_recipes = Recipe.objects.filter(
567                Q(name=request.POST["name"]) & ~Q(pk__in=custom_image_ids)
568            )
569
570            if existing_non_image_recipes.count() > 0:
571                return error_response("recipe-already-exists")
572
573            # create layer 'Custom layer' and verion if needed
574            layer, l_created = Layer.objects.get_or_create(
575                name=CustomImageRecipe.LAYER_NAME,
576                summary="Layer for custom recipes")
577
578            if l_created:
579                layer.local_source_dir = "toaster_created_layer"
580                layer.save()
581
582            # Check if we have a layer version already
583            # We don't use get_or_create here because the dirpath will change
584            # and is a required field
585            lver = Layer_Version.objects.filter(Q(project=params['project']) &
586                                                Q(layer=layer) &
587                                                Q(build=None)).last()
588            if lver is None:
589                lver, lv_created = Layer_Version.objects.get_or_create(
590                    project=params['project'],
591                    layer=layer,
592                    layer_source=LayerSource.TYPE_LOCAL,
593                    dirpath="toaster_created_layer")
594
595            # Add a dependency on our layer to the base recipe's layer
596            LayerVersionDependency.objects.get_or_create(
597                layer_version=lver,
598                depends_on=params["base"].layer_version)
599
600            # Add it to our current project if needed
601            ProjectLayer.objects.get_or_create(project=params['project'],
602                                               layercommit=lver,
603                                               optional=False)
604
605            # Create the actual recipe
606            recipe, r_created = CustomImageRecipe.objects.get_or_create(
607                name=request.POST["name"],
608                base_recipe=params["base"],
609                project=params["project"],
610                layer_version=lver,
611                is_image=True)
612
613            # If we created the object then setup these fields. They may get
614            # overwritten later on and cause the get_or_create to create a
615            # duplicate if they've changed.
616            if r_created:
617                recipe.file_path = request.POST["name"]
618                recipe.license = "MIT"
619                recipe.version = "0.1"
620                recipe.save()
621
622        except Error as err:
623            return error_response("Can't create custom recipe: %s" % err)
624
625        # Find the package list from the last build of this recipe/target
626        target = Target.objects.filter(Q(build__outcome=Build.SUCCEEDED) &
627                                       Q(build__project=params['project']) &
628                                       (Q(target=params['base'].name) |
629                                        Q(target=recipe.name))).last()
630        if target:
631            # Copy in every package
632            # We don't want these packages to be linked to anything because
633            # that underlying data may change e.g. delete a build
634            for tpackage in target.target_installed_package_set.all():
635                try:
636                    built_package = tpackage.package
637                    # The package had no recipe information so is a ghost
638                    # package skip it
639                    if built_package.recipe is None:
640                        continue
641
642                    config_package = CustomImagePackage.objects.get(
643                        name=built_package.name)
644
645                    recipe.includes_set.add(config_package)
646                except Exception as e:
647                    logger.warning("Error adding package %s %s" %
648                                   (tpackage.package.name, e))
649                    pass
650
651        # pre-create layer directory structure, so that other builds
652        # are not blocked by this new recipe dependecy
653        # NOTE: this is parallel code to 'localhostbecontroller.py'
654        be = BuildEnvironment.objects.all()[0]
655        layerpath = os.path.join(be.builddir,
656                                 CustomImageRecipe.LAYER_NAME)
657        for name in ("conf", "recipes"):
658            path = os.path.join(layerpath, name)
659            if not os.path.isdir(path):
660                os.makedirs(path)
661        # pre-create layer.conf
662        config = os.path.join(layerpath, "conf", "layer.conf")
663        if not os.path.isfile(config):
664            with open(config, "w") as conf:
665                conf.write('BBPATH .= ":${LAYERDIR}"\nBBFILES += "${LAYERDIR}/recipes/*.bb"\n')
666        # pre-create new image's recipe file
667        recipe_path = os.path.join(layerpath, "recipes", "%s.bb" %
668                                   recipe.name)
669        with open(recipe_path, "w") as recipef:
670            content = recipe.generate_recipe_file_contents()
671            if not content:
672                # Delete this incomplete image recipe object
673                recipe.delete()
674                return error_response("recipe-parent-not-exist")
675            else:
676                recipef.write(recipe.generate_recipe_file_contents())
677
678        return JsonResponse(
679            {"error": "ok",
680             "packages": recipe.get_all_packages().count(),
681             "url": reverse('customrecipe', args=(params['project'].pk,
682                                                  recipe.id))})
683
684
685class XhrCustomRecipeId(View):
686    """
687    Set of ReST API processors working with recipe id.
688
689    Entry point: /xhr_customrecipe/<recipe_id>
690
691    Methods:
692        GET - Get details of custom image recipe
693        DELETE - Delete custom image recipe
694
695    Returns:
696        GET:
697        {"error": "ok",
698        "info": dictionary of field name -> value pairs
699        of the CustomImageRecipe model}
700        DELETE:
701        {"error": "ok"}
702          or
703        {"error": <error message>}
704    """
705    @staticmethod
706    def _get_ci_recipe(recipe_id):
707        """ Get Custom Image recipe or return an error response"""
708        try:
709            custom_recipe = \
710                CustomImageRecipe.objects.get(pk=recipe_id)
711            return custom_recipe, None
712
713        except CustomImageRecipe.DoesNotExist:
714            return None, error_response("Custom recipe with id=%s "
715                                        "not found" % recipe_id)
716
717    def get(self, request, *args, **kwargs):
718        custom_recipe, error = self._get_ci_recipe(kwargs['recipe_id'])
719        if error:
720            return error
721
722        if request.method == 'GET':
723            info = {"id": custom_recipe.id,
724                    "name": custom_recipe.name,
725                    "base_recipe_id": custom_recipe.base_recipe.id,
726                    "project_id": custom_recipe.project.id}
727
728            return JsonResponse({"error": "ok", "info": info})
729
730    def delete(self, request, *args, **kwargs):
731        custom_recipe, error = self._get_ci_recipe(kwargs['recipe_id'])
732        if error:
733            return error
734
735        project = custom_recipe.project
736
737        custom_recipe.delete()
738        return JsonResponse({"error": "ok",
739                             "gotoUrl": reverse("projectcustomimages",
740                                                args=(project.pk,))})
741
742
743class XhrCustomRecipePackages(View):
744    """
745    ReST API to add/remove packages to/from custom recipe.
746
747    Entry point: /xhr_customrecipe/<recipe_id>/packages/<package_id>
748    Methods:
749         PUT - Add package to the recipe
750         DELETE - Delete package from the recipe
751         GET - Get package information
752
753     Returns:
754         {"error": "ok"}
755          or
756          {"error": <error message>}
757    """
758    @staticmethod
759    def _get_package(package_id):
760        try:
761            package = CustomImagePackage.objects.get(pk=package_id)
762            return package, None
763        except Package.DoesNotExist:
764            return None, error_response("Package with id=%s "
765                                        "not found" % package_id)
766
767    def _traverse_dependents(self, next_package_id,
768                             rev_deps, all_current_packages, tree_level=0):
769        """
770        Recurse through reverse dependency tree for next_package_id.
771        Limit the reverse dependency search to packages not already scanned,
772        that is, not already in rev_deps.
773        Limit the scan to a depth (tree_level) not exceeding the count of
774        all packages in the custom image, and if that depth is exceeded
775        return False, pop out of the recursion, and write a warning
776        to the log, but this is unlikely, suggesting a dependency loop
777        not caught by bitbake.
778        On return, the input/output arg rev_deps is appended with queryset
779        dictionary elements, annotated for use in the customimage template.
780        The list has unsorted, but unique elements.
781        """
782        max_dependency_tree_depth = all_current_packages.count()
783        if tree_level >= max_dependency_tree_depth:
784            logger.warning(
785                "The number of reverse dependencies "
786                "for this package exceeds " + max_dependency_tree_depth +
787                " and the remaining reverse dependencies will not be removed")
788            return True
789
790        package = CustomImagePackage.objects.get(id=next_package_id)
791        dependents = \
792            package.package_dependencies_target.annotate(
793                name=F('package__name'),
794                pk=F('package__pk'),
795                size=F('package__size'),
796            ).values("name", "pk", "size").exclude(
797                ~Q(pk__in=all_current_packages)
798            )
799
800        for pkg in dependents:
801            if pkg in rev_deps:
802                # already seen, skip dependent search
803                continue
804
805            rev_deps.append(pkg)
806            if (self._traverse_dependents(pkg["pk"], rev_deps,
807                                          all_current_packages,
808                                          tree_level+1)):
809                return True
810
811        return False
812
813    def _get_all_dependents(self, package_id, all_current_packages):
814        """
815        Returns sorted list of recursive reverse dependencies for package_id,
816        as a list of dictionary items, by recursing through dependency
817        relationships.
818        """
819        rev_deps = []
820        self._traverse_dependents(package_id, rev_deps, all_current_packages)
821        rev_deps = sorted(rev_deps, key=lambda x: x["name"])
822        return rev_deps
823
824    def get(self, request, *args, **kwargs):
825        recipe, error = XhrCustomRecipeId._get_ci_recipe(
826            kwargs['recipe_id'])
827        if error:
828            return error
829
830        # If no package_id then list all the current packages
831        if not kwargs['package_id']:
832            total_size = 0
833            packages = recipe.get_all_packages().values("id",
834                                                        "name",
835                                                        "version",
836                                                        "size")
837            for package in packages:
838                package['size_formatted'] = \
839                    filtered_filesizeformat(package['size'])
840                total_size += package['size']
841
842            return JsonResponse({"error": "ok",
843                                 "packages": list(packages),
844                                 "total": len(packages),
845                                 "total_size": total_size,
846                                 "total_size_formatted":
847                                 filtered_filesizeformat(total_size)})
848        else:
849            package, error = XhrCustomRecipePackages._get_package(
850                kwargs['package_id'])
851            if error:
852                return error
853
854            all_current_packages = recipe.get_all_packages()
855
856            # Dependencies for package which aren't satisfied by the
857            # current packages in the custom image recipe
858            deps = package.package_dependencies_source.for_target_or_none(
859                recipe.name)['packages'].annotate(
860                name=F('depends_on__name'),
861                pk=F('depends_on__pk'),
862                size=F('depends_on__size'),
863                ).values("name", "pk", "size").filter(
864                # There are two depends types we don't know why
865                (Q(dep_type=Package_Dependency.TYPE_TRDEPENDS) |
866                 Q(dep_type=Package_Dependency.TYPE_RDEPENDS)) &
867                ~Q(pk__in=all_current_packages)
868                )
869
870            # Reverse dependencies which are needed by packages that are
871            # in the image. Recursive search providing all dependents,
872            # not just immediate dependents.
873            reverse_deps = self._get_all_dependents(kwargs['package_id'],
874                                                    all_current_packages)
875            total_size_deps = 0
876            total_size_reverse_deps = 0
877
878            for dep in deps:
879                dep['size_formatted'] = \
880                    filtered_filesizeformat(dep['size'])
881                total_size_deps += dep['size']
882
883            for dep in reverse_deps:
884                dep['size_formatted'] = \
885                    filtered_filesizeformat(dep['size'])
886                total_size_reverse_deps += dep['size']
887
888            return JsonResponse(
889                {"error": "ok",
890                 "id": package.pk,
891                 "name": package.name,
892                 "version": package.version,
893                 "unsatisfied_dependencies": list(deps),
894                 "unsatisfied_dependencies_size": total_size_deps,
895                 "unsatisfied_dependencies_size_formatted":
896                 filtered_filesizeformat(total_size_deps),
897                 "reverse_dependencies": list(reverse_deps),
898                 "reverse_dependencies_size": total_size_reverse_deps,
899                 "reverse_dependencies_size_formatted":
900                 filtered_filesizeformat(total_size_reverse_deps)})
901
902    def put(self, request, *args, **kwargs):
903        recipe, error = XhrCustomRecipeId._get_ci_recipe(kwargs['recipe_id'])
904        package, error = self._get_package(kwargs['package_id'])
905        if error:
906            return error
907
908        included_packages = recipe.includes_set.values_list('pk',
909                                                            flat=True)
910
911        # If we're adding back a package which used to be included in this
912        # image all we need to do is remove it from the excludes
913        if package.pk in included_packages:
914            try:
915                recipe.excludes_set.remove(package)
916                return {"error": "ok"}
917            except Package.DoesNotExist:
918                return error_response("Package %s not found in excludes"
919                                      " but was in included list" %
920                                      package.name)
921        else:
922            recipe.appends_set.add(package)
923            # Make sure that package is not in the excludes set
924            try:
925                recipe.excludes_set.remove(package)
926            except:
927                pass
928
929        # Add the dependencies we think will be added to the recipe
930        # as a result of appending this package.
931        # TODO this should recurse down the entire deps tree
932        for dep in package.package_dependencies_source.all_depends():
933            try:
934                cust_package = CustomImagePackage.objects.get(
935                    name=dep.depends_on.name)
936
937                recipe.includes_set.add(cust_package)
938                try:
939                    # When adding the pre-requisite package, make
940                    # sure it's not in the excluded list from a
941                    # prior removal.
942                    recipe.excludes_set.remove(cust_package)
943                except package.DoesNotExist:
944                    # Don't care if the package had never been excluded
945                    pass
946            except:
947                logger.warning("Could not add package's suggested"
948                               "dependencies to the list")
949        return JsonResponse({"error": "ok"})
950
951    def delete(self, request, *args, **kwargs):
952        recipe, error = XhrCustomRecipeId._get_ci_recipe(kwargs['recipe_id'])
953        package, error = self._get_package(kwargs['package_id'])
954        if error:
955            return error
956
957        try:
958            included_packages = recipe.includes_set.values_list('pk',
959                                                                flat=True)
960            # If we're deleting a package which is included we need to
961            # Add it to the excludes list.
962            if package.pk in included_packages:
963                recipe.excludes_set.add(package)
964            else:
965                recipe.appends_set.remove(package)
966
967            # remove dependencies as well
968            all_current_packages = recipe.get_all_packages()
969
970            reverse_deps_dictlist = self._get_all_dependents(
971                package.pk,
972                all_current_packages)
973
974            ids = [entry['pk'] for entry in reverse_deps_dictlist]
975            reverse_deps = CustomImagePackage.objects.filter(id__in=ids)
976            for r in reverse_deps:
977                try:
978                    if r.id in included_packages:
979                        recipe.excludes_set.add(r)
980                    else:
981                        recipe.appends_set.remove(r)
982                except:
983                    pass
984
985            return JsonResponse({"error": "ok"})
986        except CustomImageRecipe.DoesNotExist:
987            return error_response("Tried to remove package that wasn't"
988                                  " present")
989
990
991class XhrProject(View):
992    """ Create, delete or edit a project
993
994    Entry point: /xhr_project/<project_id>
995    """
996    def post(self, request, *args, **kwargs):
997        """
998          Edit project control
999
1000          Args:
1001              layerAdd = layer_version_id layer_version_id ...
1002              layerDel = layer_version_id layer_version_id ...
1003              projectName = new_project_name
1004              machineName = new_machine_name
1005
1006          Returns:
1007              {"error": "ok"}
1008            or
1009              {"error": <error message>}
1010        """
1011        try:
1012            prj = Project.objects.get(pk=kwargs['project_id'])
1013        except Project.DoesNotExist:
1014            return error_response("No such project")
1015
1016        # Add layers
1017        if 'layerAdd' in request.POST and len(request.POST['layerAdd']) > 0:
1018            for layer_version_id in request.POST['layerAdd'].split(','):
1019                try:
1020                    lv = Layer_Version.objects.get(pk=int(layer_version_id))
1021                    ProjectLayer.objects.get_or_create(project=prj,
1022                                                       layercommit=lv)
1023                except Layer_Version.DoesNotExist:
1024                    return error_response("Layer version %s asked to add "
1025                                          "doesn't exist" % layer_version_id)
1026
1027        # Remove layers
1028        if 'layerDel' in request.POST and len(request.POST['layerDel']) > 0:
1029            layer_version_ids = request.POST['layerDel'].split(',')
1030            ProjectLayer.objects.filter(
1031                project=prj,
1032                layercommit_id__in=layer_version_ids).delete()
1033
1034        # Project name change
1035        if 'projectName' in request.POST:
1036            prj.name = request.POST['projectName']
1037            prj.save()
1038
1039        # Machine name change
1040        if 'machineName' in request.POST:
1041            machinevar = prj.projectvariable_set.get(name="MACHINE")
1042            machinevar.value = request.POST['machineName']
1043            machinevar.save()
1044
1045        # Distro name change
1046        if 'distroName' in request.POST:
1047            distrovar = prj.projectvariable_set.get(name="DISTRO")
1048            distrovar.value = request.POST['distroName']
1049            distrovar.save()
1050
1051        return JsonResponse({"error": "ok"})
1052
1053    def get(self, request, *args, **kwargs):
1054        """
1055        Returns:
1056            json object representing the current project
1057        or:
1058            {"error": <error message>}
1059        """
1060
1061        try:
1062            project = Project.objects.get(pk=kwargs['project_id'])
1063        except Project.DoesNotExist:
1064            return error_response("Project %s does not exist" %
1065                                  kwargs['project_id'])
1066
1067        # Create the frequently built targets list
1068
1069        freqtargets = Counter(Target.objects.filter(
1070            Q(build__project=project),
1071            ~Q(build__outcome=Build.IN_PROGRESS)
1072        ).order_by("target").values_list("target", flat=True))
1073
1074        freqtargets = freqtargets.most_common(5)
1075
1076        # We now have the targets in order of frequency but if there are two
1077        # with the same frequency then we need to make sure those are in
1078        # alphabetical order without losing the frequency ordering
1079
1080        tmp = []
1081        switch = None
1082        for i, freqtartget in enumerate(freqtargets):
1083            target, count = freqtartget
1084            try:
1085                target_next, count_next = freqtargets[i+1]
1086                if count == count_next and target > target_next:
1087                    switch = target
1088                    continue
1089            except IndexError:
1090                pass
1091
1092            tmp.append(target)
1093
1094            if switch:
1095                tmp.append(switch)
1096                switch = None
1097
1098        freqtargets = tmp
1099
1100        layers = []
1101        for layer in project.projectlayer_set.all():
1102            layers.append({
1103                "id": layer.layercommit.pk,
1104                "name": layer.layercommit.layer.name,
1105                "vcs_url": layer.layercommit.layer.vcs_url,
1106                "local_source_dir": layer.layercommit.layer.local_source_dir,
1107                "vcs_reference": layer.layercommit.get_vcs_reference(),
1108                "url": layer.layercommit.layer.layer_index_url,
1109                "layerdetailurl": layer.layercommit.get_detailspage_url(
1110                    project.pk),
1111                "xhrLayerUrl": reverse("xhr_layer",
1112                                       args=(project.pk,
1113                                             layer.layercommit.pk)),
1114                "layersource": layer.layercommit.layer_source
1115            })
1116
1117        data = {
1118            "name": project.name,
1119            "layers": layers,
1120            "freqtargets": freqtargets,
1121        }
1122
1123        if project.release is not None:
1124            data['release'] = {
1125                "id": project.release.pk,
1126                "name": project.release.name,
1127                "description": project.release.description
1128            }
1129
1130        try:
1131            data["machine"] = {"name":
1132                               project.projectvariable_set.get(
1133                                   name="MACHINE").value}
1134        except ProjectVariable.DoesNotExist:
1135            data["machine"] = None
1136        try:
1137            data["distro"] = {"name":
1138                               project.projectvariable_set.get(
1139                                   name="DISTRO").value}
1140        except ProjectVariable.DoesNotExist:
1141            data["distro"] = None
1142
1143        data['error'] = "ok"
1144
1145        return JsonResponse(data)
1146
1147    def put(self, request, *args, **kwargs):
1148        # TODO create new project api
1149        return HttpResponse()
1150
1151    def delete(self, request, *args, **kwargs):
1152        """Delete a project. Cancels any builds in progress"""
1153        try:
1154            project = Project.objects.get(pk=kwargs['project_id'])
1155            # Cancel any builds in progress
1156            for br in BuildRequest.objects.filter(
1157                    project=project,
1158                    state=BuildRequest.REQ_INPROGRESS):
1159                XhrBuildRequest.cancel_build(br)
1160
1161            # gather potential orphaned local layers attached to this project
1162            project_local_layer_list = []
1163            for pl in ProjectLayer.objects.filter(project=project):
1164                if pl.layercommit.layer_source == LayerSource.TYPE_IMPORTED:
1165                    project_local_layer_list.append(pl.layercommit.layer)
1166
1167            # deep delete the project and its dependencies
1168            project.delete()
1169
1170            # delete any local layers now orphaned
1171            _log("LAYER_ORPHAN_CHECK:Check for orphaned layers")
1172            for layer in project_local_layer_list:
1173                layer_refs = Layer_Version.objects.filter(layer=layer)
1174                _log("LAYER_ORPHAN_CHECK:Ref Count for '%s' = %d" % (layer.name,len(layer_refs)))
1175                if 0 == len(layer_refs):
1176                    _log("LAYER_ORPHAN_CHECK:DELETE orpahned '%s'" % (layer.name))
1177                    Layer.objects.filter(pk=layer.id).delete()
1178
1179        except Project.DoesNotExist:
1180            return error_response("Project %s does not exist" %
1181                                  kwargs['project_id'])
1182
1183        return JsonResponse({
1184            "error": "ok",
1185            "gotoUrl": reverse("all-projects", args=[])
1186        })
1187
1188
1189class XhrBuild(View):
1190    """ Delete a build object
1191
1192    Entry point: /xhr_build/<build_id>
1193    """
1194    def delete(self, request, *args, **kwargs):
1195        """
1196          Delete build data
1197
1198          Args:
1199              build_id = build_id
1200
1201          Returns:
1202              {"error": "ok"}
1203            or
1204              {"error": <error message>}
1205        """
1206        try:
1207            build = Build.objects.get(pk=kwargs['build_id'])
1208            project = build.project
1209            build.delete()
1210        except Build.DoesNotExist:
1211            return error_response("Build %s does not exist" %
1212                                  kwargs['build_id'])
1213        return JsonResponse({
1214            "error": "ok",
1215            "gotoUrl": reverse("projectbuilds", args=(project.pk,))
1216        })
1217