Skip to content

Commit b98459f

Browse files
authored
add support for delete tag and retry decorator for requests (#73)
* add support for delete tag and retry decorator for requests Signed-off-by: vsoch <vsoch@users.noreply.github.com>
1 parent 62cf1e3 commit b98459f

File tree

9 files changed

+181
-40
lines changed

9 files changed

+181
-40
lines changed

.github/workflows/auth-tests.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ jobs:
2323
registry_port: 5000
2424
with_auth: true
2525
REGISTRY_AUTH: "{htpasswd: {realm: localhost, path: /etc/docker/registry/auth.htpasswd}}"
26+
REGISTRY_STORAGE_DELETE_ENABLED: "true"
2627
run: |
2728
htpasswd -cB -b auth.htpasswd myuser mypass
2829
cp auth.htpasswd /etc/docker/registry/auth.htpasswd

.github/workflows/main.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ jobs:
3636
env:
3737
registry_host: localhost
3838
registry_port: ${{ job.services.registry.ports[5000] }}
39+
REGISTRY_STORAGE_DELETE_ENABLED: "true"
3940
run: |
4041
export PATH="/usr/share/miniconda/bin:$PATH"
4142
source activate oras

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and **Merged pull requests**. Critical items to know are:
1414
The versions coincide with releases on pip. Only major versions will be released as tags on Github.
1515

1616
## [0.0.x](https://github.com/oras-project/oras-py/tree/main) (0.0.x)
17+
- add support for tag deletion and retry decorators (0.1.16)
1718
- bugfix that pagination sets upper limit of 10K (0.1.15)
1819
- pagination for tags (and general function for pagination) (0.1.14)
1920
- expose upload_blob function to be consistent (0.1.13)

oras/client.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,25 @@ def get_tags(self, name: str, N=None) -> List[str]:
105105
"""
106106
return self.remote.get_tags(name, N=N)
107107

108+
def delete_tags(self, name: str, tags=Union[str, list]) -> List[str]:
109+
"""
110+
Delete one or more tags for a unique resource identifier.
111+
112+
Returns those successfully deleted.
113+
114+
:param name: container URI to parse
115+
:type name: str
116+
:param tags: single or multiple tags name to delete
117+
:type N: string or list
118+
"""
119+
if isinstance(tags, str):
120+
tags = [tags]
121+
deleted = []
122+
for tag in tags:
123+
if self.remote.delete_tag(name, tag):
124+
deleted.append(tag)
125+
return deleted
126+
108127
def push(self, *args, **kwargs):
109128
"""
110129
Push a container to the remote.

oras/container.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,17 @@ def tags_url(self, N=None) -> str:
5959
return f"{self.registry}/v2/{self.api_prefix}/tags/list"
6060
return f"{self.registry}/v2/{self.api_prefix}/tags/list?n={N}"
6161

62-
def put_manifest_url(self) -> str:
63-
return f"{self.registry}/v2/{self.api_prefix}/manifests/{self.tag}"
62+
def manifest_url(self, tag: Optional[str] = None) -> str:
63+
"""
64+
Get the manifest url for a specific tag, or the one for this container.
65+
66+
The tag provided can also correspond to a digest.
6467
65-
def get_manifest_url(self) -> str:
66-
return f"{self.registry}/v2/{self.api_prefix}/manifests/{self.tag}"
68+
:param tag: an optional tag to provide (if not provided defaults to container)
69+
:type tag: None or str
70+
"""
71+
tag = tag or self.tag
72+
return f"{self.registry}/v2/{self.api_prefix}/manifests/{tag}"
6773

6874
def __str__(self) -> str:
6975
return self.uri

oras/decorator.py

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
__copyright__ = "Copyright The ORAS Authors."
33
__license__ = "Apache-2.0"
44

5+
import time
56
from functools import partial, update_wrapper
67

8+
from oras.logger import logger
79

8-
class ensure_container:
10+
11+
class Decorator:
912
"""
10-
Ensure the first argument is a container, and not a string.
13+
Shared parent decorator class
1114
"""
1215

1316
def __init__(self, func):
@@ -17,10 +20,64 @@ def __init__(self, func):
1720
def __get__(self, obj, objtype):
1821
return partial(self.__call__, obj)
1922

23+
24+
class ensure_container(Decorator):
25+
"""
26+
Ensure the first argument is a container, and not a string.
27+
"""
28+
2029
def __call__(self, cls, *args, **kwargs):
2130
if "container" in kwargs:
2231
kwargs["container"] = cls.get_container(kwargs["container"])
2332
elif args:
2433
container = cls.get_container(args[0])
2534
args = (container, *args[1:])
2635
return self.func(cls, *args, **kwargs)
36+
37+
38+
class classretry(Decorator):
39+
"""
40+
Retry a function that is part of a class
41+
"""
42+
43+
def __init__(self, func, attempts=5, timeout=2):
44+
super().__init__(func)
45+
self.attempts = attempts
46+
self.timeout = timeout
47+
48+
def __call__(self, cls, *args, **kwargs):
49+
attempt = 0
50+
attempts = self.attempts
51+
timeout = self.timeout
52+
while attempt < attempts:
53+
try:
54+
return self.func(cls, *args, **kwargs)
55+
except Exception as e:
56+
sleep = timeout + 3**attempt
57+
logger.info(f"Retrying in {sleep} seconds - error: {e}")
58+
time.sleep(sleep)
59+
attempt += 1
60+
return self.func(cls, *args, **kwargs)
61+
62+
63+
def retry(attempts, timeout=2):
64+
"""
65+
A simple retry decorator
66+
"""
67+
68+
def decorator(func):
69+
def inner(*args, **kwargs):
70+
attempt = 0
71+
while attempt < attempts:
72+
try:
73+
return func(*args, **kwargs)
74+
except Exception as e:
75+
sleep = timeout + 3**attempt
76+
logger.info(f"Retrying in {sleep} seconds - error: {e}")
77+
time.sleep(sleep)
78+
attempt += 1
79+
return func(*args, **kwargs)
80+
81+
return inner
82+
83+
return decorator

oras/provider.py

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@
1212

1313
import oras.auth
1414
import oras.container
15+
import oras.decorator as decorator
1516
import oras.oci
1617
import oras.schemas
1718
import oras.utils
18-
from oras.decorator import ensure_container
1919
from oras.logger import logger
2020

21+
# container type can be string or container
22+
container_type = Union[str, oras.container.Container]
23+
2124

2225
class Registry:
2326
"""
@@ -66,12 +69,8 @@ def logout(self, hostname: str):
6669
return
6770
logger.info(f"You are not logged in to {hostname}")
6871

69-
@ensure_container
70-
def load_configs(
71-
self,
72-
container: Union[str, oras.container.Container],
73-
configs: Optional[list] = None,
74-
):
72+
@decorator.ensure_container
73+
def load_configs(self, container: container_type, configs: Optional[list] = None):
7574
"""
7675
Load configs to discover credentials for a specific container.
7776
@@ -185,7 +184,7 @@ def _parse_manifest_ref(self, ref: str) -> Union[Tuple[str, str], List[str]]:
185184
def upload_blob(
186185
self,
187186
blob: str,
188-
container: Union[str, oras.container.Container],
187+
container: container_type,
189188
layer: dict,
190189
do_chunked: bool = False,
191190
) -> requests.Response:
@@ -225,10 +224,42 @@ def upload_blob(
225224
response.status_code = 200
226225
return response
227226

228-
@ensure_container
229-
def get_tags(
230-
self, container: Union[str, oras.container.Container], N=None
231-
) -> List[str]:
227+
@decorator.ensure_container
228+
def delete_tag(self, container: container_type, tag: str) -> bool:
229+
"""
230+
Delete a tag for a container.
231+
232+
:param container: parsed container URI
233+
:type container: oras.container.Container or str
234+
:param tag: name of tag to delete
235+
:type tag: str
236+
"""
237+
logger.debug(f"Deleting tag {tag} for {container}")
238+
239+
head_url = f"{self.prefix}://{container.manifest_url(tag)}" # type: ignore
240+
241+
# get digest of manifest to delete
242+
response = self.do_request(
243+
head_url,
244+
"HEAD",
245+
headers={"Accept": "application/vnd.oci.image.manifest.v1+json"},
246+
)
247+
if response.status_code == 404:
248+
logger.error(f"Cannot find tag {container}:{tag}")
249+
return False
250+
251+
digest = response.headers.get("Docker-Content-Digest")
252+
if not digest:
253+
raise RuntimeError("Expected to find Docker-Content-Digest header.")
254+
255+
delete_url = f"{self.prefix}://{container.manifest_url(digest)}" # type: ignore
256+
response = self.do_request(delete_url, "DELETE")
257+
if response.status_code != 202:
258+
raise RuntimeError("Delete was not successful: {response.json()}")
259+
return True
260+
261+
@decorator.ensure_container
262+
def get_tags(self, container: container_type, N=None) -> List[str]:
232263
"""
233264
Retrieve tags for a package.
234265
@@ -291,10 +322,10 @@ def _do_paginated_request(
291322
# use link + base url to continue with next page
292323
url = f"{base_url}{link}"
293324

294-
@ensure_container
325+
@decorator.ensure_container
295326
def get_blob(
296327
self,
297-
container: Union[str, oras.container.Container],
328+
container: container_type,
298329
digest: str,
299330
stream: bool = False,
300331
head: bool = False,
@@ -315,9 +346,7 @@ def get_blob(
315346
blob_url = f"{self.prefix}://{container.get_blob_url(digest)}" # type: ignore
316347
return self.do_request(blob_url, method, headers=self.headers, stream=stream)
317348

318-
def get_container(
319-
self, name: Union[str, oras.container.Container]
320-
) -> oras.container.Container:
349+
def get_container(self, name: container_type) -> oras.container.Container:
321350
"""
322351
Courtesy function to get a container from a URI.
323352
@@ -329,9 +358,9 @@ def get_container(
329358
return oras.container.Container(name, registry=self.hostname)
330359

331360
# Functions to be deprecated in favor of exposed ones
332-
@ensure_container
361+
@decorator.ensure_container
333362
def _download_blob(
334-
self, container: Union[str, oras.container.Container], digest: str, outfile: str
363+
self, container: container_type, digest: str, outfile: str
335364
) -> str:
336365
logger.warning(
337366
"This function is deprecated in favor of download_blob and will be removed by 0.1.2"
@@ -365,7 +394,7 @@ def _upload_manifest(
365394
def _upload_blob(
366395
self,
367396
blob: str,
368-
container: Union[str, oras.container.Container],
397+
container: container_type,
369398
layer: dict,
370399
do_chunked: bool = False,
371400
) -> requests.Response:
@@ -374,9 +403,9 @@ def _upload_blob(
374403
)
375404
return self.upload_blob(blob, container, layer, do_chunked)
376405

377-
@ensure_container
406+
@decorator.ensure_container
378407
def download_blob(
379-
self, container: Union[str, oras.container.Container], digest: str, outfile: str
408+
self, container: container_type, digest: str, outfile: str
380409
) -> str:
381410
"""
382411
Stream download a blob into an output file.
@@ -563,8 +592,12 @@ def upload_manifest(
563592
"Content-Type": oras.defaults.default_manifest_media_type,
564593
"Content-Length": str(len(manifest)),
565594
}
566-
put_url = f"{self.prefix}://{container.put_manifest_url()}"
567-
return self.do_request(put_url, "PUT", headers=headers, json=manifest)
595+
return self.do_request(
596+
f"{self.prefix}://{container.manifest_url()}", # noqa
597+
"PUT",
598+
headers=headers,
599+
json=manifest,
600+
)
568601

569602
def push(self, *args, **kwargs) -> requests.Response:
570603
"""
@@ -740,11 +773,9 @@ def pull(self, *args, **kwargs) -> List[str]:
740773
files.append(outfile)
741774
return files
742775

743-
@ensure_container
776+
@decorator.ensure_container
744777
def get_manifest(
745-
self,
746-
container: Union[str, oras.container.Container],
747-
allowed_media_type: list = None,
778+
self, container: container_type, allowed_media_type: list = None
748779
) -> dict:
749780
"""
750781
Retrieve a manifest for a package.
@@ -757,13 +788,15 @@ def get_manifest(
757788
if not allowed_media_type:
758789
allowed_media_type = [oras.defaults.default_manifest_media_type]
759790
headers = {"Accept": ";".join(allowed_media_type)}
760-
url = f"{self.prefix}://{container.get_manifest_url()}" # type: ignore
761-
response = self.do_request(url, "GET", headers=headers)
791+
792+
get_manifest = f"{self.prefix}://{container.manifest_url()}" # type: ignore
793+
response = self.do_request(get_manifest, "GET", headers=headers)
762794
self._check_200_response(response)
763795
manifest = response.json()
764796
jsonschema.validate(manifest, schema=oras.schemas.manifest)
765797
return manifest
766798

799+
@decorator.classretry
767800
def do_request(
768801
self,
769802
url: str,

oras/tests/run_registry.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/bin/bash
2+
3+
# A helper script to easily run a development, local registry
4+
docker run -it -e REGISTRY_STORAGE_DELETE_ENABLED=true --rm -p 5000:5000 ghcr.io/oras-project/registry:latest

oras/tests/test_oras.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,6 @@ def test_basic_push_pull(tmp_path):
7070
res = client.push(files=[artifact], target=target)
7171
assert res.status_code in [200, 201]
7272

73-
# Test getting tags
74-
tags = client.get_tags(target)
75-
assert "v1" in tags
76-
7773
# Test pulling elsewhere
7874
files = client.pull(target=target, outdir=tmp_path)
7975
assert len(files) == 1
@@ -92,6 +88,29 @@ def test_basic_push_pull(tmp_path):
9288
assert res.status_code == 201
9389

9490

91+
@pytest.mark.skipif(with_auth, reason="token auth is needed for push and pull")
92+
def test_get_delete_tags(tmp_path):
93+
"""
94+
Test creationg, getting, and deleting tags.
95+
"""
96+
client = oras.client.OrasClient(hostname=registry, insecure=True)
97+
artifact = os.path.join(here, "artifact.txt")
98+
assert os.path.exists(artifact)
99+
100+
res = client.push(files=[artifact], target=target)
101+
assert res.status_code in [200, 201]
102+
103+
# Test getting tags
104+
tags = client.get_tags(target)
105+
assert "v1" in tags
106+
107+
# Test deleting not-existence tag
108+
assert not client.delete_tags(target, "v1-boop-boop")
109+
assert "v1" in client.delete_tags(target, "v1")
110+
tags = client.get_tags(target)
111+
assert not tags
112+
113+
95114
def test_get_many_tags():
96115
"""
97116
Test getting many tags

0 commit comments

Comments
 (0)