diff --git a/VERSION b/VERSION index 6640b05..6305c5a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.23.1-dev \ No newline at end of file +v0.23.2 \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 497c367..7b3e0a3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ - [Controller Utility Functions](libs/controller.md) - [Custom Resource Definitions](libs/crds.md) - [Error Handling](libs/errors.md) +- [Image Parsing](libs/image.md) - [JSON Patch](libs/jsonpatch.md) - [Logging](libs/logging.md) - [Key-Value Pairs](libs/pairs.md) diff --git a/docs/libs/image.md b/docs/libs/image.md new file mode 100644 index 0000000..0954081 --- /dev/null +++ b/docs/libs/image.md @@ -0,0 +1,42 @@ +# Image Parsing + +The `pkg/image` package provides utilities for parsing container image references into their constituent components. + +## ParseImage Function + +The `ParseImage` function parses a container image string and extracts the image name, tag, and digest components. This is useful for validating, manipulating, or analyzing container image references in Kubernetes controllers. + +### Function Signature + +```go +func ParseImage(image string) (imageName string, tag string, digest string, err error) +``` + +### Behavior + +- **Default Tag**: If no tag is specified, it defaults to `"latest"` +- **Digest Support**: Handles images with SHA256 digests (indicated by `@sha256:...`) +- **Registry URLs**: Properly handles registry URLs with ports (e.g., `registry.io:5000/image:tag`) +- **Validation**: Returns an error for empty image strings + +### Examples + +```go +// Basic image with tag +imageName, tag, digest, err := ParseImage("nginx:1.19.0") +// Returns: "nginx", "1.19.0", "", nil + +// Image without tag (defaults to latest) +imageName, tag, digest, err := ParseImage("nginx") +// Returns: "nginx", "latest", "", nil + +// Image with digest only +imageName, tag, digest, err := ParseImage("nginx@sha256:abcdef...") +// Returns: "nginx", "", "sha256:abcdef...", nil + +// Image with both tag and digest +imageName, tag, digest, err := ParseImage("nginx:1.19.0@sha256:abcdef...") +// Returns: "nginx", "1.19.0", "sha256:abcdef...", nil +``` + +This function is particularly useful when working with container images in Kubernetes controllers, allowing you to extract and validate image components for further processing or validation. \ No newline at end of file diff --git a/go.mod b/go.mod index 0d2176a..eee02ca 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/google/uuid v1.6.0 github.com/onsi/ginkgo/v2 v2.27.1 github.com/onsi/gomega v1.38.2 - github.com/openmcp-project/controller-utils/api v0.23.1 + github.com/openmcp-project/controller-utils/api v0.23.2 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.27.0 diff --git a/pkg/image/image.go b/pkg/image/image.go new file mode 100644 index 0000000..e025d39 --- /dev/null +++ b/pkg/image/image.go @@ -0,0 +1,55 @@ +package image + +import ( + "fmt" + "strings" +) + +// ParseImage parses a container image string and returns the image name, tag, and digest. +// If no tag is specified, it defaults to "latest". If a digest is present, it is returned as well. +// Examples of valid image strings: +// - "nginx:1.19.0" -> imageName: "nginx", tag: "1.19.0", digest: "" +// - "nginx" -> imageName: "nginx", tag: "latest", digest: "" +// - "nginx@sha256:abcdef..." -> imageName: "nginx", tag: "", digest: "sha256:abcdef..." +// - "nginx:1.19.0@sha256:abcdef..." -> imageName: "nginx", tag: "1.19.0", digest: "sha256:abcdef..." +func ParseImage(image string) (imageName string, tag string, digest string, err error) { + if image == "" { + return "", "", "", fmt.Errorf("image string cannot be empty") + } + + // Check if the image contains a digest (indicated by @) + digestIndex := strings.LastIndex(image, "@") + + var tagPart string + if digestIndex != -1 { + // Extract digest + digest = image[digestIndex+1:] + tagPart = image[:digestIndex] + } else { + tagPart = image + } + + // Find the last colon to separate the tag from the image name + // We use LastIndex to handle registry URLs with ports (e.g., registry.io:5000/image:tag) + colonIndex := strings.LastIndex(tagPart, ":") + + // If there's a digest but no colon in the tag part, it's a digest-only image + if digestIndex != -1 && colonIndex == -1 { + imageName = tagPart + return imageName, "", digest, nil + } + + // If there's no colon, it means no explicit tag was provided + // In this case, default to "latest" tag + if colonIndex == -1 { + imageName = tagPart + return imageName, "latest", digest, nil + } + + // Extract image name (everything before the last colon) + imageName = tagPart[:colonIndex] + // Extract tag (everything after the last colon) + tag = tagPart[colonIndex+1:] + + return imageName, tag, digest, nil +} diff --git a/pkg/image/image_test.go b/pkg/image/image_test.go new file mode 100644 index 0000000..cf1d8a6 --- /dev/null +++ b/pkg/image/image_test.go @@ -0,0 +1,143 @@ +package image + +import ( + "testing" +) + +func TestParseImage(t *testing.T) { + tests := []struct { + name string + image string + expectedImageName string + expectedTag string + expectedDigest string + expectError bool + }{ + { + name: "image with tag only", + image: "nginx:1.21.0", + expectedImageName: "nginx", + expectedTag: "1.21.0", + expectedDigest: "", + expectError: false, + }, + { + name: "image with tag and digest", + image: "nginx:1.21.0@sha256:abc123def456", + expectedImageName: "nginx", + expectedTag: "1.21.0", + expectedDigest: "sha256:abc123def456", + expectError: false, + }, + { + name: "image with latest tag", + image: "ubuntu:latest", + expectedImageName: "ubuntu", + expectedTag: "latest", + expectedDigest: "", + expectError: false, + }, + { + name: "image with version tag and digest", + image: "registry.io/myapp:v2.1.3@sha256:fedcba987654", + expectedImageName: "registry.io/myapp", + expectedTag: "v2.1.3", + expectedDigest: "sha256:fedcba987654", + expectError: false, + }, + { + name: "image without explicit tag (defaults to latest)", + image: "nginx", + expectedImageName: "nginx", + expectedTag: "latest", + expectedDigest: "", + expectError: false, + }, + { + name: "empty image string", + image: "", + expectedImageName: "", + expectedTag: "", + expectedDigest: "", + expectError: true, + }, + { + name: "image with multiple colons in name", + image: "registry.io:5000/namespace/image:v1.0.0", + expectedImageName: "registry.io:5000/namespace/image", + expectedTag: "v1.0.0", + expectedDigest: "", + expectError: false, + }, + { + name: "image with multiple colons and digest", + image: "registry.io:5000/namespace/image:v1.0.0@sha256:123456789abc", + expectedImageName: "registry.io:5000/namespace/image", + expectedTag: "v1.0.0", + expectedDigest: "sha256:123456789abc", + expectError: false, + }, + { + name: "image with digest only (no tag)", + image: "nginx@sha256:abc123def456", + expectedImageName: "nginx", + expectedTag: "", + expectedDigest: "sha256:abc123def456", + expectError: false, + }, + { + name: "complex registry with namespace and tag", + image: "ghcr.io/openmcp-project/components/github.com/openmcp-project/openmcp:v0.0.11", + expectedImageName: "ghcr.io/openmcp-project/components/github.com/openmcp-project/openmcp", + expectedTag: "v0.0.11", + expectedDigest: "", + expectError: false, + }, + { + name: "image with port and path", + image: "localhost:5000/my-namespace/my-image:1.2.3", + expectedImageName: "localhost:5000/my-namespace/my-image", + expectedTag: "1.2.3", + expectedDigest: "", + expectError: false, + }, + { + name: "image with port, path and digest", + image: "localhost:5000/my-namespace/my-image:1.2.3@sha256:abcdef123456", + expectedImageName: "localhost:5000/my-namespace/my-image", + expectedTag: "1.2.3", + expectedDigest: "sha256:abcdef123456", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + imageName, tag, digest, err := ParseImage(tt.image) + + if tt.expectError { + if err == nil { + t.Errorf("expected error but got none") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if imageName != tt.expectedImageName { + t.Errorf("expected image name %q, got %q", tt.expectedImageName, imageName) + } + + if tag != tt.expectedTag { + t.Errorf("expected tag %q, got %q", tt.expectedTag, tag) + } + + if digest != tt.expectedDigest { + t.Errorf("expected digest %q, got %q", tt.expectedDigest, digest) + } + }) + } +}