Skip to content
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 Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ install-test: install-dev
test: install-test
$(POETRY) run pytest -k "not kms"

test-debug: install-test
$(POETRY) run pytest -k "not kms" -vvv -s

test-trace: install-test
$(POETRY) run pytest -k "not kms" -vvv --log-cli-level=DEBUG

format: install-dev
$(POETRY) run black --extend-exclude test-data/gardenlinux .

Expand Down
839 changes: 426 additions & 413 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ readme = "README.md"
packages = [{include = "gardenlinux", from="src"}, {include = "python_gardenlinux_lib", from="src"}]

[tool.poetry.dependencies]
python = "^3.10"
python = "^3.13"
networkx = "^3.3"
PyYAML = "^6.0.2"
pytest = "^8.3.2"
Expand All @@ -19,6 +19,9 @@ oras = { git = "https://github.com/oras-project/oras-py.git", rev="caf8db5b2793
python-dotenv = "^1.0.1"
cryptography = "^44.0.0"
boto3 = "*"
click = "^8.2.0"
pygments = "^2.19.1"
opencontainers = "^0.0.14"

[tool.poetry.group.dev.dependencies]
bandit = "^1.8.3"
Expand Down
27 changes: 16 additions & 11 deletions src/gardenlinux/oci/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,6 @@ def cli():
type=click.Path(),
help="Version of image",
)
@click.option(
"--commit",
required=False,
type=click.Path(),
default=None,
help="Commit of image",
)
@click.option(
"--arch",
required=True,
Expand All @@ -58,16 +51,22 @@ def cli():
default=False,
help="Use HTTP to communicate with the registry",
)
@click.option(
"--additional_tag",
required=False,
multiple=True,
help="Additional tag to push the manifest with",
)
def push_manifest(
container,
version,
commit,
arch,
cname,
directory,
cosign_file,
manifest_file,
insecure,
additional_tag,
):
"""push artifacts from a dir to a registry, get the index-entry for the manifest in return"""
container_name = f"{container}:{version}"
Expand All @@ -77,7 +76,7 @@ def push_manifest(
insecure=insecure,
)
digest = registry.push_from_dir(
arch, version, cname, directory, manifest_file, commit=commit
arch, version, cname, directory, manifest_file, additional_tag
)
if cosign_file:
print(digest, file=open(cosign_file, "w"))
Expand Down Expand Up @@ -108,15 +107,21 @@ def push_manifest(
default=False,
help="Use HTTP to communicate with the registry",
)
def update_index(container, version, manifest_folder, insecure):
@click.option(
"--additional_tag",
required=False,
multiple=True,
help="Additional tag to push the index with",
)
def update_index(container, version, manifest_folder, insecure, additional_tag):
"""push a index entry from a list of files to an index"""
container_name = f"{container}:{version}"
registry = GlociRegistry(
container_name=container_name,
token=os.getenv("GL_CLI_REGISTRY_TOKEN"),
insecure=insecure,
)
registry.update_index(manifest_folder)
registry.update_index(manifest_folder, additional_tag)


def main():
Expand Down
203 changes: 180 additions & 23 deletions src/gardenlinux/oci/registry.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-

import base64
import configparser
import copy
import hashlib
import json
Expand Down Expand Up @@ -30,11 +31,11 @@

from ..constants import OCI_ANNOTATION_SIGNATURE_KEY, OCI_ANNOTATION_SIGNED_STRING_KEY
from ..features import CName

from .checksum import (
calculate_sha256,
verify_sha256,
)
from .wrapper import retry_on_error
from python_gardenlinux_lib.features.parse_features import get_oci_metadata_from_fileset
from .schemas import (
EmptyIndex,
Expand Down Expand Up @@ -599,9 +600,12 @@ def push_image_manifest(

return local_digest

def update_index(self, manifest_folder):
def update_index(self, manifest_folder, additional_tags: list = None):
"""
replaces an old manifest entry with a new manifest entry

:param str manifest_folder: the folder where the manifest entries are read from
:param list additional_tags: the additional tags to push the index with
"""
index = self.get_index()
# Ensure mediaType is set for existing indices
Expand Down Expand Up @@ -634,6 +638,12 @@ def update_index(self, manifest_folder):
self._check_200_response(self.upload_index(index))
logger.info(f"Index pushed with {new_entries} new entries")

for tag in additional_tags:
self.container.digest = None
self.container.tag = tag
self.upload_index(index)
logger.info(f"Index pushed with additional tag {tag}")

def create_layer(
self,
file_path: str,
Expand All @@ -649,45 +659,96 @@ def create_layer(
}
return layer

@retry_on_error(max_retries=3, initial_delay=2, backoff_factor=2)
def upload_blob(self, file_path, container, metadata=None):
"""
Upload a blob to the registry with retry logic for network errors.

Args:
file_path: Path to the file to upload
container: Container object
metadata: Optional metadata for the blob

Returns:
Response from the upload
"""
# Call the parent class's upload_blob method
return super().upload_blob(file_path, container, metadata)

def push_from_dir(
self,
architecture: str,
version: str,
cname: str,
directory: str,
manifest_file: str,
commit: Optional[str] = None,
additional_tags: list = None,
):
# Step 1 scan and extract nested artifacts:
for file in os.listdir(directory):
try:
if file.endswith(".pxe.tar.gz"):
logger.info(f"Found nested artifact {file}")
nested_tar_obj = tarfile.open(f"{directory}/{file}")
nested_tar_obj.extractall(filter="data", path=directory)
nested_tar_obj.close()
except (OSError, tarfile.FilterError, tarfile.TarError) as e:
print(f"Failed to extract nested artifact {file}", e)
exit(1)
"""
Push artifacts from a directory to a registry

Args:
architecture: Target architecture of the image
version: Version tag for the image
cname: Canonical name of the image
directory: Directory containing the artifacts
manifest_file: File to write the manifest index entry to
additional_tags: Additional tags to push the manifest with

Returns:
The digest of the pushed manifest
"""
if additional_tags is None:
additional_tags = []

try:
# scan and extract nested artifacts
for file in os.listdir(directory):
try:
if file.endswith(".pxe.tar.gz"):
logger.info(f"Found nested artifact {file}")
nested_tar_obj = tarfile.open(f"{directory}/{file}")
nested_tar_obj.extractall(filter="data", path=directory)
nested_tar_obj.close()
except (OSError, tarfile.FilterError, tarfile.TarError) as e:
print(f"Failed to extract nested artifact {file}", e)
exit(1)

# Get metadata from files
oci_metadata = get_oci_metadata_from_fileset(
os.listdir(directory), architecture
)

features = ""
commit = ""
for artifact in oci_metadata:
if artifact["media_type"] == "application/io.gardenlinux.release":
file = open(f"{directory}/{artifact["file_name"]}", "r")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use configparser.ConfigParser here as it will be compatible with the file content and makes the code more readable :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configparser seems to be designed for ini files (is compatible to env-files though) but fileshttps://docs.python.org/3/library/configparser.html#unnamed-sections seems to be a python 3.13 feature.
Garden Linux already has python 3.13 so we could bump the python version.

Added an implementation hat needs python 3.13.

lines = file.readlines()
for line in lines:
if line.strip().startswith("GARDENLINUX_FEATURES="):
features = line.strip().removeprefix(
"GARDENLINUX_FEATURES="
try:
file_path = f"{directory}/{artifact['file_name']}"

config = configparser.ConfigParser(allow_unnamed_section=True)
config.read(file_path)

if config.has_option(
configparser.UNNAMED_SECTION, "GARDENLINUX_FEATURES"
):
features = config.get(
configparser.UNNAMED_SECTION, "GARDENLINUX_FEATURES"
)
if config.has_option(
configparser.UNNAMED_SECTION, "GARDENLINUX_COMMIT_ID"
):
commit = config.get(
configparser.UNNAMED_SECTION, "GARDENLINUX_COMMIT_ID"
)
break
file.close()

except (configparser.Error, IOError) as e:
logger.error(
f"Error reading config file {artifact['file_name']}: {e}"
)
break

# Push the image manifest
digest = self.push_image_manifest(
architecture,
cname,
Expand All @@ -698,7 +759,103 @@ def push_from_dir(
manifest_file,
commit=commit,
)

# Process additional tags if provided
if additional_tags and len(additional_tags) > 0:
print(f"DEBUG: Processing {len(additional_tags)} additional tags")
logger.info(f"Processing {len(additional_tags)} additional tags")

self.push_additional_tags_manifest(
architecture,
cname,
version,
additional_tags,
container=self.container,
)

return digest
except Exception as e:
print("Error: ", e)
exit(1)
return digest

def push_additional_tags_manifest(
self, architecture, cname, version, additional_tags, container
):
"""
Push additional tags for an existing manifest using ORAS Registry methods

Args:
architecture: Target architecture of the image
cname: Canonical name of the image
version: Version tag for the image
additional_tags: List of additional tags to push
container: Container object
"""
try:
# Source tag is the tag containing the version-cname-architecture combination
source_tag = f"{version}-{cname}-{architecture}"
source_container = copy.deepcopy(container)
source_container.tag = source_tag

# Authentication credentials from environment
token = os.getenv("GL_CLI_REGISTRY_TOKEN")
username = os.getenv("GL_CLI_REGISTRY_USERNAME")
password = os.getenv("GL_CLI_REGISTRY_PASSWORD")

# Login to registry if credentials are provided
if username and password:
logger.debug(f"Logging in with username/password")
try:
self.login(username, password)
except Exception as login_error:
logger.error(f"Login error: {str(login_error)}")
elif token:
# If token is provided, set it directly on the Registry instance
logger.debug(f"Using token authentication")
self.token = base64.b64encode(token.encode("utf-8")).decode("utf-8")
self.auth.set_token_auth(self.token)

# Get the manifest from the source container
try:
logger.debug(f"Getting manifest from {source_container}")
manifest = self.get_manifest(source_container)
if not manifest:
logger.error(f"Failed to get manifest for {source_container}")
return
logger.info(
f"Successfully retrieved manifest: {manifest['mediaType'] if 'mediaType' in manifest else 'unknown'}"
)
except Exception as get_error:
logger.error(f"Error getting manifest: {str(get_error)}")
return

# For each additional tag, push the manifest using Registry.upload_manifest
for tag in additional_tags:
try:
logger.debug(f"Pushing additional tag: {tag}")

# Create a new container for this tag
tag_container = copy.deepcopy(container)
tag_container.tag = tag

logger.debug(f"Pushing to container: {tag_container}")

# Upload the manifest to the new tag
response = self.upload_manifest(manifest, tag_container)

if response and response.status_code in [200, 201]:
logger.info(f"Successfully pushed tag {tag} for manifest")
else:
status_code = getattr(response, "status_code", "unknown")
response_text = getattr(response, "text", "No response text")
logger.error(
f"Failed to push tag {tag} for manifest: {status_code}"
)

except Exception as tag_error:
logger.error(
f"Error pushing tag {tag} for manifest: {str(tag_error)}"
)

except Exception as e:
logger.error(f"Error in push_additional_tags_manifest: {str(e)}")
Loading