diff mbox series

[v2,3/5] toaster: Store recipe variable snapshots

Message ID 20260814153602.2107764-4-paolo.wattebled@savoirfairelinux.com
State New
Headers show
Series toaster: Add per-recipe variable view | expand

Commit Message

Paolo Wattebled Aug. 14, 2026, 3:35 p.m. UTC
Persist compressed recipe variable snapshots from dependency graph data
and replace each build's snapshot set atomically.

Keep existing data when older dependency payloads omit the field, remove
stale rows on refresh and cover rollback behavior with database tests.

AI-Generated: Uses GitHub Copilot and OpenCode with GPT-5.6 Sol
Signed-off-by: Paolo Wattebled <paolo.wattebled@savoirfairelinux.com>
---
 lib/bb/ui/buildinfohelper.py    |  21 ++++++-
 lib/toaster/tests/db/test_db.py | 101 ++++++++++++++++++++++++++++++++
 2 files changed, 121 insertions(+), 1 deletion(-)
diff mbox series

Patch

diff --git a/lib/bb/ui/buildinfohelper.py b/lib/bb/ui/buildinfohelper.py
index 4ee45d67a..679f016bd 100644
--- a/lib/bb/ui/buildinfohelper.py
+++ b/lib/bb/ui/buildinfohelper.py
@@ -8,8 +8,10 @@ 
 
 import sys
 import bb
+import json
 import re
 import os
+import zlib
 
 import django
 from django.utils import timezone
@@ -31,7 +33,7 @@  from orm.models import Target_Image_File, TargetKernelFile, TargetSDKFile
 from orm.models import Variable, VariableHistory
 from orm.models import Package, Package_File, Target_Installed_Package, Target_File
 from orm.models import Task_Dependency, Package_Dependency
-from orm.models import Recipe_Dependency, Provides
+from orm.models import Recipe_Dependency, RecipeVariable, Provides
 from orm.models import Project, CustomImagePackage
 from orm.models import signal_runbuilds
 
@@ -1459,6 +1461,23 @@  class BuildInfoHelper(object):
                         t.save()
             self.internal_state['recipes'][pn] = recipe
 
+        if any('recipe_variables' in recipe_data
+               for recipe_data in event._depgraph['pn'].values()):
+            recipe_variables = []
+            for pn, recipe_data in event._depgraph['pn'].items():
+                variables = recipe_data.get('recipe_variables')
+                if variables is None:
+                    continue
+                recipe_variables.append(RecipeVariable(
+                    build=self.internal_state['build'],
+                    recipe=self.internal_state['recipes'][pn],
+                    variables=variables,
+                    variable_count=len(json.loads(zlib.decompress(variables)))))
+            with transaction.atomic():
+                RecipeVariable.objects.filter(
+                    build=self.internal_state['build']).delete()
+                RecipeVariable.objects.bulk_create(recipe_variables)
+
         # we'll not get recipes for key w/ values listed in ASSUME_PROVIDED
 
         assume_provided = self.server.runCommand(["getVariable", "ASSUME_PROVIDED"])[0].split()
diff --git a/lib/toaster/tests/db/test_db.py b/lib/toaster/tests/db/test_db.py
index 072ab9436..7c48b1de3 100644
--- a/lib/toaster/tests/db/test_db.py
+++ b/lib/toaster/tests/db/test_db.py
@@ -24,6 +24,10 @@ 
 
 import sys
 import pytest
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+import json
+import zlib
 
 try:
     from StringIO import StringIO
@@ -34,6 +38,11 @@  from contextlib import contextmanager
 
 from django.core import management
 from django.test import TestCase
+from django.utils import timezone
+
+from bb.ui.buildinfohelper import BuildInfoHelper, ORMWrapper
+from orm.models import Build, Layer, Layer_Version, Project, Recipe
+from orm.models import RecipeVariable
 
 
 @contextmanager
@@ -56,3 +65,95 @@  class MigrationTest(TestCase):
 
         with capture(makemigrations) as output:
             self.assertEqual(output, "No changes detected\n")
+
+
+class RecipeVariableTest(TestCase):
+
+    def setUp(self):
+        now = timezone.now()
+        project = Project.objects.get_or_create_default_project()
+        self.build = Build.objects.create(
+            project=project, machine='', distro='', distro_version='',
+            started_on=now, completed_on=now, cooker_log_path='',
+            bitbake_version='', progress_item='')
+        layer = Layer.objects.create(name='test', layer_index_url='')
+        self.layer_version = Layer_Version.objects.create(
+            build=self.build, layer=layer, branch='', commit='',
+            local_path='/layer')
+
+    def _store(self, recipe_data):
+        if 'recipe_variables' in recipe_data:
+            recipe_data = dict(recipe_data)
+            recipe_data['recipe_variables'] = zlib.compress(json.dumps(
+                recipe_data['recipe_variables'], separators=(',', ':')
+            ).encode('utf-8'))
+        helper = BuildInfoHelper.__new__(BuildInfoHelper)
+        helper.internal_state = {'build': self.build, 'targets': []}
+        helper.orm_wrapper = ORMWrapper()
+        helper.server = Mock()
+        helper.server.runCommand.return_value = ['', None]
+        helper._get_layer_version_for_path = Mock(return_value=self.layer_version)
+        event = SimpleNamespace(_depgraph={
+            'layer-priorities': [],
+            'pn': {'test': dict({'filename': '/layer/test.bb'}, **recipe_data)},
+            'depends': {'test': []},
+            'tdepends': {},
+        })
+
+        helper.store_dependency_information(event)
+
+        return helper.internal_state['recipes']['test']
+
+    def test_dependency_information_stores_recipe_variables(self):
+        recipe = self._store({'recipe_variables': {'EMPTY': '', 'FOO': 'bar'}})
+
+        snapshot = RecipeVariable.objects.get(build=self.build, recipe=recipe)
+        self.assertEqual(
+            json.loads(zlib.decompress(snapshot.variables)),
+            {'EMPTY': '', 'FOO': 'bar'})
+        self.assertEqual(snapshot.variable_count, 2)
+
+    def test_old_payload_does_not_delete_recipe_variables(self):
+        recipe = self._store({'recipe_variables': {'FOO': 'bar'}})
+        self._store({})
+
+        snapshot = RecipeVariable.objects.get(build=self.build, recipe=recipe)
+        self.assertEqual(
+            json.loads(zlib.decompress(snapshot.variables)), {'FOO': 'bar'})
+
+    def test_dependency_information_replaces_recipe_variables(self):
+        recipe = self._store({'recipe_variables': {'FOO': 'old'}})
+        self._store({'recipe_variables': {'BAR': 'new'}})
+
+        snapshot = RecipeVariable.objects.get(build=self.build, recipe=recipe)
+        self.assertEqual(
+            json.loads(zlib.decompress(snapshot.variables)), {'BAR': 'new'})
+        self.assertEqual(snapshot.variable_count, 1)
+
+    def test_dependency_information_removes_stale_recipe_snapshot(self):
+        stale_recipe = Recipe.objects.create(
+            name='stale', version='', layer_version=self.layer_version,
+            file_path='stale.bb')
+        RecipeVariable.objects.create(
+            build=self.build, recipe=stale_recipe,
+            variables=zlib.compress(b'{"STALE":"value"}'))
+
+        current_recipe = self._store({'recipe_variables': {'FOO': 'bar'}})
+
+        self.assertFalse(RecipeVariable.objects.filter(
+            build=self.build, recipe=stale_recipe).exists())
+        self.assertTrue(RecipeVariable.objects.filter(
+            build=self.build, recipe=current_recipe).exists())
+
+    def test_dependency_information_rolls_back_failed_bulk_create(self):
+        recipe = self._store({'recipe_variables': {'FOO': 'old'}})
+        with patch.object(RecipeVariable.objects, 'bulk_create',
+                          side_effect=RuntimeError('injected failure')):
+            with self.assertRaisesRegex(RuntimeError, 'injected failure'):
+                self._store({'recipe_variables': {'FOO': 'new'}})
+
+        snapshots = RecipeVariable.objects.filter(build=self.build)
+        self.assertEqual(snapshots.count(), 1)
+        snapshot = snapshots.get(recipe=recipe)
+        self.assertEqual(
+            json.loads(zlib.decompress(snapshot.variables)), {'FOO': 'old'})