64309b9
From c4078c160caad8c019c4e890757fbde34aa83f60 Mon Sep 17 00:00:00 2001
64309b9
From: Jan Kaluza <jkaluza@redhat.com>
64309b9
Date: Dec 08 2017 13:30:51 +0000
64309b9
Subject: If pungi_runroot_enabled is true, use koji runroot also to remove expired composes.
64309b9
64309b9
64309b9
---
64309b9
64309b9
diff --git a/server/odcs/server/backend.py b/server/odcs/server/backend.py
64309b9
index c7c77b9..539b23d 100644
64309b9
--- a/server/odcs/server/backend.py
64309b9
+++ b/server/odcs/server/backend.py
64309b9
@@ -143,6 +143,33 @@ class RemoveExpiredComposesThread(BackendThread):
64309b9
         else:
64309b9
             shutil.rmtree(toplevel_dir)
64309b9
 
64309b9
+    def _get_remove_compose_dir_cmds(self, toplevel_dir):
64309b9
+        """
64309b9
+        Returns the commands needed to remove the compose with toplevel_dir
64309b9
+        compose dir as list.
64309b9
+        """
64309b9
+
64309b9
+        # Be nice and don't fail when directory does not exist.
64309b9
+        if not os.path.exists(toplevel_dir):
64309b9
+            log.warn("Cannot remove directory %s, it does not exist",
64309b9
+                     toplevel_dir)
64309b9
+            return []
64309b9
+
64309b9
+        cmds = []
64309b9
+
64309b9
+        # If toplevel_dir is a symlink, remove the symlink and
64309b9
+        # its target. If toplevel_dir is normal directory, just
64309b9
+        # remove it using rmtree.
64309b9
+        if os.path.realpath(toplevel_dir) != toplevel_dir:
64309b9
+            targetpath = os.path.realpath(toplevel_dir)
64309b9
+            cmds += ["rm", "-f", toplevel_dir]
64309b9
+            if os.path.exists(targetpath):
64309b9
+                cmds += ["&&", "rm", "-rf", targetpath]
64309b9
+        else:
64309b9
+            cmds += ["rm", "-rf", toplevel_dir]
64309b9
+
64309b9
+        return cmds
64309b9
+
64309b9
     def _get_compose_id_from_path(self, path):
64309b9
         """
64309b9
         Returns the ID of compose from directory path in conf.target_dir.
64309b9
@@ -163,6 +190,15 @@ class RemoveExpiredComposesThread(BackendThread):
64309b9
         """
64309b9
         log.info("Checking for expired composes")
64309b9
 
64309b9
+        # We store all the compose directories to remove in this list and
64309b9
+        # removes them all in the end of this method.
64309b9
+        # The reason to do it this way is that if "conf.pungi_runroot_enabled"
64309b9
+        # is True, we spawn Koji runroot task which calls "rm" commands for
64309b9
+        # every compose directory we want to remove. We need a list
64309b9
+        # of directories to be able to generate that set of rm commands later
64309b9
+        # if neede and if not, just remove the directories by this thread.
64309b9
+        compose_dirs_to_remove = []
64309b9
+
64309b9
         composes = Compose.composes_to_expire()
64309b9
         for compose in composes:
64309b9
             log.info("%r: Removing compose", compose)
64309b9
@@ -170,7 +206,7 @@ class RemoveExpiredComposesThread(BackendThread):
64309b9
             compose.time_removed = datetime.utcnow()
64309b9
             db.session.commit()
64309b9
             if not compose.reused_id:
64309b9
-                self._remove_compose_dir(compose.toplevel_dir)
64309b9
+                compose_dirs_to_remove.append(str(compose.toplevel_dir))
64309b9
 
64309b9
         # In case of ODCS error, there might be left-over directories
64309b9
         # belonging to already expired composes. Try to find them in the
64309b9
@@ -197,16 +233,34 @@ class RemoveExpiredComposesThread(BackendThread):
64309b9
             if not composes:
64309b9
                 log.info("Removing data of compose %d - it is not in "
64309b9
                          "database: %s", compose_id, path)
64309b9
-                self._remove_compose_dir(path)
64309b9
+                compose_dirs_to_remove.append(path)
64309b9
                 continue
64309b9
 
64309b9
             compose = composes[0]
64309b9
             if compose.state == COMPOSE_STATES["removed"]:
64309b9
                 log.info("%r: Removing data of compose - it has already "
64309b9
                          "expired some time ago: %s", compose_id, path)
64309b9
-                self._remove_compose_dir(path)
64309b9
+                compose_dirs_to_remove.append(path)
64309b9
                 continue
64309b9
 
64309b9
+        koji_cmds = []
64309b9
+        for compose_dir in compose_dirs_to_remove:
64309b9
+            if conf.pungi_runroot_enabled:
64309b9
+                cmds = self._get_remove_compose_dir_cmds(compose_dir)
64309b9
+                if cmds:
64309b9
+                    koji_cmds += cmds
64309b9
+                    koji_cmds += ["&&"]
64309b9
+            else:
64309b9
+                self._remove_compose_dir(compose_dir)
64309b9
+
64309b9
+        # In case we have some commands to run in Koji, spawn the Koji runroot
64309b9
+        # task and wait for result.
64309b9
+        if koji_cmds:
64309b9
+            # Remove last "&&"
64309b9
+            koji_cmds = koji_cmds[:-1]
64309b9
+            koji_session = odcs.server.utils.make_koji_session()
64309b9
+            odcs.server.utils.run_koji_runroot(koji_session, koji_cmds)
64309b9
+
64309b9
 
64309b9
 def create_koji_session():
64309b9
     """
64309b9
diff --git a/server/odcs/server/pungi.py b/server/odcs/server/pungi.py
64309b9
index aab57e4..9dbd1d3 100644
64309b9
--- a/server/odcs/server/pungi.py
64309b9
+++ b/server/odcs/server/pungi.py
64309b9
@@ -25,8 +25,6 @@ import os
64309b9
 import shutil
64309b9
 import tempfile
64309b9
 import jinja2
64309b9
-import koji
64309b9
-import munch
64309b9
 import time
64309b9
 import random
64309b9
 import string
64309b9
@@ -35,7 +33,8 @@ import odcs.server.utils
64309b9
 from odcs.server import conf, log
64309b9
 from odcs.server import comps
64309b9
 from odcs.common.types import PungiSourceType, COMPOSE_RESULTS
64309b9
-from odcs.server.utils import makedirs, download_file
64309b9
+from odcs.server.utils import (
64309b9
+    makedirs, download_file, make_koji_session, run_koji_runroot)
64309b9
 
64309b9
 
64309b9
 class PungiConfig(object):
64309b9
@@ -185,47 +184,6 @@ class Pungi(object):
64309b9
             output_path = os.path.join(topdir, "pungi.conf")
64309b9
             download_file(self.pungi_cfg, output_path)
64309b9
 
64309b9
-    def make_koji_session(self):
64309b9
-        """
64309b9
-        Creates new KojiSession according to odcs.server.conf, logins to
64309b9
-        Koji using this session and returns it.
64309b9
-        :rtype: koji.KojiSession
64309b9
-        :return: KojiSession
64309b9
-        """
64309b9
-        koji_config = munch.Munch(koji.read_config(
64309b9
-            profile_name=conf.koji_profile,
64309b9
-            user_config=conf.koji_config,
64309b9
-        ))
64309b9
-
64309b9
-        address = koji_config.server
64309b9
-        authtype = koji_config.authtype
64309b9
-        log.info("Connecting to koji %r with %r." % (address, authtype))
64309b9
-        koji_session = koji.ClientSession(address, opts=koji_config)
64309b9
-        if authtype == "kerberos":
64309b9
-            ccache = getattr(conf, "krb_ccache", None)
64309b9
-            keytab = getattr(conf, "krb_keytab", None)
64309b9
-            principal = getattr(conf, "krb_principal", None)
64309b9
-            log.debug("  ccache: %r, keytab: %r, principal: %r" % (
64309b9
-                ccache, keytab, principal))
64309b9
-            if keytab and principal:
64309b9
-                koji_session.krb_login(
64309b9
-                    principal=principal,
64309b9
-                    keytab=keytab,
64309b9
-                    ccache=ccache,
64309b9
-                )
64309b9
-            else:
64309b9
-                koji_session.krb_login(ccache=ccache)
64309b9
-        elif authtype == "ssl":
64309b9
-            koji_session.ssl_login(
64309b9
-                os.path.expanduser(koji_config.cert),
64309b9
-                None,
64309b9
-                os.path.expanduser(koji_config.serverca),
64309b9
-            )
64309b9
-        else:
64309b9
-            raise ValueError("Unrecognized koji authtype %r" % authtype)
64309b9
-
64309b9
-        return koji_session
64309b9
-
64309b9
     def get_pungi_cmd(self, conf_topdir, targetdir):
64309b9
         """
64309b9
         Returns list with pungi command line arguments needed to generate
64309b9
@@ -305,7 +263,7 @@ class Pungi(object):
64309b9
         makedirs(conf_topdir)
64309b9
         self._write_cfgs(conf_topdir)
64309b9
 
64309b9
-        koji_session = self.make_koji_session()
64309b9
+        koji_session = make_koji_session()
64309b9
         serverdir = self.upload_files_to_koji(koji_session, conf_topdir)
64309b9
 
64309b9
         # TODO: Copy keytab from secret repo and generate koji profile.
64309b9
@@ -313,30 +271,7 @@ class Pungi(object):
64309b9
         cmd += ["cp", "/mnt/koji/work/%s/*" % serverdir, ".", "&&"]
64309b9
         cmd += self.get_pungi_cmd("./", conf.pungi_runroot_target_dir)
64309b9
 
64309b9
-        kwargs = {
64309b9
-            'channel': conf.pungi_parent_runroot_channel,
64309b9
-            'packages': conf.pungi_parent_runroot_packages,
64309b9
-            'mounts': conf.pungi_parent_runroot_mounts,
64309b9
-            'weight': conf.pungi_parent_runroot_weight
64309b9
-        }
64309b9
-
64309b9
-        task_id = koji_session.runroot(
64309b9
-            conf.pungi_parent_runroot_tag, conf.pungi_parent_runroot_arch,
64309b9
-            " ".join(cmd), **kwargs)
64309b9
-
64309b9
-        while True:
64309b9
-            # wait for the task to finish
64309b9
-            if koji_session.taskFinished(task_id):
64309b9
-                break
64309b9
-            log.info("Waiting for Koji runroot task %r to finish...", task_id)
64309b9
-            time.sleep(60)
64309b9
-
64309b9
-        info = koji_session.getTaskInfo(task_id)
64309b9
-        if info is None:
64309b9
-            raise RuntimeError("Cannot get status of Koji task %r" % task_id)
64309b9
-        state = koji.TASK_STATES[info['state']]
64309b9
-        if state in ('FAILED', 'CANCELED'):
64309b9
-            raise RuntimeError("Koji runroot task %r failed." % task_id)
64309b9
+        run_koji_runroot(koji_session, cmd)
64309b9
 
64309b9
     def run(self):
64309b9
         """
64309b9
diff --git a/server/odcs/server/utils.py b/server/odcs/server/utils.py
64309b9
index 63c3574..20e2e50 100644
64309b9
--- a/server/odcs/server/utils.py
64309b9
+++ b/server/odcs/server/utils.py
64309b9
@@ -26,11 +26,86 @@ import os
64309b9
 import time
64309b9
 import subprocess
64309b9
 import requests
64309b9
+import munch
64309b9
+import koji
64309b9
 from distutils.spawn import find_executable
64309b9
 
64309b9
 from odcs.server import conf, log
64309b9
 
64309b9
 
64309b9
+def make_koji_session():
64309b9
+    """
64309b9
+    Creates new KojiSession according to odcs.server.conf, logins to
64309b9
+    Koji using this session and returns it.
64309b9
+    :rtype: koji.KojiSession
64309b9
+    :return: KojiSession
64309b9
+    """
64309b9
+    koji_config = munch.Munch(koji.read_config(
64309b9
+        profile_name=conf.koji_profile,
64309b9
+        user_config=conf.koji_config,
64309b9
+    ))
64309b9
+
64309b9
+    address = koji_config.server
64309b9
+    authtype = koji_config.authtype
64309b9
+    log.info("Connecting to koji %r with %r." % (address, authtype))
64309b9
+    koji_session = koji.ClientSession(address, opts=koji_config)
64309b9
+    if authtype == "kerberos":
64309b9
+        ccache = getattr(conf, "krb_ccache", None)
64309b9
+        keytab = getattr(conf, "krb_keytab", None)
64309b9
+        principal = getattr(conf, "krb_principal", None)
64309b9
+        log.debug("  ccache: %r, keytab: %r, principal: %r" % (
64309b9
+            ccache, keytab, principal))
64309b9
+        if keytab and principal:
64309b9
+            koji_session.krb_login(
64309b9
+                principal=principal,
64309b9
+                keytab=keytab,
64309b9
+                ccache=ccache,
64309b9
+            )
64309b9
+        else:
64309b9
+            koji_session.krb_login(ccache=ccache)
64309b9
+    elif authtype == "ssl":
64309b9
+        koji_session.ssl_login(
64309b9
+            os.path.expanduser(koji_config.cert),
64309b9
+            None,
64309b9
+            os.path.expanduser(koji_config.serverca),
64309b9
+        )
64309b9
+    else:
64309b9
+        raise ValueError("Unrecognized koji authtype %r" % authtype)
64309b9
+
64309b9
+    return koji_session
64309b9
+
64309b9
+
64309b9
+def run_koji_runroot(koji_session, cmd):
64309b9
+    """
64309b9
+    Runs the cmd defined as list of commands in Koji runroot and waits for the
64309b9
+    task to finish. Raises RuntimeError in case the task execution failed.
64309b9
+    """
64309b9
+    kwargs = {
64309b9
+        'channel': conf.pungi_parent_runroot_channel,
64309b9
+        'packages': conf.pungi_parent_runroot_packages,
64309b9
+        'mounts': conf.pungi_parent_runroot_mounts,
64309b9
+        'weight': conf.pungi_parent_runroot_weight
64309b9
+    }
64309b9
+
64309b9
+    task_id = koji_session.runroot(
64309b9
+        conf.pungi_parent_runroot_tag, conf.pungi_parent_runroot_arch,
64309b9
+        " ".join(cmd), **kwargs)
64309b9
+
64309b9
+    while True:
64309b9
+        # wait for the task to finish
64309b9
+        if koji_session.taskFinished(task_id):
64309b9
+            break
64309b9
+        log.info("Waiting for Koji runroot task %r to finish...", task_id)
64309b9
+        time.sleep(60)
64309b9
+
64309b9
+    info = koji_session.getTaskInfo(task_id)
64309b9
+    if info is None:
64309b9
+        raise RuntimeError("Cannot get status of Koji task %r" % task_id)
64309b9
+    state = koji.TASK_STATES[info['state']]
64309b9
+    if state in ('FAILED', 'CANCELED'):
64309b9
+        raise RuntimeError("Koji runroot task %r failed." % task_id)
64309b9
+
64309b9
+
64309b9
 def download_file(url, output_path):
64309b9
     """
64309b9
     Downloads file from URL `url` to `output_path`.
64309b9
diff --git a/server/tests/test_pungi.py b/server/tests/test_pungi.py
64309b9
index 4d2591d..8709780 100644
64309b9
--- a/server/tests/test_pungi.py
64309b9
+++ b/server/tests/test_pungi.py
64309b9
@@ -201,7 +201,7 @@ class TestPungiRunroot(unittest.TestCase):
64309b9
         self.config_patcher.patch('pungi_runroot_target_dir_url', 'http://kojipkgs.fedoraproject.org/compose/odcs')
64309b9
         self.config_patcher.start()
64309b9
 
64309b9
-        self.patch_make_koji_session = patch("odcs.server.pungi.Pungi.make_koji_session")
64309b9
+        self.patch_make_koji_session = patch("odcs.server.pungi.make_koji_session")
64309b9
         self.make_koji_session = self.patch_make_koji_session.start()
64309b9
         self.koji_session = MagicMock()
64309b9
         self.koji_session.runroot.return_value = 123
64309b9
diff --git a/server/tests/test_remove_expired_composes_thread.py b/server/tests/test_remove_expired_composes_thread.py
64309b9
index fb387f7..e4551dc 100644
64309b9
--- a/server/tests/test_remove_expired_composes_thread.py
64309b9
+++ b/server/tests/test_remove_expired_composes_thread.py
64309b9
@@ -21,17 +21,18 @@
64309b9
 # Written by Jan Kaluza <jkaluza@redhat.com>
64309b9
 
64309b9
 from odcs.server import db, conf
64309b9
+import odcs.server.backend
64309b9
 from odcs.server.models import Compose
64309b9
 from odcs.common.types import COMPOSE_STATES, COMPOSE_RESULTS
64309b9
 from odcs.server.backend import RemoveExpiredComposesThread
64309b9
 from odcs.server.pungi import PungiSourceType
64309b9
 from datetime import datetime, timedelta
64309b9
 
64309b9
-from .utils import ModelsBaseTest
64309b9
+from .utils import ModelsBaseTest, ConfigPatcher
64309b9
 
64309b9
 import os
64309b9
 import mock
64309b9
-from mock import patch
64309b9
+from mock import patch, MagicMock
64309b9
 
64309b9
 
64309b9
 class TestRemoveExpiredComposesThread(ModelsBaseTest):
64309b9
@@ -211,3 +212,149 @@ class TestRemoveExpiredComposesThread(ModelsBaseTest):
64309b9
         self.thread._remove_compose_dir(toplevel_dir)
64309b9
         unlink.assert_not_called()
64309b9
         rmtree.assert_called_once()
64309b9
+
64309b9
+
64309b9
+class TestRemoveExpiredComposesThreadPungiRunroot(ModelsBaseTest):
64309b9
+    maxDiff = None
64309b9
+
64309b9
+    def setUp(self):
64309b9
+        super(TestRemoveExpiredComposesThreadPungiRunroot, self).setUp()
64309b9
+
64309b9
+        compose = Compose.create(
64309b9
+            db.session, "unknown", PungiSourceType.MODULE, "testmodule-master",
64309b9
+            COMPOSE_RESULTS["repository"], 60)
64309b9
+        db.session.add(compose)
64309b9
+        db.session.commit()
64309b9
+
64309b9
+        self.config_patcher = ConfigPatcher(odcs.server.backend.conf)
64309b9
+        self.config_patcher.patch("pungi_runroot_enabled", True)
64309b9
+        self.config_patcher.start()
64309b9
+
64309b9
+        self.thread = RemoveExpiredComposesThread()
64309b9
+
64309b9
+        self.patch_make_koji_session = patch("odcs.server.utils.make_koji_session")
64309b9
+        self.make_koji_session = self.patch_make_koji_session.start()
64309b9
+        self.koji_session = MagicMock()
64309b9
+        self.make_koji_session.return_value = self.koji_session
64309b9
+
64309b9
+        self.patch_run_koji_runroot = patch("odcs.server.utils.run_koji_runroot")
64309b9
+        self.run_koji_runroot = self.patch_run_koji_runroot.start()
64309b9
+
64309b9
+    def tearDown(self):
64309b9
+        super(TestRemoveExpiredComposesThreadPungiRunroot, self).tearDown()
64309b9
+        self.config_patcher.stop()
64309b9
+        self.patch_make_koji_session.stop()
64309b9
+
64309b9
+    def _mock_glob(self, glob, dirs):
64309b9
+        glob_ret_values = [[], []]
64309b9
+        for d in dirs:
64309b9
+            path = os.path.join(conf.target_dir, d)
64309b9
+            if d.startswith("latest-"):
64309b9
+                glob_ret_values[0].append(path)
64309b9
+            else:
64309b9
+                glob_ret_values[1].append(path)
64309b9
+        glob.side_effect = glob_ret_values
64309b9
+
64309b9
+    @patch("os.path.isdir")
64309b9
+    @patch("glob.glob")
64309b9
+    def test_remove_left_composes(self, glob, isdir):
64309b9
+        isdir.return_value = True
64309b9
+        self._mock_glob(glob, ["latest-odcs-96-1", "odcs-96-1-20171005.n.0"])
64309b9
+        self.thread.do_work()
64309b9
+
64309b9
+        self.run_koji_runroot.assert_not_called()
64309b9
+
64309b9
+    @patch("os.path.isdir")
64309b9
+    @patch("glob.glob")
64309b9
+    def test_remove_left_composes_not_dir(
64309b9
+            self, glob, isdir):
64309b9
+        isdir.return_value = False
64309b9
+        self._mock_glob(glob, ["latest-odcs-96-1"])
64309b9
+        self.thread.do_work()
64309b9
+        self.run_koji_runroot.assert_not_called()
64309b9
+
64309b9
+    @patch("os.path.isdir")
64309b9
+    @patch("glob.glob")
64309b9
+    def test_remove_left_composes_wrong_dir(
64309b9
+            self, glob, isdir):
64309b9
+        isdir.return_value = True
64309b9
+        self._mock_glob(glob, ["latest-odcs-", "odcs-", "odcs-abc"])
64309b9
+        self.thread.do_work()
64309b9
+        self.run_koji_runroot.assert_not_called()
64309b9
+
64309b9
+    @patch("os.path.isdir")
64309b9
+    @patch("glob.glob")
64309b9
+    def test_remove_left_composes_valid_compose(
64309b9
+            self, glob, isdir):
64309b9
+        isdir.return_value = True
64309b9
+        self._mock_glob(glob, ["latest-odcs-1-1", "odcs-1-1-2017.n.0"])
64309b9
+        c = db.session.query(Compose).filter(Compose.id == 1).one()
64309b9
+        c.state = COMPOSE_STATES["done"]
64309b9
+        db.session.add(c)
64309b9
+        db.session.commit()
64309b9
+        self.thread.do_work()
64309b9
+        self.run_koji_runroot.assert_not_called()
64309b9
+
64309b9
+    @patch("os.path.isdir")
64309b9
+    @patch("glob.glob")
64309b9
+    @patch("os.path.realpath")
64309b9
+    @patch("os.path.exists")
64309b9
+    def test_remove_left_composes_expired_compose(
64309b9
+            self, exists, realpath, glob, isdir):
64309b9
+        exists.return_value = True
64309b9
+        realpath.return_value = "/odcs-real"
64309b9
+        isdir.return_value = True
64309b9
+        self._mock_glob(glob, ["latest-odcs-1-1", "odcs-1-1-2017.n.0"])
64309b9
+        c = db.session.query(Compose).filter(Compose.id == 1).one()
64309b9
+        c.state = COMPOSE_STATES["removed"]
64309b9
+        db.session.add(c)
64309b9
+        db.session.commit()
64309b9
+        self.thread.do_work()
64309b9
+        self.run_koji_runroot.assert_called_once_with(
64309b9
+            self.koji_session,
64309b9
+            ['rm', '-f', os.path.join(conf.target_dir, 'latest-odcs-1-1'), '&&',
64309b9
+             'rm', '-rf', '/odcs-real', '&&',
64309b9
+             'rm', '-f', os.path.join(conf.target_dir, 'odcs-1-1-2017.n.0'), '&&',
64309b9
+             'rm', '-rf', '/odcs-real'])
64309b9
+
64309b9
+    @patch("os.path.realpath")
64309b9
+    @patch("os.path.exists")
64309b9
+    def test_remove_compose_dir_symlink(
64309b9
+            self, exists, realpath):
64309b9
+        exists.return_value = True
64309b9
+        toplevel_dir = "/odcs"
64309b9
+        realpath.return_value = "/odcs-real"
64309b9
+
64309b9
+        cmds = self.thread._get_remove_compose_dir_cmds(toplevel_dir)
64309b9
+        self.assertEqual(
64309b9
+            cmds, ['rm', '-f', '/odcs', '&&', 'rm', '-rf', '/odcs-real'])
64309b9
+
64309b9
+    @patch("shutil.rmtree")
64309b9
+    @patch("os.unlink")
64309b9
+    @patch("os.path.realpath")
64309b9
+    @patch("os.path.exists")
64309b9
+    def test_remove_compose_dir_broken_symlink(
64309b9
+            self, exists, realpath, unlink, rmtree):
64309b9
+        def mocked_exists(p):
64309b9
+            return p != "/odcs-real"
64309b9
+        exists.side_effect = mocked_exists
64309b9
+        toplevel_dir = "/odcs"
64309b9
+        realpath.return_value = "/odcs-real"
64309b9
+
64309b9
+        cmds = self.thread._get_remove_compose_dir_cmds(toplevel_dir)
64309b9
+        self.assertEqual(
64309b9
+            cmds, ['rm', '-f', '/odcs'])
64309b9
+
64309b9
+    @patch("shutil.rmtree")
64309b9
+    @patch("os.unlink")
64309b9
+    @patch("os.path.realpath")
64309b9
+    @patch("os.path.exists")
64309b9
+    def test_remove_compose_dir_real_dir(
64309b9
+            self, exists, realpath, unlink, rmtree):
64309b9
+        exists.return_value = True
64309b9
+        toplevel_dir = "/odcs"
64309b9
+        realpath.return_value = "/odcs"
64309b9
+
64309b9
+        cmds = self.thread._get_remove_compose_dir_cmds(toplevel_dir)
64309b9
+        self.assertEqual(
64309b9
+            cmds, ['rm', '-rf', '/odcs'])
64309b9