Skip to content

Commit

Permalink
feat: build, sign and publish registries
Browse files Browse the repository at this point in the history
Signed-off-by: Callahan Kovacs <[email protected]>
  • Loading branch information
mr-cal committed Sep 27, 2024
1 parent 5ea2c39 commit a161e44
Show file tree
Hide file tree
Showing 11 changed files with 792 additions and 128 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.
"""
37 changes: 35 additions & 2 deletions snapcraft/models/assertions.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,43 @@

"""Assertion models."""

from typing import Literal
import numbers
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, dict):
return {_to_string(key): _to_string(value) for key, value in data.items()}

if isinstance(data, list):
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 +82,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 +90,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
114 changes: 97 additions & 17 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 @@ -150,6 +169,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,7 +181,10 @@ 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}.", permanent=True
)
return edited_assertion
except (yaml.YAMLError, CraftValidationError) as err:
Expand All @@ -178,11 +201,13 @@ 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.",
permanent=True,
)
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.",
f"Could not find an existing {self._assertion_name} named '{name}'.",
permanent=True,
)
yaml_data = self._generate_yaml_from_template(
Expand All @@ -204,30 +229,85 @@ 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())

with craft_cli.emit.pause():
snap_sign = subprocess.Popen(
cmdline, stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
signed_assertion, _ = snap_sign.communicate(
input=unsigned_assertion.encode()
)

if snap_sign.returncode != 0:
raise errors.SnapcraftAssertionError("failed to sign assertion")

craft_cli.emit.progress("Signed assertion.", permanent=True)
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.")
self._remove_temp_file(yaml_file)
return

# TODO: build, sign, and push assertion (#5018)
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}", permanent=True
)

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.",
)
signed_assertion = self._sign_assertion(built_assertion, key_name)
self._post_assertion(signed_assertion)
craft_cli.emit.message(
f"Successfully edited {self._assertion_name} {name!r}."
)
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)
11 changes: 8 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,6 @@ 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,
)
Loading

0 comments on commit a161e44

Please sign in to comment.