diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 63114e745..8a7c931f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,17 @@ --- repos: + - repo: local + hooks: + - id: pytest + name: pytest + entry: pytest tests/test_nova_statedir_ownership.py + language: python + types: [python] + pass_filenames: false + always_run: true + additional_dependencies: + - pytest + - oslotest - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: diff --git a/molecule/test-helpers/verify_podman.yaml b/molecule/test-helpers/verify_podman.yaml index 876aa58cc..1eb0fdf5b 100644 --- a/molecule/test-helpers/verify_podman.yaml +++ b/molecule/test-helpers/verify_podman.yaml @@ -3,7 +3,7 @@ block: - name: Check if podman container exists {{ item }} become: true - ansible.builtin.command: podman ps -a --filter name={{item}} --format \{\{.Names\}\} + ansible.builtin.command: podman ps -a --filter name=^{{item}}\$ --format \{\{.Names\}\} register: containers - name: Assert podman container exists {{ item }} ansible.builtin.assert: diff --git a/roles/edpm_nova/handlers/main.yml b/roles/edpm_nova/handlers/main.yml index 2f2a4432d..d3fc8d8ee 100644 --- a/roles/edpm_nova/handlers/main.yml +++ b/roles/edpm_nova/handlers/main.yml @@ -19,3 +19,10 @@ state: restarted name: "edpm_nova_compute.service" listen: "Restart nova" + +- name: Restart nova init container + become: true + containers.podman.podman_container: + name: nova_compute_init + state: started + listen: "Restart nova init" diff --git a/roles/edpm_nova/tasks/configure.yml b/roles/edpm_nova/tasks/configure.yml index 96c64e6c0..105c06735 100644 --- a/roles/edpm_nova/tasks/configure.yml +++ b/roles/edpm_nova/tasks/configure.yml @@ -183,7 +183,9 @@ # openstack Caracal) # https://blueprints.launchpad.net/nova/+spec/libvirt-migrate-with-hostname-instead-of-ip - {"src": "02-nova-host-specific.conf.j2", "dest": "02-nova-host-specific.conf"} + - {"src": "nova_statedir_ownership.py", "dest": "nova_statedir_ownership.py"} notify: + - Restart nova init - Restart nova - name: Create .ssh directory for the nova user on the host diff --git a/roles/edpm_nova/tasks/install.yml b/roles/edpm_nova/tasks/install.yml index 143416e53..994bdc68f 100644 --- a/roles/edpm_nova/tasks/install.yml +++ b/roles/edpm_nova/tasks/install.yml @@ -19,6 +19,30 @@ notify: - Restart nova +- name: Render nova init container + tags: + - install + - nova + ansible.builtin.template: + src: "nova_compute_init.json.j2" + dest: "/var/lib/openstack/config/containers/nova_compute_init.json" + setype: "container_file_t" + mode: "0700" + notify: + - Restart nova init + +- name: Deploy nova init container + tags: + - install + - nova + ansible.builtin.include_role: + name: osp.edpm.edpm_container_manage + vars: + edpm_container_manage_config: '/var/lib/openstack/config/containers' + edpm_container_manage_healthcheck_disabled: true + edpm_container_manage_config_patterns: 'nova_compute_init.json' + edpm_container_manage_clean_orphans: false + - name: Deploy nova container tags: - install diff --git a/roles/edpm_nova/templates/config.json.j2 b/roles/edpm_nova/templates/config.json.j2 index 0fe7fc061..4deabba6a 100644 --- a/roles/edpm_nova/templates/config.json.j2 +++ b/roles/edpm_nova/templates/config.json.j2 @@ -52,11 +52,6 @@ "owner": "nova:nova", "perm:": "0600" }, - { - "path": "/var/lib/nova", - "owner": "nova:nova", - "recurse": true - }, { "path": "/var/lib/nova/.ssh/", "owner": "nova:nova", diff --git a/roles/edpm_nova/templates/nova_compute_init.json.j2 b/roles/edpm_nova/templates/nova_compute_init.json.j2 new file mode 100644 index 000000000..2b81e6bf2 --- /dev/null +++ b/roles/edpm_nova/templates/nova_compute_init.json.j2 @@ -0,0 +1,20 @@ +{ + "image": "{{ edpm_nova_compute_image }}", + "privileged": false, + "user": "root", + "restart": "never", + "command": "bash -c $* -- eval python3 /sbin/nova_statedir_ownership.py | logger -t nova_compute_init", + "net": "none", + "security_opt": ["label=disable"], + "detach": false, + "environment": { + "NOVA_STATEDIR_OWNERSHIP_SKIP": "/var/lib/nova/compute_id", + "__OS_DEBUG": false + }, + "volumes": [ + "/dev/log:/dev/log", + "/var/lib/nova:/var/lib/nova:shared", + "/var/lib/_nova_secontext:/var/lib/_nova_secontext:shared,z", + "/var/lib/openstack/config/nova/nova_statedir_ownership.py:/sbin/nova_statedir_ownership.py:z" + ] +} diff --git a/roles/edpm_nova/templates/nova_statedir_ownership.py b/roles/edpm_nova/templates/nova_statedir_ownership.py new file mode 100644 index 000000000..cd5f19f4e --- /dev/null +++ b/roles/edpm_nova/templates/nova_statedir_ownership.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python +# +# Copyright 2018 Red Hat Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +import logging +import os +import pwd +import selinux +import stat +import sys + +debug = os.getenv('__OS_DEBUG', 'false') + +if debug.lower() == 'true': + loglevel = logging.DEBUG +else: + loglevel = logging.INFO + +logging.basicConfig(stream=sys.stdout, level=loglevel) +LOG = logging.getLogger('nova_statedir') + + +class PathManager(object): + """Helper class to manipulate ownership of a given path""" + def __init__(self, path): + self.path = path + self.uid = None + self.gid = None + self.is_dir = None + self.secontext = None + self._update() + + def _update(self): + try: + statinfo = os.stat(self.path) + self.is_dir = stat.S_ISDIR(statinfo.st_mode) + self.uid = statinfo.st_uid + self.gid = statinfo.st_gid + self.secontext = selinux.lgetfilecon(self.path)[1] + except Exception: + LOG.exception('Could not update metadata for %s', self.path) + raise + + def __str__(self): + return "uid: {} gid: {} path: {}{}".format( + self.uid, + self.gid, + self.path, + '/' if self.is_dir else '' + ) + + def has_owner(self, uid, gid): + return self.uid == uid and self.gid == gid + + def has_either(self, uid, gid): + return self.uid == uid or self.gid == gid + + def chown(self, uid, gid): + target_uid = -1 + target_gid = -1 + if self.uid != uid: + target_uid = uid + if self.gid != gid: + target_gid = gid + if (target_uid, target_gid) != (-1, -1): + LOG.info('Changing ownership of %s from %d:%d to %d:%d', + self.path, + self.uid, + self.gid, + self.uid if target_uid == -1 else target_uid, + self.gid if target_gid == -1 else target_gid) + try: + os.chown(self.path, target_uid, target_gid) + self._update() + except Exception: + LOG.exception('Could not change ownership of %s: ', + self.path) + raise + else: + LOG.info('Ownership of %s already %d:%d', + self.path, + uid, + gid) + + def chcon(self, context): + # If dir returns whether to recursively set context + try: + try: + selinux.lsetfilecon(self.path, context) + LOG.info('Setting selinux context of %s to %s', + self.path, context) + return True + except OSError as e: + if self.is_dir and e.errno == 95: + # Operation not supported, assume NFS mount and skip + LOG.info('Setting selinux context not supported for %s', + self.path) + return False + else: + raise + except Exception: + LOG.exception('Could not set selinux context of %s to %s:', + self.path, context) + raise + + +class NovaStatedirOwnershipManager(object): + """Class to manipulate the ownership of the nova statedir (/var/lib/nova). + + The nova uid/gid differ on the host and container images. An upgrade + that switches from host systemd services to docker requires a change in + ownership. Previously this was a naive recursive chown, however this + causes issues if nova instance are shared via an NFS mount: any open + filehandles in qemu/libvirt fail with an I/O error (LP1778465). + + Instead the upgrade/FFU ansible tasks now lay down a marker file when + stopping and disabling the host systemd services. We use this file to + determine the host nova uid/gid. We then walk the tree and update any + files that have the host uid/gid to the docker nova uid/gid. As files + owned by root/qemu etc... are ignored this avoids the issues with open + filehandles. The marker is removed once the tree has been walked. + + For subsequent runs, or for a new deployment, we simply ensure that the + docker nova user/group owns all directories. This is required as the + directories are created with root ownership in host_prep_tasks (the + docker nova uid/gid is not known in this context). + """ + def __init__(self, statedir, upgrade_marker='upgrade_marker', + nova_user='nova', secontext_marker='../_nova_secontext', + exclude_paths=None): + self.statedir = statedir + self.nova_user = nova_user + + self.upgrade_marker_path = os.path.join(statedir, upgrade_marker) + self.secontext_marker_path = os.path.normpath(os.path.join(statedir, secontext_marker)) + self.upgrade = os.path.exists(self.upgrade_marker_path) + + self.exclude_paths = [self.upgrade_marker_path] + if exclude_paths is not None: + for p in exclude_paths: + if not p.startswith(os.path.sep): + p = os.path.join(self.statedir, p) + self.exclude_paths.append(p) + + self.target_uid, self.target_gid = self._get_nova_ids() + self.previous_uid, self.previous_gid = self._get_previous_nova_ids() + self.id_change = (self.target_uid, self.target_gid) != \ + (self.previous_uid, self.previous_gid) + self.target_secontext = self._get_secontext() + + def _get_nova_ids(self): + nova_uid, nova_gid = pwd.getpwnam(self.nova_user)[2:4] + return nova_uid, nova_gid + + def _get_previous_nova_ids(self): + if self.upgrade: + statinfo = os.stat(self.upgrade_marker_path) + return statinfo.st_uid, statinfo.st_gid + else: + return self._get_nova_ids() + + def _get_secontext(self): + if os.path.exists(self.secontext_marker_path): + return selinux.lgetfilecon(self.secontext_marker_path)[1] + else: + return None + + def _walk(self, top, chcon=True): + for f in os.listdir(top): + pathname = os.path.join(top, f) + + if pathname in self.exclude_paths: + continue + + try: + pathinfo = PathManager(pathname) + LOG.info("Checking %s", pathinfo) + if pathinfo.is_dir: + # Always chown the directories + pathinfo.chown(self.target_uid, self.target_gid) + chcon_r = chcon + if chcon: + chcon_r = pathinfo.chcon(self.target_secontext) + self._walk(pathname, chcon_r) + elif self.id_change: + # Only chown files if it's an upgrade and the file is owned by + # the host nova uid/gid + pathinfo.chown( + self.target_uid if pathinfo.uid == self.previous_uid + else pathinfo.uid, + self.target_gid if pathinfo.gid == self.previous_gid + else pathinfo.gid + ) + if chcon: + pathinfo.chcon(self.target_secontext) + except Exception: + # Likely to have been caused by external systems + # interacting with this directory tree, + # especially on NFS e.g snapshot dirs. + # Just ignore it and continue on to the next entry + continue + + def run(self): + LOG.info('Applying nova statedir ownership') + LOG.info('Target ownership for %s: %d:%d', + self.statedir, + self.target_uid, + self.target_gid) + + pathinfo = PathManager(self.statedir) + LOG.info("Checking %s", pathinfo) + pathinfo.chown(self.target_uid, self.target_gid) + chcon = self.target_secontext is not None + + if chcon: + pathinfo.chcon(self.target_secontext) + + self._walk(self.statedir, chcon) + + if self.upgrade: + LOG.info('Removing upgrade_marker %s', + self.upgrade_marker_path) + os.unlink(self.upgrade_marker_path) + + LOG.info('Nova statedir ownership complete') + + +def get_exclude_paths(): + exclude_paths = os.environ.get('NOVA_STATEDIR_OWNERSHIP_SKIP') + if exclude_paths is not None: + exclude_paths = exclude_paths.split(os.pathsep) + return exclude_paths + + +if __name__ == '__main__': + NovaStatedirOwnershipManager('/var/lib/nova', exclude_paths=get_exclude_paths()).run() diff --git a/tests/test_nova_statedir_ownership.py b/tests/test_nova_statedir_ownership.py new file mode 100644 index 000000000..8eee891bc --- /dev/null +++ b/tests/test_nova_statedir_ownership.py @@ -0,0 +1,426 @@ +# +# Copyright 2018 Red Hat Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from unittest import mock + +import contextlib +import os +from os import stat as orig_stat +import stat +import sys + +from oslotest import base + + +class FakeSelinux(object): + @staticmethod + def lgetfilecon(path): + pass + + @staticmethod + def lsetfilecon(path, context): + pass + + +sys.path.append('roles/edpm_nova/templates') +sys.modules["selinux"] = FakeSelinux + +from nova_statedir_ownership import get_exclude_paths # noqa: E402 +from nova_statedir_ownership import \ + NovaStatedirOwnershipManager # noqa: E402 +from nova_statedir_ownership import PathManager # noqa: E402 + +# Real chown would require root, so in order to test this we need to fake +# all of the methods that interact with the filesystem + +current_uid = 100 +current_gid = 100 + + +class FakeStatInfo(object): + def __init__(self, st_mode, st_uid, st_gid): + self.st_mode = st_mode + self.st_uid = st_uid + self.st_gid = st_gid + + def get_ids(self): + return (self.st_uid, self.st_gid) + + +def generate_testtree1(nova_uid, nova_gid): + return { + '/var/lib/nova': { + 'stat': FakeStatInfo(st_mode=stat.S_IFDIR, + st_uid=nova_uid, + st_gid=nova_gid), + 'nfs': False, + }, + '/var/lib/_nova_secontext': { + 'stat': FakeStatInfo(st_mode=stat.S_IFDIR, + st_uid=nova_uid, + st_gid=nova_gid), + 'nfs': False, + }, + + '/var/lib/nova/instances': { + 'stat': FakeStatInfo(st_mode=stat.S_IFDIR, + st_uid=nova_uid, + st_gid=nova_gid), + 'nfs': False, + }, + '/var/lib/nova/instances/removeddir': { + 'stat': FakeStatInfo(st_mode=stat.S_IFDIR, + st_uid=nova_uid, + st_gid=nova_gid), + 'nfs': False, + 'removed_when': 'listdir' + }, + '/var/lib/nova/instances/removedfile': { + 'stat': FakeStatInfo(st_mode=stat.S_IFREG, + st_uid=0, + st_gid=nova_gid), + 'nfs': False, + 'removed_when': 'lgetfilecon' + }, + '/var/lib/nova/instances/removedfile2': { + 'stat': FakeStatInfo(st_mode=stat.S_IFREG, + st_uid=0, + st_gid=nova_gid), + 'nfs': False, + 'removed_when': 'lsetfilecon' + }, + '/var/lib/nova/instances/removedfile3': { + 'nfs': False, + 'removed_when': 'stat' + }, + + '/var/lib/nova/instances/removeddir2': { + 'stat': FakeStatInfo(st_mode=stat.S_IFDIR, + st_uid=nova_uid, + st_gid=nova_gid), + 'nfs': False, + 'removed_when': 'lsetfilecon' + }, + '/var/lib/nova/instances/foo': { + 'stat': FakeStatInfo(st_mode=stat.S_IFDIR, + st_uid=nova_uid, + st_gid=nova_gid), + 'nfs': True, + }, + '/var/lib/nova/instances/foo/bar': { + 'stat': FakeStatInfo(st_mode=stat.S_IFREG, + st_uid=0, + st_gid=0), + 'nfs': True, + }, + '/var/lib/nova/instances/foo/baz': { + 'stat': FakeStatInfo(st_mode=stat.S_IFREG, + st_uid=nova_uid, + st_gid=nova_gid), + 'nfs': True, + }, + '/var/lib/nova/instances/foo/removeddir': { + 'stat': FakeStatInfo(st_mode=stat.S_IFDIR, + st_uid=nova_uid, + st_gid=nova_gid), + 'nfs': True, + 'removed_when': 'listdir' + }, + '/var/lib/nova/instances/foo/removeddir2': { + 'stat': FakeStatInfo(st_mode=stat.S_IFDIR, + st_uid=0, + st_gid=nova_gid), + 'nfs': True, + 'removed_when': 'chown' + }, + '/var/lib/nova/instances/foo/abc': { + 'stat': FakeStatInfo(st_mode=stat.S_IFREG, + st_uid=0, + st_gid=nova_gid), + 'nfs': True, + }, + '/var/lib/nova/instances/foo/def': { + 'stat': FakeStatInfo(st_mode=stat.S_IFREG, + st_uid=nova_uid, + st_gid=0), + 'nfs': True, + }, + } + + +def generate_testtree2(marker_uid, marker_gid, *args, **kwargs): + tree = generate_testtree1(*args, **kwargs) + tree.update({ + '/var/lib/nova/upgrade_marker': { + 'stat': FakeStatInfo(st_mode=stat.S_IFREG, + st_uid=marker_uid, + st_gid=marker_gid), + 'nfs': False, + } + }) + return tree + + +def check_removed(path, op, testtree): + if op == testtree.get(path, {}).get('removed_when', ''): + raise OSError(2, 'No such file or directory: ' + path) + + +def generate_fake_stat(testtree): + def fake_stat(path): + check_removed(path, 'stat', testtree) + if path.startswith('/var'): + return testtree.get(path, {}).get('stat') + else: + # Tracebacks need to use the real stat + return orig_stat(path) + return fake_stat + + +def generate_fake_chown(testtree): + def fake_chown(path, uid, gid): + check_removed(path, 'chown', testtree) + if uid != -1: + testtree[path]['stat'].st_uid = uid + if gid != -1: + testtree[path]['stat'].st_gid = gid + return fake_chown + + +def generate_fake_exists(testtree): + def fake_exists(path): + check_removed(path, 'exists', testtree) + return path in testtree + return fake_exists + + +def generate_fake_listdir(testtree): + def fake_listdir(path): + check_removed(path, 'listdir', testtree) + path_parts = path.split('/') + for entry in testtree: + entry_parts = entry.split('/') + if (entry_parts[:len(path_parts)] == path_parts + and len(entry_parts) == len(path_parts) + 1): + yield entry + return fake_listdir + + +def generate_fake_unlink(testtree): + def fake_unlink(path): + check_removed(path, 'unlink', testtree) + del testtree[path] + return fake_unlink + + +def generate_fake_lgetfilecon(testtree): + def fake_lgetfilecon(path): + check_removed(path, 'lgetfilecon', testtree) + + +def generate_fake_lsetfilecon(testtree): + def fake_lsetfilecon(path, context): + check_removed(path, 'lsetfilecon', testtree) + if testtree[path]['nfs']: + raise OSError(95, 'Operation not supported') + + +@contextlib.contextmanager +def fake_testtree(testtree): + fake_stat = generate_fake_stat(testtree) + fake_chown = generate_fake_chown(testtree) + fake_exists = generate_fake_exists(testtree) + fake_listdir = generate_fake_listdir(testtree) + fake_unlink = generate_fake_unlink(testtree) + fake_lsetfilecon = generate_fake_lsetfilecon(testtree) + fake_lgetfilecon = generate_fake_lgetfilecon(testtree) + with mock.patch('os.chown', + side_effect=fake_chown) as fake_chown: + with mock.patch('os.path.exists', + side_effect=fake_exists) as fake_exists: + with mock.patch('os.listdir', + side_effect=fake_listdir) as fake_listdir: + with mock.patch('pwd.getpwnam', + return_value=(0, 0, current_uid, current_gid)): + with mock.patch('os.stat', + side_effect=fake_stat) as fake_stat: + with mock.patch( + 'os.unlink', + side_effect=fake_unlink) as fake_unlink: + with mock.patch( + 'selinux.lgetfilecon', + side_effect=fake_lgetfilecon, + return_value=[10, 'newcontext'] + ) as fake_lgetfilecon: + with mock.patch( + 'selinux.lsetfilecon', + side_effect=fake_lsetfilecon, + ) as fake_lsetfilecon: + yield (fake_chown, + fake_exists, + fake_listdir, + fake_stat, + fake_unlink, + fake_lgetfilecon, + fake_lsetfilecon) + + +def assert_ids(testtree, path, uid, gid): + statinfo = testtree[path]['stat'] + assert (uid, gid) == (statinfo.st_uid, statinfo.st_gid), \ + "{}: expected ownership {}:{} actual {}:{}".format( + path, uid, gid, statinfo.st_uid, statinfo.st_gid + ) + + +class PathManagerCase(base.BaseTestCase): + def test_file(self): + testtree = generate_testtree1(current_uid, current_gid) + + with fake_testtree(testtree): + pathinfo = PathManager('/var/lib/nova/instances/foo/baz') + self.assertTrue(pathinfo.has_owner(current_uid, current_gid)) + self.assertTrue(pathinfo.has_either(current_uid, 0)) + self.assertTrue(pathinfo.has_either(0, current_gid)) + self.assertFalse(pathinfo.is_dir) + self.assertEqual(str(pathinfo), 'uid: {} gid: {} path: {}'.format( + current_uid, current_gid, '/var/lib/nova/instances/foo/baz' + )) + + def test_dir(self): + testtree = generate_testtree1(current_uid, current_gid) + + with fake_testtree(testtree): + pathinfo = PathManager('/var/lib/nova') + self.assertTrue(pathinfo.has_owner(current_uid, current_gid)) + self.assertTrue(pathinfo.has_either(current_uid, 0)) + self.assertTrue(pathinfo.has_either(0, current_gid)) + self.assertTrue(pathinfo.is_dir) + self.assertEqual(str(pathinfo), 'uid: {} gid: {} path: {}'.format( + current_uid, current_gid, '/var/lib/nova/' + )) + + def test_chown(self): + testtree = generate_testtree1(current_uid, current_gid) + + with fake_testtree(testtree): + pathinfo = PathManager('/var/lib/nova/instances/foo/baz') + self.assertTrue(pathinfo.has_owner(current_uid, current_gid)) + pathinfo.chown(current_uid + 1, current_gid) + assert_ids(testtree, pathinfo.path, current_uid + 1, current_gid) + + def test_chgrp(self): + testtree = generate_testtree1(current_uid, current_gid) + + with fake_testtree(testtree): + pathinfo = PathManager('/var/lib/nova/instances/foo/baz') + self.assertTrue(pathinfo.has_owner(current_uid, current_gid)) + pathinfo.chown(current_uid, current_gid + 1) + assert_ids(testtree, pathinfo.path, current_uid, current_gid + 1) + + def test_chown_chgrp(self): + testtree = generate_testtree1(current_uid, current_gid) + + with fake_testtree(testtree): + pathinfo = PathManager('/var/lib/nova/instances/foo/baz') + self.assertTrue(pathinfo.has_owner(current_uid, current_gid)) + pathinfo.chown(current_uid + 1, current_gid + 1) + assert_ids(testtree, pathinfo.path, + current_uid + 1, current_gid + 1) + + +class NovaStatedirOwnershipManagerTestCase(base.BaseTestCase): + def test_no_upgrade_marker(self): + testtree = generate_testtree1(current_uid, current_gid) + + with fake_testtree(testtree) as (fake_chown, _, _, _, _, _, fake_lsetfilecon): + NovaStatedirOwnershipManager('/var/lib/nova').run() + fake_chown.assert_called_once_with('/var/lib/nova/instances/foo/removeddir2', 100, -1) + fake_lsetfilecon.assert_any_call('/var/lib/nova', 'newcontext') + fake_lsetfilecon.assert_any_call('/var/lib/nova/instances/foo', 'newcontext') + chcon_paths = [x[0][0] for x in fake_lsetfilecon.call_args_list] + self.assertNotIn('/var/lib/nova/instances/foo/bar', chcon_paths) + + def test_upgrade_marker_no_id_change(self): + testtree = generate_testtree2(current_uid, + current_gid, + current_uid, + current_gid) + + with fake_testtree(testtree) as (fake_chown, _, _, _, fake_unlink, _, _): + NovaStatedirOwnershipManager('/var/lib/nova').run() + fake_chown.assert_called_once_with('/var/lib/nova/instances/foo/removeddir2', 100, -1) + fake_unlink.assert_called_with('/var/lib/nova/upgrade_marker') + + def test_upgrade_marker_id_change(self): + other_uid = current_uid + 1 + other_gid = current_gid + 1 + testtree = generate_testtree2(other_uid, + other_gid, + other_uid, + other_gid) + + # Determine which paths should change uid/gid + expected_changes = {} + for k, v in testtree.items(): + if k == '/var/lib/nova/upgrade_marker': + # Ignore the marker, it should be deleted + continue + if k == '/var/lib/_nova_secontext': + # Ignore, outside tree + continue + if testtree[k].get('removed_when', False): + # Ignore, deleted + continue + v = v['stat'] + if v.st_uid == other_uid or v.st_gid == other_gid: + expected_changes[k] = ( + current_uid if v.st_uid == other_uid else v.st_uid, + current_gid if v.st_gid == other_gid else v.st_gid + ) + + with fake_testtree(testtree) as (_, _, _, _, fake_unlink, _, _): + NovaStatedirOwnershipManager('/var/lib/nova').run() + for fn, expected in expected_changes.items(): + assert_ids(testtree, fn, expected[0], expected[1]) + fake_unlink.assert_called_with('/var/lib/nova/upgrade_marker') + + def test_exclude_path(self): + testtree = generate_testtree1(current_uid, current_gid) + + with fake_testtree(testtree) as ( + fake_chown, _, fake_listdir, fake_stat, _, _, _): + manager = NovaStatedirOwnershipManager( + '/var/lib/nova', + exclude_paths=['instances/foo/bar', '/var/lib/nova/instances/foo/removeddir'] + ) + manager.run() + self.assertIn('/var/lib/nova/instances/foo/bar', manager.exclude_paths) + self.assertIn('/var/lib/nova/instances/foo/removeddir', manager.exclude_paths) + self.assertNotIn(mock.call('/var/lib/nova/instances/foo/bar'), fake_stat.call_args_list) + self.assertNotIn(mock.call('/var/lib/nova/instances/foo/bar'), fake_chown.call_args_list) + self.assertNotIn(mock.call('/var/lib/nova/instances/foo/removeddir'), fake_stat.call_args_list) + self.assertNotIn(mock.call('/var/lib/nova/instances/foo/removeddir'), fake_chown.call_args_list) + self.assertNotIn(mock.call('/var/lib/nova/instances/foo/removeddir'), fake_listdir.call_args_list) + + @mock.patch.dict(os.environ, {'NOVA_STATEDIR_OWNERSHIP_SKIP': 'foo:bar:foo/bar/baz'}) + def test_get_exclude_paths(self): + expected = [ + 'foo', + 'bar', + 'foo/bar/baz' + ] + exclude_paths = get_exclude_paths() + self.assertEqual(exclude_paths, expected)