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

feat: build, sign and publish registries #5072

Merged
merged 6 commits into from
Oct 2, 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
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
Loading