Repository: suse-edge/edge-image-builder (go)

Issue:
Support for image repo authentication for embedded artifact images
It is unclear if it is supported, or, if so,  how someone could authenticate with an image repository to populate the embedded artifact images during image creation. This would be a welcome addition if it does not yet exist. It would be nice if we could supply username and password in the image definition file for embedded artifacts like is done for the helm repositories section. 

Pull request:
Add support for authenticated container registries
Closes #436 

Patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 0dbbaec..919d0f1 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -12,6 +12,7 @@
 * Updated Embedded Artifact Registry documentation
 * Improved Helm chart handling to allow deploying multiple Helm charts with the same chart name
 * Added warning when running cross-architecture builds
+* Added support for getting container images from authenticated container registries
 * Dependency upgrades
   * Go module version is now upgraded from `1.22` to `1.24`
   * Updated MetalLB from `0.14.9` to `0.1.0+up0.14.9`
@@ -26,6 +27,7 @@
 * Added the `operatingSystem.rawConfiguration.luksKey` field for specifying the LINUX UNIFIED KEY SETUP for modifying RAW Encrypted images
 * Added the `operatingSystem.rawConfiguration.expandEncryptedPartition` field to specify if the LUKS encrypted partition should be expanded during build time
 * Added the `kubernetes.helm.charts.releaseName` field to allow for deploying multiple instances of the same Helm chart.
+* Added the `embeddedArtifactRegistry.registries` field to allow providing credentials for authenticated registries
 
 ### Image Configuration Directory Changes
 
diff --git a/docs/building-images.md b/docs/building-images.md
index 3bdc8ad..8a753b5 100644
--- a/docs/building-images.md
+++ b/docs/building-images.md
@@ -371,6 +371,11 @@ embeddedArtifactRegistry:
   images:
     - name: hello-world:latest
     - name: nginx:stable@sha256:b03c8dfc241047d827e1e14d69533205b387d476d97ef7efce58605a6c3acb84
+  registries:
+    - uri: registry.suse.com
+      authentication:
+        username: user
+        password: pass
 ```
 
 > **_NOTE:_** When providing images tagged with a `sha256` digest, the digest must be the manifest digest for the 
@@ -380,6 +385,12 @@ embeddedArtifactRegistry:
 
 * `images` - Defines a list of container images to download and host on the node.
   * `name` - Required; Specifies the name, with a tag or digest, of a container image to be pulled and stored.
+* `registries` - Optional, only required for authenticated registries; Defines a list of registries along with the 
+* credentials used to access them.
+  * `uri` - Required for authenticated registries; Specifies the URI of an authenticated registry.
+  * `authentication` - Required for authenticated registries. 
+    * `username` - Required; Defines the username for accessing the specified registry.
+    * `password` - Required; Defines the password for accessing the specified registry.
 
 # Image Configuration Directory
 
diff --git a/pkg/combustion/registry.go b/pkg/combustion/registry.go
index fa4a9cd..9d93860 100644
--- a/pkg/combustion/registry.go
+++ b/pkg/combustion/registry.go
@@ -92,6 +92,16 @@ func generateRegistryTar(imageTarDest string, outputWriter io.Writer) error {
 	return nil
 }
 
+func loginToRegistry(registry image.Registry, outputWriter io.Writer) error {
+	args := []string{"login", registry.URI, "--username", registry.Authentication.Username, "--password", registry.Authentication.Password}
+
+	cmd := exec.Command(hauler, args...)
+	cmd.Stdout = outputWriter
+	cmd.Stderr = outputWriter
+
+	return cmd.Run()
+}
+
 func writeRegistryScript(ctx *image.Context) (string, error) {
 	values := struct {
 		RegistryPort      string
@@ -212,9 +222,6 @@ func (c *Combustion) populateRegistry(ctx *image.Context, images []string) error
 		return fmt.Errorf("creating container image cache dir: %w", err)
 	}
 
-	bar := progressbar.Default(int64(len(images)), "Populating Embedded Artifact Registry...")
-	zap.S().Infof("Adding the following images to the embedded artifact registry:\n%s", images)
-
 	const registryLogFileName = "embedded-registry.log"
 	logFilename := filepath.Join(ctx.BuildDir, registryLogFileName)
 
@@ -229,6 +236,15 @@ func (c *Combustion) populateRegistry(ctx *image.Context, images []string) error
 		}
 	}()
 
+	for _, registry := range ctx.ImageDefinition.EmbeddedArtifactRegistry.Registries {
+		if err = loginToRegistry(registry, logFile); err != nil {
+			return fmt.Errorf("logging into registry '%s': %w", registry.URI, err)
+		}
+	}
+
+	bar := progressbar.Default(int64(len(images)), "Populating Embedded Artifact Registry...")
+	zap.S().Infof("Adding the following images to the embedded artifact registry:\n%s", images)
+
 	arch := ctx.ImageDefinition.Image.Arch.Short()
 
 	for _, img := range images {
diff --git a/pkg/image/definition.go b/pkg/image/definition.go
index 9b66f68..95d0539 100644
--- a/pkg/image/definition.go
+++ b/pkg/image/definition.go
@@ -181,12 +181,23 @@ type Proxy struct {
 
 type EmbeddedArtifactRegistry struct {
 	ContainerImages []ContainerImage `yaml:"images"`
+	Registries      []Registry       `yaml:"registries"`
 }
 
 type ContainerImage struct {
 	Name string `yaml:"name"`
 }
 
+type Registry struct {
+	URI            string                 `yaml:"uri"`
+	Authentication RegistryAuthentication `yaml:"authentication"`
+}
+
+type RegistryAuthentication struct {
+	Username string `yaml:"username"`
+	Password string `yaml:"password"`
+}
+
 type Kubernetes struct {
 	Version   string    `yaml:"version"`
 	Network   Network   `yaml:"network"`
diff --git a/pkg/image/validation/registry.go b/pkg/image/validation/registry.go
index 93d6c79..eec4844 100644
--- a/pkg/image/validation/registry.go
+++ b/pkg/image/validation/registry.go
@@ -3,6 +3,7 @@ package validation
 import (
 	"fmt"
 
+	"github.com/containers/image/v5/docker/reference"
 	"github.com/suse-edge/edge-image-builder/pkg/image"
 )
 
@@ -13,6 +14,7 @@ const (
 func validateEmbeddedArtifactRegistry(ctx *image.Context) []FailedValidation {
 	var failures []FailedValidation
 
+	failures = append(failures, validateRegistries(&ctx.ImageDefinition.EmbeddedArtifactRegistry)...)
 	failures = append(failures, validateContainerImages(&ctx.ImageDefinition.EmbeddedArtifactRegistry)...)
 
 	return failures
@@ -40,3 +42,66 @@ func validateContainerImages(ear *image.EmbeddedArtifactRegistry) []FailedValida
 
 	return failures
 }
+
+func validateRegistries(ear *image.EmbeddedArtifactRegistry) []FailedValidation {
+	var failures []FailedValidation
+
+	failures = append(failures, validateURLs(ear)...)
+	failures = append(failures, validateCredentials(ear)...)
+
+	return failures
+}
+
+func validateURLs(ear *image.EmbeddedArtifactRegistry) []FailedValidation {
+	var failures []FailedValidation
+
+	seenRegistryURLs := make(map[string]bool)
+	for _, registry := range ear.Registries {
+		if registry.URI == "" {
+			failures = append(failures, FailedValidation{
+				UserMessage: "The 'uri' field is required for each entry in 'embeddedArtifactRegistry.registries'.",
+			})
+		}
+
+		_, err := reference.Parse(registry.URI)
+		if err != nil {
+			failures = append(failures, FailedValidation{
+				UserMessage: fmt.Sprintf("Embedded artifact registry URI '%s' could not be parsed.", registry.URI),
+				Error:       err,
+			})
+
+			continue
+		}
+
+		if seenRegistryURLs[registry.URI] {
+			msg := fmt.Sprintf("Duplicate registry URI '%s' found in the 'embeddedArtifactRegistry.registries' section.", registry.URI)
+			failures = append(failures, FailedValidation{
+				UserMessage: msg,
+			})
+		}
+
+		seenRegistryURLs[registry.URI] = true
+	}
+
+	return failures
+}
+
+func validateCredentials(ear *image.EmbeddedArtifactRegistry) []FailedValidation {
+	var failures []FailedValidation
+
+	for _, registry := range ear.Registries {
+		if registry.Authentication.Username == "" {
+			failures = append(failures, FailedValidation{
+				UserMessage: "The 'username' field is required for each entry in 'embeddedArtifactRegistry.registries.credentials'.",
+			})
+		}
+
+		if registry.Authentication.Password == "" {
+			failures = append(failures, FailedValidation{
+				UserMessage: "The 'password' field is required for each entry in 'embeddedArtifactRegistry.registries.credentials'.",
+			})
+		}
+	}
+
+	return failures
+}


Test patch:
diff --git a/pkg/image/definition_test.go b/pkg/image/definition_test.go
index dc0f29b..3c206ad 100644
--- a/pkg/image/definition_test.go
+++ b/pkg/image/definition_test.go
@@ -166,6 +166,16 @@ func TestParse(t *testing.T) {
 	assert.Equal(t, "hello-world:latest", embeddedArtifactRegistry.ContainerImages[0].Name)
 	assert.Equal(t, "nginx:stable@sha256:b03c8dfc241047d827e1e14d69533205b387d476d97ef7efce58605a6c3acb84", embeddedArtifactRegistry.ContainerImages[1].Name)
 
+	registries := definition.EmbeddedArtifactRegistry.Registries
+
+	assert.Equal(t, registries[0].URI, "docker.io")
+	assert.Equal(t, registries[0].Authentication.Username, "user")
+	assert.Equal(t, registries[0].Authentication.Password, "pass")
+
+	assert.Equal(t, registries[1].URI, "registry.suse.com")
+	assert.Equal(t, registries[1].Authentication.Username, "suse-user")
+	assert.Equal(t, registries[1].Authentication.Password, "suse-pass")
+
 	// Kubernetes
 	kubernetes := definition.Kubernetes
 
diff --git a/pkg/image/testdata/full-valid-example.yaml b/pkg/image/testdata/full-valid-example.yaml
index 0639bd4..e2ab6fc 100644
--- a/pkg/image/testdata/full-valid-example.yaml
+++ b/pkg/image/testdata/full-valid-example.yaml
@@ -82,6 +82,15 @@ embeddedArtifactRegistry:
   images:
     - name: hello-world:latest
     - name: nginx:stable@sha256:b03c8dfc241047d827e1e14d69533205b387d476d97ef7efce58605a6c3acb84
+  registries:
+    - uri: docker.io
+      authentication:
+        username: user
+        password: pass
+    - uri: registry.suse.com
+      authentication:
+        username: suse-user
+        password: suse-pass
 kubernetes:
   version: v1.30.3+rke2r1
   network:
diff --git a/pkg/image/validation/registry_test.go b/pkg/image/validation/registry_test.go
index 5d5dbaa..710f5f1 100644
--- a/pkg/image/validation/registry_test.go
+++ b/pkg/image/validation/registry_test.go
@@ -22,6 +22,22 @@ func TestValidateEmbeddedArtifactRegistry(t *testing.T) {
 						Name: "foo",
 					},
 				},
+				Registries: []image.Registry{
+					{
+						URI: "docker.io",
+						Authentication: image.RegistryAuthentication{
+							Username: "user",
+							Password: "pass",
+						},
+					},
+					{
+						URI: "192.168.1.100:5000",
+						Authentication: image.RegistryAuthentication{
+							Username: "user2",
+							Password: "pass2",
+						},
+					},
+				},
 			},
 		},
 		`image definition failure`: {
@@ -128,3 +144,132 @@ func TestValidateContainerImages(t *testing.T) {
 		})
 	}
 }
+
+func TestValidateRegistries(t *testing.T) {
+	tests := map[string]struct {
+		Registry               image.EmbeddedArtifactRegistry
+		ExpectedFailedMessages []string
+	}{
+		`no authentication`: {
+			Registry: image.EmbeddedArtifactRegistry{},
+		},
+		`URI no credentials`: {
+			Registry: image.EmbeddedArtifactRegistry{
+				Registries: []image.Registry{
+					{
+						URI:            "docker.io",
+						Authentication: image.RegistryAuthentication{},
+					},
+				},
+			},
+			ExpectedFailedMessages: []string{
+				"The 'username' field is required for each entry in 'embeddedArtifactRegistry.registries.credentials'.",
+				"The 'password' field is required for each entry in 'embeddedArtifactRegistry.registries.credentials'.",
+			},
+		},
+		`credentials missing username`: {
+			Registry: image.EmbeddedArtifactRegistry{
+				Registries: []image.Registry{
+					{
+						URI: "docker.io",
+						Authentication: image.RegistryAuthentication{
+							Username: "",
+							Password: "pass",
+						},
+					},
+				},
+			},
+			ExpectedFailedMessages: []string{
+				"The 'username' field is required for each entry in 'embeddedArtifactRegistry.registries.credentials'.",
+			},
+		},
+		`credentials missing password`: {
+			Registry: image.EmbeddedArtifactRegistry{
+				Registries: []image.Registry{
+					{
+						URI: "docker.io",
+						Authentication: image.RegistryAuthentication{
+							Username: "user",
+							Password: "",
+						},
+					},
+				},
+			},
+			ExpectedFailedMessages: []string{
+				"The 'password' field is required for each entry in 'embeddedArtifactRegistry.registries.credentials'.",
+			},
+		},
+		`credentials duplicate URI`: {
+			Registry: image.EmbeddedArtifactRegistry{
+				Registries: []image.Registry{
+					{
+						URI: "docker.io",
+						Authentication: image.RegistryAuthentication{
+							Username: "user",
+							Password: "pass",
+						},
+					},
+					{
+						URI: "docker.io",
+						Authentication: image.RegistryAuthentication{
+							Username: "user2",
+							Password: "pass2",
+						},
+					},
+				},
+			},
+			ExpectedFailedMessages: []string{
+				"Duplicate registry URI 'docker.io' found in the 'embeddedArtifactRegistry.registries' section.",
+			},
+		},
+		`invalid registry URI`: {
+			Registry: image.EmbeddedArtifactRegistry{
+				Registries: []image.Registry{
+					{
+						URI: "docker...io",
+						Authentication: image.RegistryAuthentication{
+							Username: "user",
+							Password: "pass",
+						},
+					},
+					{
+						URI: "/docker.io/images",
+						Authentication: image.RegistryAuthentication{
+							Username: "user",
+							Password: "pass",
+						},
+					},
+					{
+						URI: "https://docker.io/images",
+						Authentication: image.RegistryAuthentication{
+							Username: "user",
+							Password: "pass",
+						},
+					},
+				},
+			},
+			ExpectedFailedMessages: []string{
+				"Embedded artifact registry URI 'docker...io' could not be parsed.",
+				"Embedded artifact registry URI '/docker.io/images' could not be parsed.",
+				"Embedded artifact registry URI 'https://docker.io/images' could not be parsed.",
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			ear := test.Registry
+			failures := validateRegistries(&ear)
+			assert.Len(t, failures, len(test.ExpectedFailedMessages))
+
+			var foundMessages []string
+			for _, foundValidation := range failures {
+				foundMessages = append(foundMessages, foundValidation.UserMessage)
+			}
+
+			for _, expectedMessage := range test.ExpectedFailedMessages {
+				assert.Contains(t, foundMessages, expectedMessage)
+			}
+		})
+	}
+}