Skip to content

Commit 3c3e366

Browse files
authored
allow adding additional tags to OCI index and manifests (#111)
* add a retry_on_error decoration for gardenlinux.oci * add make targets test-debug and test-trace adds the possibility to get debug logs for pytest e.g. debug which OCI tags exist * generate test data in a pythonic way add create_test_data to conftest remove generate_test_certificates from helper as it is on conftest already * add gardenlinux.oci.registry.push_additional_tags_manifest enables pushing additional tags to OCI manifest * update gardenlinux.oci.registry.update_index enables pushing additional tags to OCI index * rename src/gardenlinux/oci/helper.py src/gardenlinux/oci/wrapper.py
1 parent fb6cde7 commit 3c3e366

File tree

12 files changed

+1135
-687
lines changed

12 files changed

+1135
-687
lines changed

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ install-test: install-dev
4646
test: install-test
4747
$(POETRY) run pytest -k "not kms"
4848

49+
test-debug: install-test
50+
$(POETRY) run pytest -k "not kms" -vvv -s
51+
52+
test-trace: install-test
53+
$(POETRY) run pytest -k "not kms" -vvv --log-cli-level=DEBUG
54+
4955
format: install-dev
5056
$(POETRY) run black --extend-exclude test-data/gardenlinux .
5157

poetry.lock

Lines changed: 426 additions & 413 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ readme = "README.md"
88
packages = [{include = "gardenlinux", from="src"}, {include = "python_gardenlinux_lib", from="src"}]
99

1010
[tool.poetry.dependencies]
11-
python = "^3.10"
11+
python = "^3.13"
1212
networkx = "^3.3"
1313
PyYAML = "^6.0.2"
1414
pytest = "^8.3.2"
@@ -19,6 +19,9 @@ oras = { git = "https://github.com/oras-project/oras-py.git", rev="caf8db5b2793
1919
python-dotenv = "^1.0.1"
2020
cryptography = "^44.0.0"
2121
boto3 = "*"
22+
click = "^8.2.0"
23+
pygments = "^2.19.1"
24+
opencontainers = "^0.0.14"
2225

2326
[tool.poetry.group.dev.dependencies]
2427
bandit = "^1.8.3"

src/gardenlinux/oci/__main__.py

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,6 @@ def cli():
2626
type=click.Path(),
2727
help="Version of image",
2828
)
29-
@click.option(
30-
"--commit",
31-
required=False,
32-
type=click.Path(),
33-
default=None,
34-
help="Commit of image",
35-
)
3629
@click.option(
3730
"--arch",
3831
required=True,
@@ -58,16 +51,22 @@ def cli():
5851
default=False,
5952
help="Use HTTP to communicate with the registry",
6053
)
54+
@click.option(
55+
"--additional_tag",
56+
required=False,
57+
multiple=True,
58+
help="Additional tag to push the manifest with",
59+
)
6160
def push_manifest(
6261
container,
6362
version,
64-
commit,
6563
arch,
6664
cname,
6765
directory,
6866
cosign_file,
6967
manifest_file,
7068
insecure,
69+
additional_tag,
7170
):
7271
"""push artifacts from a dir to a registry, get the index-entry for the manifest in return"""
7372
container_name = f"{container}:{version}"
@@ -77,7 +76,7 @@ def push_manifest(
7776
insecure=insecure,
7877
)
7978
digest = registry.push_from_dir(
80-
arch, version, cname, directory, manifest_file, commit=commit
79+
arch, version, cname, directory, manifest_file, additional_tag
8180
)
8281
if cosign_file:
8382
print(digest, file=open(cosign_file, "w"))
@@ -108,15 +107,21 @@ def push_manifest(
108107
default=False,
109108
help="Use HTTP to communicate with the registry",
110109
)
111-
def update_index(container, version, manifest_folder, insecure):
110+
@click.option(
111+
"--additional_tag",
112+
required=False,
113+
multiple=True,
114+
help="Additional tag to push the index with",
115+
)
116+
def update_index(container, version, manifest_folder, insecure, additional_tag):
112117
"""push a index entry from a list of files to an index"""
113118
container_name = f"{container}:{version}"
114119
registry = GlociRegistry(
115120
container_name=container_name,
116121
token=os.getenv("GL_CLI_REGISTRY_TOKEN"),
117122
insecure=insecure,
118123
)
119-
registry.update_index(manifest_folder)
124+
registry.update_index(manifest_folder, additional_tag)
120125

121126

122127
def main():

src/gardenlinux/oci/registry.py

Lines changed: 180 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# -*- coding: utf-8 -*-
22

33
import base64
4+
import configparser
45
import copy
56
import hashlib
67
import json
@@ -30,11 +31,11 @@
3031

3132
from ..constants import OCI_ANNOTATION_SIGNATURE_KEY, OCI_ANNOTATION_SIGNED_STRING_KEY
3233
from ..features import CName
33-
3434
from .checksum import (
3535
calculate_sha256,
3636
verify_sha256,
3737
)
38+
from .wrapper import retry_on_error
3839
from python_gardenlinux_lib.features.parse_features import get_oci_metadata_from_fileset
3940
from .schemas import (
4041
EmptyIndex,
@@ -599,9 +600,12 @@ def push_image_manifest(
599600

600601
return local_digest
601602

602-
def update_index(self, manifest_folder):
603+
def update_index(self, manifest_folder, additional_tags: list = None):
603604
"""
604605
replaces an old manifest entry with a new manifest entry
606+
607+
:param str manifest_folder: the folder where the manifest entries are read from
608+
:param list additional_tags: the additional tags to push the index with
605609
"""
606610
index = self.get_index()
607611
# Ensure mediaType is set for existing indices
@@ -634,6 +638,12 @@ def update_index(self, manifest_folder):
634638
self._check_200_response(self.upload_index(index))
635639
logger.info(f"Index pushed with {new_entries} new entries")
636640

641+
for tag in additional_tags:
642+
self.container.digest = None
643+
self.container.tag = tag
644+
self.upload_index(index)
645+
logger.info(f"Index pushed with additional tag {tag}")
646+
637647
def create_layer(
638648
self,
639649
file_path: str,
@@ -649,45 +659,96 @@ def create_layer(
649659
}
650660
return layer
651661

662+
@retry_on_error(max_retries=3, initial_delay=2, backoff_factor=2)
663+
def upload_blob(self, file_path, container, metadata=None):
664+
"""
665+
Upload a blob to the registry with retry logic for network errors.
666+
667+
Args:
668+
file_path: Path to the file to upload
669+
container: Container object
670+
metadata: Optional metadata for the blob
671+
672+
Returns:
673+
Response from the upload
674+
"""
675+
# Call the parent class's upload_blob method
676+
return super().upload_blob(file_path, container, metadata)
677+
652678
def push_from_dir(
653679
self,
654680
architecture: str,
655681
version: str,
656682
cname: str,
657683
directory: str,
658684
manifest_file: str,
659-
commit: Optional[str] = None,
685+
additional_tags: list = None,
660686
):
661-
# Step 1 scan and extract nested artifacts:
662-
for file in os.listdir(directory):
663-
try:
664-
if file.endswith(".pxe.tar.gz"):
665-
logger.info(f"Found nested artifact {file}")
666-
nested_tar_obj = tarfile.open(f"{directory}/{file}")
667-
nested_tar_obj.extractall(filter="data", path=directory)
668-
nested_tar_obj.close()
669-
except (OSError, tarfile.FilterError, tarfile.TarError) as e:
670-
print(f"Failed to extract nested artifact {file}", e)
671-
exit(1)
687+
"""
688+
Push artifacts from a directory to a registry
689+
690+
Args:
691+
architecture: Target architecture of the image
692+
version: Version tag for the image
693+
cname: Canonical name of the image
694+
directory: Directory containing the artifacts
695+
manifest_file: File to write the manifest index entry to
696+
additional_tags: Additional tags to push the manifest with
697+
698+
Returns:
699+
The digest of the pushed manifest
700+
"""
701+
if additional_tags is None:
702+
additional_tags = []
672703

673704
try:
705+
# scan and extract nested artifacts
706+
for file in os.listdir(directory):
707+
try:
708+
if file.endswith(".pxe.tar.gz"):
709+
logger.info(f"Found nested artifact {file}")
710+
nested_tar_obj = tarfile.open(f"{directory}/{file}")
711+
nested_tar_obj.extractall(filter="data", path=directory)
712+
nested_tar_obj.close()
713+
except (OSError, tarfile.FilterError, tarfile.TarError) as e:
714+
print(f"Failed to extract nested artifact {file}", e)
715+
exit(1)
716+
717+
# Get metadata from files
674718
oci_metadata = get_oci_metadata_from_fileset(
675719
os.listdir(directory), architecture
676720
)
677721

678722
features = ""
723+
commit = ""
679724
for artifact in oci_metadata:
680725
if artifact["media_type"] == "application/io.gardenlinux.release":
681-
file = open(f"{directory}/{artifact["file_name"]}", "r")
682-
lines = file.readlines()
683-
for line in lines:
684-
if line.strip().startswith("GARDENLINUX_FEATURES="):
685-
features = line.strip().removeprefix(
686-
"GARDENLINUX_FEATURES="
726+
try:
727+
file_path = f"{directory}/{artifact['file_name']}"
728+
729+
config = configparser.ConfigParser(allow_unnamed_section=True)
730+
config.read(file_path)
731+
732+
if config.has_option(
733+
configparser.UNNAMED_SECTION, "GARDENLINUX_FEATURES"
734+
):
735+
features = config.get(
736+
configparser.UNNAMED_SECTION, "GARDENLINUX_FEATURES"
737+
)
738+
if config.has_option(
739+
configparser.UNNAMED_SECTION, "GARDENLINUX_COMMIT_ID"
740+
):
741+
commit = config.get(
742+
configparser.UNNAMED_SECTION, "GARDENLINUX_COMMIT_ID"
687743
)
688-
break
689-
file.close()
690744

745+
except (configparser.Error, IOError) as e:
746+
logger.error(
747+
f"Error reading config file {artifact['file_name']}: {e}"
748+
)
749+
break
750+
751+
# Push the image manifest
691752
digest = self.push_image_manifest(
692753
architecture,
693754
cname,
@@ -698,7 +759,103 @@ def push_from_dir(
698759
manifest_file,
699760
commit=commit,
700761
)
762+
763+
# Process additional tags if provided
764+
if additional_tags and len(additional_tags) > 0:
765+
print(f"DEBUG: Processing {len(additional_tags)} additional tags")
766+
logger.info(f"Processing {len(additional_tags)} additional tags")
767+
768+
self.push_additional_tags_manifest(
769+
architecture,
770+
cname,
771+
version,
772+
additional_tags,
773+
container=self.container,
774+
)
775+
776+
return digest
701777
except Exception as e:
702778
print("Error: ", e)
703779
exit(1)
704-
return digest
780+
781+
def push_additional_tags_manifest(
782+
self, architecture, cname, version, additional_tags, container
783+
):
784+
"""
785+
Push additional tags for an existing manifest using ORAS Registry methods
786+
787+
Args:
788+
architecture: Target architecture of the image
789+
cname: Canonical name of the image
790+
version: Version tag for the image
791+
additional_tags: List of additional tags to push
792+
container: Container object
793+
"""
794+
try:
795+
# Source tag is the tag containing the version-cname-architecture combination
796+
source_tag = f"{version}-{cname}-{architecture}"
797+
source_container = copy.deepcopy(container)
798+
source_container.tag = source_tag
799+
800+
# Authentication credentials from environment
801+
token = os.getenv("GL_CLI_REGISTRY_TOKEN")
802+
username = os.getenv("GL_CLI_REGISTRY_USERNAME")
803+
password = os.getenv("GL_CLI_REGISTRY_PASSWORD")
804+
805+
# Login to registry if credentials are provided
806+
if username and password:
807+
logger.debug(f"Logging in with username/password")
808+
try:
809+
self.login(username, password)
810+
except Exception as login_error:
811+
logger.error(f"Login error: {str(login_error)}")
812+
elif token:
813+
# If token is provided, set it directly on the Registry instance
814+
logger.debug(f"Using token authentication")
815+
self.token = base64.b64encode(token.encode("utf-8")).decode("utf-8")
816+
self.auth.set_token_auth(self.token)
817+
818+
# Get the manifest from the source container
819+
try:
820+
logger.debug(f"Getting manifest from {source_container}")
821+
manifest = self.get_manifest(source_container)
822+
if not manifest:
823+
logger.error(f"Failed to get manifest for {source_container}")
824+
return
825+
logger.info(
826+
f"Successfully retrieved manifest: {manifest['mediaType'] if 'mediaType' in manifest else 'unknown'}"
827+
)
828+
except Exception as get_error:
829+
logger.error(f"Error getting manifest: {str(get_error)}")
830+
return
831+
832+
# For each additional tag, push the manifest using Registry.upload_manifest
833+
for tag in additional_tags:
834+
try:
835+
logger.debug(f"Pushing additional tag: {tag}")
836+
837+
# Create a new container for this tag
838+
tag_container = copy.deepcopy(container)
839+
tag_container.tag = tag
840+
841+
logger.debug(f"Pushing to container: {tag_container}")
842+
843+
# Upload the manifest to the new tag
844+
response = self.upload_manifest(manifest, tag_container)
845+
846+
if response and response.status_code in [200, 201]:
847+
logger.info(f"Successfully pushed tag {tag} for manifest")
848+
else:
849+
status_code = getattr(response, "status_code", "unknown")
850+
response_text = getattr(response, "text", "No response text")
851+
logger.error(
852+
f"Failed to push tag {tag} for manifest: {status_code}"
853+
)
854+
855+
except Exception as tag_error:
856+
logger.error(
857+
f"Error pushing tag {tag} for manifest: {str(tag_error)}"
858+
)
859+
860+
except Exception as e:
861+
logger.error(f"Error in push_additional_tags_manifest: {str(e)}")

0 commit comments

Comments
 (0)