Пример #1
0
 def test_config_default(self, tmp_path):
     """Default values for Config."""
     assert Config(folder=tmp_path, kubeconfig=tmp_path,
                   kubecontext="ctx") == Config(
                       folder=tmp_path,
                       kubecontext="ctx",
                       kubeconfig=tmp_path,
                       selectors=Selectors(kinds=set(DEFAULT_PRIORITIES),
                                           namespaces=[],
                                           labels=set()),
                       priorities=list(DEFAULT_PRIORITIES),
                       groupby=GroupBy(label="", order=[]),
                       filters={},
                   )
Пример #2
0
    def test_translate_resource_kinds(self, k8sconfig):
        """Translate various spellings in `selectors.kinds`"""
        cfg = Config(
            folder=Filepath('/tmp'),
            kubeconfig="",
            kubecontext=None,
            selectors=Selectors(
                kinds={"svc", 'DEPLOYMENT', "Secret"},
                namespaces=['default'],
                labels=["app=morty", "foo=bar"],
            ),
            groupby=GroupBy("", []),
            priorities=["ns", "DEPLOYMENT"],
        )

        # Convert the resource names to their correct K8s kind.
        ret = sq.translate_resource_kinds(cfg, k8sconfig)
        assert ret.selectors.kinds == {"Service", "Deployment", "Secret"}
        assert ret.priorities == ["Namespace", "Deployment"]

        # Add two invalid resource names. This must succeed but return the
        # resource names without having changed them.
        cfg.selectors.kinds.clear()
        cfg.selectors.kinds.update({"invalid", "k8s-resource-kind"})
        cfg.priorities.clear()
        cfg.priorities.extend(["invalid", "k8s-resource-kind"])
        ret = sq.translate_resource_kinds(cfg, k8sconfig)
        assert ret.selectors.kinds == {"invalid", "k8s-resource-kind"}
        assert ret.priorities == ["invalid", "k8s-resource-kind"]
Пример #3
0
    def test_parse_commandline_get_grouping(self, tmp_path):
        """GET supports file hierarchy options."""
        kubeconfig = tmp_path / "kubeconfig.yaml"
        kubeconfig.write_text("")
        base_cmd = ("square.py", "get", "all",
                    "--kubeconfig", str(tmp_path / "kubeconfig.yaml"))

        # ----------------------------------------------------------------------
        # Default file system hierarchy.
        # ----------------------------------------------------------------------
        with mock.patch("sys.argv", base_cmd):
            param = main.parse_commandline_args()
            assert param.groupby is None

        # ----------------------------------------------------------------------
        # User defined file system hierarchy.
        # ----------------------------------------------------------------------
        cmd = ("--groupby", "ns", "kind")
        with mock.patch("sys.argv", base_cmd + cmd):
            param = main.parse_commandline_args()
            assert param.groupby == ["ns", "kind"]

        cfg, err = main.compile_config(param)
        assert not err
        assert cfg.groupby == GroupBy(label="", order=["ns", "kind"])

        # ----------------------------------------------------------------------
        # Include a label into the hierarchy and use "ns" twice.
        # ----------------------------------------------------------------------
        cmd = ("--groupby", "ns", "label=foo", "ns")
        with mock.patch("sys.argv", base_cmd + cmd):
            param = main.parse_commandline_args()
            assert param.groupby == ["ns", "label=foo", "ns"]

        cfg, err = main.compile_config(param)
        assert not err
        assert cfg.groupby == GroupBy(label="foo", order=["ns", "label", "ns"])

        # ----------------------------------------------------------------------
        # The label resource, unlike "ns" or "kind", can only be specified
        # at most once.
        # ----------------------------------------------------------------------
        cmd = ("--groupby", "ns", "label=foo", "label=bar")
        with mock.patch("sys.argv", base_cmd + cmd):
            param = main.parse_commandline_args()
            assert param.groupby == ["ns", "label=foo", "label=bar"]

        expected = Config(
            folder=Filepath(""),
            kubeconfig=Filepath(""),
            kubecontext=None,
            selectors=Selectors(set(), [], []),
            groupby=GroupBy("", []),
            priorities=[],
        )
        assert main.compile_config(param) == (expected, True)
Пример #4
0
 def test_compile_config_missing_k8s_credentials(self, fname_param_config):
     """Gracefully abort if kubeconfig does not exist"""
     _, param, _ = fname_param_config
     param.kubeconfig += "does-not-exist"
     assert main.compile_config(param) == (
         Config(
             folder=Filepath(""),
             kubeconfig=Filepath(""),
             kubecontext=None,
             selectors=Selectors(set(), [], []),
             groupby=GroupBy("", []),
             priorities=[],
         ), True)
Пример #5
0
    def test_expand_all_kinds(self, m_cluster, k8sconfig):
        """Must expand the short names if possible, and leave as is otherwise."""
        m_cluster.side_effect = lambda *args: (k8sconfig, False)

        # Expand an empty list of `Selectors.kinds` to the full range of
        # available K8s resources that Square could manage.
        cfg = Config(
            folder=pathlib.Path('/tmp'),
            kubeconfig="",
            kubecontext=None,
            selectors=Selectors(
                kinds=set(),
                namespaces=['default'],
                labels=["app=morty", "foo=bar"],
            ),
            groupby=GroupBy("", []),
            priorities=("Namespace", "Deployment"),
        )

        # Do nothing if `Selectors.kinds` is non-empty.
        ret, err = main.expand_all_kinds(cfg)
        assert not err and ret.selectors.kinds == set(k8sconfig.kinds)

        cfg = Config(
            folder=pathlib.Path('/tmp'),
            kubeconfig="",
            kubecontext=None,
            selectors=Selectors(
                kinds={"svc", "Deployment"},
                namespaces=['default'],
                labels=["app=morty", "foo=bar"],
            ),
            groupby=GroupBy("", []),
            priorities=("Namespace", "Deployment"),
        )

        # Convert the resource names to their correct K8s kind.
        ret, err = main.expand_all_kinds(cfg)
        assert not err and ret == cfg
Пример #6
0
def main():
    # ----------------------------------------------------------------------
    #                                 Setup
    # ----------------------------------------------------------------------
    # Kubernetes credentials.
    kubeconfig, kubecontext = pathlib.Path(os.environ["KUBECONFIG"]), None

    # Optional: Set log level (0 = ERROR, 1 = WARNING, 2 = INFO, 3 = DEBUG).
    square.square.setup_logging(3)

    # Populate the `Config` structure. All main functions expect this.
    config = Config(
        kubeconfig=kubeconfig,
        kubecontext=kubecontext,

        # Store manifests in this folder.
        folder=pathlib.PosixPath('manifests'),

        # Organise the manifests in `folder` above in this order.
        groupby=GroupBy(label="app", order=["ns", "label", "kind"]),

        # Specify the resources to operate on. These ones model the demo setup in
        # the `../integration-test-cluster`.
        selectors=Selectors(
            kinds=["Deployment", "Service", "Namespace"],
            labels=["app=demoapp-1"],
            namespaces=["square-tests-1", "square-tests-2"],
        ),
    )

    # ----------------------------------------------------------------------
    #          Import resources, create a plan and then apply it
    # ----------------------------------------------------------------------
    # Download the manifests into the `folder` defined earlier.
    err = square.get(config)
    assert not err

    # Compute the plan and show it. It will not show any differences because we
    # planned against the resources we just downloaded.
    plan, err = square.plan(config)
    assert not err
    square.show_plan(plan)

    # Apply the plan - will not actually do anything in this case, because the
    # plan is empty.
    err = square.apply_plan(config, plan)
    assert not err
Пример #7
0
    def test_compile_hierarchy_ok(self, fname_param_config):
        """Parse the `--groupby` argument."""
        _, param, _ = fname_param_config

        err_resp = Config(
            folder=Filepath(""),
            kubeconfig=Filepath(""),
            kubecontext=None,
            selectors=Selectors(set(), [], []),
            groupby=GroupBy("", []),
            priorities=[],
        ), True

        # ----------------------------------------------------------------------
        # Default hierarchy.
        # ----------------------------------------------------------------------
        for cmd in ["apply", "get", "plan"]:
            param.parser = cmd
            ret, err = main.compile_config(param)
            assert not err
            assert ret.groupby == GroupBy(label="app", order=["ns", "label", "kind"])
            del cmd, ret, err

        # ----------------------------------------------------------------------
        # User defined hierarchy with a valid label.
        # ----------------------------------------------------------------------
        param.parser = "get"
        param.groupby = ("ns", "kind", "label=app", "ns")
        ret, err = main.compile_config(param)
        assert not err
        assert ret.groupby == GroupBy(label="app", order=["ns", "kind", "label", "ns"])

        # ----------------------------------------------------------------------
        # User defined hierarchy with invalid labels.
        # ----------------------------------------------------------------------
        param.parser = "get"
        invalid_labels = ["label", "label=", "label=foo=bar"]
        for label in invalid_labels:
            param.groupby = ("ns", "kind", label, "ns")
            assert main.compile_config(param) == err_resp

        # ----------------------------------------------------------------------
        # User defined hierarchy with invalid resource types.
        # ----------------------------------------------------------------------
        param.parser = "get"
        param.groupby = ("ns", "unknown")
        assert main.compile_config(param) == err_resp
Пример #8
0
    def test_expand_all_kinds_err_config(self, k8sconfig):
        """Abort if the kubeconfig file does not exist."""
        cfg = Config(
            folder=pathlib.Path('/tmp'),
            kubeconfig=Filepath("/does/not/exist"),
            kubecontext=None,
            selectors=Selectors(
                kinds=set(),
                namespaces=['default'],
                labels=["app=morty", "foo=bar"],
            ),
            groupby=GroupBy("", tuple()),
            priorities=("Namespace", "Deployment"),
        )

        _, err = main.expand_all_kinds(cfg)
        assert err
Пример #9
0
    def test_nonpreferred_api(self, tmp_path):
        """Sync `autoscaling/v1` and `autoscaling/v2beta` at the same time.

        This test is designed to verify that Square will interrogate the
        correct K8s endpoint versions to download the manifest.

        """
        # Only show INFO and above or otherwise this test will produce a
        # humongous amount of useless logs from all the K8s calls.
        square.square.setup_logging(2)

        config = Config(
            folder=tmp_path,
            groupby=GroupBy(label="app", order=[]),
            kubecontext=None,
            kubeconfig=Filepath("/tmp/kubeconfig-kind.yaml"),
            selectors=Selectors(
                kinds={"Namespace", "HorizontalPodAutoscaler"},
                namespaces=["test-hpa"],
                labels=[],
            ),
        )

        # Copy the manifest with the namespace and the two HPAs to the temporary path.
        manifests = list(
            yaml.safe_load_all(open("tests/support/k8s-test-hpa.yaml")))
        man_path = tmp_path / "manifest.yaml"
        man_path.write_text(yaml.dump_all(manifests))
        assert len(manifests) == 3

        # ---------------------------------------------------------------------
        # Deploy the resources: one namespace with two HPAs in it. On will be
        # deployed via `autoscaling/v1` the other via `autoscaling/v2beta2`.
        # ---------------------------------------------------------------------
        sh.kubectl("apply", "--kubeconfig", config.kubeconfig, "-f",
                   str(man_path))

        # ---------------------------------------------------------------------
        # Sync all manifests. This must do nothing. In particular, it must not
        # change the `apiVersion` of either HPA.
        # ---------------------------------------------------------------------
        assert not square.square.get_resources(config)
        assert list(yaml.safe_load_all(man_path.read_text())) == manifests
Пример #10
0
def load(fname: Filepath) -> Tuple[Config, bool]:
    """Parse the Square configuration file `fname` and return it as a `Config`."""
    err_resp = Config(Filepath(""), kubeconfig=Filepath(""),
                      kubecontext=None), True
    fname = Filepath(fname)

    # Load the configuration file.
    try:
        raw = yaml.safe_load(Filepath(fname).read_text())
    except FileNotFoundError as e:
        logit.error(f"Cannot load config file <{fname}>: {e.args[1]}")
        return err_resp
    except yaml.YAMLError as exc:
        msg = f"Could not parse YAML file {fname}"

        # Special case: parser supplied location information.
        mark = getattr(exc, "problem_mark", SimpleNamespace(line=-1,
                                                            column=-1))
        line, col = (mark.line + 1, mark.column + 1)
        msg = f"YAML format error in {fname}: Line {line} Column {col}"
        logit.error(msg)
        return err_resp

    if not isinstance(raw, dict):
        logit.error(f"Config file <{fname}> has invalid structure")
        return err_resp

    # Parse the configuration into `ConfigFile` structure.
    try:
        cfg = Config(**raw)
    except (pydantic.ValidationError, TypeError) as e:
        logit.error(f"Schema is invalid: {e}")
        return err_resp

    # Remove the "_common_" filter and merge it into all the other filters.
    common = cfg.filters.pop("_common_", [])
    cfg.filters = {k: merge(common, v) for k, v in cfg.filters.items()}
    cfg.filters["_common_"] = common

    # Ensure the path is an absolute path.
    cfg.folder = fname.parent.absolute() / cfg.folder

    # Convert the list to a set. No functional reason.
    cfg.selectors.kinds = set(cfg.selectors.kinds)
    return cfg, False
Пример #11
0
def compile_config(cmdline_param) -> Tuple[Config, bool]:
    """Return `Config` from `cmdline_param`.

    Inputs:
        cmdline_param: SimpleNamespace

    Returns:
        Config, err

    """
    err_resp = Config(
        folder=Filepath(""),
        kubeconfig=Filepath(""),
        kubecontext=None,
        selectors=Selectors(set(), namespaces=[], labels=[]),
        groupby=GroupBy("", []),
        priorities=[],
    ), True

    # Convenience.
    p = cmdline_param

    # Load the default configuration unless the user specified an explicit one.
    if p.configfile:
        logit.info(f"Loading configuration file <{p.configfile}>")
        cfg, err = square.cfgfile.load(p.configfile)

        # Use the folder the `load()` function determined.
        folder = cfg.folder

        # Look for `--kubeconfig`. Default to the value in config file.
        kubeconfig = p.kubeconfig or cfg.kubeconfig
    else:
        # Pick the configuration file, depending on whether the user specified
        # `--no-config`, `--config` and whether a `.square.yaml` file exists.
        default_cfg = DEFAULT_CONFIG_FILE
        dot_square = Filepath(".square.yaml")
        if p.no_config:
            cfg_file = default_cfg
        else:
            cfg_file = dot_square if dot_square.exists() else default_cfg

        logit.info(f"Loading configuration file <{cfg_file}>")
        cfg, err = square.cfgfile.load(cfg_file)

        # Determine which Kubeconfig to use. The order is: `--kubeconfig`,
        # `--config`, `.square`, `KUBECONFIG` environment variable.
        if cfg_file == default_cfg:
            kubeconfig = p.kubeconfig or os.getenv("KUBECONFIG", "")
        else:
            kubeconfig = p.kubeconfig or str(cfg.kubeconfig) or os.getenv("KUBECONFIG", "")  # noqa
        del dot_square, default_cfg

        # Use the current working directory as the folder directory because the
        # user did not specify an explicit configuration file which would
        # contain the desired folder.
        folder = Filepath.cwd()

        # Abort if neither `--kubeconfig` nor KUBECONFIG env var.
        if not kubeconfig:
            logit.error("Must specify a Kubernetes config file.")
            return err_resp
    if err:
        return err_resp

    # Override the folder if user specified "--folder".
    folder = folder if p.folder is None else p.folder

    # Expand the user's home folder, ie if the path contains a "~".
    folder = Filepath(folder).expanduser()
    kubeconfig = Filepath(kubeconfig).expanduser()

    # ------------------------------------------------------------------------
    # GroupBy (determines the folder hierarchy that GET will create).
    # In : `--groupby ns kind label=app`
    # Out: GroupBy(order=["ns", "kind", "label"], label="app")
    # ------------------------------------------------------------------------
    # Unpack the ordering and replace all `label=*` with `label`.
    # NOTE: `p.groups` does not necessarily exist because the option only makes
    #        sense for eg `square GET` and is thus not implemented for `square apply`.
    order = getattr(p, "groupby", None)
    if order is None:
        groupby = cfg.groupby
    else:
        clean_order = [_ if not _.startswith("label") else "label" for _ in order]
        if not set(clean_order).issubset({"ns", "kind", "label"}):
            logit.error("Invalid definition of `groupby`")
            return err_resp

        labels = [_ for _ in order if _.startswith("label")]
        if len(labels) > 1:
            logit.error("Can only specify one `label=<name>` in `--groupby`.")
            return err_resp

        # Unpack the label name from the `--groupby` argument.
        # Example, if user specified `--groupby label=app` then we need to extract
        # the `app` part. This also includes basic sanity checks.
        label_name = ""
        if len(labels) == 1:
            try:
                _, label_name = labels[0].split("=")
                assert len(label_name) > 0
            except (ValueError, AssertionError):
                logit.error(f"Invalid label specification <{labels[0]}>")
                return err_resp

        # Compile the final `GroupBy` structure.
        groupby = GroupBy(order=list(clean_order), label=label_name)
        del order, clean_order, label_name

    # ------------------------------------------------------------------------
    # General: folder, kubeconfig, kubecontext, ...
    # ------------------------------------------------------------------------
    kinds = set(p.kinds) if p.kinds else cfg.selectors.kinds

    # Use the value from the (default) config file unless the user overrode
    # them on the command line.
    kubeconfig = Filepath(kubeconfig)
    kubecontext = p.kubecontext or cfg.kubecontext
    namespaces = cfg.selectors.namespaces if p.namespaces is None else p.namespaces
    sel_labels = cfg.selectors.labels if p.labels is None else p.labels
    priorities = p.priorities or cfg.priorities
    selectors = Selectors(kinds, namespaces, sel_labels)

    # Use filters from (default) config file because they cannot be specified
    # on the command line.
    filters = cfg.filters

    # ------------------------------------------------------------------------
    # Verify inputs.
    # ------------------------------------------------------------------------
    # Abort without credentials.
    if not kubeconfig.exists():
        logit.error(f"Cannot find Kubernetes config file <{kubeconfig}>")
        return err_resp

    # -------------------------------------------------------------------------
    # Assemble the full configuration and return it.
    # -------------------------------------------------------------------------
    cfg = Config(
        folder=folder,
        kubeconfig=kubeconfig,
        kubecontext=kubecontext,
        selectors=selectors,
        groupby=groupby,
        priorities=priorities,
        filters=filters,
    )
    return cfg, False
Пример #12
0
    def test_nondefault_resources(self, tmp_path):
        """Manage an `autoscaling/v1` and `autoscaling/v2beta` at the same time.

        This test is designed to verify that Square will interrogate the
        correct K8s endpoint versions to compute the plan for a resource.

        """
        # Only show INFO and above or otherwise this test will produce a
        # humongous amount of logs from all the K8s calls.
        square.square.setup_logging(2)

        config = Config(
            folder=tmp_path,
            groupby=GroupBy(label="app", order=[]),
            kubecontext=None,
            kubeconfig=Filepath("/tmp/kubeconfig-kind.yaml"),
            selectors=Selectors(kinds={"Namespace", "HorizontalPodAutoscaler"},
                                namespaces=["test-hpa"],
                                labels=[]),
        )

        # Copy the manifest with the namespace and the two HPAs to the temporary path.
        manifests = list(
            yaml.safe_load_all(open("tests/support/k8s-test-hpa.yaml")))
        man_path = tmp_path / "manifest.yaml"
        man_path.write_text(yaml.dump_all(manifests))
        assert len(manifests) == 3

        # ---------------------------------------------------------------------
        # Deploy the resources: one namespace with two HPAs in it. On will be
        # deployed via `autoscaling/v1` the other via `autoscaling/v2beta2`.
        # ---------------------------------------------------------------------
        sh.kubectl("apply", "--kubeconfig", config.kubeconfig, "-f",
                   str(man_path))

        # ---------------------------------------------------------------------
        # The plan must be empty because Square must have interrogated the
        # correct API endpoints for each HPA.
        # ---------------------------------------------------------------------
        plan_1, err = square.square.make_plan(config)
        assert not err
        assert plan_1.create == plan_1.patch == plan_1.delete == []
        del plan_1

        # ---------------------------------------------------------------------
        # Modify the v2beta2 HPA manifest and verify that Square now wants to
        # patch that resource.
        # ---------------------------------------------------------------------
        # Make a change to the manifest and save it.
        tmp_manifests = copy.deepcopy(manifests)
        assert tmp_manifests[2]["apiVersion"] == "autoscaling/v2beta2"
        tmp_manifests[2]["spec"]["metrics"][0]["external"]["metric"][
            "name"] = "foo"
        man_path.write_text(yaml.dump_all(tmp_manifests))

        # The plan must report one patch.
        plan_2, err = square.square.make_plan(config)
        assert not err
        assert plan_2.create == plan_2.delete == [] and len(plan_2.patch) == 1
        assert plan_2.patch[0].meta.name == "hpav2beta2"
        assert plan_2.patch[0].meta.apiVersion == "autoscaling/v2beta2"
        del plan_2

        # ---------------------------------------------------------------------
        # Delete both HPAs with Square.
        # ---------------------------------------------------------------------
        # Keep only the namespace manifest and save the file.
        tmp_manifests = copy.deepcopy(manifests[:1])
        man_path.write_text(yaml.dump_all(tmp_manifests))

        # Square must now want to delete both HPAs.
        plan_3, err = square.square.make_plan(config)
        assert not err
        assert plan_3.create == plan_3.patch == [] and len(plan_3.delete) == 2
        assert {_.meta.name for _ in plan_3.delete} == {"hpav1", "hpav2beta2"}
        assert not square.square.apply_plan(config, plan_3)
        del plan_3

        # ---------------------------------------------------------------------
        # Re-create both HPAs with Square.
        # ---------------------------------------------------------------------
        # Restore the original manifest file.
        man_path.write_text(yaml.dump_all(manifests))

        # Create a plan. That plan must want to restore both HPAs.
        plan_4, err = square.square.make_plan(config)
        assert not err
        assert plan_4.delete == plan_4.patch == [] and len(plan_4.create) == 2
        assert {_.meta.name for _ in plan_4.create} == {"hpav1", "hpav2beta2"}
        assert {_.meta.apiVersion
                for _ in plan_4.create
                } == {"autoscaling/v1", "autoscaling/v2beta2"}
        assert not square.square.apply_plan(config, plan_4)
        del plan_4

        # Apply the plan.
        plan_5, err = square.square.make_plan(config)
        assert not err
        assert plan_5.create == plan_5.patch == plan_5.delete == []
        del plan_5

        # ---------------------------------------------------------------------
        # Verify that a change in the `apiVersion` would mean a patch.
        # ---------------------------------------------------------------------
        # Manually change the API version of one of the HPAs.
        tmp_manifests = copy.deepcopy(manifests)
        assert tmp_manifests[1]["apiVersion"] == "autoscaling/v1"
        tmp_manifests[1]["apiVersion"] = "autoscaling/v2beta2"
        man_path.write_text(yaml.dump_all(tmp_manifests))

        # Square must now produce a single non-empty patch.
        plan_6, err = square.square.make_plan(config)
        assert not err
        assert plan_6.delete == plan_6.create == [] and len(plan_6.patch) == 1
        assert plan_6.patch[0].meta.name == "hpav1"
        assert plan_6.patch[0].meta.apiVersion == "autoscaling/v2beta2"
Пример #13
0
    def test_workflow(self, tmp_path):
        """Delete and restore full namespace with Square.

        We will use `kubectl` to create a new namespace and populate it with
        resources. Then we will use Square to backup it up, delete it and
        finally restore it again.

        """
        # Only show INFO and above or otherwise this test will produce a
        # humongous amount of logs from all the K8s calls.
        square.square.setup_logging(2)

        # Define the resource priority and kinds we have in our workflow
        # manifests. Only target the `test-workflow` labels to avoid problems
        # with non-namespaced resources.
        priorities = (
            "Namespace",
            "Secret",
            "ConfigMap",
            "ClusterRole",
            "ClusterRoleBinding",
            "Role",
            "RoleBinding",
        )
        namespace = "test-workflow"

        config = Config(
            folder=tmp_path / "backup",
            groupby=GroupBy(label="app", order=[]),
            kubecontext=None,
            kubeconfig=Filepath("/tmp/kubeconfig-kind.yaml"),
            priorities=priorities,
            selectors=Selectors(kinds=set(priorities),
                                namespaces=[namespace],
                                labels=["app=test-workflow"]),
        )

        # ---------------------------------------------------------------------
        # Deploy a new namespace with only a few resources. There are no
        # deployments among them to speed up the deletion of the namespace.
        # ---------------------------------------------------------------------
        sh.kubectl("apply", "--kubeconfig", config.kubeconfig, "-f",
                   "tests/support/k8s-test-resources.yaml")

        # ---------------------------------------------------------------------
        # Create a plan for "square-tests". The plan must delete all resources
        # because we have not downloaded any manifests yet.
        # ---------------------------------------------------------------------
        plan_1, err = square.square.make_plan(config)
        assert not err
        assert plan_1.create == plan_1.patch == [] and len(plan_1.delete) > 0

        # ---------------------------------------------------------------------
        # Backup all resources. A plan against that backup must be empty.
        # ---------------------------------------------------------------------
        assert not (config.folder / "_other.yaml").exists()
        err = square.square.get_resources(config)
        assert not err and (config.folder / "_other.yaml").exists()

        plan_2, err = square.square.make_plan(config)
        assert not err
        assert plan_2.create == plan_2.patch == plan_2.delete == []

        # ---------------------------------------------------------------------
        # Apply the first plan to delete all resources including the namespace.
        # ---------------------------------------------------------------------
        assert not square.square.apply_plan(config, plan_1)

        # ---------------------------------------------------------------------
        # Wait until K8s has deleted the namespace.
        # ---------------------------------------------------------------------
        for i in range(120):
            time.sleep(1)
            try:
                sh.kubectl("get", "ns", namespace, "--kubeconfig",
                           config.kubeconfig)
            except sh.ErrorReturnCode_1:
                break
        else:
            assert False, f"Could not delete the namespace <{namespace}> in time"

        # ---------------------------------------------------------------------
        # Use backup manifests to restore the namespace.
        # ---------------------------------------------------------------------
        plan_3, err = square.square.make_plan(config)
        assert not err
        assert plan_3.patch == plan_3.delete == [] and len(plan_3.create) > 0

        # Apply the new plan.
        assert not square.square.apply_plan(config, plan_3)

        plan_4, err = square.square.make_plan(config)
        assert not err
        assert plan_4.create == plan_4.patch == plan_4.delete == []