Skip to content

Commit

Permalink
feat: endpoint for pushing a resource revision (#140)
Browse files Browse the repository at this point in the history
  • Loading branch information
lengau authored Dec 13, 2023
1 parent 5558716 commit 7deb4a7
Show file tree
Hide file tree
Showing 6 changed files with 361 additions and 6 deletions.
44 changes: 42 additions & 2 deletions craft_store/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import logging
from abc import ABCMeta, abstractmethod
from pathlib import Path
from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, cast
from typing import Any, Callable, Dict, List, Literal, Optional, Sequence, Union, cast
from urllib.parse import urlparse

import requests
Expand All @@ -34,6 +34,7 @@
from .models.resource_revision_model import (
CharmResourceRevision,
CharmResourceRevisionUpdateRequest,
CharmResourceType,
RequestCharmResourceBaseList,
)
from .models.revisions_model import RevisionModel
Expand Down Expand Up @@ -290,6 +291,45 @@ def notify_revision(

return models.revisions_model.RevisionsResponseModel.unmarshal(response)

def push_resource(
self,
name: str,
resource_name: str,
*,
upload_id: str,
resource_type: Optional[CharmResourceType] = None,
bases: Optional[RequestCharmResourceBaseList] = None,
) -> str:
"""Push a resource revision to the server.
:param name: the (snap, charm, etc.) name to attach the upload to
:param resource_name: The name of the resource.
:param upload_id: The ID of the upload (the output of :attr:`.upload`)
:param resource_type: If necessary for the namespace, the type of resource.
:param bases: A list of bases that this file supports.
:returns: The path and query string (as a single string) of the status URL.
API docs: http://api.staging.charmhub.io/docs/default.html#push_resource
The status URL returned is likely a pointer to ``list_upload_reviews``:
http://api.staging.charmhub.io/docs/default.html#list_upload_reviews
"""
endpoint = self._base_url + self._endpoints.get_resource_revisions_endpoint(
name, resource_name
)
request_model: Dict[str, Union[str, List[Dict[str, Any]]]] = {
"upload-id": upload_id,
}
if resource_type:
request_model["type"] = resource_type
if bases:
request_model["bases"] = [base.dict(skip_defaults=False) for base in bases]

response = self.request("POST", endpoint, json=request_model)
response_model = response.json()
return str(response_model["status-url"])

def list_revisions(self, name: str) -> List[RevisionModel]:
"""Get the list of existing revisions for a package.
Expand Down Expand Up @@ -340,7 +380,7 @@ def update_resource_revisions(
)
endpoint = f"/v1/{namespace}/{name}/resources/{resource_name}/revisions"

body = {"resource-revision-updates": [update.marshal() for update in updates]}
body = {"resource-revision-updates": [update.dict() for update in updates]}

response = self.request("PATCH", self._base_url + endpoint, json=body).json()

Expand Down
16 changes: 16 additions & 0 deletions craft_store/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,14 @@ def get_revisions_endpoint(self, name: str) -> str:
"""Return the slug to the revisions endpoint."""
return f"/v1/{self.namespace}/{name}/revisions"

def get_resources_endpoint(self, name: str) -> str:
"""Return the slug to the resourcess endpoint."""
return f"/v1/{self.namespace}/{name}/resources"

def get_resource_revisions_endpoint(self, name: str, resource_name: str) -> str:
"""Return the slug to the resource revisions endpoint."""
return self.get_resources_endpoint(name) + f"/{resource_name}/revisions"


@dataclasses.dataclass(repr=True)
class _SnapStoreEndpoints(Endpoints):
Expand Down Expand Up @@ -172,6 +180,14 @@ def get_releases_endpoint(self, name: str) -> str:
def get_revisions_endpoint(self, name: str) -> str:
raise NotImplementedError

@overrides
def get_resources_endpoint(self, name: str) -> str:
raise NotImplementedError

@overrides
def get_resource_revisions_endpoint(self, name: str, resource_name: str) -> str:
raise NotImplementedError


CHARMHUB: Final = Endpoints(
namespace="charm",
Expand Down
4 changes: 3 additions & 1 deletion craft_store/models/resource_revision_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ class CharmResourceRevision(MarshableModel):
updated_by: Optional[str] = None


class RequestCharmResourceBase(ResponseCharmResourceBase):
class RequestCharmResourceBase(MarshableModel):
"""A base for a charm resource for use in requests."""

name: str = "all"
channel: str = "all"
architectures: RequestArchitectureList = ["all"]


Expand Down
160 changes: 157 additions & 3 deletions tests/integration/charmhub/test_charmhub_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,28 +14,39 @@
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""Full workflow tests for charms."""
import datetime
import hashlib
import time

from craft_store.models import revisions_model
from craft_store.models.resource_revision_model import (
CharmResourceRevisionUpdateRequest,
CharmResourceType,
RequestCharmResourceBase,
ResponseCharmResourceBase,
)

from tests.integration.conftest import needs_charmhub_credentials


@needs_charmhub_credentials()
def test_full_charm_workflow(charm_client, charmhub_charm_name, fake_charms):
# This is intentionally long since it goes through a full workflow
def test_full_charm_workflow( # noqa: PLR0912, PLR0915
tmp_path, charm_client, charmhub_charm_name, fake_charms
):
"""A full workflow test for uploading a charm.
Steps include:
1. Check if charm name is registered.
1.1. Register if necessary.
2. Upload one or more charm revisions
Once we implement https://github.com/canonical/craft-store/issues/124:
3. Upload one or more OCI images (must already exist on disk)
3. Upload one or more OCI images (must already exist on disk) - TODO
4. Upload a fresh file
5. Upload an empty file
6. Modify the bases for a resource.
"""
# 1.
charmhub_charm_name += "-workflow"
registered_names = charm_client.list_registered_names(include_collaborations=True)
for name in registered_names:
if name.name == charmhub_charm_name:
Expand Down Expand Up @@ -81,3 +92,146 @@ def test_full_charm_workflow(charm_client, charmhub_charm_name, fake_charms):
raise AssertionError(
"One or more revisions did not get a number", revision_statuses
)

# 3. Rocks!
# TODO: This requires the docker registry and stuff

# Prep for file upload stuff
arch_dependent_base = RequestCharmResourceBase(architectures=["amd64"])
neutral_base = RequestCharmResourceBase(architectures=["all"])

# 4. Upload a fresh file.
fresh_file = tmp_path / "fresh_file"
file_contents = datetime.datetime.now(tz=datetime.timezone.utc).isoformat().encode()
fresh_file.write_bytes(file_contents)
file_upload_id = charm_client.upload_file(filepath=fresh_file)
file_status_url = charm_client.push_resource(
charmhub_charm_name,
"my-file",
upload_id=file_upload_id,
resource_type=CharmResourceType.FILE,
bases=[arch_dependent_base],
) # Response is a list_upload_reviews path + query parameter.
timeout = time.monotonic() + 120
while True:
file_status = charm_client.request(
"GET", charm_client._base_url + file_status_url
).json()["revisions"][0]
if file_status["status"] in ("approved", "rejected"):
break
if time.monotonic() > timeout:
raise TimeoutError(
"File upload was not approved or rejected after 120s", file_status
)
time.sleep(2)
assert file_status["revision"] is not None
file_revisions = charm_client.list_resource_revisions(
name=charmhub_charm_name, resource_name="my-file"
)
for file_revision in file_revisions:
if file_revision.revision == file_status["revision"]:
break
else:
raise ValueError("File revision from status URL does not appear in revisions.")

file_sha256 = hashlib.sha256(file_contents).hexdigest()
assert file_revision.size == len(
file_contents
), "Uploaded file size does not match file."
assert (
file_revision.sha256 == file_sha256
), "Uploaded file hash does not match file."
assert file_revision.bases == [arch_dependent_base]

# 5. Upload a zero-byte file (probably a repeat)
zero_byte_file = tmp_path / "zero_bytes"
zero_byte_file.touch()
file_upload_id = charm_client.upload_file(filepath=zero_byte_file)
file_status_url = charm_client.push_resource(
charmhub_charm_name,
"my-file",
upload_id=file_upload_id,
resource_type=CharmResourceType.FILE,
bases=[arch_dependent_base],
)
timeout = time.monotonic() + 120
while True:
file_status = charm_client.request(
"GET", charm_client._base_url + file_status_url
).json()["revisions"][0]
if file_status["status"] in ("approved", "rejected"):
break
if time.monotonic() > timeout:
raise TimeoutError(
"Zero-byte file upload was not approved or rejected after 120s",
file_status,
)
time.sleep(2)
assert file_status["revision"] is not None
file_revisions = charm_client.list_resource_revisions(
name=charmhub_charm_name, resource_name="my-file"
)
for zb_file_revision in file_revisions:
if zb_file_revision.revision == file_status["revision"]:
break
else:
raise ValueError(
"Zero-byte file revision from status URL does not appear in revisions."
)
assert zb_file_revision.size == 0
assert zb_file_revision.sha3_384 == hashlib.sha3_384(b"").hexdigest()
assert "amd64" in zb_file_revision.bases[0].architectures

# 6. Modify bases for the files.
assert (
charm_client.update_resource_revisions(
CharmResourceRevisionUpdateRequest(
revision=file_revision.revision,
bases=[arch_dependent_base, neutral_base],
),
CharmResourceRevisionUpdateRequest(
revision=zb_file_revision.revision,
bases=[arch_dependent_base, neutral_base],
),
name=charmhub_charm_name,
resource_name="my-file",
)
== 2
)
file_revisions = charm_client.list_resource_revisions(
name=charmhub_charm_name, resource_name="my-file"
)
new_zb_revision = None
new_revision = None
for rev in file_revisions:
if rev.revision == zb_file_revision.revision:
new_zb_revision = rev
if rev.revision == file_revision.revision:
new_revision = rev
if new_revision and new_zb_revision:
break
assert new_revision is not None, "File revision not found in resource revisions"
assert (
new_zb_revision is not None
), "Zero-byte file revision not found in resource revisions"
combined_base = ResponseCharmResourceBase(
name="all", channel="all", architectures=["amd64", "all"]
)
assert new_revision.bases == [combined_base]
assert new_zb_revision.bases == [combined_base]

charm_client.update_resource_revision(
charmhub_charm_name,
"my-file",
revision=zb_file_revision.revision,
bases=[neutral_base],
)
file_revisions = charm_client.list_resource_revisions(
name=charmhub_charm_name, resource_name="my-file"
)
for rev in file_revisions:
if rev.revision == zb_file_revision.revision:
break
else:
raise ValueError("No file with the appropriate revision found")
assert rev.bases == [neutral_base]
Loading

0 comments on commit 7deb4a7

Please sign in to comment.