diff --git a/dvc/command/machine.py b/dvc/command/machine.py index 9d7c72b65e..3554ca9cd7 100644 --- a/dvc/command/machine.py +++ b/dvc/command/machine.py @@ -69,6 +69,22 @@ def run(self): return 0 +class CmdMachineList(CmdMachineConfig): + def run(self): + levels = [self.args.level] if self.args.level else self.config.LEVELS + for level in levels: + conf = self.config.read(level)["machine"] + if self.args.name: + conf = conf.get(self.args.name, {}) + prefix = self._config_file_prefix( + self.args.show_origin, self.config, level + ) + configs = list(self._format_config(conf, prefix)) + if configs: + ui.write("\n".join(configs)) + return 0 + + class CmdMachineModify(CmdMachineConfig): def run(self): from dvc.config import merge @@ -219,6 +235,27 @@ def add_parser(subparsers, parent_parser): ) machine_default_parser.set_defaults(func=CmdMachineDefault) + machine_LIST_HELP = "List the configuration of one/all machines." + machine_list_parser = machine_subparsers.add_parser( + "list", + parents=[parent_config_parser, parent_parser], + description=append_doc_link(machine_LIST_HELP, "machine/list"), + help=machine_LIST_HELP, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + machine_list_parser.add_argument( + "--show-origin", + default=False, + action="store_true", + help="Show the source file containing each config value.", + ) + machine_list_parser.add_argument( + "name", + nargs="?", + type=str, + help="name of machine to specify", + ) + machine_list_parser.set_defaults(func=CmdMachineList) machine_MODIFY_HELP = "Modify the configuration of an machine." machine_modify_parser = machine_subparsers.add_parser( "modify", diff --git a/dvc/config_schema.py b/dvc/config_schema.py index 1b55d45dc8..cd445d3ff6 100644 --- a/dvc/config_schema.py +++ b/dvc/config_schema.py @@ -229,7 +229,17 @@ class RelPath(str): "machine": { str: { "cloud": All(Lower, Choices("aws", "azure")), + "region": All( + Lower, Choices("us-west", "us-east", "eu-west", "eu-north") + ), + "image": str, + "name": str, + "spot": Bool, + "spot_price": Coerce(float), + "instance_hdd_size": Coerce(int), "instance_type": Lower, + "instance_gpu": Lower, + "ssh_private": str, "startup_script": str, }, }, diff --git a/tests/func/machine/__init__.py b/tests/func/machine/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/func/machine/test_machine_config.py b/tests/func/machine/test_machine_config.py new file mode 100644 index 0000000000..6b89d9d99b --- /dev/null +++ b/tests/func/machine/test_machine_config.py @@ -0,0 +1,123 @@ +import os +import textwrap + +import pytest + +from dvc.main import main + +CONFIG_TEXT = textwrap.dedent( + """\ + [feature] + machine = true + ['machine \"foo\"'] + cloud = aws + """ +) + + +@pytest.mark.parametrize( + "slot,value", + [ + ("region", "us-west"), + ("image", "iterative-cml"), + ("name", "iterative_test"), + ("spot", "True"), + ("spot_price", "1.2345"), + ("spot_price", "12345"), + ("instance_hdd_size", "10"), + ("instance_type", "l"), + ("instance_gpu", "tesla"), + ("ssh_private", "secret"), + ], +) +def test_machine_modify_susccess(tmp_dir, dvc, slot, value): + (tmp_dir / ".dvc" / "config").write_text(CONFIG_TEXT) + assert main(["machine", "modify", "foo", slot, value]) == 0 + assert ( + tmp_dir / ".dvc" / "config" + ).read_text() == CONFIG_TEXT + f" {slot} = {value}\n" + assert main(["machine", "modify", "--unset", "foo", slot]) == 0 + assert (tmp_dir / ".dvc" / "config").read_text() == CONFIG_TEXT + + +def test_machine_modify_startup_script(tmp_dir, dvc): + slot, value = "startup_script", "start.sh" + (tmp_dir / ".dvc" / "config").write_text(CONFIG_TEXT) + assert main(["machine", "modify", "foo", slot, value]) == 0 + assert ( + tmp_dir / ".dvc" / "config" + ).read_text() == CONFIG_TEXT + f" {slot} = ../{value}\n" + assert main(["machine", "modify", "--unset", "foo", slot]) == 0 + assert (tmp_dir / ".dvc" / "config").read_text() == CONFIG_TEXT + + +@pytest.mark.parametrize( + "slot,value,msg", + [ + ( + "region", + "other-west", + "expected one of us-west, us-east, eu-west, eu-north", + ), + ("spot_price", "NUM", "expected float"), + ("instance_hdd_size", "BIG", "expected int"), + ], +) +def test_machine_modify_fail(tmp_dir, dvc, caplog, slot, value, msg): + (tmp_dir / ".dvc" / "config").write_text(CONFIG_TEXT) + + assert main(["machine", "modify", "foo", slot, value]) == 251 + assert (tmp_dir / ".dvc" / "config").read_text() == CONFIG_TEXT + assert msg in caplog.text + + +FULL_CONFIG_TEXT = textwrap.dedent( + """\ + [feature] + machine = true + ['machine \"bar\"'] + cloud = azure + ['machine \"foo\"'] + cloud = aws + region = us-west + image = iterative-cml + name = iterative_test + spot = True + spot_price = 1.2345 + instance_hdd_size = 10 + instance_type = l + instance_gpu = tesla + ssh_private = secret + startup_script = {} + """.format( + os.path.join("..", "start.sh") + ) +) + + +def test_machine_list(tmp_dir, dvc, capsys): + (tmp_dir / ".dvc" / "config").write_text(FULL_CONFIG_TEXT) + + assert main(["machine", "list"]) == 0 + cap = capsys.readouterr() + assert "cloud=azure" in cap.out + + assert main(["machine", "list", "foo"]) == 0 + cap = capsys.readouterr() + assert "cloud=azure" not in cap.out + assert "cloud=aws" in cap.out + assert "region=us-west" in cap.out + assert "image=iterative-cml" in cap.out + assert "name=iterative_test" in cap.out + assert "spot=True" in cap.out + assert "spot_price=1.2345" in cap.out + assert "instance_hdd_size=10" in cap.out + assert "instance_type=l" in cap.out + assert "instance_gpu=tesla" in cap.out + assert "ssh_private=secret" in cap.out + assert ( + "startup_script={}".format( + os.path.join(tmp_dir, ".dvc", "..", "start.sh") + ) + in cap.out + ) diff --git a/tests/unit/command/test_machine.py b/tests/unit/command/test_machine.py index 02af8feeb6..dd77b9bc5d 100644 --- a/tests/unit/command/test_machine.py +++ b/tests/unit/command/test_machine.py @@ -5,10 +5,23 @@ CmdMachineAdd, CmdMachineCreate, CmdMachineDestroy, + CmdMachineList, + CmdMachineModify, CmdMachineRemove, CmdMachineSsh, ) +DATA = { + ".dvc": { + "config": ( + "[feature]\n" + " machine = true\n" + "['machine \"foo\"']\n" + " cloud = aws" + ) + } +} + def test_add(tmp_dir): tmp_dir.gen({".dvc": {"config": "[feature]\n machine = true"}}) @@ -21,18 +34,7 @@ def test_add(tmp_dir): def test_remove(tmp_dir): - tmp_dir.gen( - { - ".dvc": { - "config": ( - "[feature]\n" - " machine = true\n" - "['machine \"foo\"']\n" - " cloud = aws" - ) - } - } - ) + tmp_dir.gen(DATA) cli_args = parse_args(["machine", "remove", "foo"]) assert cli_args.func == CmdMachineRemove cmd = cli_args.func(cli_args) @@ -78,3 +80,25 @@ def test_ssh(tmp_dir, dvc, mocker): assert cmd.run() == 0 m.assert_called_once_with("foo") + + +def test_list(tmp_dir, mocker): + from dvc.ui import ui + + tmp_dir.gen(DATA) + cli_args = parse_args(["machine", "list", "foo"]) + assert cli_args.func == CmdMachineList + cmd = cli_args.func(cli_args) + m = mocker.patch.object(ui, "write", autospec=True) + assert cmd.run() == 0 + m.assert_called_once_with("cloud=aws") + + +def test_modified(tmp_dir): + tmp_dir.gen(DATA) + cli_args = parse_args(["machine", "modify", "foo", "cloud", "azure"]) + assert cli_args.func == CmdMachineModify + cmd = cli_args.func(cli_args) + assert cmd.run() == 0 + config = configobj.ConfigObj(str(tmp_dir / ".dvc" / "config")) + assert config['machine "foo"']["cloud"] == "azure"