From 5c8182130f52d9843378e8f908cf6bba098235e8 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Mon, 27 May 2024 17:58:14 +0300 Subject: [PATCH 01/13] Make "--api-server" an admim option This way we would be able to set settings.SERVER for all "admin" sub commands. Signed-off-by: Martin Vrachev --- repository_service_tuf/cli/admin/__init__.py | 18 +- repository_service_tuf/cli/admin/ceremony.py | 66 ++++-- .../cli/admin/import_artifacts.py | 23 +-- repository_service_tuf/cli/admin/sign.py | 31 +-- tests/conftest.py | 44 +++- tests/unit/cli/admin/test_admin.py | 13 ++ tests/unit/cli/admin/test_ceremony.py | 188 +++++++++++++++++- tests/unit/cli/admin/test_import_artifacts.py | 86 ++++---- tests/unit/cli/admin/test_sign.py | 82 +------- 9 files changed, 346 insertions(+), 205 deletions(-) create mode 100644 tests/unit/cli/admin/test_admin.py diff --git a/repository_service_tuf/cli/admin/__init__.py b/repository_service_tuf/cli/admin/__init__.py index 94bf9ae2..4664c1ba 100644 --- a/repository_service_tuf/cli/admin/__init__.py +++ b/repository_service_tuf/cli/admin/__init__.py @@ -8,13 +8,29 @@ Provides alternative ceremony, metadata update, and sign admin cli commands. """ +from typing import Optional from repository_service_tuf.cli import click, rstuf +def _set_settings(context: click.Context, api_server: Optional[str]): + """Set context.obj['settings'] attributes.""" + settings = context.obj["settings"] + if api_server: + settings.SERVER = api_server + + @rstuf.group() # type: ignore -def admin(): +@click.option( + "--api-server", + help="URL to an RSTUF API.", + required=False, +) +@click.pass_context +def admin(context: click.Context, api_server: Optional[str]): """Administrative Commands""" + # Because of tests it has to be in a separate function. + _set_settings(context, api_server) # pragma: no cover @admin.group() diff --git a/repository_service_tuf/cli/admin/ceremony.py b/repository_service_tuf/cli/admin/ceremony.py index b5276f6a..73151d66 100644 --- a/repository_service_tuf/cli/admin/ceremony.py +++ b/repository_service_tuf/cli/admin/ceremony.py @@ -2,8 +2,8 @@ # # SPDX-License-Identifier: MIT -import json from dataclasses import asdict +from typing import Any, Optional import click from rich.markdown import Markdown @@ -31,28 +31,43 @@ _online_settings_prompt, _print_root, _root_threshold_prompt, - _warn_no_save, ) +from repository_service_tuf.helpers.api_client import ( + URL, + bootstrap_status, + send_payload, + task_status, +) +from repository_service_tuf.helpers.tuf import save_payload + +DEFAULT_PATH = "ceremony-payload.json" @admin.command() # type: ignore -@click.option( - "--save", - "-s", - is_flag=False, - flag_value="ceremony-payload.json", - help="Write json result to FILENAME (default: 'ceremony-payload.json')", +@click.argument( + "output", + required=False, type=click.File("w"), ) -def ceremony(save) -> None: +@click.pass_context +def ceremony(context: Any, output: Optional[click.File]) -> None: """Bootstrap Ceremony to create initial root metadata and RSTUF config.""" console.print("\n", Markdown("# Metadata Bootstrap Tool")) + settings = context.obj["settings"] + # Running online ceremony requires connection to the server and + # confirmation that the server is indeed ready for bootstap. + if not settings.get("SERVER") and not output: + raise click.ClickException( + "Either '--api-sever'/'SERVER' in RSTUF config or 'OUTPUT' needed" + ) - if not save: # pragma: no cover - _warn_no_save() + if settings.get("SERVER"): + bs_status = bootstrap_status(settings) + if bs_status.get("data", {}).get("bootstrap") is True: + raise click.ClickException(f"{bs_status.get('message')}") + # Performs ceremony steps. root = Root() - ########################################################################### # Configure online role settings console.print(Markdown("## Online role settings")) @@ -95,11 +110,22 @@ def ceremony(save) -> None: _add_root_signatures_prompt(root_md, None) ########################################################################### - # Dump payload - # TODO: post to API - if save: - metadatas = Metadatas(root_md.to_dict()) - settings = Settings(roles) - payload = CeremonyPayload(settings, metadatas) - json.dump(asdict(payload), save, indent=2) - console.print(f"Saved result to '{save.name}'") + metadatas = Metadatas(root_md.to_dict()) + roles_settings = Settings(roles) + bootstrap_payload = CeremonyPayload(roles_settings, metadatas) + # Dump payload when the user explicitly wants or doesn't send it to the API + if output: + path = output.name if output is not None else DEFAULT_PATH + save_payload(path, asdict(bootstrap_payload)) + console.print(f"Saved result to '{path}'") + + if settings.get("SERVER"): + task_id = send_payload( + settings=settings, + url=URL.BOOTSTRAP.value, + payload=asdict(bootstrap_payload), + expected_msg="Bootstrap accepted.", + command_name="Bootstrap", + ) + task_status(task_id, settings, "Bootstrap status: ") + console.print("\nCeremony done. 🔐 🎉. Bootstrap completed.") diff --git a/repository_service_tuf/cli/admin/import_artifacts.py b/repository_service_tuf/cli/admin/import_artifacts.py index 8a3b41c4..066f4722 100644 --- a/repository_service_tuf/cli/admin/import_artifacts.py +++ b/repository_service_tuf/cli/admin/import_artifacts.py @@ -123,11 +123,6 @@ def _get_succinct_roles(api_server: str) -> SuccinctRoles: @admin.command() # type: ignore -@click.option( - "--api-server", - required=False, - help="RSTUF API URL i.e.: http://127.0.0.1 .", -) @click.option( "--db-uri", required=True, @@ -150,15 +145,22 @@ def _get_succinct_roles(api_server: str) -> SuccinctRoles: @click.pass_context def import_artifacts( context: Any, - api_server: str, db_uri: str, csv: List[str], skip_publish_artifacts: bool, ): """ - Import artifacts to RSTUF from exported CSV file.\n - Note: sqlalchemy needs to be installed in order to use this command.\n + Import artifacts information from exported CSV file and send it to RSTUF + API deployment. + + \b + Note: there are two additional requirements for this command: + \b + 1) sqlalchemy needs to be installed in order to use this command: pip install repository-service-tuf[sqlalchemy,psycopg2] + + \b + 2) '--api-server' admin option or 'SERVER' in RSTUF config set """ # SQLAlchemy is an optional dependency and is required only for users who @@ -171,9 +173,6 @@ def import_artifacts( "pip install repository-service-tuf[sqlalchemy,psycopg2]" ) settings = context.obj["settings"] - if api_server: - settings.SERVER = api_server - if settings.get("SERVER") is None: raise click.ClickException( "Requires '--api-server' " @@ -188,7 +187,7 @@ def import_artifacts( ) # load all required infrastructure - succinct_roles = _get_succinct_roles(api_server) + succinct_roles = _get_succinct_roles(settings.SERVER) engine = create_engine(f"{db_uri}") db_metadata = MetaData() db_client: Connection = engine.connect() diff --git a/repository_service_tuf/cli/admin/sign.py b/repository_service_tuf/cli/admin/sign.py index 530476ce..dac60ef2 100644 --- a/repository_service_tuf/cli/admin/sign.py +++ b/repository_service_tuf/cli/admin/sign.py @@ -66,11 +66,6 @@ def _get_pending_roles(settings: Any) -> Dict[str, Dict[str, Any]]: @metadata.command() # type: ignore -@click.option( - "--api-server", - help="URL to an RSTUF API.", - required=False, -) @click.option( "--save", "-s", @@ -87,34 +82,12 @@ def _get_pending_roles(settings: Any) -> Dict[str, Dict[str, Any]]: @click.pass_context def sign( context: click.Context, - api_server: Optional[str], save: Optional[click.File], signing_json_input_file: Optional[click.File], ) -> None: - """ - Add one signature to root metadata. - - There are two ways to use this command: - - 1) utilizing access to the RSTUF API and signing pending metadata roles - - 2) provide a local file using the 'SIGNING_JSON_INPUT_FILE' argument - - The result of this command will be saved locally in a 'sign-payload.json' - file if '--api-server' is not provided or at custom path if '--save' is - used. - - When using method 2: - - - 'SIGNING_JSON_INPUT_FILE' must be a file containing the JSON response - from the 'GET /api/v1/metadata/sign' API endpoint. - - - '--api-server' will be ignored. - """ + """Add one signature to root metadata.""" console.print("\n", Markdown("# Metadata Signing Tool")) settings = context.obj["settings"] - if api_server: - settings.SERVER = api_server if settings.get("SERVER") is None and signing_json_input_file is None: raise click.ClickException( "Either '--api-sever'/'SERVER' in RSTUF config or " @@ -171,7 +144,7 @@ def sign( # Send payload to the API and/or save it locally payload = SignPayload(signature=signature.to_dict()) - if save or not settings.get("SERVER"): + if save: path = save.name if save is not None else DEFAULT_PATH save_payload(path, asdict(payload)) console.print(f"Saved result to '{path}'") diff --git a/tests/conftest.py b/tests/conftest.py index b3450cc3..4e941e2b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,8 @@ from securesystemslib.signer import CryptoSigner, SSlibKey from tuf.api.metadata import Metadata, Root +import repository_service_tuf.cli.admin.ceremony as ceremony +from repository_service_tuf.cli.admin.import_artifacts import import_artifacts from repository_service_tuf.helpers.tuf import ( BootstrapSetup, MetadataInfo, @@ -39,15 +41,18 @@ _PROMPT = "rich.console.Console.input" -def _create_test_context() -> Dict[str, Any]: +def _create_test_context(api_url: Optional[str]) -> Dict[str, Any]: setting_file = os.path.join(TemporaryDirectory().name, "test_settings.yml") test_settings = Dynaconf(settings_files=[setting_file]) + if api_url: + test_settings.SERVER = api_url + return {"settings": test_settings, "config": setting_file} @pytest.fixture def test_context() -> Dict[str, Any]: - return _create_test_context() + return _create_test_context(None) def _create_client() -> CliRunner: @@ -301,22 +306,43 @@ def invoke_command( test_context: Optional[Context] = None, ) -> Result: client = _create_client() - out_file_name = "out_file_.json" - context = _create_test_context() if test_context is None else test_context + out_file_name = "out_file.json" + + if cmd.name == ceremony.ceremony.name: + out_file_name = ceremony.DEFAULT_PATH + # For ceremony out file name is an argument. + out_args = [out_file_name] + elif cmd.name == import_artifacts.name: + out_args = [] + else: + out_args = ["-s", out_file_name] + + api_url = None + if "--api-server" in args: + api_server_flag_index = args.index("--api-server") + api_url = args.pop(api_server_flag_index + 1) + args.remove("--api-server") + + context = test_context + if test_context is None: + context = _create_test_context(api_url) + with client.isolated_filesystem(): result_obj = client.invoke( cmd, - args=args + ["-s", out_file_name], + args=args + out_args, input="\n".join(inputs), obj=context, catch_exceptions=False, ) + result_obj.context = context if std_err_empty: assert result_obj.stderr == "" - with open(out_file_name) as f: - result_obj.data = json.load(f) - - result_obj.context = context + if len(out_args) > 0: + # There are commands that doesn't save a file like + # 'import_artifacts'. For them out_args is empty. + with open(out_file_name) as f: + result_obj.data = json.load(f) return result_obj diff --git a/tests/unit/cli/admin/test_admin.py b/tests/unit/cli/admin/test_admin.py new file mode 100644 index 00000000..dba0889e --- /dev/null +++ b/tests/unit/cli/admin/test_admin.py @@ -0,0 +1,13 @@ +import pretend + +from repository_service_tuf.cli.admin import _set_settings + + +class TestAdmin: + def test_admin__set_settings_api_server(self): + api_server = "http://localhost:80" + fake_settings = pretend.stub(SERVER=None) + context = pretend.stub(obj={"settings": fake_settings}) + _set_settings(context, api_server) + + assert context.obj["settings"].SERVER == api_server diff --git a/tests/unit/cli/admin/test_ceremony.py b/tests/unit/cli/admin/test_ceremony.py index aa39baf6..775734a7 100644 --- a/tests/unit/cli/admin/test_ceremony.py +++ b/tests/unit/cli/admin/test_ceremony.py @@ -1,17 +1,34 @@ import json +import pretend + from repository_service_tuf.cli.admin import ceremony from tests.conftest import _PAYLOADS, _PEMS, invoke_command class TestCeremony: - def test_ceremony(self, ceremony_inputs, patch_getpass, patch_utcnow): + def test_ceremony_with_custom_output( + self, + ceremony_inputs, + client, + test_context, + patch_getpass, + patch_utcnow, + ): input_step1, input_step2, input_step3, input_step4 = ceremony_inputs - result = invoke_command( - ceremony.ceremony, - input_step1 + input_step2 + input_step3 + input_step4, - [], - ) + custom_path = "file.json" + with client.isolated_filesystem(): + result = client.invoke( + ceremony.ceremony, + args=[custom_path], + input="\n".join( + input_step1 + input_step2 + input_step3 + input_step4 + ), + obj=test_context, + catch_exceptions=False, + ) + with open(custom_path) as f: + result.data = json.load(f) with open(_PAYLOADS / "ceremony.json") as f: expected = json.load(f) @@ -21,6 +38,7 @@ def test_ceremony(self, ceremony_inputs, patch_getpass, patch_utcnow): assert [s["keyid"] for s in sigs_r] == [s["keyid"] for s in sigs_e] assert result.data == expected + assert f"Saved result to '{custom_path}'" in result.stdout def test_ceremony_threshold_less_than_2( self, ceremony_inputs, patch_getpass, patch_utcnow @@ -58,7 +76,7 @@ def test_ceremony_threshold_less_than_2( assert result.data == expected assert "Please enter threshold above 1" in result.stdout - def test_ceremony_non_positive_expiration( + def test_ceremony__non_positive_expiration( self, ceremony_inputs, patch_getpass, patch_utcnow ): _, input_step2, input_step3, input_step4 = ceremony_inputs @@ -89,6 +107,134 @@ def test_ceremony_non_positive_expiration( assert result.data == expected assert "Please enter a valid positive integer number" in result.stdout + def test_ceremony_api_server( + self, + ceremony_inputs, + monkeypatch, + patch_getpass, + patch_utcnow, + ): + status = {"data": {"bootstrap": False}} + fake_bootstrap_status = pretend.call_recorder(lambda a: status) + monkeypatch.setattr( + ceremony, "bootstrap_status", fake_bootstrap_status + ) + fake_task_id = "123ab" + fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) + monkeypatch.setattr(ceremony, "send_payload", fake_send_payload) + fake_task_status = pretend.call_recorder(lambda *a: None) + monkeypatch.setattr(ceremony, "task_status", fake_task_status) + input_step1, input_step2, input_step3, input_step4 = ceremony_inputs + args = ["--api-server", "http://localhost:80"] + + result = invoke_command( + ceremony.ceremony, + input_step1 + input_step2 + input_step3 + input_step4, + args, + ) + + with open(_PAYLOADS / "ceremony.json") as f: + expected = json.load(f) + + sigs_r = result.data["metadata"]["root"].pop("signatures") + sigs_e = expected["metadata"]["root"].pop("signatures") + + assert [s["keyid"] for s in sigs_r] == [s["keyid"] for s in sigs_e] + assert result.data == expected + + assert fake_bootstrap_status.calls == [ + pretend.call(result.context["settings"]) + ] + # One of the used key with id "50d7e110ad65f3b2dba5c3cfc8c5ca259be9774cc26be3410044ffd4be3aa5f3" # noqa + # is an ecdsa type meaning it's not deterministic and have different + # signature each run. That's why we do more granular check to work + # around that limitation. + call = fake_send_payload.calls[0] + assert call.kwargs["settings"] == result.context["settings"] + assert call.kwargs["url"] == ceremony.URL.BOOTSTRAP.value + # The "payload" arg of fake_send_payload() calls is the same as + # result.data which already has been verified. + assert call.kwargs["expected_msg"] == "Bootstrap accepted." + assert call.kwargs["command_name"] == "Bootstrap" + assert fake_task_status.calls == [ + pretend.call( + fake_task_id, result.context["settings"], "Bootstrap status: " + ) + ] + assert "Ceremony done. 🔐 🎉. Bootstrap completed." in result.stdout + + def test_ceremony_api_server_with_output_argument( + self, + ceremony_inputs, + monkeypatch, + client, + test_context, + patch_getpass, + patch_utcnow, + ): + """Test case 3 using custom OUTPUT argument.""" + status = {"data": {"bootstrap": False}} + fake_bootstrap_status = pretend.call_recorder(lambda a: status) + monkeypatch.setattr( + ceremony, "bootstrap_status", fake_bootstrap_status + ) + fake_task_id = "123ab" + fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) + monkeypatch.setattr(ceremony, "send_payload", fake_send_payload) + fake_task_status = pretend.call_recorder(lambda *a: None) + monkeypatch.setattr(ceremony, "task_status", fake_task_status) + input_step1, input_step2, input_step3, input_step4 = ceremony_inputs + test_context["settings"].SERVER = "http://localhost:80" + custom_path = "file.json" + with client.isolated_filesystem(): + result = client.invoke( + ceremony.ceremony, + args=[custom_path], + input="\n".join( + input_step1 + input_step2 + input_step3 + input_step4 + ), + obj=test_context, + catch_exceptions=False, + ) + assert result.stderr == "" + with open(custom_path) as f: + result.data = json.load(f) + + with open(_PAYLOADS / "ceremony.json") as f: + expected = json.load(f) + + with open(_PAYLOADS / "ceremony.json") as f: + expected = json.load(f) + + sigs_r = result.data["metadata"]["root"].pop("signatures") + sigs_e = expected["metadata"]["root"].pop("signatures") + + assert [s["keyid"] for s in sigs_r] == [s["keyid"] for s in sigs_e] + assert result.data == expected + + assert fake_bootstrap_status.calls == [ + pretend.call(test_context["settings"]) + ] + # One of the used key with id "50d7e110ad65f3b2dba5c3cfc8c5ca259be9774cc26be3410044ffd4be3aa5f3" # noqa + # is an ecdsa type meaning it's not deterministic and have different + # signature each run. That's why we do more granular check to work + # around that limitation. + call = fake_send_payload.calls[0] + assert call.kwargs["settings"] == test_context["settings"] + assert call.kwargs["url"] == ceremony.URL.BOOTSTRAP.value + # The "payload" arg of fake_send_payload() calls is the same as + # result.data which already has been verified. + assert call.kwargs["expected_msg"] == "Bootstrap accepted." + assert call.kwargs["command_name"] == "Bootstrap" + + assert fake_task_status.calls == [ + pretend.call( + fake_task_id, test_context["settings"], "Bootstrap status: " + ) + ] + assert f"Saved result to '{custom_path}'" in result.stdout + assert "Ceremony done. 🔐 🎉. Bootstrap completed." in result.stdout + def test_ceremony_online_key_one_of_root_keys( self, ceremony_inputs, patch_getpass, patch_utcnow ): @@ -149,3 +295,31 @@ def test_ceremony_try_setting_root_keys_less_than_threshold( # Asser that at least root_threshold number of public keys are added. root_role = result.data["metadata"]["root"]["signed"]["roles"]["root"] assert len(root_role["keyids"]) == root_role["threshold"] + + +class TestCeremonyError: + def test_ceremony_bootstrap_api_server_locked_for_bootstrap( + self, ceremony_inputs, monkeypatch + ): + status = { + "data": {"bootstrap": True}, + "message": "Locked for bootstrap", + } + fake_bootstrap_status = pretend.call_recorder(lambda a: status) + monkeypatch.setattr( + ceremony, "bootstrap_status", fake_bootstrap_status + ) + input_step1, input_step2, input_step3, input_step4 = ceremony_inputs + args = ["--api-server", "http://localhost"] + + result = invoke_command( + ceremony.ceremony, + input_step1 + input_step2 + input_step3 + input_step4, + args, + std_err_empty=False, + ) + + assert status["message"] in result.stderr + assert fake_bootstrap_status.calls == [ + pretend.call(result.context["settings"]) + ] diff --git a/tests/unit/cli/admin/test_import_artifacts.py b/tests/unit/cli/admin/test_import_artifacts.py index 938859f0..375fd529 100644 --- a/tests/unit/cli/admin/test_import_artifacts.py +++ b/tests/unit/cli/admin/test_import_artifacts.py @@ -10,6 +10,7 @@ import pytest from repository_service_tuf.cli.admin import import_artifacts +from tests.conftest import invoke_command class TestImportArtifactsFunctions: @@ -256,12 +257,10 @@ def test__get_succinct_roles_failed_parsing(self, monkeypatch): class TestImportArtifactsGroupCLI: - def test_import_artifacts(self, client, test_context): + def test_import_artifacts(self): # Required to properly mock functions imported inside import_artifacts import sqlalchemy - test_context["settings"].SERVER = "fake-server" - import_artifacts.bootstrap_status = pretend.call_recorder( lambda *a: {"data": {"bootstrap": True}, "message": "some msg"} ) @@ -293,7 +292,7 @@ def test_import_artifacts(self, client, test_context): lambda *a: {"status": "SUCCESS"} ) - options = [ + args = [ "--api-server", "http://127.0.0.1", "--db-uri", @@ -303,13 +302,11 @@ def test_import_artifacts(self, client, test_context): "--csv", "artifacts2of2.csv", ] - result = client.invoke( - import_artifacts.import_artifacts, options, obj=test_context - ) + result = invoke_command(import_artifacts.import_artifacts, [], args) assert result.exit_code == 0, result.output assert "Finished." in result.output assert import_artifacts.bootstrap_status.calls == [ - pretend.call(test_context["settings"]) + pretend.call(result.context["settings"]) ] assert import_artifacts._get_succinct_roles.calls == [ pretend.call("http://127.0.0.1") @@ -321,20 +318,18 @@ def test_import_artifacts(self, client, test_context): pretend.call(csv_files=("artifacts1of2.csv", "artifacts2of2.csv")) ] assert import_artifacts.publish_artifacts.calls == [ - pretend.call(test_context["settings"]) + pretend.call(result.context["settings"]) ] assert import_artifacts.task_status.calls == [ pretend.call( "fake_task_id", - test_context["settings"], + result.context["settings"], "Import status: task ", ) ] - def test_import_artifacts_no_api_server_config_no_param( - self, client, test_context - ): - options = [ + def test_import_artifacts_no_api_server_config_no_param(self): + args = [ "--db-uri", "postgresql://postgres:secret@127.0.0.1:5433", "--csv", @@ -342,20 +337,16 @@ def test_import_artifacts_no_api_server_config_no_param( "--csv", "artifacts2of2.csv", ] - result = client.invoke( - import_artifacts.import_artifacts, options, obj=test_context + result = invoke_command( + import_artifacts.import_artifacts, [], args, std_err_empty=False ) assert result.exit_code == 1, result.stderr assert "Requires '--api-server' " in result.stderr - def test_import_artifacts_skip_publish_artifacts( - self, client, test_context - ): + def test_import_artifacts_skip_publish_artifacts(self): # Required to properly mock functions imported inside import_artifacts import sqlalchemy - test_context["settings"].SERVER = "fake-server" - import_artifacts.bootstrap_status = pretend.call_recorder( lambda *a: {"data": {"bootstrap": True}, "message": "some msg"} ) @@ -387,7 +378,7 @@ def test_import_artifacts_skip_publish_artifacts( lambda *a: {"status": "SUCCESS"} ) - options = [ + args = [ "--api-server", "http://127.0.0.1", "--db-uri", @@ -398,14 +389,12 @@ def test_import_artifacts_skip_publish_artifacts( "artifacts2of2.csv", "--skip-publish-artifacts", ] - result = client.invoke( - import_artifacts.import_artifacts, options, obj=test_context - ) + result = invoke_command(import_artifacts.import_artifacts, [], args) assert result.exit_code == 0, result.output assert "Finished." in result.output assert "No artifacts published" in result.output assert import_artifacts.bootstrap_status.calls == [ - pretend.call(test_context["settings"]) + pretend.call(result.context["settings"]) ] assert import_artifacts._get_succinct_roles.calls == [ pretend.call("http://127.0.0.1") @@ -419,9 +408,7 @@ def test_import_artifacts_skip_publish_artifacts( assert import_artifacts.publish_artifacts.calls == [] assert import_artifacts.task_status.calls == [] - def test_import_artifacts_sqlalchemy_import_fails( - self, client, test_context - ): + def test_import_artifacts_sqlalchemy_import_fails(self): import builtins real_import = builtins.__import__ @@ -436,30 +423,26 @@ def fake_import(name, *args, **kwargs): builtins.__import__ = fake_import - test_context["settings"].SERVER = "fake-server" - options = ["--api-server", "", "--db-uri", "", "--csv", ""] - result = client.invoke( - import_artifacts.import_artifacts, options, obj=test_context - ) + args = ["--api-server", "", "--db-uri", "", "--csv", ""] + with pytest.raises(ModuleNotFoundError) as exc: + invoke_command( + import_artifacts.import_artifacts, + [], + args, + std_err_empty=False, + ) # Return the original import to not cause other exceptions. builtins.__import__ = real_import + assert "pip install repository-service-tuf[sqlalchemy" in str(exc) - assert result.exit_code == 1 - assert isinstance(result.exception, ModuleNotFoundError) - exc_msg = result.exception.msg - assert "pip install repository-service-tuf[sqlalchemy" in exc_msg - - def test_import_artifacts_bootstrap_check_failed( - self, client, test_context - ): - test_context["settings"].SERVER = "fake-server" + def test_import_artifacts_bootstrap_check_failed(self): import_artifacts.bootstrap_status = pretend.raiser( import_artifacts.click.ClickException("Server ERROR") ) - options = [ + args = [ "--api-server", "http://127.0.0.1", "--db-uri", @@ -470,20 +453,19 @@ def test_import_artifacts_bootstrap_check_failed( "artifacts2of2.csv", ] - result = client.invoke( - import_artifacts.import_artifacts, options, obj=test_context + result = invoke_command( + import_artifacts.import_artifacts, [], args, std_err_empty=False ) assert result.exit_code == 1 assert "Server ERROR" in result.stderr, result.stderr - def test_import_artifacts_without_bootstrap(self, client, test_context): - test_context["settings"].SERVER = "fake-server" + def test_import_artifacts_without_bootstrap(self): import_artifacts.bootstrap_status = pretend.call_recorder( lambda *a: {"data": {"bootstrap": False}, "message": "some msg"} ) - options = [ + args = [ "--api-server", "http://127.0.0.1", "--db-uri", @@ -493,8 +475,8 @@ def test_import_artifacts_without_bootstrap(self, client, test_context): "--csv", "artifacts2of2.csv", ] - result = client.invoke( - import_artifacts.import_artifacts, options, obj=test_context + result = invoke_command( + import_artifacts.import_artifacts, [], args, std_err_empty=False ) assert result.exit_code == 1, result.stderr assert ( @@ -502,5 +484,5 @@ def test_import_artifacts_without_bootstrap(self, client, test_context): in result.stderr ) assert import_artifacts.bootstrap_status.calls == [ - pretend.call(test_context["settings"]) + pretend.call(result.context["settings"]) ] diff --git a/tests/unit/cli/admin/test_sign.py b/tests/unit/cli/admin/test_sign.py index 0a44e750..d3cde6ee 100644 --- a/tests/unit/cli/admin/test_sign.py +++ b/tests/unit/cli/admin/test_sign.py @@ -42,7 +42,7 @@ def test_sign_with_previous_root(self, patch_getpass): assert ( result.data["signature"]["keyid"] == expected["signature"]["keyid"] ) - assert "Metadata Signed and sent to the API! 🔑" in result.output + assert "Metadata Signed and sent to the API! 🔑" in result.stdout assert sign.request_server.calls == [ pretend.call( "http://127.0.0.1", @@ -105,7 +105,7 @@ def test_sign_bootstap_root(self, patch_getpass): assert result.data["role"] == "root" assert result.data["signature"]["keyid"] == expected["keyid"] - assert "Metadata Signed and sent to the API! 🔑" in result.output + assert "Metadata Signed and sent to the API! 🔑" in result.stdout assert sign.request_server.calls == [ pretend.call( "http://127.0.0.1", @@ -165,43 +165,9 @@ def test_sign_local_file_input_and_custom_save( assert result.data["role"] == "root" assert result.data["signature"]["keyid"] == expected["keyid"] assert result.data["signature"]["sig"] == expected["sig"] - assert f"Saved result to '{custom_path}'" in result.output + assert f"Saved result to '{custom_path}'" in result.stdout - def test_sign_local_file_no_save_option_given( - self, client, test_context, patch_getpass - ): - inputs = [ - "1", # Please enter signing key index - f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa - ] - - args = [f"{_PAYLOADS / 'sign_pending_roles.json'}"] - default_path = "sign-payload.json" - with client.isolated_filesystem(): - result = client.invoke( - sign.sign, - args=args, - input="\n".join(inputs), - obj=test_context, - catch_exceptions=False, - ) - assert result.stderr == "" - with open(default_path) as f: - result.data = json.load(f) - - expected = { - "keyid": "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc", # noqa - "sig": "917046f9076eae41876be7c031be149aa2a960fd21f0d52f72128f55d9c423e2ec1632f98c96693dd801bd064e37efd6e5a5d32712fd5701a42099ece6b88c05", # noqa - } - - assert result.data["role"] == "root" - assert result.data["signature"]["keyid"] == expected["keyid"] - assert result.data["signature"]["sig"] == expected["sig"] - assert f"Saved result to '{default_path}'" in result.output - - def test_sign_bootstap_root_with_file_input_with_api_server( - self, patch_getpass - ): + def test_sign_with_file_input_and_api_server_set(self, patch_getpass): inputs = [ "1", # Please enter signing key index f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa @@ -220,7 +186,7 @@ def test_sign_bootstap_root_with_file_input_with_api_server( assert result.data["role"] == "root" assert result.data["signature"]["keyid"] == expected["keyid"] - assert "Metadata Signed and sent to the API! 🔑" in result.output + assert "Metadata Signed and sent to the API! 🔑" in result.stdout assert sign.send_payload.calls == [ pretend.call( result.context["settings"], @@ -244,40 +210,6 @@ def test_sign_bootstap_root_with_file_input_with_api_server( ) ] - def test_sign_bootstap_root_with_file_input_no_api_server_no_save( - self, client, patch_getpass, test_context - ): - inputs = [ - "1", # Please enter signing key index - f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa - ] - - sign_input_path = f"{_PAYLOADS / 'sign_pending_roles.json'}" - args = [sign_input_path] - - with client.isolated_filesystem(): - result = client.invoke( - sign.sign, - args=args, - input="\n".join(inputs), - obj=test_context, - catch_exceptions=False, - ) - - assert result.stderr == "" - with open(sign.DEFAULT_PATH) as f: - result.data = json.load(f) - - expected = { - "keyid": "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc", # noqa - "sig": "828a659bc34972504b9dab16bc44818b8a7d49ffee2a9021df6a6be4dd3b7a026d1f890b952303d1cf32dda90fbdf60e9fcfeb5f0af6498f0f55cad31c750a02", # noqa - } - - assert result.data["role"] == "root" - assert result.data["signature"]["keyid"] == expected["keyid"] - assert "Metadata Signed and sent to the API! 🔑" not in result.output - assert f"Saved result to {sign.DEFAULT_PATH}" - def test_sign_no_api_server_and_no_file_input(self): result = invoke_command(sign.sign, [], [], std_err_empty=False) @@ -312,7 +244,7 @@ def test_sign_with_previous_root_but_wrong_version(self, patch_getpass): sign.sign, inputs, args, std_err_empty=False ) - assert test_result.exit_code == 1, test_result.output + assert test_result.exit_code == 1, test_result.stdout assert "Previous root v1 needed to sign root v2" in test_result.stderr assert sign.request_server.calls == [ pretend.call( @@ -349,7 +281,7 @@ def test_sign_fully_signed_metadata(self, patch_getpass): sign.sign, inputs, args, std_err_empty=False ) - assert test_result.exit_code == 1, test_result.output + assert test_result.exit_code == 1, test_result.stdout assert "Metadata already fully signed." in test_result.stderr assert sign.request_server.calls == [ pretend.call( From be0eb5c59a9ac0ed875793d4c2cc37c2817f1834 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Wed, 5 Jun 2024 19:31:05 +0300 Subject: [PATCH 02/13] Make ceremony "output" and sign "save" consistent Rename the ceremony "output" argument and sign "--save" option to become consistent using the same option "--out". Signed-off-by: Martin Vrachev --- repository_service_tuf/cli/admin/ceremony.py | 21 ++++++++-------- repository_service_tuf/cli/admin/sign.py | 14 +++++------ tests/conftest.py | 12 ++++------ tests/unit/cli/admin/test_ceremony.py | 25 ++++++++++++++++---- tests/unit/cli/admin/test_sign.py | 4 ++-- 5 files changed, 44 insertions(+), 32 deletions(-) diff --git a/repository_service_tuf/cli/admin/ceremony.py b/repository_service_tuf/cli/admin/ceremony.py index 73151d66..87fee895 100644 --- a/repository_service_tuf/cli/admin/ceremony.py +++ b/repository_service_tuf/cli/admin/ceremony.py @@ -44,21 +44,23 @@ @admin.command() # type: ignore -@click.argument( - "output", - required=False, +@click.option( + "--out", + is_flag=False, + flag_value=DEFAULT_PATH, + help=f"Write output json result to FILENAME (default: '{DEFAULT_PATH}')", type=click.File("w"), ) @click.pass_context -def ceremony(context: Any, output: Optional[click.File]) -> None: +def ceremony(context: Any, out: Optional[click.File]) -> None: """Bootstrap Ceremony to create initial root metadata and RSTUF config.""" console.print("\n", Markdown("# Metadata Bootstrap Tool")) settings = context.obj["settings"] # Running online ceremony requires connection to the server and # confirmation that the server is indeed ready for bootstap. - if not settings.get("SERVER") and not output: + if not settings.get("SERVER") and not out: raise click.ClickException( - "Either '--api-sever'/'SERVER' in RSTUF config or 'OUTPUT' needed" + "Either '--api-sever'/'SERVER' in RSTUF config or '--out' needed" ) if settings.get("SERVER"): @@ -114,10 +116,9 @@ def ceremony(context: Any, output: Optional[click.File]) -> None: roles_settings = Settings(roles) bootstrap_payload = CeremonyPayload(roles_settings, metadatas) # Dump payload when the user explicitly wants or doesn't send it to the API - if output: - path = output.name if output is not None else DEFAULT_PATH - save_payload(path, asdict(bootstrap_payload)) - console.print(f"Saved result to '{path}'") + if out: + save_payload(out.name, asdict(bootstrap_payload)) + console.print(f"Saved result to '{out.name}'") if settings.get("SERVER"): task_id = send_payload( diff --git a/repository_service_tuf/cli/admin/sign.py b/repository_service_tuf/cli/admin/sign.py index dac60ef2..1f2d021d 100644 --- a/repository_service_tuf/cli/admin/sign.py +++ b/repository_service_tuf/cli/admin/sign.py @@ -67,11 +67,10 @@ def _get_pending_roles(settings: Any) -> Dict[str, Dict[str, Any]]: @metadata.command() # type: ignore @click.option( - "--save", - "-s", + "--out", is_flag=False, flag_value=DEFAULT_PATH, - help=f"Write json result to FILENAME (default: '{DEFAULT_PATH}')", + help=f"Write output json result to FILENAME (default: '{DEFAULT_PATH}')", type=click.File("w"), ) @click.argument( @@ -82,7 +81,7 @@ def _get_pending_roles(settings: Any) -> Dict[str, Dict[str, Any]]: @click.pass_context def sign( context: click.Context, - save: Optional[click.File], + out: Optional[click.File], signing_json_input_file: Optional[click.File], ) -> None: """Add one signature to root metadata.""" @@ -144,10 +143,9 @@ def sign( # Send payload to the API and/or save it locally payload = SignPayload(signature=signature.to_dict()) - if save: - path = save.name if save is not None else DEFAULT_PATH - save_payload(path, asdict(payload)) - console.print(f"Saved result to '{path}'") + if out: + save_payload(out.name, asdict(payload)) + console.print(f"Saved result to '{out.name}'") if settings.get("SERVER"): console.print(f"\nSending signature to {settings.SERVER}") diff --git a/tests/conftest.py b/tests/conftest.py index 4e941e2b..dfaa8e20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,8 +21,8 @@ from securesystemslib.signer import CryptoSigner, SSlibKey from tuf.api.metadata import Metadata, Root -import repository_service_tuf.cli.admin.ceremony as ceremony from repository_service_tuf.cli.admin.import_artifacts import import_artifacts +from repository_service_tuf.cli.admin.update import update from repository_service_tuf.helpers.tuf import ( BootstrapSetup, MetadataInfo, @@ -308,14 +308,12 @@ def invoke_command( client = _create_client() out_file_name = "out_file.json" - if cmd.name == ceremony.ceremony.name: - out_file_name = ceremony.DEFAULT_PATH - # For ceremony out file name is an argument. - out_args = [out_file_name] - elif cmd.name == import_artifacts.name: + if cmd.name == import_artifacts.name: out_args = [] - else: + elif cmd.name == update.name: out_args = ["-s", out_file_name] + else: + out_args = ["--out", out_file_name] api_url = None if "--api-server" in args: diff --git a/tests/unit/cli/admin/test_ceremony.py b/tests/unit/cli/admin/test_ceremony.py index 775734a7..a5a44976 100644 --- a/tests/unit/cli/admin/test_ceremony.py +++ b/tests/unit/cli/admin/test_ceremony.py @@ -7,7 +7,7 @@ class TestCeremony: - def test_ceremony_with_custom_output( + def test_ceremony_with_custom_out( self, ceremony_inputs, client, @@ -20,7 +20,7 @@ def test_ceremony_with_custom_output( with client.isolated_filesystem(): result = client.invoke( ceremony.ceremony, - args=[custom_path], + args=["--out", custom_path], input="\n".join( input_step1 + input_step2 + input_step3 + input_step4 ), @@ -38,7 +38,6 @@ def test_ceremony_with_custom_output( assert [s["keyid"] for s in sigs_r] == [s["keyid"] for s in sigs_e] assert result.data == expected - assert f"Saved result to '{custom_path}'" in result.stdout def test_ceremony_threshold_less_than_2( self, ceremony_inputs, patch_getpass, patch_utcnow @@ -163,7 +162,7 @@ def test_ceremony_api_server( ] assert "Ceremony done. 🔐 🎉. Bootstrap completed." in result.stdout - def test_ceremony_api_server_with_output_argument( + def test_ceremony_api_server_with_out_option( self, ceremony_inputs, monkeypatch, @@ -189,7 +188,7 @@ def test_ceremony_api_server_with_output_argument( with client.isolated_filesystem(): result = client.invoke( ceremony.ceremony, - args=[custom_path], + args=["--out", custom_path], input="\n".join( input_step1 + input_step2 + input_step3 + input_step4 ), @@ -298,6 +297,22 @@ def test_ceremony_try_setting_root_keys_less_than_threshold( class TestCeremonyError: + def test_ceremony_no_api_server_and_no_output_option( + self, client, test_context, ceremony_inputs + ): + input_step1, input_step2, input_step3, input_step4 = ceremony_inputs + result = client.invoke( + ceremony.ceremony, + args=[], + input="\n".join( + input_step1 + input_step2 + input_step3 + input_step4 + ), + obj=test_context, + catch_exceptions=False, + ) + + assert "Either '--api-sever'/'SERVER'" in result.stderr + def test_ceremony_bootstrap_api_server_locked_for_bootstrap( self, ceremony_inputs, monkeypatch ): diff --git a/tests/unit/cli/admin/test_sign.py b/tests/unit/cli/admin/test_sign.py index d3cde6ee..93e6f8ed 100644 --- a/tests/unit/cli/admin/test_sign.py +++ b/tests/unit/cli/admin/test_sign.py @@ -136,7 +136,7 @@ def test_sign_bootstap_root(self, patch_getpass): ) ] - def test_sign_local_file_input_and_custom_save( + def test_sign_local_file_input_and_custom_out( self, client, test_context, patch_getpass ): inputs = [ @@ -149,7 +149,7 @@ def test_sign_local_file_input_and_custom_save( with client.isolated_filesystem(): result = client.invoke( sign.sign, - args=args + ["-s", custom_path], + args=args + ["--out", custom_path], input="\n".join(inputs), obj=test_context, catch_exceptions=False, From d911b345d43735988db22026ede69cb11804a2dd Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Thu, 6 Jun 2024 18:22:38 +0300 Subject: [PATCH 03/13] Make sign input argument an option Making the sign input file as an "--in" option makes more sense considering we added an "--out" option in the previous commits. Signed-off-by: Martin Vrachev --- repository_service_tuf/cli/admin/sign.py | 25 ++++++++++++++---------- tests/unit/cli/admin/test_sign.py | 10 +++++----- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/repository_service_tuf/cli/admin/sign.py b/repository_service_tuf/cli/admin/sign.py index 1f2d021d..f5cd943d 100644 --- a/repository_service_tuf/cli/admin/sign.py +++ b/repository_service_tuf/cli/admin/sign.py @@ -66,37 +66,42 @@ def _get_pending_roles(settings: Any) -> Dict[str, Dict[str, Any]]: @metadata.command() # type: ignore +@click.option( + "--in", + "input", + help=( + "Input ile containing the JSON response from the " + "'GET /api/v1/metadata/sign' RSTUF API endpoint." + ), + type=click.File("r"), + required=False, +) @click.option( "--out", is_flag=False, flag_value=DEFAULT_PATH, help=f"Write output json result to FILENAME (default: '{DEFAULT_PATH}')", type=click.File("w"), -) -@click.argument( - "signing_json_input_file", required=False, - type=click.File("rb"), ) @click.pass_context def sign( context: click.Context, + input: Optional[click.File], out: Optional[click.File], - signing_json_input_file: Optional[click.File], ) -> None: """Add one signature to root metadata.""" console.print("\n", Markdown("# Metadata Signing Tool")) settings = context.obj["settings"] - if settings.get("SERVER") is None and signing_json_input_file is None: + if settings.get("SERVER") is None and input is None: raise click.ClickException( - "Either '--api-sever'/'SERVER' in RSTUF config or " - "'SIGNING_JSON_INPUT_FILE' must be set" + "Either '--api-sever'/'SERVER' in RSTUF config or '--in' needed" ) ########################################################################### # Load roots pending_roles: Dict[str, Dict[str, Any]] - if signing_json_input_file: - pending_roles = _parse_pending_data(json.load(signing_json_input_file)) # type: ignore # noqa + if input: + pending_roles = _parse_pending_data(json.load(input)) # type: ignore else: pending_roles = _get_pending_roles(settings) diff --git a/tests/unit/cli/admin/test_sign.py b/tests/unit/cli/admin/test_sign.py index 93e6f8ed..5f1133ee 100644 --- a/tests/unit/cli/admin/test_sign.py +++ b/tests/unit/cli/admin/test_sign.py @@ -136,7 +136,7 @@ def test_sign_bootstap_root(self, patch_getpass): ) ] - def test_sign_local_file_input_and_custom_out( + def test_sign_input_option_and_custom_out( self, client, test_context, patch_getpass ): inputs = [ @@ -144,7 +144,7 @@ def test_sign_local_file_input_and_custom_out( f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa ] - args = [f"{_PAYLOADS / 'sign_pending_roles.json'}"] + args = ["--in", f"{_PAYLOADS / 'sign_pending_roles.json'}"] custom_path = "custom_sign_path.json" with client.isolated_filesystem(): result = client.invoke( @@ -167,7 +167,7 @@ def test_sign_local_file_input_and_custom_out( assert result.data["signature"]["sig"] == expected["sig"] assert f"Saved result to '{custom_path}'" in result.stdout - def test_sign_with_file_input_and_api_server_set(self, patch_getpass): + def test_sign_with_input_option_and_api_server_set(self, patch_getpass): inputs = [ "1", # Please enter signing key index f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa @@ -176,7 +176,7 @@ def test_sign_with_file_input_and_api_server_set(self, patch_getpass): sign.task_status = pretend.call_recorder(lambda *a: "OK") sign_input_path = f"{_PAYLOADS / 'sign_pending_roles.json'}" api_server = "http://localhost:80" - args = ["--api-server", api_server, sign_input_path] + args = ["--api-server", api_server, "--in", sign_input_path] result = invoke_command(sign.sign, inputs, args) expected = { @@ -210,7 +210,7 @@ def test_sign_with_file_input_and_api_server_set(self, patch_getpass): ) ] - def test_sign_no_api_server_and_no_file_input(self): + def test_sign_no_api_server_and_no_input_option(self): result = invoke_command(sign.sign, [], [], std_err_empty=False) assert "Either '--api-sever'/'SERVER'" in result.stderr From 0469a7a408dfe9a65a0fd5723cc9ff75b7b883ee Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Thu, 6 Jun 2024 19:10:23 +0300 Subject: [PATCH 04/13] Update guide docs Signed-off-by: Martin Vrachev --- docs/source/guide/index.rst | 55 ++++++++++++------------ repository_service_tuf/cli/admin/sign.py | 4 +- 2 files changed, 29 insertions(+), 30 deletions(-) diff --git a/docs/source/guide/index.rst b/docs/source/guide/index.rst index 1146c86e..629c18f3 100644 --- a/docs/source/guide/index.rst +++ b/docs/source/guide/index.rst @@ -97,14 +97,15 @@ It executes administrative commands to the Repository Service for TUF. Administrative Commands - ╭─ Options ─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ --help -h Show this message and exit. │ - ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ - ╭─ Commands ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ ceremony Bootstrap Ceremony to create initial root metadata and RSTUF config. │ - │ import-artifacts Import artifacts to RSTUF from exported CSV file. │ - │ metadata Metadata management. │ - ╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭─ Options ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ --api-server TEXT URL to an RSTUF API. │ + │ --help -h Show this message and exit. │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭─ Commands ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ ceremony Bootstrap Ceremony to create initial root metadata and RSTUF config. │ + │ import-artifacts Import artifacts information from exported CSV file and send it to RSTUF API deployment. │ + │ metadata Metadata management. │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ .. rstuf-cli-admin-ceremony @@ -129,10 +130,10 @@ You can do the Ceremony offline. This means on a disconnected computer Bootstrap Ceremony to create initial root metadata and RSTUF config. - ╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ - │ --save -s FILENAME Write json result to FILENAME (default: 'ceremony-payload.json') │ - │ --help -h Show this message and exit. │ - ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────╮ + │ --out FILENAME Write output json result to FILENAME (default: 'ceremony-payload.json') │ + │ --help -h Show this message and exit. │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯ There are four steps in the ceremony. @@ -176,22 +177,15 @@ sign (``sign``) ❯ rstuf admin metadata sign -h - Usage: rstuf admin metadata sign [OPTIONS] [SIGNING_JSON_INPUT_FILE] + Usage: rstuf admin metadata sign [OPTIONS] Add one signature to root metadata. - There are two ways to use this command: - 1) utilizing access to the RSTUF API and signing pending metadata roles - 2) provide a local file using the SIGNING_JSON_INPUT_FILE argument - When using method 2: - - 'SIGNING_JSON_INPUT_FILE' must be a file containing the JSON response from the 'GET /api/v1/metadata/sign' API endpoint. - - '--api-server' will be ignored. - - the result of the command will be saved into the 'sign-payload.json' file unless a different name is provided with '--save'. -╭─ Options ────────────────────────────────────────────────────────────────────────────────╮ -│ --api-server TEXT URL to an RSTUF API. │ -│ --save -s FILENAME Write json result to FILENAME (default: 'sign-payload.json') │ -│ --help -h Show this message and exit. │ -╰──────────────────────────────────────────────────────────────────────────────────────────╯ + ╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ --in FILENAME Input file containing the JSON response from the 'GET /api/v1/metadata/sign' RSTUF API endpoint. │ + │ --out FILENAME Write output JSON result to FILENAME (default: 'sign-payload.json') │ + │ --help -h Show this message and exit. │ + ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -243,12 +237,17 @@ See the below CSV file example: ❯ rstuf admin import-artifacts -h - Usage: rstuf admin import-artifacts [OPTIONS] + Usage: rstuf admin import-artifacts [OPTIONS] + + Import artifacts information from exported CSV file and send it to RSTUF API deployment. + Note: there are two additional requirements for this command: + + 1) sqlalchemy needs to be installed in order to use this command: + pip install repository-service-tuf[sqlalchemy,psycopg2] - Import artifacts to RSTUF from exported CSV file. + 2) '--api-server' admin option or 'SERVER' in RSTUF config set ╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ --api-server TEXT RSTUF API URL i.e.: http://127.0.0.1 . │ │ * --db-uri TEXT RSTUF DB URI. i.e.: postgresql://postgres:secret@127.0.0.1:5433 [required] │ │ * --csv TEXT CSV file to import. Multiple --csv parameters are allowed. See rstuf CLI guide for more details. [required] │ │ --skip-publish-artifacts Skip publishing artifacts in TUF Metadata. │ diff --git a/repository_service_tuf/cli/admin/sign.py b/repository_service_tuf/cli/admin/sign.py index f5cd943d..6c590470 100644 --- a/repository_service_tuf/cli/admin/sign.py +++ b/repository_service_tuf/cli/admin/sign.py @@ -70,7 +70,7 @@ def _get_pending_roles(settings: Any) -> Dict[str, Dict[str, Any]]: "--in", "input", help=( - "Input ile containing the JSON response from the " + "Input file containing the JSON response from the " "'GET /api/v1/metadata/sign' RSTUF API endpoint." ), type=click.File("r"), @@ -80,7 +80,7 @@ def _get_pending_roles(settings: Any) -> Dict[str, Dict[str, Any]]: "--out", is_flag=False, flag_value=DEFAULT_PATH, - help=f"Write output json result to FILENAME (default: '{DEFAULT_PATH}')", + help=f"Write output JSON result to FILENAME (default: '{DEFAULT_PATH}')", type=click.File("w"), required=False, ) From 6035ecd701981737a4431734ad4e8cd087a85d03 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Tue, 11 Jun 2024 13:57:47 +0300 Subject: [PATCH 05/13] Replace save_payload with json.dump Signed-off-by: Martin Vrachev --- repository_service_tuf/cli/admin/ceremony.py | 4 ++-- repository_service_tuf/cli/admin/sign.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/repository_service_tuf/cli/admin/ceremony.py b/repository_service_tuf/cli/admin/ceremony.py index 87fee895..0fd57458 100644 --- a/repository_service_tuf/cli/admin/ceremony.py +++ b/repository_service_tuf/cli/admin/ceremony.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: MIT +import json from dataclasses import asdict from typing import Any, Optional @@ -38,7 +39,6 @@ send_payload, task_status, ) -from repository_service_tuf.helpers.tuf import save_payload DEFAULT_PATH = "ceremony-payload.json" @@ -117,7 +117,7 @@ def ceremony(context: Any, out: Optional[click.File]) -> None: bootstrap_payload = CeremonyPayload(roles_settings, metadatas) # Dump payload when the user explicitly wants or doesn't send it to the API if out: - save_payload(out.name, asdict(bootstrap_payload)) + json.dump(asdict(bootstrap_payload), out, indent=2) # type: ignore console.print(f"Saved result to '{out.name}'") if settings.get("SERVER"): diff --git a/repository_service_tuf/cli/admin/sign.py b/repository_service_tuf/cli/admin/sign.py index 6c590470..e06ddf78 100644 --- a/repository_service_tuf/cli/admin/sign.py +++ b/repository_service_tuf/cli/admin/sign.py @@ -33,7 +33,6 @@ send_payload, task_status, ) -from repository_service_tuf.helpers.tuf import save_payload def _parse_pending_data(pending_roles_resp: Dict[str, Any]) -> Dict[str, Any]: @@ -149,7 +148,7 @@ def sign( payload = SignPayload(signature=signature.to_dict()) if out: - save_payload(out.name, asdict(payload)) + json.dump(asdict(payload), out, indent=2) # type: ignore console.print(f"Saved result to '{out.name}'") if settings.get("SERVER"): From 06bf664418a09a4ef8c1d887bb9830764b9e0352 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Thu, 13 Jun 2024 19:27:27 +0300 Subject: [PATCH 06/13] Remove pre-ceremony server check based on feedback Signed-off-by: Martin Vrachev --- repository_service_tuf/cli/admin/ceremony.py | 6 --- tests/unit/cli/admin/test_ceremony.py | 43 -------------------- 2 files changed, 49 deletions(-) diff --git a/repository_service_tuf/cli/admin/ceremony.py b/repository_service_tuf/cli/admin/ceremony.py index 0fd57458..a0cd7b70 100644 --- a/repository_service_tuf/cli/admin/ceremony.py +++ b/repository_service_tuf/cli/admin/ceremony.py @@ -35,7 +35,6 @@ ) from repository_service_tuf.helpers.api_client import ( URL, - bootstrap_status, send_payload, task_status, ) @@ -63,11 +62,6 @@ def ceremony(context: Any, out: Optional[click.File]) -> None: "Either '--api-sever'/'SERVER' in RSTUF config or '--out' needed" ) - if settings.get("SERVER"): - bs_status = bootstrap_status(settings) - if bs_status.get("data", {}).get("bootstrap") is True: - raise click.ClickException(f"{bs_status.get('message')}") - # Performs ceremony steps. root = Root() ########################################################################### diff --git a/tests/unit/cli/admin/test_ceremony.py b/tests/unit/cli/admin/test_ceremony.py index a5a44976..3e4e4295 100644 --- a/tests/unit/cli/admin/test_ceremony.py +++ b/tests/unit/cli/admin/test_ceremony.py @@ -113,11 +113,6 @@ def test_ceremony_api_server( patch_getpass, patch_utcnow, ): - status = {"data": {"bootstrap": False}} - fake_bootstrap_status = pretend.call_recorder(lambda a: status) - monkeypatch.setattr( - ceremony, "bootstrap_status", fake_bootstrap_status - ) fake_task_id = "123ab" fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) monkeypatch.setattr(ceremony, "send_payload", fake_send_payload) @@ -141,9 +136,6 @@ def test_ceremony_api_server( assert [s["keyid"] for s in sigs_r] == [s["keyid"] for s in sigs_e] assert result.data == expected - assert fake_bootstrap_status.calls == [ - pretend.call(result.context["settings"]) - ] # One of the used key with id "50d7e110ad65f3b2dba5c3cfc8c5ca259be9774cc26be3410044ffd4be3aa5f3" # noqa # is an ecdsa type meaning it's not deterministic and have different # signature each run. That's why we do more granular check to work @@ -171,12 +163,6 @@ def test_ceremony_api_server_with_out_option( patch_getpass, patch_utcnow, ): - """Test case 3 using custom OUTPUT argument.""" - status = {"data": {"bootstrap": False}} - fake_bootstrap_status = pretend.call_recorder(lambda a: status) - monkeypatch.setattr( - ceremony, "bootstrap_status", fake_bootstrap_status - ) fake_task_id = "123ab" fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) monkeypatch.setattr(ceremony, "send_payload", fake_send_payload) @@ -211,9 +197,6 @@ def test_ceremony_api_server_with_out_option( assert [s["keyid"] for s in sigs_r] == [s["keyid"] for s in sigs_e] assert result.data == expected - assert fake_bootstrap_status.calls == [ - pretend.call(test_context["settings"]) - ] # One of the used key with id "50d7e110ad65f3b2dba5c3cfc8c5ca259be9774cc26be3410044ffd4be3aa5f3" # noqa # is an ecdsa type meaning it's not deterministic and have different # signature each run. That's why we do more granular check to work @@ -312,29 +295,3 @@ def test_ceremony_no_api_server_and_no_output_option( ) assert "Either '--api-sever'/'SERVER'" in result.stderr - - def test_ceremony_bootstrap_api_server_locked_for_bootstrap( - self, ceremony_inputs, monkeypatch - ): - status = { - "data": {"bootstrap": True}, - "message": "Locked for bootstrap", - } - fake_bootstrap_status = pretend.call_recorder(lambda a: status) - monkeypatch.setattr( - ceremony, "bootstrap_status", fake_bootstrap_status - ) - input_step1, input_step2, input_step3, input_step4 = ceremony_inputs - args = ["--api-server", "http://localhost"] - - result = invoke_command( - ceremony.ceremony, - input_step1 + input_step2 + input_step3 + input_step4, - args, - std_err_empty=False, - ) - - assert status["message"] in result.stderr - assert fake_bootstrap_status.calls == [ - pretend.call(result.context["settings"]) - ] From 7c832d41c18b5bece6bbb40da284b50703e5dbb5 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Mon, 17 Jun 2024 13:28:46 +0300 Subject: [PATCH 07/13] Tests: api-server in test_context and not as arg Signed-off-by: Martin Vrachev --- tests/conftest.py | 18 ++--- tests/unit/cli/admin/test_ceremony.py | 6 +- tests/unit/cli/admin/test_import_artifacts.py | 49 +++++++++----- tests/unit/cli/admin/test_sign.py | 67 +++++++++---------- 4 files changed, 71 insertions(+), 69 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index dfaa8e20..76c7261e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -41,18 +41,15 @@ _PROMPT = "rich.console.Console.input" -def _create_test_context(api_url: Optional[str]) -> Dict[str, Any]: +def _create_test_context() -> Dict[str, Any]: setting_file = os.path.join(TemporaryDirectory().name, "test_settings.yml") test_settings = Dynaconf(settings_files=[setting_file]) - if api_url: - test_settings.SERVER = api_url - return {"settings": test_settings, "config": setting_file} @pytest.fixture def test_context() -> Dict[str, Any]: - return _create_test_context(None) + return _create_test_context() def _create_client() -> CliRunner: @@ -302,8 +299,8 @@ def invoke_command( cmd: Command, inputs: List[str], args: List[str], - std_err_empty: bool = True, test_context: Optional[Context] = None, + std_err_empty: bool = True, ) -> Result: client = _create_client() out_file_name = "out_file.json" @@ -315,15 +312,10 @@ def invoke_command( else: out_args = ["--out", out_file_name] - api_url = None - if "--api-server" in args: - api_server_flag_index = args.index("--api-server") - api_url = args.pop(api_server_flag_index + 1) - args.remove("--api-server") + if not test_context: + test_context = _create_test_context() context = test_context - if test_context is None: - context = _create_test_context(api_url) with client.isolated_filesystem(): result_obj = client.invoke( diff --git a/tests/unit/cli/admin/test_ceremony.py b/tests/unit/cli/admin/test_ceremony.py index 3e4e4295..99e05d58 100644 --- a/tests/unit/cli/admin/test_ceremony.py +++ b/tests/unit/cli/admin/test_ceremony.py @@ -112,6 +112,7 @@ def test_ceremony_api_server( monkeypatch, patch_getpass, patch_utcnow, + test_context, ): fake_task_id = "123ab" fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) @@ -119,12 +120,13 @@ def test_ceremony_api_server( fake_task_status = pretend.call_recorder(lambda *a: None) monkeypatch.setattr(ceremony, "task_status", fake_task_status) input_step1, input_step2, input_step3, input_step4 = ceremony_inputs - args = ["--api-server", "http://localhost:80"] + test_context["settings"].SERVER = "http://localhost:80" result = invoke_command( ceremony.ceremony, input_step1 + input_step2 + input_step3 + input_step4, - args, + [], + test_context, ) with open(_PAYLOADS / "ceremony.json") as f: diff --git a/tests/unit/cli/admin/test_import_artifacts.py b/tests/unit/cli/admin/test_import_artifacts.py index 375fd529..35c2e652 100644 --- a/tests/unit/cli/admin/test_import_artifacts.py +++ b/tests/unit/cli/admin/test_import_artifacts.py @@ -257,7 +257,7 @@ def test__get_succinct_roles_failed_parsing(self, monkeypatch): class TestImportArtifactsGroupCLI: - def test_import_artifacts(self): + def test_import_artifacts(self, test_context): # Required to properly mock functions imported inside import_artifacts import sqlalchemy @@ -293,8 +293,6 @@ def test_import_artifacts(self): ) args = [ - "--api-server", - "http://127.0.0.1", "--db-uri", "postgresql://postgres:secret@127.0.0.1:5433", "--csv", @@ -302,7 +300,11 @@ def test_import_artifacts(self): "--csv", "artifacts2of2.csv", ] - result = invoke_command(import_artifacts.import_artifacts, [], args) + test_context["settings"].SERVER = "http://127.0.0.1" + + result = invoke_command( + import_artifacts.import_artifacts, [], args, test_context + ) assert result.exit_code == 0, result.output assert "Finished." in result.output assert import_artifacts.bootstrap_status.calls == [ @@ -343,7 +345,7 @@ def test_import_artifacts_no_api_server_config_no_param(self): assert result.exit_code == 1, result.stderr assert "Requires '--api-server' " in result.stderr - def test_import_artifacts_skip_publish_artifacts(self): + def test_import_artifacts_skip_publish_artifacts(self, test_context): # Required to properly mock functions imported inside import_artifacts import sqlalchemy @@ -379,8 +381,6 @@ def test_import_artifacts_skip_publish_artifacts(self): ) args = [ - "--api-server", - "http://127.0.0.1", "--db-uri", "postgresql://postgres:secret@127.0.0.1:5433", "--csv", @@ -389,7 +389,11 @@ def test_import_artifacts_skip_publish_artifacts(self): "artifacts2of2.csv", "--skip-publish-artifacts", ] - result = invoke_command(import_artifacts.import_artifacts, [], args) + test_context["settings"].SERVER = "http://127.0.0.1" + + result = invoke_command( + import_artifacts.import_artifacts, [], args, test_context + ) assert result.exit_code == 0, result.output assert "Finished." in result.output assert "No artifacts published" in result.output @@ -408,7 +412,7 @@ def test_import_artifacts_skip_publish_artifacts(self): assert import_artifacts.publish_artifacts.calls == [] assert import_artifacts.task_status.calls == [] - def test_import_artifacts_sqlalchemy_import_fails(self): + def test_import_artifacts_sqlalchemy_import_fails(self, test_context): import builtins real_import = builtins.__import__ @@ -423,12 +427,14 @@ def fake_import(name, *args, **kwargs): builtins.__import__ = fake_import - args = ["--api-server", "", "--db-uri", "", "--csv", ""] + args = ["--db-uri", "", "--csv", ""] + test_context["settings"].SERVER = "" with pytest.raises(ModuleNotFoundError) as exc: invoke_command( import_artifacts.import_artifacts, [], args, + test_context, std_err_empty=False, ) @@ -436,15 +442,13 @@ def fake_import(name, *args, **kwargs): builtins.__import__ = real_import assert "pip install repository-service-tuf[sqlalchemy" in str(exc) - def test_import_artifacts_bootstrap_check_failed(self): + def test_import_artifacts_bootstrap_check_failed(self, test_context): import_artifacts.bootstrap_status = pretend.raiser( import_artifacts.click.ClickException("Server ERROR") ) args = [ - "--api-server", - "http://127.0.0.1", "--db-uri", "postgresql://postgres:secret@127.0.0.1:5433", "--csv", @@ -452,22 +456,25 @@ def test_import_artifacts_bootstrap_check_failed(self): "--csv", "artifacts2of2.csv", ] + test_context["settings"].SERVER = "http://127.0.0.1" result = invoke_command( - import_artifacts.import_artifacts, [], args, std_err_empty=False + import_artifacts.import_artifacts, + [], + args, + test_context, + std_err_empty=False, ) assert result.exit_code == 1 assert "Server ERROR" in result.stderr, result.stderr - def test_import_artifacts_without_bootstrap(self): + def test_import_artifacts_without_bootstrap(self, test_context): import_artifacts.bootstrap_status = pretend.call_recorder( lambda *a: {"data": {"bootstrap": False}, "message": "some msg"} ) args = [ - "--api-server", - "http://127.0.0.1", "--db-uri", "postgresql://postgres:secret@127.0.0.1:5433", "--csv", @@ -475,8 +482,14 @@ def test_import_artifacts_without_bootstrap(self): "--csv", "artifacts2of2.csv", ] + test_context["settings"].SERVER = "http://127.0.0.1" + result = invoke_command( - import_artifacts.import_artifacts, [], args, std_err_empty=False + import_artifacts.import_artifacts, + [], + args, + test_context, + std_err_empty=False, ) assert result.exit_code == 1, result.stderr assert ( diff --git a/tests/unit/cli/admin/test_sign.py b/tests/unit/cli/admin/test_sign.py index 5f1133ee..73d7414a 100644 --- a/tests/unit/cli/admin/test_sign.py +++ b/tests/unit/cli/admin/test_sign.py @@ -14,7 +14,7 @@ class TestSign: - def test_sign_with_previous_root(self, patch_getpass): + def test_sign_with_previous_root(self, test_context, patch_getpass): inputs = [ "1", # Please enter signing key index f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa @@ -31,9 +31,10 @@ def test_sign_with_previous_root(self, patch_getpass): ) sign.send_payload = pretend.call_recorder(lambda *a: "fake-taskid") sign.task_status = pretend.call_recorder(lambda *a: "OK") - args = ["--api-server", "http://127.0.0.1"] + api_server = "http://127.0.0.1" + test_context["settings"].SERVER = api_server - result = invoke_command(sign.sign, inputs, args) + result = invoke_command(sign.sign, inputs, [], test_context) with open(_PAYLOADS / "sign.json") as f: expected = json.load(f) @@ -44,11 +45,7 @@ def test_sign_with_previous_root(self, patch_getpass): ) assert "Metadata Signed and sent to the API! 🔑" in result.stdout assert sign.request_server.calls == [ - pretend.call( - "http://127.0.0.1", - "api/v1/metadata/sign/", - Methods.GET, - ) + pretend.call(api_server, "api/v1/metadata/sign/", Methods.GET) ] assert sign.send_payload.calls == [ pretend.call( @@ -73,7 +70,7 @@ def test_sign_with_previous_root(self, patch_getpass): ) ] - def test_sign_bootstap_root(self, patch_getpass): + def test_sign_bootstap_root(self, test_context, patch_getpass): inputs = [ "1", # Please enter signing key index f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa @@ -94,9 +91,10 @@ def test_sign_bootstap_root(self, patch_getpass): ) sign.send_payload = pretend.call_recorder(lambda *a: "fake-taskid") sign.task_status = pretend.call_recorder(lambda *a: "OK") - args = ["--api-server", "http://127.0.0.1"] + api_server = "http://127.0.0.1" + test_context["settings"].SERVER = api_server - result = invoke_command(sign.sign, inputs, args) + result = invoke_command(sign.sign, inputs, [], test_context) expected = { "keyid": "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc", # noqa @@ -107,11 +105,7 @@ def test_sign_bootstap_root(self, patch_getpass): assert result.data["signature"]["keyid"] == expected["keyid"] assert "Metadata Signed and sent to the API! 🔑" in result.stdout assert sign.request_server.calls == [ - pretend.call( - "http://127.0.0.1", - "api/v1/metadata/sign/", - Methods.GET, - ) + pretend.call(api_server, "api/v1/metadata/sign/", Methods.GET) ] assert sign.send_payload.calls == [ pretend.call( @@ -167,7 +161,9 @@ def test_sign_input_option_and_custom_out( assert result.data["signature"]["sig"] == expected["sig"] assert f"Saved result to '{custom_path}'" in result.stdout - def test_sign_with_input_option_and_api_server_set(self, patch_getpass): + def test_sign_with_input_option_and_api_server_set( + self, test_context, patch_getpass + ): inputs = [ "1", # Please enter signing key index f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa @@ -175,9 +171,10 @@ def test_sign_with_input_option_and_api_server_set(self, patch_getpass): sign.send_payload = pretend.call_recorder(lambda *a: "fake-taskid") sign.task_status = pretend.call_recorder(lambda *a: "OK") sign_input_path = f"{_PAYLOADS / 'sign_pending_roles.json'}" - api_server = "http://localhost:80" - args = ["--api-server", api_server, "--in", sign_input_path] - result = invoke_command(sign.sign, inputs, args) + test_context["settings"].SERVER = "http://localhost:80" + args = ["--in", sign_input_path] + + result = invoke_command(sign.sign, inputs, args, test_context) expected = { "keyid": "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc", # noqa @@ -217,7 +214,9 @@ def test_sign_no_api_server_and_no_input_option(self): class TestSignError: - def test_sign_with_previous_root_but_wrong_version(self, patch_getpass): + def test_sign_with_previous_root_but_wrong_version( + self, test_context, patch_getpass + ): inputs = [ "1", # Please enter signing key index f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa @@ -239,22 +238,20 @@ def test_sign_with_previous_root_but_wrong_version(self, patch_getpass): sign.request_server = pretend.call_recorder( lambda *a, **kw: fake_response ) - args = ["--api-server", "http://127.0.0.1"] + api_server = "http://127.0.0.1" + test_context["settings"].SERVER = api_server + test_result = invoke_command( - sign.sign, inputs, args, std_err_empty=False + sign.sign, inputs, [], test_context, std_err_empty=False ) assert test_result.exit_code == 1, test_result.stdout assert "Previous root v1 needed to sign root v2" in test_result.stderr assert sign.request_server.calls == [ - pretend.call( - "http://127.0.0.1", - "api/v1/metadata/sign/", - Methods.GET, - ) + pretend.call(api_server, "api/v1/metadata/sign/", Methods.GET) ] - def test_sign_fully_signed_metadata(self, patch_getpass): + def test_sign_fully_signed_metadata(self, test_context, patch_getpass): inputs = [ "1", # Please enter signing key index f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa @@ -276,19 +273,17 @@ def test_sign_fully_signed_metadata(self, patch_getpass): sign.request_server = pretend.call_recorder( lambda *a, **kw: fake_response ) - args = ["--api-server", "http://127.0.0.1"] + api_server = "http://127.0.0.1" + test_context["settings"].SERVER = api_server + test_result = invoke_command( - sign.sign, inputs, args, std_err_empty=False + sign.sign, inputs, [], test_context, std_err_empty=False ) assert test_result.exit_code == 1, test_result.stdout assert "Metadata already fully signed." in test_result.stderr assert sign.request_server.calls == [ - pretend.call( - "http://127.0.0.1", - "api/v1/metadata/sign/", - Methods.GET, - ) + pretend.call(api_server, "api/v1/metadata/sign/", Methods.GET) ] From 9f1ef9f136b8a79f00f25994daee4d150f8b5208 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Mon, 17 Jun 2024 17:09:11 +0300 Subject: [PATCH 08/13] Tests: make custom out tests to use invoke helper Signed-off-by: Martin Vrachev --- tests/conftest.py | 9 +++--- tests/unit/cli/admin/test_ceremony.py | 41 +++++++++------------------ tests/unit/cli/admin/test_sign.py | 25 +++++++--------- 3 files changed, 29 insertions(+), 46 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 76c7261e..fef14547 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -304,6 +304,9 @@ def invoke_command( ) -> Result: client = _create_client() out_file_name = "out_file.json" + if "--out" in args: + out_index = args.index("--out") + out_file_name = args[out_index + 1] if cmd.name == import_artifacts.name: out_args = [] @@ -315,18 +318,16 @@ def invoke_command( if not test_context: test_context = _create_test_context() - context = test_context - with client.isolated_filesystem(): result_obj = client.invoke( cmd, args=args + out_args, input="\n".join(inputs), - obj=context, + obj=test_context, catch_exceptions=False, ) - result_obj.context = context + result_obj.context = test_context if std_err_empty: assert result_obj.stderr == "" if len(out_args) > 0: diff --git a/tests/unit/cli/admin/test_ceremony.py b/tests/unit/cli/admin/test_ceremony.py index 99e05d58..48d36d01 100644 --- a/tests/unit/cli/admin/test_ceremony.py +++ b/tests/unit/cli/admin/test_ceremony.py @@ -17,18 +17,11 @@ def test_ceremony_with_custom_out( ): input_step1, input_step2, input_step3, input_step4 = ceremony_inputs custom_path = "file.json" - with client.isolated_filesystem(): - result = client.invoke( - ceremony.ceremony, - args=["--out", custom_path], - input="\n".join( - input_step1 + input_step2 + input_step3 + input_step4 - ), - obj=test_context, - catch_exceptions=False, - ) - with open(custom_path) as f: - result.data = json.load(f) + result = invoke_command( + ceremony.ceremony, + "\n".join(input_step1 + input_step2 + input_step3 + input_step4), + args=["--out", custom_path], + ) with open(_PAYLOADS / "ceremony.json") as f: expected = json.load(f) @@ -173,22 +166,14 @@ def test_ceremony_api_server_with_out_option( input_step1, input_step2, input_step3, input_step4 = ceremony_inputs test_context["settings"].SERVER = "http://localhost:80" custom_path = "file.json" - with client.isolated_filesystem(): - result = client.invoke( - ceremony.ceremony, - args=["--out", custom_path], - input="\n".join( - input_step1 + input_step2 + input_step3 + input_step4 - ), - obj=test_context, - catch_exceptions=False, - ) - assert result.stderr == "" - with open(custom_path) as f: - result.data = json.load(f) - - with open(_PAYLOADS / "ceremony.json") as f: - expected = json.load(f) + result = invoke_command( + ceremony.ceremony, + input="\n".join( + input_step1 + input_step2 + input_step3 + input_step4 + ), + args=["--out", custom_path], + test_context=test_context, + ) with open(_PAYLOADS / "ceremony.json") as f: expected = json.load(f) diff --git a/tests/unit/cli/admin/test_sign.py b/tests/unit/cli/admin/test_sign.py index 73d7414a..f7d3103a 100644 --- a/tests/unit/cli/admin/test_sign.py +++ b/tests/unit/cli/admin/test_sign.py @@ -137,19 +137,16 @@ def test_sign_input_option_and_custom_out( "1", # Please enter signing key index f"{_PEMS / 'JH.ed25519'}", # Please enter path to encrypted private key # noqa ] - - args = ["--in", f"{_PAYLOADS / 'sign_pending_roles.json'}"] - custom_path = "custom_sign_path.json" - with client.isolated_filesystem(): - result = client.invoke( - sign.sign, - args=args + ["--out", custom_path], - input="\n".join(inputs), - obj=test_context, - catch_exceptions=False, - ) - with open(custom_path) as f: - result.data = json.load(f) + input_path = f"{_PAYLOADS / 'sign_pending_roles.json'}" + custom_out_path = "custom_sign_path.json" + args = ["--in", input_path, "--out", custom_out_path] + + result = invoke_command( + sign.sign, + input="\n".join(inputs), + args=args, + test_context=test_context, + ) expected = { "keyid": "c6d8bf2e4f48b41ac2ce8eca21415ca8ef68c133b47fc33df03d4070a7e1e9cc", # noqa @@ -159,7 +156,7 @@ def test_sign_input_option_and_custom_out( assert result.data["role"] == "root" assert result.data["signature"]["keyid"] == expected["keyid"] assert result.data["signature"]["sig"] == expected["sig"] - assert f"Saved result to '{custom_path}'" in result.stdout + assert f"Saved result to '{custom_out_path}'" in result.stdout def test_sign_with_input_option_and_api_server_set( self, test_context, patch_getpass From b57b1d8832c549f2dd3edffbfa68007d9942b005 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Tue, 18 Jun 2024 15:25:58 +0300 Subject: [PATCH 09/13] Fix unit tests Signed-off-by: Martin Vrachev --- tests/unit/cli/admin/test_ceremony.py | 6 ++---- tests/unit/cli/admin/test_sign.py | 7 ++----- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/unit/cli/admin/test_ceremony.py b/tests/unit/cli/admin/test_ceremony.py index 48d36d01..5850c6a9 100644 --- a/tests/unit/cli/admin/test_ceremony.py +++ b/tests/unit/cli/admin/test_ceremony.py @@ -19,7 +19,7 @@ def test_ceremony_with_custom_out( custom_path = "file.json" result = invoke_command( ceremony.ceremony, - "\n".join(input_step1 + input_step2 + input_step3 + input_step4), + input_step1 + input_step2 + input_step3 + input_step4, args=["--out", custom_path], ) @@ -168,9 +168,7 @@ def test_ceremony_api_server_with_out_option( custom_path = "file.json" result = invoke_command( ceremony.ceremony, - input="\n".join( - input_step1 + input_step2 + input_step3 + input_step4 - ), + inputs=input_step1 + input_step2 + input_step3 + input_step4, args=["--out", custom_path], test_context=test_context, ) diff --git a/tests/unit/cli/admin/test_sign.py b/tests/unit/cli/admin/test_sign.py index f7d3103a..a56a2906 100644 --- a/tests/unit/cli/admin/test_sign.py +++ b/tests/unit/cli/admin/test_sign.py @@ -131,7 +131,7 @@ def test_sign_bootstap_root(self, test_context, patch_getpass): ] def test_sign_input_option_and_custom_out( - self, client, test_context, patch_getpass + self, test_context, patch_getpass ): inputs = [ "1", # Please enter signing key index @@ -142,10 +142,7 @@ def test_sign_input_option_and_custom_out( args = ["--in", input_path, "--out", custom_out_path] result = invoke_command( - sign.sign, - input="\n".join(inputs), - args=args, - test_context=test_context, + sign.sign, inputs=inputs, args=args, test_context=test_context ) expected = { From 4ca025f95512f8a21fdd70bd84fc2beab2de9834 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Thu, 13 Jun 2024 21:02:16 +0300 Subject: [PATCH 10/13] Add send command for bootstrap, update and sign Signed-off-by: Martin Vrachev --- .../cli/admin/send/__init__.py | 20 +++++++++ .../cli/admin/send/bootstrap.py | 35 +++++++++++++++ repository_service_tuf/cli/admin/send/sign.py | 35 +++++++++++++++ .../cli/admin/send/update.py | 35 +++++++++++++++ tests/conftest.py | 15 ++++++- tests/unit/cli/admin/send/__init__.py | 0 tests/unit/cli/admin/send/test_bootstrap.py | 41 ++++++++++++++++++ tests/unit/cli/admin/send/test_send.py | 20 +++++++++ tests/unit/cli/admin/send/test_sign.py | 43 +++++++++++++++++++ tests/unit/cli/admin/send/test_update.py | 43 +++++++++++++++++++ 10 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 repository_service_tuf/cli/admin/send/__init__.py create mode 100644 repository_service_tuf/cli/admin/send/bootstrap.py create mode 100644 repository_service_tuf/cli/admin/send/sign.py create mode 100644 repository_service_tuf/cli/admin/send/update.py create mode 100644 tests/unit/cli/admin/send/__init__.py create mode 100644 tests/unit/cli/admin/send/test_bootstrap.py create mode 100644 tests/unit/cli/admin/send/test_send.py create mode 100644 tests/unit/cli/admin/send/test_sign.py create mode 100644 tests/unit/cli/admin/send/test_update.py diff --git a/repository_service_tuf/cli/admin/send/__init__.py b/repository_service_tuf/cli/admin/send/__init__.py new file mode 100644 index 00000000..5c012834 --- /dev/null +++ b/repository_service_tuf/cli/admin/send/__init__.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2023-2024 Repository Service for TUF Contributors + +# SPDX-License-Identifier: MIT + +from repository_service_tuf.cli.admin import admin, click + + +def _validate_settings(context: click.Context): + settings = context.obj["settings"] + if not settings.get("SERVER"): + raise click.ClickException( + "Needed '--api-sever' admin option or 'SERVER' in RSTUF cofig" + ) + + +@admin.group() # type: ignore +@click.pass_context +def send(context: click.Context): + """Send a payload to an existing RSTUF API deployment""" + _validate_settings(context) diff --git a/repository_service_tuf/cli/admin/send/bootstrap.py b/repository_service_tuf/cli/admin/send/bootstrap.py new file mode 100644 index 00000000..4ede390e --- /dev/null +++ b/repository_service_tuf/cli/admin/send/bootstrap.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2023-2024 Repository Service for TUF Contributors + +# SPDX-License-Identifier: MIT + +import json + +from repository_service_tuf.cli import console +from repository_service_tuf.cli.admin.send import click, send +from repository_service_tuf.helpers.api_client import ( + URL, + send_payload, + task_status, +) + + +@send.command() # type: ignore +@click.argument( + "bootstrap_payload", + type=click.File("r"), + required=True, +) +@click.pass_context +def bootstrap(context: click.Context, bootstrap_payload: click.File): + """Send payload and bootstrap to an existing RSTUF API deployment.""" + settings = context.obj["settings"] + + task_id = send_payload( + settings=settings, + url=URL.BOOTSTRAP.value, + payload=json.load(bootstrap_payload), # type: ignore + expected_msg="Bootstrap accepted.", + command_name="Bootstrap", + ) + task_status(task_id, settings, "Bootstrap status: ") + console.print("\nBootstrap completed. 🔐 🎉") diff --git a/repository_service_tuf/cli/admin/send/sign.py b/repository_service_tuf/cli/admin/send/sign.py new file mode 100644 index 00000000..381d7595 --- /dev/null +++ b/repository_service_tuf/cli/admin/send/sign.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2023-2024 Repository Service for TUF Contributors + +# SPDX-License-Identifier: MIT + +import json + +from repository_service_tuf.cli import console +from repository_service_tuf.cli.admin.send import click, send +from repository_service_tuf.helpers.api_client import ( + URL, + send_payload, + task_status, +) + + +@send.command() # type: ignore +@click.argument( + "sign_payload", + type=click.File("r"), + required=True, +) +@click.pass_context +def sign(context: click.Context, sign_payload: click.File): + """Send sign payload to an existing RSTUF API deployment.""" + settings = context.obj["settings"] + + task_id = send_payload( + settings=settings, + url=URL.METADATA_SIGN.value, + payload=json.load(sign_payload), # type: ignore + expected_msg="Metadata sign accepted.", + command_name="Metadata sign", + ) + task_status(task_id, settings, "Metadata sign status: ") + console.print("\nMetadata Signed! 🔑\n") diff --git a/repository_service_tuf/cli/admin/send/update.py b/repository_service_tuf/cli/admin/send/update.py new file mode 100644 index 00000000..38b46969 --- /dev/null +++ b/repository_service_tuf/cli/admin/send/update.py @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2023-2024 Repository Service for TUF Contributors + +# SPDX-License-Identifier: MIT + +import json + +from repository_service_tuf.cli import console +from repository_service_tuf.cli.admin.send import click, send +from repository_service_tuf.helpers.api_client import ( + URL, + send_payload, + task_status, +) + + +@send.command() # type: ignore +@click.argument( + "metadata_update_payload", + type=click.File("r"), + required=True, +) +@click.pass_context +def update(context: click.Context, metadata_update_payload: click.File): + """Send metadata update payload to an existing RSTUF API deployment.""" + settings = context.obj["settings"] + + task_id = send_payload( + settings=settings, + url=URL.METADATA.value, + payload=json.load(metadata_update_payload), # type: ignore + expected_msg="Metadata update accepted.", + command_name="Metadata Update", + ) + task_status(task_id, settings, "Metadata Update status: ") + console.print("Root metadata update completed. 🔐 🎉") diff --git a/tests/conftest.py b/tests/conftest.py index fef14547..d5f91632 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,11 @@ from tuf.api.metadata import Metadata, Root from repository_service_tuf.cli.admin.import_artifacts import import_artifacts +from repository_service_tuf.cli.admin.send.bootstrap import ( + bootstrap as send_bootstrap, +) +from repository_service_tuf.cli.admin.send.sign import sign as send_sign +from repository_service_tuf.cli.admin.send.update import update as send_update from repository_service_tuf.cli.admin.update import update from repository_service_tuf.helpers.tuf import ( BootstrapSetup, @@ -308,9 +313,15 @@ def invoke_command( out_index = args.index("--out") out_file_name = args[out_index + 1] - if cmd.name == import_artifacts.name: + commands_no_out_args = [ + import_artifacts, + send_bootstrap, + send_sign, + send_update, + ] + if cmd in commands_no_out_args: out_args = [] - elif cmd.name == update.name: + elif cmd == update: out_args = ["-s", out_file_name] else: out_args = ["--out", out_file_name] diff --git a/tests/unit/cli/admin/send/__init__.py b/tests/unit/cli/admin/send/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/cli/admin/send/test_bootstrap.py b/tests/unit/cli/admin/send/test_bootstrap.py new file mode 100644 index 00000000..fe6ba4dd --- /dev/null +++ b/tests/unit/cli/admin/send/test_bootstrap.py @@ -0,0 +1,41 @@ +import json + +import pretend + +from repository_service_tuf.cli.admin.send import bootstrap +from repository_service_tuf.helpers.api_client import URL +from tests.conftest import _PAYLOADS, invoke_command + +PATH = "repository_service_tuf.cli.admin.send.bootstrap" + + +class TestSendBootstrap: + def test_bootstrap(self, monkeypatch): + fake_task_id = "task_id" + fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) + monkeypatch.setattr(f"{PATH}.send_payload", fake_send_payload) + fake_task_status = pretend.call_recorder(lambda *a: None) + monkeypatch.setattr(f"{PATH}.task_status", fake_task_status) + bootstrap_payload_path = f"{_PAYLOADS / 'ceremony.json'}" + args = ["--api-server", "http://localhost:80", bootstrap_payload_path] + + result = invoke_command(bootstrap.bootstrap, [], args) + + with open(_PAYLOADS / "ceremony.json") as f: + expected_data = json.load(f) + + assert fake_send_payload.calls == [ + pretend.call( + settings=result.context["settings"], + url=URL.BOOTSTRAP.value, + payload=expected_data, + expected_msg="Bootstrap accepted.", + command_name="Bootstrap", + ) + ] + assert fake_task_status.calls == [ + pretend.call( + fake_task_id, result.context["settings"], "Bootstrap status: " + ) + ] + assert "Bootstrap completed. 🔐 🎉" in result.stdout diff --git a/tests/unit/cli/admin/send/test_send.py b/tests/unit/cli/admin/send/test_send.py new file mode 100644 index 00000000..847de0a7 --- /dev/null +++ b/tests/unit/cli/admin/send/test_send.py @@ -0,0 +1,20 @@ +import pretend +import pytest + +from repository_service_tuf.cli.admin import click, send + + +class TestSend: + def test__validate_settings(self, test_context): + test_context["settings"].SERVER = "http://localhost:80" + fake_context = pretend.stub(obj={"settings": test_context["settings"]}) + send._validate_settings(fake_context) + + assert fake_context.obj["settings"].SERVER == "http://localhost:80" + + def test__validate_settings_server_missing(self, test_context): + fake_context = pretend.stub(obj={"settings": test_context["settings"]}) + with pytest.raises(click.ClickException) as err: + send._validate_settings(fake_context) + + assert "Needed '--api-sever' admin option or 'SERVER'" in str(err) diff --git a/tests/unit/cli/admin/send/test_sign.py b/tests/unit/cli/admin/send/test_sign.py new file mode 100644 index 00000000..771af3a4 --- /dev/null +++ b/tests/unit/cli/admin/send/test_sign.py @@ -0,0 +1,43 @@ +import json + +import pretend + +from repository_service_tuf.cli.admin.send import sign +from repository_service_tuf.helpers.api_client import URL +from tests.conftest import _PAYLOADS, invoke_command + +PATH = "repository_service_tuf.cli.admin.send.sign" + + +class TestSendSign: + def test_sign(self, monkeypatch): + fake_task_id = "task_id" + fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) + monkeypatch.setattr(f"{PATH}.send_payload", fake_send_payload) + fake_task_status = pretend.call_recorder(lambda *a: None) + monkeypatch.setattr(f"{PATH}.task_status", fake_task_status) + sign_payload_path = f"{_PAYLOADS / 'sign.json'}" + args = ["--api-server", "http://localhost:80", sign_payload_path] + + result = invoke_command(sign.sign, [], args) + + with open(_PAYLOADS / "sign.json") as f: + expected_data = json.load(f) + + assert fake_send_payload.calls == [ + pretend.call( + settings=result.context["settings"], + url=URL.METADATA_SIGN.value, + payload=expected_data, + expected_msg="Metadata sign accepted.", + command_name="Metadata sign", + ) + ] + assert fake_task_status.calls == [ + pretend.call( + fake_task_id, + result.context["settings"], + "Metadata sign status: ", + ) + ] + assert "Metadata Signed! 🔑" in result.stdout diff --git a/tests/unit/cli/admin/send/test_update.py b/tests/unit/cli/admin/send/test_update.py new file mode 100644 index 00000000..b958c2a6 --- /dev/null +++ b/tests/unit/cli/admin/send/test_update.py @@ -0,0 +1,43 @@ +import json + +import pretend + +from repository_service_tuf.cli.admin.send import update +from repository_service_tuf.helpers.api_client import URL +from tests.conftest import _PAYLOADS, invoke_command + +PATH = "repository_service_tuf.cli.admin.send.update" + + +class TestSendMdUpdate: + def test_update(self, monkeypatch): + fake_task_id = "task_id" + fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) + monkeypatch.setattr(f"{PATH}.send_payload", fake_send_payload) + fake_task_status = pretend.call_recorder(lambda *a: None) + monkeypatch.setattr(f"{PATH}.task_status", fake_task_status) + update_payload_path = f"{_PAYLOADS / 'update.json'}" + args = ["--api-server", "http://localhost:80", update_payload_path] + + result = invoke_command(update.update, [], args) + + with open(_PAYLOADS / "update.json") as f: + expected_data = json.load(f) + + assert fake_send_payload.calls == [ + pretend.call( + settings=result.context["settings"], + url=URL.METADATA.value, + payload=expected_data, + expected_msg="Metadata update accepted.", + command_name="Metadata Update", + ) + ] + assert fake_task_status.calls == [ + pretend.call( + fake_task_id, + result.context["settings"], + "Metadata Update status: ", + ) + ] + assert "Root metadata update completed. 🔐 🎉" in result.stdout From c41a7b699ca78b4baace7d6e328508c66b9c1128 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Fri, 14 Jun 2024 16:31:01 +0300 Subject: [PATCH 11/13] Update docs Signed-off-by: Martin Vrachev --- .../repository-service-tuf-cli-C3.puml | 8 +++ docs/source/devel/index.rst | 1 + .../repository_service_tuf.cli.admin.send.rst | 37 +++++++++++ docs/source/guide/index.rst | 64 +++++++++++++++++++ .../cli/admin/send/bootstrap.py | 7 +- repository_service_tuf/cli/admin/send/sign.py | 7 +- .../cli/admin/send/update.py | 7 +- 7 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 docs/source/devel/repository_service_tuf.cli.admin.send.rst diff --git a/docs/diagrams/repository-service-tuf-cli-C3.puml b/docs/diagrams/repository-service-tuf-cli-C3.puml index 856a7759..0639dba4 100644 --- a/docs/diagrams/repository-service-tuf-cli-C3.puml +++ b/docs/diagrams/repository-service-tuf-cli-C3.puml @@ -51,6 +51,12 @@ System_Boundary(trs_cli, "Repository Service for TUF CLI") #APPLICATION { Container(sign, "sign") } + Container_Boundary(send, "send"){ + Container(bootstrap, "bootstrap") + Container(update, "update") + Container(sign, "sign") + + } } Container_Boundary(key, "key") { Container(key_generate, "generate") @@ -77,12 +83,14 @@ Rel(info, tuf, " ") Rel(ceremony, api_client, " ") Rel(metadata, tuf, " ") Rel(metadata, api_client, " ") +Rel(send, api_client, " ") Rel(api_client, requests, " ") Rel(dynaconf, user, " ", $tags="os") Rel_U(ceremony, click, " ") Rel_U(key_generate, click, " ") Rel_U(info, click, " ") Rel_U(metadata, click, " ") +Rel_U(send, click, " ") Rel(requests, tuf_repository_service_api, " ") HIDE_STEREOTYPE() diff --git a/docs/source/devel/index.rst b/docs/source/devel/index.rst index 2ae5cd20..0a77c06d 100644 --- a/docs/source/devel/index.rst +++ b/docs/source/devel/index.rst @@ -17,6 +17,7 @@ Component level repository_service_tuf repository_service_tuf.cli repository_service_tuf.cli.admin + repository_service_tuf.cli.admin.send repository_service_tuf.cli.artifact repository_service_tuf.cli.key repository_service_tuf.helpers diff --git a/docs/source/devel/repository_service_tuf.cli.admin.send.rst b/docs/source/devel/repository_service_tuf.cli.admin.send.rst new file mode 100644 index 00000000..e5f48310 --- /dev/null +++ b/docs/source/devel/repository_service_tuf.cli.admin.send.rst @@ -0,0 +1,37 @@ +repository\_service\_tuf.cli.admin.send package +=============================================== + +Submodules +---------- + +repository\_service\_tuf.cli.admin.send.bootstrap module +-------------------------------------------------------- + +.. automodule:: repository_service_tuf.cli.admin.send.bootstrap + :members: + :undoc-members: + :show-inheritance: + +repository\_service\_tuf.cli.admin.send.sign module +--------------------------------------------------- + +.. automodule:: repository_service_tuf.cli.admin.send.sign + :members: + :undoc-members: + :show-inheritance: + +repository\_service\_tuf.cli.admin.send.update module +----------------------------------------------------- + +.. automodule:: repository_service_tuf.cli.admin.send.update + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: repository_service_tuf.cli.admin.send + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/guide/index.rst b/docs/source/guide/index.rst index 629c18f3..0c996feb 100644 --- a/docs/source/guide/index.rst +++ b/docs/source/guide/index.rst @@ -105,6 +105,7 @@ It executes administrative commands to the Repository Service for TUF. │ ceremony Bootstrap Ceremony to create initial root metadata and RSTUF config. │ │ import-artifacts Import artifacts information from exported CSV file and send it to RSTUF API deployment. │ │ metadata Metadata management. │ + │ send Send a payload to an existing RSTUF API deployment │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ @@ -187,6 +188,69 @@ sign (``sign``) │ --help -h Show this message and exit. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +.. rstuf-cli-admin-send + +Send generated payload (``send``) +--------------------------------- + +.. rstuf-cli-admin-send-bootstrap + +send bootstrap (``sign``) +......................... + +.. code:: shell + + ❯ rstuf admin --api-server send bootstrap --help + + Usage: rstuf admin send bootstrap [OPTIONS] BOOTSTRAP_PAYLOAD + + Send payload and bootstrap to an existing RSTUF API deployment. + Note: 'BOOTSTRAP_PAYLOAD' argument must be generated by using: + 'rstuf admin ceremony' command. + + ╭─ Options ──────────────────────────────────────────╮ + │ --help -h Show this message and exit. │ + ╰────────────────────────────────────────────────────╯ + + +.. rstuf-cli-admin-send-update + +send metadata update (``update``) +................................. + +.. code:: shell + + ❯ rstuf admin --api-server send update --help + + Usage: rstuf admin send update [OPTIONS] METADATA_UPDATE_PAYLOAD + + Send metadata update payload to an existing RSTUF API deployment. + Note: 'METADATA_UPDATE_PAYLOAD' argument must be generated by using: + 'rstuf admin metadata update' command. + + ╭─ Options ──────────────────────────────────────────╮ + │ --help -h Show this message and exit. │ + ╰────────────────────────────────────────────────────╯ + + +.. rstuf-cli-admin-send-sign + +send sign (``sign``) +.................... + +.. code:: shell + + ❯ rstuf admin --api-server send update --help + + Usage: rstuf admin send sign [OPTIONS] SIGN_PAYLOAD + + Send sign payload to an existing RSTUF API deployment. + Note: 'SIGN_PAYLOAD' argument must be generated by using: + 'rstuf admin metadata sign' command. + + ╭─ Options ──────────────────────────────────────────╮ + │ --help -h Show this message and exit. │ + ╰────────────────────────────────────────────────────╯ .. rstuf-cli-admin-import-artifacts diff --git a/repository_service_tuf/cli/admin/send/bootstrap.py b/repository_service_tuf/cli/admin/send/bootstrap.py index 4ede390e..6ad292d8 100644 --- a/repository_service_tuf/cli/admin/send/bootstrap.py +++ b/repository_service_tuf/cli/admin/send/bootstrap.py @@ -21,7 +21,12 @@ ) @click.pass_context def bootstrap(context: click.Context, bootstrap_payload: click.File): - """Send payload and bootstrap to an existing RSTUF API deployment.""" + """ + Send payload and bootstrap to an existing RSTUF API deployment. + + Note: 'BOOTSTRAP_PAYLOAD' argument must be generated by using:\n + 'rstuf admin ceremony' command. + """ settings = context.obj["settings"] task_id = send_payload( diff --git a/repository_service_tuf/cli/admin/send/sign.py b/repository_service_tuf/cli/admin/send/sign.py index 381d7595..ecc16899 100644 --- a/repository_service_tuf/cli/admin/send/sign.py +++ b/repository_service_tuf/cli/admin/send/sign.py @@ -21,7 +21,12 @@ ) @click.pass_context def sign(context: click.Context, sign_payload: click.File): - """Send sign payload to an existing RSTUF API deployment.""" + """ + Send sign payload to an existing RSTUF API deployment. + + Note: 'SIGN_PAYLOAD' argument must be generated by using:\n + 'rstuf admin metadata sign' command. + """ settings = context.obj["settings"] task_id = send_payload( diff --git a/repository_service_tuf/cli/admin/send/update.py b/repository_service_tuf/cli/admin/send/update.py index 38b46969..693f11bd 100644 --- a/repository_service_tuf/cli/admin/send/update.py +++ b/repository_service_tuf/cli/admin/send/update.py @@ -21,7 +21,12 @@ ) @click.pass_context def update(context: click.Context, metadata_update_payload: click.File): - """Send metadata update payload to an existing RSTUF API deployment.""" + """ + Send metadata update payload to an existing RSTUF API deployment. + + Note: 'METADATA_UPDATE_PAYLOAD' argument must be generated by using:\n + 'rstuf admin metadata update' command. + """ settings = context.obj["settings"] task_id = send_payload( From 31ec832fd1f1695ca6eb7d97931c8eb875231b2c Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Fri, 14 Jun 2024 17:00:04 +0300 Subject: [PATCH 12/13] Ignore line for coverage Signed-off-by: Martin Vrachev --- repository_service_tuf/cli/admin/send/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/repository_service_tuf/cli/admin/send/__init__.py b/repository_service_tuf/cli/admin/send/__init__.py index 5c012834..72f9a090 100644 --- a/repository_service_tuf/cli/admin/send/__init__.py +++ b/repository_service_tuf/cli/admin/send/__init__.py @@ -17,4 +17,4 @@ def _validate_settings(context: click.Context): @click.pass_context def send(context: click.Context): """Send a payload to an existing RSTUF API deployment""" - _validate_settings(context) + _validate_settings(context) # pragma: no cover From 6edd6eccf3ecb840e5012069d2a236ad9048a22e Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Tue, 18 Jun 2024 16:27:54 +0300 Subject: [PATCH 13/13] Fix ft tests by saving api-server in context Signed-off-by: Martin Vrachev --- tests/unit/cli/admin/send/test_bootstrap.py | 7 ++++--- tests/unit/cli/admin/send/test_sign.py | 7 ++++--- tests/unit/cli/admin/send/test_update.py | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/unit/cli/admin/send/test_bootstrap.py b/tests/unit/cli/admin/send/test_bootstrap.py index fe6ba4dd..79d75a0c 100644 --- a/tests/unit/cli/admin/send/test_bootstrap.py +++ b/tests/unit/cli/admin/send/test_bootstrap.py @@ -10,16 +10,17 @@ class TestSendBootstrap: - def test_bootstrap(self, monkeypatch): + def test_bootstrap(self, test_context, monkeypatch): fake_task_id = "task_id" fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) monkeypatch.setattr(f"{PATH}.send_payload", fake_send_payload) fake_task_status = pretend.call_recorder(lambda *a: None) monkeypatch.setattr(f"{PATH}.task_status", fake_task_status) bootstrap_payload_path = f"{_PAYLOADS / 'ceremony.json'}" - args = ["--api-server", "http://localhost:80", bootstrap_payload_path] + test_context["settings"].SERVER = "http://127.0.0.1" + args = [bootstrap_payload_path] - result = invoke_command(bootstrap.bootstrap, [], args) + result = invoke_command(bootstrap.bootstrap, [], args, test_context) with open(_PAYLOADS / "ceremony.json") as f: expected_data = json.load(f) diff --git a/tests/unit/cli/admin/send/test_sign.py b/tests/unit/cli/admin/send/test_sign.py index 771af3a4..9923b4cf 100644 --- a/tests/unit/cli/admin/send/test_sign.py +++ b/tests/unit/cli/admin/send/test_sign.py @@ -10,16 +10,17 @@ class TestSendSign: - def test_sign(self, monkeypatch): + def test_sign(self, test_context, monkeypatch): fake_task_id = "task_id" fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) monkeypatch.setattr(f"{PATH}.send_payload", fake_send_payload) fake_task_status = pretend.call_recorder(lambda *a: None) monkeypatch.setattr(f"{PATH}.task_status", fake_task_status) sign_payload_path = f"{_PAYLOADS / 'sign.json'}" - args = ["--api-server", "http://localhost:80", sign_payload_path] + test_context["settings"].SERVER = "http://127.0.0.1" + args = [sign_payload_path] - result = invoke_command(sign.sign, [], args) + result = invoke_command(sign.sign, [], args, test_context) with open(_PAYLOADS / "sign.json") as f: expected_data = json.load(f) diff --git a/tests/unit/cli/admin/send/test_update.py b/tests/unit/cli/admin/send/test_update.py index b958c2a6..d76f89fb 100644 --- a/tests/unit/cli/admin/send/test_update.py +++ b/tests/unit/cli/admin/send/test_update.py @@ -10,16 +10,17 @@ class TestSendMdUpdate: - def test_update(self, monkeypatch): + def test_update(self, test_context, monkeypatch): fake_task_id = "task_id" fake_send_payload = pretend.call_recorder(lambda **kw: fake_task_id) monkeypatch.setattr(f"{PATH}.send_payload", fake_send_payload) fake_task_status = pretend.call_recorder(lambda *a: None) monkeypatch.setattr(f"{PATH}.task_status", fake_task_status) update_payload_path = f"{_PAYLOADS / 'update.json'}" - args = ["--api-server", "http://localhost:80", update_payload_path] + test_context["settings"].SERVER = "http://127.0.0.1" + args = [update_payload_path] - result = invoke_command(update.update, [], args) + result = invoke_command(update.update, [], args, test_context) with open(_PAYLOADS / "update.json") as f: expected_data = json.load(f)