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={}, )
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"]
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)
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)
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
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
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
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
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
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
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
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"
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 == []