Repository: canonical/snapcraft

Issue:
record lxc image used
[design](https://forum.snapcraft.io/t/record-container-image-information/2527)


Discussion:

Resolving patch:
diff --git a/snapcraft/internal/errors.py b/snapcraft/internal/errors.py
--- a/snapcraft/internal/errors.py
+++ b/snapcraft/internal/errors.py
@@ -347,3 +347,11 @@ class OsReleaseVersionIdError(SnapcraftError):
 class OsReleaseCodenameError(SnapcraftError):
 
     fmt = 'Unable to determine host OS version codename'
+
+
+class InvalidContainerImageInfoError(SnapcraftError):
+
+    fmt = 'Error parsing the container image info: {image_info}'
+
+    def __init__(self, image_info):
+        super().__init__(image_info=image_info)
diff --git a/snapcraft/internal/lxd/_containerbuild.py b/snapcraft/internal/lxd/_containerbuild.py
--- a/snapcraft/internal/lxd/_containerbuild.py
+++ b/snapcraft/internal/lxd/_containerbuild.py
@@ -15,6 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import collections
 import json
 import logging
 import os
@@ -138,6 +139,33 @@ def _configure_container(self):
         subprocess.check_call([
             'lxc', 'config', 'set', self._container_name,
             'environment.LC_ALL', 'C.UTF-8'])
+        self._set_image_info_env_var()
+
+    def _set_image_info_env_var(self):
+        FAILURE_WARNING_FORMAT = (
+            'Failed to get container image info: {}\n'
+            'It will not be recorded in manifest.')
+        try:
+            image_info = json.loads(subprocess.check_output(
+                ['lxc', 'image', 'list',
+                 '--format=json', self._image]).decode())
+        except subprocess.CalledProcessError as e:
+            message = '{}, output: {}'.format(str(e), e.output)
+            logger.warning(FAILURE_WARNING_FORMAT.format(message))
+            return
+        except json.decoder.JSONDecodeError as e:
+            logger.warning(FAILURE_WARNING_FORMAT.format('Not in JSON format'))
+            return
+        edited_image_info = collections.OrderedDict()
+        for field in ('fingerprint', 'architecture', 'created_at'):
+            if field in image_info[0]:
+                edited_image_info[field] = image_info[0][field]
+        # Pass the image info to the container so it can be used when recording
+        # information about the build environment.
+        subprocess.check_call([
+            'lxc', 'config', 'set', self._container_name,
+            'environment.SNAPCRAFT_IMAGE_INFO',
+            json.dumps(edited_image_info)])
 
     def execute(self, step='snap', args=None):
         with self._container_running():
diff --git a/snapcraft/internal/meta.py b/snapcraft/internal/meta.py
--- a/snapcraft/internal/meta.py
+++ b/snapcraft/internal/meta.py
@@ -18,6 +18,7 @@
 import collections
 import contextlib
 import configparser
+import json
 import logging
 import os
 import re
@@ -157,9 +158,19 @@ def _record_manifest_and_source_snapcraft_yaml(self):
             annotated_snapcraft = self._annotate_snapcraft(
                 copy.deepcopy(self._config_data))
             with open(manifest_file_path, 'w') as manifest_file:
-                yaml.dump(annotated_snapcraft, manifest_file)
+                yaml.dump(
+                    annotated_snapcraft, manifest_file,
+                    default_flow_style=False)
 
     def _annotate_snapcraft(self, data):
+        image_info = os.environ.get('SNAPCRAFT_IMAGE_INFO')
+        if image_info:
+            try:
+                image_info_dict = json.loads(image_info)
+            except json.decoder.JSONDecodeError as exception:
+                raise errors.InvalidContainerImageInfoError(
+                    image_info) from exception
+            data['image-info'] = image_info_dict
         for field in ('build-packages', 'build-snaps'):
             data[field] = get_global_state().assets.get(field, [])
         for part in data['parts']:


Test patch:
diff --git a/snapcraft/tests/commands/test_snap.py b/snapcraft/tests/commands/test_snap.py
--- a/snapcraft/tests/commands/test_snap.py
+++ b/snapcraft/tests/commands/test_snap.py
@@ -465,6 +465,11 @@ def test_snap_containerized(self,
                   'environment.SNAPCRAFT_SETUP_CORE', '1']),
             call(['lxc', 'config', 'set', container_name,
                   'environment.LC_ALL', 'C.UTF-8']),
+            call(['lxc', 'config', 'set', container_name,
+                  'environment.SNAPCRAFT_IMAGE_INFO',
+                  '{"fingerprint": "test-fingerprint", '
+                  '"architecture": "test-architecture", '
+                  '"created_at": "test-created-at"}']),
             call(['lxc', 'config', 'set', container_name,
                   'raw.idmap', 'both {} 0'.format(self.expected_idmap)]),
             call(['lxc', 'config', 'device', 'add', container_name,
@@ -564,6 +569,11 @@ def test_snap_containerized_exists_stopped(self,
                   'environment.SNAPCRAFT_SETUP_CORE', '1']),
             call(['lxc', 'config', 'set', container_name,
                   'environment.LC_ALL', 'C.UTF-8']),
+            call(['lxc', 'config', 'set', container_name,
+                  'environment.SNAPCRAFT_IMAGE_INFO',
+                  '{"fingerprint": "test-fingerprint", '
+                  '"architecture": "test-architecture", '
+                  '"created_at": "test-created-at"}']),
             call(['lxc', 'config', 'set', container_name,
                   'raw.idmap', 'both {} 0'.format(self.expected_idmap)]),
             call(['lxc', 'config', 'device', 'remove', container_name,
diff --git a/snapcraft/tests/fixture_setup.py b/snapcraft/tests/fixture_setup.py
--- a/snapcraft/tests/fixture_setup.py
+++ b/snapcraft/tests/fixture_setup.py
@@ -535,40 +535,46 @@ def _setUp(self):
         self.architecture_mock.return_value = ('64bit', 'ELF')
         self.addCleanup(patcher.stop)
 
+    def call_effect(self, *args, **kwargs):
+        if args[0] == ['lxc', 'remote', 'get-default']:
+            return 'local'.encode('utf-8')
+        elif args[0][:2] == ['lxc', 'info']:
+            return '''
+                environment:
+                  kernel_architecture: {}
+                '''.format(self.kernel_arch).encode('utf-8')
+        elif args[0][:3] == ['lxc', 'list', '--format=json']:
+            if self.status and args[0][3] == self.name:
+                return string.Template('''
+                    [{"name": "$NAME",
+                      "status": "$STATUS",
+                      "devices": $DEVICES}]
+                    ''').substitute({
+                        # Container name without remote prefix
+                        'NAME': self.name.split(':')[-1],
+                        'STATUS': self.status,
+                        'DEVICES': self.devices,
+                    }).encode('utf-8')
+            return '[]'.encode('utf-8')
+        elif (args[0][0] == 'lxc' and
+              args[0][1] in ['init', 'start', 'launch', 'stop']):
+            return self._lxc_create_start_stop(args)
+        elif args[0][:2] == ['lxc', 'exec']:
+            return self._lxc_exec(args)
+        elif args[0][:4] == ['lxc', 'image', 'list', '--format=json']:
+            return (
+                '[{"architecture":"test-architecture",'
+                '"fingerprint":"test-fingerprint",'
+                '"created_at":"test-created-at"}]').encode('utf-8')
+        elif args[0][0] == 'sha384sum':
+            return 'deadbeef {}'.format(args[0][1]).encode('utf-8')
+        elif '/usr/lib/sftp-server' in args[0]:
+            return self._popen(args[0])
+        else:
+            return ''.encode('utf-8')
+
     def check_output_side_effect(self):
-        def call_effect(*args, **kwargs):
-            if args[0] == ['lxc', 'remote', 'get-default']:
-                return 'local'.encode('utf-8')
-            elif args[0][:2] == ['lxc', 'info']:
-                return '''
-                    environment:
-                      kernel_architecture: {}
-                    '''.format(self.kernel_arch).encode('utf-8')
-            elif args[0][:3] == ['lxc', 'list', '--format=json']:
-                if self.status and args[0][3] == self.name:
-                    return string.Template('''
-                        [{"name": "$NAME",
-                          "status": "$STATUS",
-                          "devices": $DEVICES}]
-                        ''').substitute({
-                            # Container name without remote prefix
-                            'NAME': self.name.split(':')[-1],
-                            'STATUS': self.status,
-                            'DEVICES': self.devices,
-                            }).encode('utf-8')
-                return '[]'.encode('utf-8')
-            elif (args[0][0] == 'lxc' and
-                  args[0][1] in ['init', 'start', 'launch', 'stop']):
-                return self._lxc_create_start_stop(args)
-            elif args[0][:2] == ['lxc', 'exec']:
-                return self._lxc_exec(args)
-            elif args[0][0] == 'sha384sum':
-                return 'deadbeef {}'.format(args[0][1]).encode('utf-8')
-            elif '/usr/lib/sftp-server' in args[0]:
-                return self._popen(args[0])
-            else:
-                return ''.encode('utf-8')
-        return call_effect
+        return self.call_effect
 
     def _lxc_create_start_stop(self, args):
         if args[0][1] == 'init':
diff --git a/snapcraft/tests/test_lifecycle.py b/snapcraft/tests/test_lifecycle.py
--- a/snapcraft/tests/test_lifecycle.py
+++ b/snapcraft/tests/test_lifecycle.py
@@ -1,3 +1,4 @@
+
 # -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
 #
 # Copyright (C) 2015-2017 Canonical Ltd
@@ -851,7 +852,8 @@ def test_prime_with_build_info_records_manifest(self):
                 stage: []
                 stage-packages: []
                 uname: Linux test uname 4.10 x86_64
-            architectures: [{}]
+            architectures:
+            - {}
             build-packages: []
             build-snaps: []
             """.format(self.project_options.deb_arch))
@@ -888,18 +890,19 @@ def test_prime_with_installed_snaps(self):
               test-part:
                 build-packages: []
                 installed-packages: []
-                installed-snaps: {}
+                installed-snaps:
+                - test-snap-1=test-snap-1-revision
+                - test-snap-2=test-snap-2-revision
                 plugin: nil
                 prime: []
                 stage: []
                 stage-packages: []
                 uname: Linux test uname 4.10 x86_64
-            architectures: [{}]
+            architectures:
+            - {}
             build-packages: []
             build-snaps: []
-            """.format('[test-snap-1=test-snap-1-revision, '
-                       'test-snap-2=test-snap-2-revision]',
-                       self.project_options.deb_arch))
+            """.format(self.project_options.deb_arch))
         self.assertThat(
             os.path.join('prime', 'snap', 'manifest.yaml'),
             FileContains(expected))
@@ -931,19 +934,20 @@ def test_prime_with_installed_packages(self):
             parts:
               test-part:
                 build-packages: []
-                installed-packages: {}
+                installed-packages:
+                - test-package1=test-version1
+                - test-package2=test-version2
                 installed-snaps: []
                 plugin: nil
                 prime: []
                 stage: []
                 stage-packages: []
                 uname: Linux test uname 4.10 x86_64
-            architectures: [{}]
+            architectures:
+            - {}
             build-packages: []
             build-snaps: []
-            """.format('[test-package1=test-version1, '
-                       'test-package2=test-version2]',
-                       self.project_options.deb_arch))
+            """.format(self.project_options.deb_arch))
         self.assertThat(
             os.path.join('prime', 'snap', 'manifest.yaml'),
             FileContains(expected))
@@ -981,12 +985,15 @@ def test_prime_with_stage_packages(self):
                 plugin: nil
                 prime: []
                 stage: []
-                stage-packages: [test-package1=test-version1, test-package2=test-version2]
+                stage-packages:
+                - test-package1=test-version1
+                - test-package2=test-version2
                 uname: Linux test uname 4.10 x86_64
-            architectures: [{}]
+            architectures:
+            - {}
             build-packages: []
             build-snaps: []
-            """.format(self.project_options.deb_arch))  # NOQA
+            """.format(self.project_options.deb_arch))
         self.assertThat(
             os.path.join('prime', 'snap', 'manifest.yaml'),
             FileContains(expected))
@@ -1017,7 +1024,9 @@ def test_prime_with_global_build_packages(self, _):
             description: test
             confinement: strict
             grade: stable
-            build-packages: [test-package1=test-version1, test-package2=test-version2]
+            build-packages:
+            - test-package1=test-version1
+            - test-package2=test-version2
             parts:
               test-part:
                 build-packages: []
@@ -1028,9 +1037,10 @@ def test_prime_with_global_build_packages(self, _):
                 stage: []
                 stage-packages: []
                 uname: Linux test uname 4.10 x86_64
-            architectures: [{}]
+            architectures:
+            - {}
             build-snaps: []
-            """.format(self.project_options.deb_arch))  # NOQA
+            """.format(self.project_options.deb_arch))
         self.assertThat(
             os.path.join('prime', 'snap', 'manifest.yaml'),
             FileContains(expected))
@@ -1077,8 +1087,10 @@ def test_prime_with_source_details(self, _):
                 stage: []
                 stage-packages: []
                 uname: Linux test uname 4.10 x86_64
-            architectures: [{}]
-            build-packages: [git=testversion]
+            architectures:
+            - {}
+            build-packages:
+            - git=testversion
             build-snaps: []
             """.format(self.project_options.deb_arch))
         self.assertThat(
@@ -1111,7 +1123,8 @@ def test_prime_with_build_package_with_any_architecture(self, _):
             grade: stable
             parts:
               test-part:
-                build-packages: ['test-package:any']
+                build-packages:
+                - test-package:any
                 installed-packages: []
                 installed-snaps: []
                 plugin: nil
@@ -1119,8 +1132,10 @@ def test_prime_with_build_package_with_any_architecture(self, _):
                 stage: []
                 stage-packages: []
                 uname: Linux test uname 4.10 x86_64
-            architectures: [{}]
-            build-packages: [test-package=test-version]
+            architectures:
+            - {}
+            build-packages:
+            - test-package=test-version
             build-snaps: []
             """.format(self.project_options.deb_arch))
         self.assertThat(
@@ -1155,7 +1170,8 @@ def test_prime_with_virtual_build_package(self, _):
             grade: stable
             parts:
               test-part:
-                build-packages: [test-virtual-package]
+                build-packages:
+                - test-virtual-package
                 installed-packages: []
                 installed-snaps: []
                 plugin: nil
@@ -1163,8 +1179,10 @@ def test_prime_with_virtual_build_package(self, _):
                 stage: []
                 stage-packages: []
                 uname: Linux test uname 4.10 x86_64
-            architectures: [{}]
-            build-packages: [test-provider-package=test-version]
+            architectures:
+            - {}
+            build-packages:
+            - test-provider-package=test-version
             build-snaps: []
             """.format(self.project_options.deb_arch))
         self.assertThat(
@@ -1203,7 +1221,8 @@ def test_prime_with_plugin_manifest(self, fake_plugin_manifest):
                 stage-packages: []
                 test-plugin-manifest: test-value
                 uname: Linux test uname 4.10 x86_64
-            architectures: [{}]
+            architectures:
+            - {}
             build-packages: []
             build-snaps: []
             """.format(self.project_options.deb_arch))
@@ -1211,6 +1230,69 @@ def test_prime_with_plugin_manifest(self, fake_plugin_manifest):
             os.path.join('prime', 'snap', 'manifest.yaml'),
             FileContains(expected))
 
+    def test_prime_with_image_info_records_manifest(self):
+        self.useFixture(fixtures.EnvironmentVariable(
+            'SNAPCRAFT_BUILD_INFO', '1'))
+        test_image_info = (
+            '{"architecture": "test-architecture", '
+            '"created_at": "test-created-at", '
+            '"fingerprint": "test-fingerprint"}')
+        self.useFixture(fixtures.EnvironmentVariable(
+            'SNAPCRAFT_IMAGE_INFO', test_image_info))
+        self.make_snapcraft_yaml(
+            textwrap.dedent("""\
+                parts:
+                  test-part:
+                    plugin: nil
+                """))
+        lifecycle.execute('prime', self.project_options)
+
+        expected = textwrap.dedent("""\
+            name: test
+            version: 0
+            summary: test
+            description: test
+            confinement: strict
+            grade: stable
+            parts:
+              test-part:
+                build-packages: []
+                installed-packages: []
+                installed-snaps: []
+                plugin: nil
+                prime: []
+                stage: []
+                stage-packages: []
+                uname: Linux test uname 4.10 x86_64
+            architectures:
+            - {}
+            image-info:
+              architecture: test-architecture
+              created_at: test-created-at
+              fingerprint: test-fingerprint
+            build-packages: []
+            build-snaps: []
+            """.format(self.project_options.deb_arch))
+        self.assertThat(
+            os.path.join('prime', 'snap', 'manifest.yaml'),
+            FileContains(expected))
+
+    def test_prime_with_invalid_image_info_raises_exception(self):
+        self.useFixture(fixtures.EnvironmentVariable(
+            'SNAPCRAFT_BUILD_INFO', '1'))
+        self.useFixture(fixtures.EnvironmentVariable(
+            'SNAPCRAFT_IMAGE_INFO', 'not-json'))
+        self.make_snapcraft_yaml(
+            textwrap.dedent("""\
+                parts:
+                  test-part:
+                    plugin: nil
+                """))
+        raised = self.assertRaises(
+                errors.InvalidContainerImageInfoError,
+                lifecycle.execute, 'prime', self.project_options)
+        self.assertThat(raised.image_info, Equals('not-json'))
+
 
 class RecordManifestWithDeprecatedSnapKeywordTestCase(
         RecordManifestBaseTestCase):
@@ -1244,11 +1326,13 @@ def test_prime_step_records_prime_keyword(self):
                 installed-packages: []
                 installed-snaps: []
                 plugin: nil
-                prime: [-*]
+                prime:
+                - -*
                 stage: []
                 stage-packages: []
                 uname: Linux test uname 4.10 x86_64
-            architectures: [{}]
+            architectures:
+            - {}
             build-packages: []
             build-snaps: []
             """.format(self.project_options.deb_arch))
diff --git a/snapcraft/tests/test_lxd.py b/snapcraft/tests/test_lxd.py
--- a/snapcraft/tests/test_lxd.py
+++ b/snapcraft/tests/test_lxd.py
@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+import json
 import logging
 import os
 import requests
@@ -39,18 +40,7 @@
 from snapcraft._options import _get_deb_arch
 
 
-class LXDTestCase(tests.TestCase):
-
-    scenarios = [
-        ('local', dict(remote='local', target_arch=None, server='x86_64')),
-        ('remote', dict(remote='myremote', target_arch=None, server='x86_64')),
-        ('cross', dict(remote='local', target_arch='armhf', server='x86_64',
-                       cross=True)),
-        ('arm remote', dict(remote='pi', target_arch=None, server='armv7l')),
-        ('arm same', dict(remote='pi', target_arch='armhf', server='armv7l')),
-        ('arm cross', dict(remote='pi', target_arch='arm64', server='armv7l',
-                           cross=True)),
-    ]
+class LXDBaseTestCase(tests.TestCase):
 
     def setUp(self):
         super().setUp()
@@ -65,6 +55,20 @@ def setUp(self):
         self.project_options = ProjectOptions(target_deb_arch=self.target_arch)
 
 
+class LXDTestCase(LXDBaseTestCase):
+
+    scenarios = [
+        ('local', dict(remote='local', target_arch=None, server='x86_64')),
+        ('remote', dict(remote='myremote', target_arch=None, server='x86_64')),
+        ('cross', dict(remote='local', target_arch='armhf', server='x86_64',
+                       cross=True)),
+        ('arm remote', dict(remote='pi', target_arch=None, server='armv7l')),
+        ('arm same', dict(remote='pi', target_arch='armhf', server='armv7l')),
+        ('arm cross', dict(remote='pi', target_arch='arm64', server='armv7l',
+                           cross=True)),
+    ]
+
+
 class CleanbuilderTestCase(LXDTestCase):
 
     def make_containerbuild(self):
@@ -104,6 +108,11 @@ def test_cleanbuild(self, mock_pet, mock_inject, mock_container_run):
                   'environment.SNAPCRAFT_SETUP_CORE', '1']),
             call(['lxc', 'config', 'set', container_name,
                   'environment.LC_ALL', 'C.UTF-8']),
+            call(['lxc', 'config', 'set', container_name,
+                  'environment.SNAPCRAFT_IMAGE_INFO',
+                  '{"fingerprint": "test-fingerprint", '
+                  '"architecture": "test-architecture", '
+                  '"created_at": "test-created-at"}']),
             call(['lxc', 'file', 'push', os.path.realpath('project.tar'),
                   '{}/root/build_project/project.tar'.format(container_name)]),
         ])
@@ -613,3 +622,48 @@ def call_effect(*args, **kwargs):
         # Should not attempt to stop a container that wasn't started
         self.assertNotIn(call(['lxc', 'stop', '-f', self.fake_lxd.name]),
                          self.fake_lxd.check_call_mock.call_args_list)
+
+
+class FailedImageInfoTestCase(LXDBaseTestCase):
+
+    remote = 'local'
+    server = 'x86_64'
+    target_arch = None
+
+    scenarios = [
+        ('CalledProcessError', dict(
+            exception=CalledProcessError,
+            kwargs=dict(cmd='testcmd', returncode=1, output='test output'),
+            expected_warn=(
+                "Failed to get container image info: Command 'testcmd' "
+                "returned non-zero exit status 1, output: test output\n"
+                "It will not be recorded in manifest.\n"))),
+        ('JSONDecodeError', dict(
+            exception=json.decoder.JSONDecodeError,
+            kwargs=dict(msg='dummy', doc='dummy', pos=1),
+            expected_warn=(
+                "Failed to get container image info: Not in JSON format\n"
+                "It will not be recorded in manifest.\n"))),
+    ]
+
+    def make_containerbuild(self):
+        return lxd.Project(output='snap.snap', source='project.tar',
+                           metadata={'name': 'project'},
+                           project_options=self.project_options,
+                           remote=self.remote)
+
+    def test_failed_image_info_just_warns(self):
+        self.fake_logger = fixtures.FakeLogger(level=logging.WARN)
+        self.useFixture(self.fake_logger)
+
+        def call_effect(*args, **kwargs):
+            if args[0][:3] == ['lxc', 'image', 'list']:
+                raise self.exception(**self.kwargs)
+            return d(*args, **kwargs)
+
+        d = self.fake_lxd.check_output_mock.side_effect
+
+        self.fake_lxd.check_output_mock.side_effect = call_effect
+
+        self.make_containerbuild().execute()
+        self.assertEqual(self.fake_logger.output, self.expected_warn)