Skip to content

Commit

Permalink
feat: build, sign and publish registries (#5072)
Browse files Browse the repository at this point in the history
Finalizes support for the `edit-registries` command by building, signing
and publishing registries.

Fixes #5018

Signed-off-by: Callahan Kovacs <[email protected]>
  • Loading branch information
mr-cal authored Oct 2, 2024
1 parent 0449661 commit 932f9e4
Show file tree
Hide file tree
Showing 11 changed files with 809 additions and 130 deletions.
6 changes: 6 additions & 0 deletions snapcraft/commands/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ class StoreEditRegistriesCommand(craft_application.commands.AppCommand):
If the registries set does not exist, then a new registries set will be created.
If a key name is not provided, the default key is used.
The account ID of the authenticated account can be determined with the
``snapcraft whoami`` command.
Expand All @@ -100,10 +102,14 @@ def fill_parser(self, parser: "argparse.ArgumentParser") -> None:
parser.add_argument(
"name", metavar="name", help="Name of the registries set to edit"
)
parser.add_argument(
"--key-name", metavar="key-name", help="Key used to sign the registries set"
)

@override
def run(self, parsed_args: "argparse.Namespace"):
self._services.registries.edit_assertion(
name=parsed_args.name,
account_id=parsed_args.account_id,
key_name=parsed_args.key_name,
)
7 changes: 7 additions & 0 deletions snapcraft/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,10 @@ def __init__(self, message: str, *, resolution: str) -> None:
resolution=resolution,
docs_url="https://snapcraft.io/docs/snapcraft-authentication",
)


class SnapcraftAssertionError(SnapcraftError):
"""Error raised when an assertion (validation or registries set) is invalid.
Not to be confused with Python's built-in AssertionError.
"""
38 changes: 36 additions & 2 deletions snapcraft/models/assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,44 @@

"""Assertion models."""

from typing import Literal
import numbers
from collections import abc
from typing import Any, Literal

import pydantic
from craft_application import models
from typing_extensions import Self


def cast_dict_scalars_to_strings(data: dict) -> dict:
"""Cast all scalars in a dictionary to strings.
Supported scalar types are str, bool, and numbers.
"""
return {_to_string(key): _to_string(value) for key, value in data.items()}


def _to_string(data: Any) -> Any:
"""Recurse through nested dicts and lists and cast scalar values to strings.
Supported scalar types are str, bool, and numbers.
"""
# check for a string first, as it is the most common scenario
if isinstance(data, str):
return data

if isinstance(data, abc.Mapping):
return {_to_string(key): _to_string(value) for key, value in data.items()}

if isinstance(data, abc.Collection):
return [_to_string(i) for i in data]

if isinstance(data, (numbers.Number, bool)):
return str(data)

return data


class Registry(models.CraftBaseModel):
"""Access and data definitions for a specific facet of a snap or system."""

Expand Down Expand Up @@ -52,7 +83,6 @@ class EditableRegistryAssertion(models.CraftBaseModel):
"""Issuer of the registry assertion and owner of the signing key."""

name: str
summary: str | None = None
revision: int | None = 0

views: dict[str, Rules]
Expand All @@ -61,6 +91,10 @@ class EditableRegistryAssertion(models.CraftBaseModel):
body: str | None = None
"""A JSON schema that defines the storage structure."""

def marshal_scalars_as_strings(self) -> dict[str, Any]:
"""Marshal the model where all scalars are represented as strings."""
return cast_dict_scalars_to_strings(self.marshal())


class RegistryAssertion(EditableRegistryAssertion):
"""A full registries assertion containing editable and non-editable fields."""
Expand Down
119 changes: 101 additions & 18 deletions snapcraft/services/assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from craft_application.errors import CraftValidationError
from craft_application.services import base
from craft_application.util import safe_yaml_load
from craft_store.errors import StoreServerError
from typing_extensions import override

from snapcraft import const, errors, models, store, utils
Expand Down Expand Up @@ -68,6 +69,24 @@ def _get_assertions(self, name: str | None = None) -> list[models.Assertion]:
:returns: A list of assertions.
"""

@abc.abstractmethod
def _build_assertion(self, assertion: models.EditableAssertion) -> models.Assertion:
"""Build an assertion from an editable assertion.
:param assertion: The editable assertion to build.
:returns: The built assertion.
"""

@abc.abstractmethod
def _post_assertion(self, assertion_data: bytes) -> models.Assertion:
"""Post an assertion to the store.
:param assertion_data: A signed assertion represented as bytes.
:returns: The published assertion.
"""

@abc.abstractmethod
def _normalize_assertions(
self, assertions: list[models.Assertion]
Expand Down Expand Up @@ -102,6 +121,15 @@ def _generate_yaml_from_template(self, name: str, account_id: str) -> str:
:returns: A multi-line yaml string.
"""

@abc.abstractmethod
def _get_success_message(self, assertion: models.Assertion) -> str:
"""Create a message after an assertion has been successfully posted.
:param assertion: The published assertion.
:returns: The success message to log.
"""

def list_assertions(self, *, output_format: str, name: str | None = None) -> None:
"""List assertions from the store.
Expand Down Expand Up @@ -150,6 +178,7 @@ def _edit_yaml_file(self, filepath: pathlib.Path) -> models.EditableAssertion:
:returns: The edited assertion.
"""
craft_cli.emit.progress(f"Editing {self._assertion_name}.")
while True:
craft_cli.emit.debug(f"Using {self._editor_cmd} to edit file.")
with craft_cli.emit.pause():
Expand All @@ -161,8 +190,9 @@ def _edit_yaml_file(self, filepath: pathlib.Path) -> models.EditableAssertion:
data=data,
# filepath is only shown for pydantic errors and snapcraft should
# not expose the temp file name
filepath=pathlib.Path(self._assertion_name.replace(" ", "-")),
filepath=pathlib.Path(self._assertion_name),
)
craft_cli.emit.progress(f"Edited {self._assertion_name}.")
return edited_assertion
except (yaml.YAMLError, CraftValidationError) as err:
craft_cli.emit.message(f"{err!s}")
Expand All @@ -178,12 +208,12 @@ def _get_yaml_data(self, name: str, account_id: str) -> str:

if assertions := self._get_assertions(name=name):
yaml_data = self._generate_yaml_from_model(assertions[0])
craft_cli.emit.progress(
f"Retrieved {self._assertion_name} '{name}' from the store.",
)
else:
craft_cli.emit.progress(
f"Creating a new {self._assertion_name} because no existing "
f"{self._assertion_name} named '{name}' was found for the "
"authenticated account.",
permanent=True,
f"Could not find an existing {self._assertion_name} named '{name}'.",
)
yaml_data = self._generate_yaml_from_template(
name=name, account_id=account_id
Expand All @@ -204,30 +234,83 @@ def _remove_temp_file(filepath: pathlib.Path) -> None:
craft_cli.emit.trace(f"Removing temporary file '{filepath}'.")
filepath.unlink()

def edit_assertion(self, *, name: str, account_id: str) -> None:
@staticmethod
def _sign_assertion(assertion: models.Assertion, key_name: str | None) -> bytes:
"""Sign an assertion with `snap sign`.
:param assertion: The assertion to sign.
:param key_name: Name of the key to sign the assertion.
:returns: A signed assertion represented as bytes.
"""
craft_cli.emit.progress("Signing assertion.")
cmdline = ["snap", "sign"]
if key_name:
cmdline += ["-k", key_name]

# snapd expects a json string where all scalars are strings
unsigned_assertion = json.dumps(assertion.marshal_scalars_as_strings())

try:
# pause the emitter for passphrase prompts
with craft_cli.emit.pause():
signed_assertion = subprocess.check_output(
cmdline, input=unsigned_assertion.encode()
)
except subprocess.CalledProcessError as sign_error:
raise errors.SnapcraftAssertionError(
"Failed to sign assertion"
) from sign_error

craft_cli.emit.progress("Signed assertion.")
craft_cli.emit.trace(f"Signed assertion: {signed_assertion.decode()}")
return signed_assertion

def edit_assertion(
self, *, name: str, account_id: str, key_name: str | None = None
) -> None:
"""Edit, sign and upload an assertion.
If the assertion does not exist, a new assertion is created from a template.
:param name: The name of the assertion to edit.
:param account_id: The account ID associated with the registries set.
:param key_name: Name of the key to sign the assertion.
"""
yaml_data = self._get_yaml_data(name=name, account_id=account_id)
yaml_file = self._write_to_file(yaml_data)
original_assertion = self._editable_assertion_class.unmarshal(
safe_yaml_load(io.StringIO(yaml_data))
)
edited_assertion = self._edit_yaml_file(yaml_file)

if edited_assertion == original_assertion:
craft_cli.emit.message("No changes made.")
try:
while True:
try:
edited_assertion = self._edit_yaml_file(yaml_file)
if edited_assertion == original_assertion:
craft_cli.emit.message("No changes made.")
break

craft_cli.emit.progress(f"Building {self._assertion_name}.")
built_assertion = self._build_assertion(edited_assertion)
craft_cli.emit.progress(f"Built {self._assertion_name}.")

signed_assertion = self._sign_assertion(built_assertion, key_name)
published_assertion = self._post_assertion(signed_assertion)
craft_cli.emit.message(
self._get_success_message(published_assertion)
)
break
except (
StoreServerError,
errors.SnapcraftAssertionError,
) as assertion_error:
craft_cli.emit.message(str(assertion_error))
if not utils.confirm_with_user(
f"Do you wish to amend the {self._assertion_name}?"
):
raise errors.SnapcraftError(
"operation aborted"
) from assertion_error
finally:
self._remove_temp_file(yaml_file)
return

# TODO: build, sign, and push assertion (#5018)

self._remove_temp_file(yaml_file)
craft_cli.emit.message(f"Successfully edited {self._assertion_name} {name!r}.")
raise errors.FeatureNotImplemented(
f"Building, signing and uploading {self._assertion_name} is not implemented.",
)
15 changes: 12 additions & 3 deletions snapcraft/services/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
"""\
account-id: {account_id}
name: {set_name}
# summary: {summary}
# The revision for this registries set
# revision: {revision}
{views}
Expand Down Expand Up @@ -85,6 +84,14 @@ def _editable_assertion_class(self) -> type[models.EditableAssertion]:
def _get_assertions(self, name: str | None = None) -> list[models.Assertion]:
return self._store_client.list_registries(name=name)

@override
def _build_assertion(self, assertion: models.EditableAssertion) -> models.Assertion:
return self._store_client.build_registries(registries=assertion)

@override
def _post_assertion(self, assertion_data: bytes) -> models.Assertion:
return self._store_client.post_registries(registries_data=assertion_data)

@override
def _normalize_assertions(
self, assertions: list[models.Assertion]
Expand All @@ -110,7 +117,6 @@ def _generate_yaml_from_model(self, assertion: models.Assertion) -> str:
{"views": assertion.marshal().get("views")}, default_flow_style=False
),
body=dump_yaml({"body": assertion.body}, default_flow_style=False),
summary=assertion.summary,
set_name=assertion.name,
revision=assertion.revision,
)
Expand All @@ -121,7 +127,10 @@ def _generate_yaml_from_template(self, name: str, account_id: str) -> str:
account_id=account_id,
views=_REGISTRY_SETS_VIEWS_TEMPLATE,
body=_REGISTRY_SETS_BODY_TEMPLATE,
summary="A brief summary of the registries set",
set_name=name,
revision=1,
)

@override
def _get_success_message(self, assertion: models.Assertion) -> str:
return f"Successfully created revision {assertion.revision!r} for {assertion.name!r}."
Loading

0 comments on commit 932f9e4

Please sign in to comment.