Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add '--dry-run' option for 'ceremony' and 'sign' commands #630

Merged
merged 3 commits into from
Jun 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions docs/source/guide/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -129,12 +129,19 @@ You can do the Ceremony offline. This means on a disconnected computer

Usage: rstuf admin ceremony [OPTIONS]

Bootstrap Ceremony to create initial root metadata and RSTUF config.
Perform ceremony and send result to API to trigger bootstrap.
* If `--out [FILENAME]` is passed, result is written to local FILENAME
(in addition to being sent to API).

╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────╮
│ --out FILENAME Write output json result to FILENAME (default: 'ceremony-payload.json') │
│ --help -h Show this message and exit. │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────╯
* If `--dry-run` is passed, result is not sent to API.
You can still pass `--out [FILENAME]` to store the result locally.
The `--api-server` admin option and `SERVER` from config will be ignored.

╭─ Options ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ --out FILENAME Write output json result to FILENAME (default: 'ceremony-payload.json') │
│ --dry-run Run ceremony in dry-run mode without sending result to API. Ignores options and configurations related to API. │
│ --help -h Show this message and exit. │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

There are four steps in the ceremony.

Expand Down Expand Up @@ -180,13 +187,18 @@ sign (``sign``)

Usage: rstuf admin metadata sign [OPTIONS]

Add one signature to root metadata.

╭─ 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. │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
Perform sign for pending event and send result to API.
* If `--in FILENAME` is passed, input is not read from API but from local FILENAME.
* If `--out [FILENAME]` is passed, result is written to local FILENAME (in addition to being sent to API).
* If `--dry-run` is passed, result is not sent to API. You can still pass `--out [FILENAME]` to store the result locally.
* If `--in` and `--dry-run` is passed, `--api-server` admin option and `SERVER` from config will be ignored.

╭─ 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') │
│ --dry-run Run sign in dry-run mode without sending result to API. Ignores options and configurations related to API. │
│ --help -h Show this message and exit. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

.. rstuf-cli-admin-send

Expand Down
31 changes: 26 additions & 5 deletions repository_service_tuf/cli/admin/ceremony.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,37 @@
help=f"Write output json result to FILENAME (default: '{DEFAULT_PATH}')",
type=click.File("w"),
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help=(
"Run ceremony in dry-run mode without sending result to API. "
"Ignores options and configurations related to API."
),
)
@click.pass_context
def ceremony(context: Any, out: Optional[click.File]) -> None:
"""Bootstrap Ceremony to create initial root metadata and RSTUF config."""
def ceremony(context: Any, out: Optional[click.File], dry_run: bool) -> None:
"""
Perform ceremony and send result to API to trigger bootstrap.

\b
* If `--out [FILENAME]` is passed, result is written to local FILENAME
(in addition to being sent to API).

\b
* If `--dry-run` is passed, result is not sent to API.
You can still pass `--out [FILENAME]` to store the result locally.
The `--api-server` admin option and `SERVER` from config will be ignored.
"""
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 out:
if not settings.get("SERVER") and not dry_run:
raise click.ClickException(
"Either '--api-sever'/'SERVER' in RSTUF config or '--out' needed"
"Either '--api-sever' admin option/'SERVER' in RSTUF config or "
"'--dry-run' needed"
)

# Performs ceremony steps.
Expand Down Expand Up @@ -114,7 +135,7 @@ def ceremony(context: Any, out: Optional[click.File]) -> None:
json.dump(asdict(bootstrap_payload), out, indent=2) # type: ignore
console.print(f"Saved result to '{out.name}'")

if settings.get("SERVER"):
if settings.get("SERVER") and not dry_run:
task_id = send_payload(
settings=settings,
url=URL.BOOTSTRAP.value,
Expand Down
40 changes: 37 additions & 3 deletions repository_service_tuf/cli/admin/sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,52 @@ def _get_pending_roles(settings: Any) -> Dict[str, Dict[str, Any]]:
type=click.File("w"),
required=False,
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help=(
"Run sign in dry-run mode without sending result to API. "
"Ignores options and configurations related to API."
),
)
@click.pass_context
def sign(
context: click.Context,
input: Optional[click.File],
out: Optional[click.File],
dry_run: bool,
) -> None:
"""Add one signature to root metadata."""
"""
Perform sign for pending event and send result to API.

* If `--in FILENAME` is passed, input is not read from API but from local
FILENAME.

* If `--out [FILENAME]` is passed, result is written to local FILENAME
(in addition to being sent to API).

* If `--dry-run` is passed, result is not sent to API.
You can still pass `--out [FILENAME]` to store the result locally.

* If `--in` and `--dry-run` is passed, `--api-server` admin option and
`SERVER` from config will be ignored.
"""
console.print("\n", Markdown("# Metadata Signing Tool"))
settings = context.obj["settings"]
# Make sure there is a way to get a DAS metadata for signing.
if settings.get("SERVER") is None and input is None:
raise click.ClickException(
"Either '--api-sever'/'SERVER' in RSTUF config or '--in' needed"
"Either '--api-sever' admin option/'SERVER' in RSTUF config or "
"'--in' needed"
)

# Make sure user understands that result will be send to the API and if the
# the user wants something else should use '--dry-run'.
if settings.get("SERVER") is None and not dry_run:
raise click.ClickException(
"Either '--api-sever' admin option/'SERVER' in RSTUF config or "
"'--dry-run' needed"
)
###########################################################################
# Load roots
Expand Down Expand Up @@ -150,7 +184,7 @@ def sign(
json.dump(asdict(payload), out, indent=2) # type: ignore
console.print(f"Saved result to '{out.name}'")

if settings.get("SERVER"):
if settings.get("SERVER") and not dry_run:
console.print(f"\nSending signature to {settings.SERVER}")
task_id = send_payload(
settings,
Expand Down
75 changes: 55 additions & 20 deletions tests/unit/cli/admin/test_ceremony.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


class TestCeremony:
def test_ceremony_with_custom_out(
def test_ceremony_with_dry_run_and_custom_out(
self,
monkeypatch,
ceremony_inputs,
Expand All @@ -17,7 +17,10 @@ def test_ceremony_with_custom_out(
patch_getpass,
patch_utcnow,
):

"""
Test that '--dry-run' and '--out' are compatible without connecting to
the API.
"""
# public keys and signing keys selection options
monkeypatch.setattr(f"{_HELPERS}._select", key_selection)

Expand All @@ -26,7 +29,7 @@ def test_ceremony_with_custom_out(
result = invoke_command(
ceremony.ceremony,
input_step1 + input_step2 + input_step3 + input_step4,
args=["--out", custom_path],
args=["--dry-run", "--out", custom_path],
)

with open(_PAYLOADS / "ceremony.json") as f:
Expand All @@ -37,6 +40,8 @@ def test_ceremony_with_custom_out(

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
assert "Bootstrap completed." not in result.stdout

def test_ceremony_threshold_less_than_2(
self,
Expand Down Expand Up @@ -65,7 +70,7 @@ def test_ceremony_threshold_less_than_2(
result = invoke_command(
ceremony.ceremony,
input_step1 + input_step2 + input_step3 + input_step4,
[],
["--dry-run"],
)

with open(_PAYLOADS / "ceremony.json") as f:
Expand Down Expand Up @@ -103,7 +108,7 @@ def test_ceremony__non_positive_expiration(
result = invoke_command(
ceremony.ceremony,
input_step1 + input_step2 + input_step3 + input_step4,
[],
["--dry-run"],
)

with open(_PAYLOADS / "ceremony.json") as f:
Expand Down Expand Up @@ -132,6 +137,7 @@ def test_ceremony_api_server(
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"
# public keys and signing keys selection options
monkeypatch.setattr(f"{_HELPERS}._select", key_selection)

result = invoke_command(
Expand Down Expand Up @@ -186,6 +192,7 @@ 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"
# public keys and signing keys selection options
monkeypatch.setattr(f"{_HELPERS}._select", key_selection)

result = invoke_command(
Expand Down Expand Up @@ -246,7 +253,7 @@ def test_ceremony_online_key_one_of_root_keys(
result = invoke_command(
ceremony.ceremony,
input_step1 + input_step2 + input_step3 + input_step4,
[],
["--dry-run"],
)

with open(_PAYLOADS / "ceremony.json") as f:
Expand All @@ -259,20 +266,48 @@ def test_ceremony_online_key_one_of_root_keys(
assert result.data == expected
assert "Key already in use." in result.stdout


class TestCeremonyError:
def test_ceremony_no_api_server_and_no_output_option(
self, client, test_context, ceremony_inputs
def test_ceremony_dry_run_with_server_config_set(
self,
monkeypatch,
ceremony_inputs,
key_selection,
client,
test_context,
patch_getpass,
patch_utcnow,
):
"""
Test that '--dry-run' is with higher priority than 'settings.SERVER'.
"""
# public keys and signing keys selection options
monkeypatch.setattr(f"{_HELPERS}._select", key_selection)
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,
)
test_context["settings"].SERVER = "http://localhost:80"
# We want to test when only "--dry-run" is used we will not save a file
# locally and will not send payload to the API.
# Given that "invoke_command" always saves a file, so the result can be
# read we cannot use it.
with client.isolated_filesystem():
result = client.invoke(
ceremony.ceremony,
args=["--dry-run"],
input="\n".join(
input_step1 + input_step2 + input_step3 + input_step4
),
obj=test_context,
catch_exceptions=False,
)

assert result.stderr == ""
assert "Saved result to " not in result.stdout
assert "Bootstrap completed." not in result.stdout


class TestCeremonyError:
def test_ceremony_no_api_server_and_no_dry_run_option(self):
result = invoke_command(ceremony.ceremony, [], [], std_err_empty=False)

assert "Either '--api-sever'/'SERVER'" in result.stderr
err_prefix = "Either '--api-sever' admin option/'SERVER'"
err_suffix = "or '--dry-run'"
assert err_prefix in result.stderr
assert err_suffix in result.stderr
Loading
Loading