From 243a2edf18b851c822f0cff7b9a2c921125b62fd Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 9 Sep 2026 16:02:12 +0530 Subject: [PATCH 1/5] registry: add authenticated registry backed by shared storage Add a second registry instance, bink-auth-registry, protected by htpasswd basic auth. It shares the storage volume with the existing unauthenticated registry, so images pushed to localhost:5000 are immediately pullable from localhost:5001 with credentials, without duplicating any data. Sharing storage between two registry processes requires both to agree on REGISTRY_HTTP_SECRET, so the existing registry is given the same shared secret. Credentials are supplied by the caller and are never persisted anywhere inspectable: the bcrypt htpasswd entry is generated at start time and no username or password hash is recorded as a container label. Because there is nothing to compare against, an existing authenticated registry is only started, not recreated; to change credentials, stop the registry and start it again. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- go.mod | 2 +- internal/config/defaults.go | 6 + internal/registry/registry.go | 303 +++++++++++++++++++++++++---- internal/registry/registry_test.go | 176 +++++++++++++++++ 4 files changed, 453 insertions(+), 34 deletions(-) create mode 100644 internal/registry/registry_test.go diff --git a/go.mod b/go.mod index 079a2c7..6efa880 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/spf13/viper v1.21.0 go.podman.io/common v0.69.1 go.podman.io/podman/v6 v6.1.1 + golang.org/x/crypto v0.54.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.37.0 k8s.io/apimachinery v0.37.0 @@ -168,7 +169,6 @@ require ( go.podman.io/storage v1.64.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.54.0 // indirect golang.org/x/mod v0.37.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 829e504..c933645 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -76,6 +76,12 @@ const ( RegistryStaticIP = "10.88.0.2" RegistryHostname = "registry" RegistryVolume = "bink-registry-data" + RegistryHTTPSecret = "bink-shared-secret" + + AuthRegistryContainerName = "bink-auth-registry" + AuthRegistryPort = 5001 + AuthRegistryStaticIP = "10.88.0.3" + AuthRegistryHostname = "auth-registry" HAProxyImage = "docker.io/library/haproxy:lts-alpine" HAProxyContainerName = "haproxy" diff --git a/internal/registry/registry.go b/internal/registry/registry.go index f6f201b..88e975f 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -5,8 +5,10 @@ package registry import ( "context" + "errors" "fmt" "net" + "net/http" "strings" "github.com/bootc-dev/bink/internal/config" @@ -14,9 +16,13 @@ import ( "github.com/sirupsen/logrus" nettypes "go.podman.io/common/libnetwork/types" "go.podman.io/podman/v6/libpod/define" + "go.podman.io/podman/v6/pkg/errorhandling" "go.podman.io/podman/v6/pkg/specgen" + "golang.org/x/crypto/bcrypt" ) +const authHtpasswdEnv = "BINK_AUTH_HTPASSWD" + type Manager struct { podman *podman.Client } @@ -45,27 +51,16 @@ func (m *Manager) EnsureRegistry(ctx context.Context) error { return fmt.Errorf("checking registry container: %w", err) } + ensure := func(ctx context.Context) error { + return m.ensureExistingContainer(ctx, config.RegistryContainerName, "Registry") + } + if exists { - status, err := m.podman.ContainerStatus(ctx, config.RegistryContainerName) - if err != nil { - return fmt.Errorf("checking registry status: %w", err) - } - switch status { - case define.ContainerStateRunning.String(): - logrus.Info("Registry already running") - return nil - default: - logrus.Infof("Registry container is %s, starting it", status) - if err := m.podman.ContainerStart(ctx, config.RegistryContainerName); err != nil { - return fmt.Errorf("starting registry: %w", err) - } - logrus.Info("Registry started") - return nil - } + return ensure(ctx) } if err := m.createContainer(ctx); err != nil { - return err + return m.recoverFromConcurrentCreate(ctx, config.RegistryContainerName, err, ensure) } logrus.Infof("Registry running at %s:%d (host: localhost:%d)", @@ -96,6 +91,9 @@ func (m *Manager) createContainer(ctx context.Context) error { Options: []string{"z"}, }, }, + Environment: map[string]string{ + "REGISTRY_HTTP_SECRET": config.RegistryHTTPSecret, + }, Labels: map[string]string{ config.LabelComponent: "registry", }, @@ -103,37 +101,60 @@ func (m *Manager) createContainer(ctx context.Context) error { _, err := m.podman.ContainerCreate(ctx, opts) if err != nil { - if strings.Contains(err.Error(), "already in use") { - logrus.Info("Registry container was created concurrently") - return nil - } return fmt.Errorf("creating registry container: %w", err) } return nil } +// ensureExistingContainer starts a registry container that already exists, using label +// for user-facing log and error messages. +func (m *Manager) ensureExistingContainer(ctx context.Context, name, label string) error { + status, err := m.podman.ContainerStatus(ctx, name) + if err != nil { + return fmt.Errorf("checking %s status: %w", strings.ToLower(label), err) + } + if status == define.ContainerStateRunning.String() { + logrus.Infof("%s already running", label) + return nil + } + + logrus.Infof("%s container is %s, starting it", label, status) + if err := m.podman.ContainerStart(ctx, name); err != nil { + return fmt.Errorf("starting %s: %w", strings.ToLower(label), err) + } + logrus.Infof("%s started", label) + return nil +} + func (m *Manager) StopRegistry(ctx context.Context) error { exists, err := m.podman.ContainerExists(ctx, config.RegistryContainerName) if err != nil { return fmt.Errorf("checking registry container: %w", err) } - if !exists { - logrus.Info("Registry container not found") - return nil - } + if exists { + logrus.Info("Stopping registry container") + if err := m.podman.ContainerStop(ctx, config.RegistryContainerName); err != nil && !isPodmanNotFound(err) { + logrus.Warnf("Failed to stop registry: %v", err) + } - logrus.Info("Stopping registry container") - if err := m.podman.ContainerStop(ctx, config.RegistryContainerName); err != nil { - logrus.Warnf("Failed to stop registry: %v", err) + if err := m.podman.ContainerRemove(ctx, config.RegistryContainerName, true); err != nil && !isPodmanNotFound(err) { + return fmt.Errorf("removing registry container: %w", err) + } + } else { + logrus.Info("Registry container not found") } - if err := m.podman.ContainerRemove(ctx, config.RegistryContainerName, true); err != nil { - return fmt.Errorf("removing registry container: %w", err) + volumeExists, err := m.podman.VolumeExists(ctx, config.RegistryVolume) + if err != nil { + return fmt.Errorf("checking registry volume: %w", err) } - - if err := m.podman.VolumeRemove(ctx, config.RegistryVolume); err != nil { - logrus.Warnf("Failed to remove registry volume: %v", err) + if volumeExists { + if err := m.podman.VolumeRemove(ctx, config.RegistryVolume); err != nil && !isPodmanNotFound(err) { + return fmt.Errorf("removing registry volume: %w", err) + } + } else { + logrus.Info("Registry volume not found") } logrus.Info("Registry stopped and removed") @@ -173,3 +194,219 @@ func (m *Manager) RegistryInfo(ctx context.Context) (*RegistryStatus, error) { info.Running = status == define.ContainerStateRunning.String() return info, nil } + +// EnsureAuthRegistry starts (or creates) the authenticated registry. Credentials are not +// stored anywhere inspectable, so they cannot be compared against an already-running +// container: to change them, stop the registry and start it again. +func (m *Manager) EnsureAuthRegistry(ctx context.Context, username, password string) error { + logrus.Info("Ensuring authenticated registry is running") + if err := ValidateAuthCredentials(username, password); err != nil { + return err + } + + if err := m.podman.EnsureImage(ctx, config.RegistryImage); err != nil { + return fmt.Errorf("ensuring registry image: %w", err) + } + + if err := m.podman.VolumeCreate(ctx, config.RegistryVolume, nil); err != nil { + return fmt.Errorf("creating registry volume: %w", err) + } + + exists, err := m.podman.ContainerExists(ctx, config.AuthRegistryContainerName) + if err != nil { + return fmt.Errorf("checking auth registry container: %w", err) + } + + ensure := func(ctx context.Context) error { + return m.ensureExistingContainer(ctx, config.AuthRegistryContainerName, "Authenticated registry") + } + + if exists { + return ensure(ctx) + } + + if err := m.createAuthContainer(ctx, username, password); err != nil { + return m.recoverFromConcurrentCreate(ctx, config.AuthRegistryContainerName, err, ensure) + } + + logrus.Infof("Authenticated registry running at %s:%d (host: localhost:%d)", + config.AuthRegistryStaticIP, config.AuthRegistryPort, config.AuthRegistryPort) + return nil +} + +func (m *Manager) createAuthContainer(ctx context.Context, username, password string) error { + htpasswdEntry, err := generateHtpasswd(username, password) + if err != nil { + return fmt.Errorf("generating htpasswd: %w", err) + } + + opts := &podman.ContainerCreateOptions{ + Name: config.AuthRegistryContainerName, + Image: config.RegistryImage, + Entrypoint: []string{"/bin/sh", "-c", + `mkdir -p /auth && printf '%s\n' "$` + authHtpasswdEnv + `" > /auth/htpasswd && exec /entrypoint.sh /etc/docker/registry/config.yml`, + }, + NetworkOptions: map[string]nettypes.PerNetworkOptions{ + config.DefaultNetworkName: { + StaticIPs: []net.IP{net.ParseIP(config.AuthRegistryStaticIP)}, + }, + }, + PortMappings: []nettypes.PortMapping{ + { + HostPort: uint16(config.AuthRegistryPort), + ContainerPort: uint16(config.AuthRegistryPort), + Protocol: "tcp", + }, + }, + Volumes: []*specgen.NamedVolume{ + { + Name: config.RegistryVolume, + Dest: "/var/lib/registry", + Options: []string{"ro", "z"}, + }, + }, + Environment: map[string]string{ + authHtpasswdEnv: htpasswdEntry, + "REGISTRY_HTTP_ADDR": fmt.Sprintf("0.0.0.0:%d", config.AuthRegistryPort), + "REGISTRY_AUTH": "htpasswd", + "REGISTRY_AUTH_HTPASSWD_REALM": "Registry Realm", + "REGISTRY_AUTH_HTPASSWD_PATH": "/auth/htpasswd", + "REGISTRY_HTTP_SECRET": config.RegistryHTTPSecret, + }, + Labels: map[string]string{ + config.LabelComponent: "auth-registry", + }, + } + + _, err = m.podman.ContainerCreate(ctx, opts) + if err != nil { + return fmt.Errorf("creating auth registry container: %w", err) + } + return nil +} + +func (m *Manager) StopAuthRegistry(ctx context.Context) error { + exists, err := m.podman.ContainerExists(ctx, config.AuthRegistryContainerName) + if err != nil { + return fmt.Errorf("checking auth registry container: %w", err) + } + + if !exists { + logrus.Info("Auth registry container not found") + return nil + } + + logrus.Info("Stopping auth registry container") + if err := m.podman.ContainerStop(ctx, config.AuthRegistryContainerName); err != nil && !isPodmanNotFound(err) { + logrus.Warnf("Failed to stop auth registry: %v", err) + } + + if err := m.podman.ContainerRemove(ctx, config.AuthRegistryContainerName, true); err != nil && !isPodmanNotFound(err) { + return fmt.Errorf("removing auth registry container: %w", err) + } + + logrus.Info("Auth registry stopped and removed") + return nil +} + +type AuthRegistryStatus struct { + Running bool + IP string + HostPort int + PullURL string +} + +func (m *Manager) AuthRegistryInfo(ctx context.Context) (*AuthRegistryStatus, error) { + info := &AuthRegistryStatus{ + IP: config.AuthRegistryStaticIP, + HostPort: config.AuthRegistryPort, + PullURL: fmt.Sprintf("%s.%s:%d", config.AuthRegistryHostname, config.ClusterDomain, config.AuthRegistryPort), + } + + exists, err := m.podman.ContainerExists(ctx, config.AuthRegistryContainerName) + if err != nil { + return info, fmt.Errorf("checking auth registry container: %w", err) + } + + if !exists { + return info, nil + } + + status, err := m.podman.ContainerStatus(ctx, config.AuthRegistryContainerName) + if err != nil { + return info, fmt.Errorf("checking auth registry status: %w", err) + } + + info.Running = status == define.ContainerStateRunning.String() + return info, nil +} + +func isPodmanNotFound(err error) bool { + var podmanErr *errorhandling.ErrorModel + return errors.As(err, &podmanErr) && podmanErr.ResponseCode == http.StatusNotFound +} + +// recoverFromConcurrentCreate handles parallel EnsureRegistry/EnsureAuthRegistry calls +// where two processes both attempt to create the same named container. +func (m *Manager) recoverFromConcurrentCreate(ctx context.Context, name string, createErr error, ensure func(context.Context) error) error { + if isContainerAlreadyExists(createErr) { + logrus.Infof("%s was created concurrently", name) + return ensure(ctx) + } + + exists, checkErr := m.podman.ContainerExists(ctx, name) + if checkErr != nil { + return errors.Join(createErr, fmt.Errorf("checking %s after create failure: %w", name, checkErr)) + } + if exists { + logrus.Infof("%s was created concurrently", name) + return ensure(ctx) + } + return createErr +} + +func isContainerAlreadyExists(err error) bool { + if errors.Is(err, define.ErrCtrExists) { + return true + } + var podmanErr *errorhandling.ErrorModel + if errors.As(err, &podmanErr) && podmanErr.ResponseCode == http.StatusConflict { + return true + } + msg := err.Error() + return strings.Contains(msg, "already in use") || strings.Contains(msg, define.ErrCtrExists.Error()) +} + +// ValidateAuthCredentials checks that credentials can be represented safely in an htpasswd file. +func ValidateAuthCredentials(username, password string) error { + if username == "" { + return fmt.Errorf("registry username must not be empty") + } + if strings.ContainsAny(username, ":\r\n") { + return fmt.Errorf("registry username must not contain ':', carriage returns, or newlines") + } + if password == "" { + return fmt.Errorf("registry password must not be empty") + } + return nil +} + +// AuthRegistryRequested reports whether credentials request an authenticated registry. +// Supplying only one credential is rejected rather than silently disabling authentication. +func AuthRegistryRequested(username, password string) (bool, error) { + if username == "" && password == "" { + return false, nil + } + if err := ValidateAuthCredentials(username, password); err != nil { + return false, err + } + return true, nil +} + +func generateHtpasswd(username, password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("hashing password: %w", err) + } + return fmt.Sprintf("%s:%s", username, string(hash)), nil +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..1db139d --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: 2026 The bink Authors +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "errors" + "fmt" + "net/http" + "strings" + "testing" + + . "github.com/onsi/gomega" + "go.podman.io/podman/v6/libpod/define" + "go.podman.io/podman/v6/pkg/errorhandling" + "golang.org/x/crypto/bcrypt" +) + +func TestIsContainerAlreadyExists(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "container already exists", + err: define.ErrCtrExists, + want: true, + }, + { + name: "wrapped container already exists", + err: fmt.Errorf("creating registry container: %w", define.ErrCtrExists), + want: true, + }, + { + name: "name already in use", + err: errors.New(`the container name "bink-registry" is already in use by abc123`), + want: true, + }, + { + name: "wrapped name already in use", + err: fmt.Errorf("creating container: %w", errors.New(`the container name "bink-registry" is already in use`)), + want: true, + }, + { + name: "conflict", + err: &errorhandling.ErrorModel{ + ResponseCode: http.StatusConflict, + }, + want: true, + }, + { + name: "not found", + err: &errorhandling.ErrorModel{ + ResponseCode: http.StatusNotFound, + }, + }, + { + name: "unrelated error", + err: errors.New("network unreachable"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + g.Expect(isContainerAlreadyExists(tt.err)).To(Equal(tt.want)) + }) + } +} + +func TestIsPodmanNotFound(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "not found", + err: &errorhandling.ErrorModel{ + ResponseCode: http.StatusNotFound, + }, + want: true, + }, + { + name: "wrapped not found", + err: fmt.Errorf("removing container: %w", &errorhandling.ErrorModel{ + ResponseCode: http.StatusNotFound, + }), + want: true, + }, + { + name: "conflict", + err: &errorhandling.ErrorModel{ + ResponseCode: http.StatusConflict, + }, + }, + { + name: "unstructured error", + err: errors.New("container not found"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + g.Expect(isPodmanNotFound(tt.err)).To(Equal(tt.want)) + }) + } +} + +func TestValidateAuthCredentials(t *testing.T) { + tests := []struct { + name string + username string + password string + wantErr string + }{ + {name: "valid", username: "test-user", password: "test-password"}, + {name: "empty username", password: "test-password", wantErr: "registry username must not be empty"}, + {name: "empty password", username: "test-user", wantErr: "registry password must not be empty"}, + {name: "colon in username", username: "test:user", password: "test-password", wantErr: "registry username must not contain ':', carriage returns, or newlines"}, + {name: "newline in username", username: "test\nuser", password: "test-password", wantErr: "registry username must not contain ':', carriage returns, or newlines"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + err := ValidateAuthCredentials(tt.username, tt.password) + if tt.wantErr == "" { + g.Expect(err).ToNot(HaveOccurred()) + } else { + g.Expect(err).To(MatchError(tt.wantErr)) + } + }) + } +} + +func TestAuthRegistryRequested(t *testing.T) { + tests := []struct { + name string + username string + password string + want bool + wantErr string + }{ + {name: "no credentials"}, + {name: "both credentials", username: "test-user", password: "test-password", want: true}, + {name: "username only", username: "test-user", wantErr: "registry password must not be empty"}, + {name: "password only", password: "test-password", wantErr: "registry username must not be empty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + got, err := AuthRegistryRequested(tt.username, tt.password) + if tt.wantErr == "" { + g.Expect(err).ToNot(HaveOccurred()) + } else { + g.Expect(err).To(MatchError(tt.wantErr)) + } + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func TestGenerateHtpasswd(t *testing.T) { + g := NewWithT(t) + entry, err := generateHtpasswd("test-user", "test-password") + g.Expect(err).ToNot(HaveOccurred()) + + username, passwordHash, found := strings.Cut(entry, ":") + g.Expect(found).To(BeTrue()) + g.Expect(username).To(Equal("test-user")) + g.Expect(bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte("test-password"))).To(Succeed()) +} From 822c787faab999f5656aae7e8e3c83f4c398224b Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 9 Sep 2026 16:02:23 +0530 Subject: [PATCH 2/5] dns, cloudinit: configure VMs to reach the authenticated registry Add a cluster-hosts entry for the authenticated registry and register it as an insecure registry in the VM container runtime configuration, so that pods can pull from it over plain HTTP using imagePullSecrets. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- containerfiles/dns/cluster-hosts | 1 + internal/node/cloudinit.go | 8 ++++++++ internal/node/cloudinit_test.go | 14 ++++++++++++++ internal/node/templates/user-data.yaml.tmpl | 10 +++++++++- 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/containerfiles/dns/cluster-hosts b/containerfiles/dns/cluster-hosts index c3bc0a1..9ba7edc 100644 --- a/containerfiles/dns/cluster-hosts +++ b/containerfiles/dns/cluster-hosts @@ -1 +1,2 @@ 10.88.0.2 registry registry.cluster.local +10.88.0.3 auth-registry auth-registry.cluster.local diff --git a/internal/node/cloudinit.go b/internal/node/cloudinit.go index 55dac2a..abbe620 100644 --- a/internal/node/cloudinit.go +++ b/internal/node/cloudinit.go @@ -39,6 +39,10 @@ type CloudInitData struct { RegistryHostname string ServiceCIDR string TargetImgRef string + + AuthRegistryStaticIP string + AuthRegistryPort int + AuthRegistryHostname string } func (n *Node) newCloudInitData(sshPubKey string) CloudInitData { @@ -56,6 +60,10 @@ func (n *Node) newCloudInitData(sshPubKey string) CloudInitData { RegistryHostname: config.RegistryHostname, ServiceCIDR: config.ServiceCIDR, TargetImgRef: n.TargetImgRef, + + AuthRegistryStaticIP: config.AuthRegistryStaticIP, + AuthRegistryPort: config.AuthRegistryPort, + AuthRegistryHostname: config.AuthRegistryHostname, } } diff --git a/internal/node/cloudinit_test.go b/internal/node/cloudinit_test.go index c856045..94e8ba3 100644 --- a/internal/node/cloudinit_test.go +++ b/internal/node/cloudinit_test.go @@ -4,6 +4,7 @@ package node import ( + "fmt" "strings" "testing" @@ -25,6 +26,10 @@ func testCloudInitData() CloudInitData { RegistryPort: config.RegistryPort, RegistryHostname: config.RegistryHostname, ServiceCIDR: config.ServiceCIDR, + + AuthRegistryStaticIP: config.AuthRegistryStaticIP, + AuthRegistryPort: config.AuthRegistryPort, + AuthRegistryHostname: config.AuthRegistryHostname, } } @@ -117,6 +122,15 @@ func TestRenderTemplate_UserData(t *testing.T) { if !strings.Contains(s, registryURL) { t.Errorf("missing registry URL %s", registryURL) } + + authRegistryURL := fmt.Sprintf("%s:%d", config.AuthRegistryStaticIP, config.AuthRegistryPort) + if !strings.Contains(s, authRegistryURL) { + t.Errorf("missing auth registry URL %s", authRegistryURL) + } + authRegistryFQDN := fmt.Sprintf("%s.%s:%d", config.AuthRegistryHostname, config.ClusterDomain, config.AuthRegistryPort) + if !strings.Contains(s, authRegistryFQDN) { + t.Errorf("missing auth registry FQDN %s", authRegistryFQDN) + } } func TestValidateYAML(t *testing.T) { diff --git a/internal/node/templates/user-data.yaml.tmpl b/internal/node/templates/user-data.yaml.tmpl index ba51563..30f3e75 100644 --- a/internal/node/templates/user-data.yaml.tmpl +++ b/internal/node/templates/user-data.yaml.tmpl @@ -42,7 +42,7 @@ write_files: - path: /etc/crio/crio.conf.d/03-local-registry.conf content: | [crio.image] - insecure_registries = ["{{.RegistryStaticIP}}:{{.RegistryPort}}", "{{.RegistryHostname}}.{{.ClusterDomain}}:{{.RegistryPort}}"] + insecure_registries = ["{{.RegistryStaticIP}}:{{.RegistryPort}}", "{{.RegistryHostname}}.{{.ClusterDomain}}:{{.RegistryPort}}", "{{.AuthRegistryStaticIP}}:{{.AuthRegistryPort}}", "{{.AuthRegistryHostname}}.{{.ClusterDomain}}:{{.AuthRegistryPort}}"] - path: /etc/containers/registries.conf.d/10-local-registry.conf content: | [[registry]] @@ -52,6 +52,14 @@ write_files: [[registry]] location = "{{.RegistryHostname}}.{{.ClusterDomain}}:{{.RegistryPort}}" insecure = true + + [[registry]] + location = "{{.AuthRegistryStaticIP}}:{{.AuthRegistryPort}}" + insecure = true + + [[registry]] + location = "{{.AuthRegistryHostname}}.{{.ClusterDomain}}:{{.AuthRegistryPort}}" + insecure = true - path: /etc/systemd/system/var-mnt-cluster_images.mount content: | [Unit] From 4b4f3ae3d80e40e93e105c42bf3b18a5ff38bf77 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 9 Sep 2026 16:02:23 +0530 Subject: [PATCH 3/5] cli: wire authenticated registry into cluster lifecycle Start and stop the authenticated registry alongside the unauthenticated one during the cluster lifecycle, and report both in "bink registry info". Add --registry-user and --registry-password flags to "bink cluster start" and "bink registry start". Credentials must be provided explicitly; there is no default password, and neither the username nor the password is ever printed back to the user. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- internal/cli/cluster/start.go | 23 +++++++++++++++++++-- internal/cli/cluster/start_test.go | 22 ++++++++++++++++++++ internal/cli/cluster/stop.go | 2 +- internal/cli/registry/info.go | 26 ++++++++++++++++++----- internal/cli/registry/start.go | 30 ++++++++++++++++++++++++--- internal/cli/registry/start_test.go | 32 +++++++++++++++++++++++++++++ internal/cli/registry/stop.go | 25 +++++++++++++++++----- 7 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 internal/cli/cluster/start_test.go create mode 100644 internal/cli/registry/start_test.go diff --git a/internal/cli/cluster/start.go b/internal/cli/cluster/start.go index 353b696..75af550 100644 --- a/internal/cli/cluster/start.go +++ b/internal/cli/cluster/start.go @@ -30,6 +30,8 @@ func newStartCmd() *cobra.Command { var exposePath string var hostNetworkPopulator bool var targetImgRef string + var registryUser string + var registryPassword string cmd := &cobra.Command{ Use: "start", @@ -45,7 +47,7 @@ func newStartCmd() *cobra.Command { bink cluster start --memory 4096 --expose ./kubeconfig`, RunE: func(cmd *cobra.Command, args []string) error { logger := logrus.New() - return runStart(cmd.Context(), logger, nodeName, nodeImage, apiPort, memory, maxMemory, exposePath, hostNetworkPopulator, targetImgRef) + return runStart(cmd.Context(), logger, nodeName, nodeImage, apiPort, memory, maxMemory, exposePath, hostNetworkPopulator, targetImgRef, registryUser, registryPassword) }, } @@ -57,11 +59,18 @@ func newStartCmd() *cobra.Command { cmd.Flags().StringVar(&exposePath, "expose", "", "Expose API and save kubeconfig to PATH after cluster is up") cmd.Flags().BoolVar(&hostNetworkPopulator, "host-network-populator", false, "Use host networking for the image populator container (fixes DNS in nested podman)") cmd.Flags().StringVar(&targetImgRef, "target-imgref", "", "Override the bootc image reference tracked by the VM (e.g., registry.cluster.local:5000/node:latest)") + cmd.Flags().StringVar(®istryUser, "registry-user", "", "Username for the authenticated registry") + cmd.Flags().StringVar(®istryPassword, "registry-password", "", "Password for the authenticated registry") return cmd } -func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeImage string, apiPort int, memory int, maxMemory int, exposePath string, hostNetworkPopulator bool, targetImgRef string) error { +func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeImage string, apiPort int, memory int, maxMemory int, exposePath string, hostNetworkPopulator bool, targetImgRef string, registryUser string, registryPassword string) error { + authRegistryRequested, err := registry.AuthRegistryRequested(registryUser, registryPassword) + if err != nil { + return fmt.Errorf("invalid auth registry credentials: %w", err) + } + logger.Info("=== Creating Kubernetes cluster ===") logger.Info("") @@ -85,6 +94,11 @@ func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeI if err := registryMgr.EnsureRegistry(ctx); err != nil { return fmt.Errorf("ensuring registry: %w", err) } + if authRegistryRequested { + if err := registryMgr.EnsureAuthRegistry(ctx, registryUser, registryPassword); err != nil { + return fmt.Errorf("ensuring auth registry: %w", err) + } + } logger.Info("") logger.Info("Step 3: Ensuring DNS container...") @@ -197,6 +211,11 @@ func runStart(ctx context.Context, logger *logrus.Logger, nodeName string, nodeI logger.Infof(" Push: podman push --tls-verify=false localhost:%d/:", config.RegistryPort) logger.Infof(" Pull (in-cluster): %s.%s:%d/:", config.RegistryHostname, config.ClusterDomain, config.RegistryPort) logger.Info("") + if authRegistryRequested { + logger.Info("Auth registry (pull with credentials):") + logger.Infof(" Pull (in-cluster): %s.%s:%d/:", config.AuthRegistryHostname, config.ClusterDomain, config.AuthRegistryPort) + logger.Info("") + } if exposePath != "" { logger.Info("Step 9: Exposing API server...") diff --git a/internal/cli/cluster/start_test.go b/internal/cli/cluster/start_test.go new file mode 100644 index 0000000..3b365d8 --- /dev/null +++ b/internal/cli/cluster/start_test.go @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2026 The bink Authors +// SPDX-License-Identifier: Apache-2.0 + +package cluster + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestStartCredentialFlagsDefaultToEmpty(t *testing.T) { + g := NewWithT(t) + cmd := newStartCmd() + + username, err := cmd.Flags().GetString("registry-user") + g.Expect(err).ToNot(HaveOccurred()) + password, err := cmd.Flags().GetString("registry-password") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(username).To(BeEmpty()) + g.Expect(password).To(BeEmpty()) +} diff --git a/internal/cli/cluster/stop.go b/internal/cli/cluster/stop.go index a8d878a..2c97e4a 100644 --- a/internal/cli/cluster/stop.go +++ b/internal/cli/cluster/stop.go @@ -118,7 +118,7 @@ func runStop(ctx context.Context, logger *logrus.Logger, force, removeData bool) } else { logger.Info("✅ All cluster data removed") } - logger.Info("Note: Shared registry (bink-registry) is preserved. Use 'bink registry stop' to remove it.") + logger.Info("Note: Shared registries (bink-registry, bink-auth-registry) are preserved. Use 'bink registry stop' to remove them.") } return nil diff --git a/internal/cli/registry/info.go b/internal/cli/registry/info.go index e6e234c..13efdf0 100644 --- a/internal/cli/registry/info.go +++ b/internal/cli/registry/info.go @@ -31,11 +31,27 @@ func newInfoCmd() *cobra.Command { status = define.ContainerStateRunning.String() } - fmt.Printf("Registry: %s\n", status) - fmt.Printf("IP: %s\n", info.IP) - fmt.Printf("Host port: %d\n", info.HostPort) - fmt.Printf("Push: podman push --tls-verify=false %s/:\n", info.PushURL) - fmt.Printf("Pull: %s/:\n", info.PullURL) + fmt.Printf("Registry (unauthenticated): %s\n", status) + fmt.Printf(" IP: %s\n", info.IP) + fmt.Printf(" Host port: %d\n", info.HostPort) + fmt.Printf(" Push: podman push --tls-verify=false %s/:\n", info.PushURL) + fmt.Printf(" Pull: %s/:\n", info.PullURL) + fmt.Println() + + authInfo, err := mgr.AuthRegistryInfo(cmd.Context()) + if err != nil { + return fmt.Errorf("getting auth registry info: %w", err) + } + + authStatus := "stopped" + if authInfo.Running { + authStatus = define.ContainerStateRunning.String() + } + + fmt.Printf("Registry (authenticated): %s\n", authStatus) + fmt.Printf(" IP: %s\n", authInfo.IP) + fmt.Printf(" Host port: %d\n", authInfo.HostPort) + fmt.Printf(" Pull: %s/:\n", authInfo.PullURL) return nil }, diff --git a/internal/cli/registry/start.go b/internal/cli/registry/start.go index de392de..5653fcd 100644 --- a/internal/cli/registry/start.go +++ b/internal/cli/registry/start.go @@ -11,23 +11,47 @@ import ( ) func newStartCmd() *cobra.Command { + var authOnly bool + var registryUser string + var registryPassword string + cmd := &cobra.Command{ Use: "start", Short: "Start the local container registry", - Long: "Start the shared local registry container, creating it if it doesn't exist", + Long: "Start the shared local registry containers, creating them if they don't exist", RunE: func(cmd *cobra.Command, args []string) error { + authRequested, err := registrypkg.AuthRegistryRequested(registryUser, registryPassword) + if err != nil { + return fmt.Errorf("invalid auth registry credentials: %w", err) + } + if authOnly && !authRequested { + return fmt.Errorf("invalid auth registry credentials: registry username and password are required with --auth") + } + mgr, err := registrypkg.NewManager() if err != nil { return fmt.Errorf("creating registry manager: %w", err) } - if err := mgr.EnsureRegistry(cmd.Context()); err != nil { - return fmt.Errorf("starting registry: %w", err) + if !authOnly { + if err := mgr.EnsureRegistry(cmd.Context()); err != nil { + return fmt.Errorf("starting registry: %w", err) + } + } + + if authRequested { + if err := mgr.EnsureAuthRegistry(cmd.Context(), registryUser, registryPassword); err != nil { + return fmt.Errorf("starting auth registry: %w", err) + } } return nil }, } + cmd.Flags().BoolVar(&authOnly, "auth", false, "Start only the authenticated registry") + cmd.Flags().StringVar(®istryUser, "registry-user", "", "Username for the authenticated registry") + cmd.Flags().StringVar(®istryPassword, "registry-password", "", "Password for the authenticated registry") + return cmd } diff --git a/internal/cli/registry/start_test.go b/internal/cli/registry/start_test.go new file mode 100644 index 0000000..02b9524 --- /dev/null +++ b/internal/cli/registry/start_test.go @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: 2026 The bink Authors +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestStartAuthFlagsRequireCredentials(t *testing.T) { + g := NewWithT(t) + cmd := newStartCmd() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"--auth"}) + + g.Expect(cmd.Execute()).To(MatchError("invalid auth registry credentials: registry username and password are required with --auth")) +} + +func TestStartCredentialFlagsDefaultToEmpty(t *testing.T) { + g := NewWithT(t) + cmd := newStartCmd() + + username, err := cmd.Flags().GetString("registry-user") + g.Expect(err).ToNot(HaveOccurred()) + password, err := cmd.Flags().GetString("registry-password") + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(username).To(BeEmpty()) + g.Expect(password).To(BeEmpty()) +} diff --git a/internal/cli/registry/stop.go b/internal/cli/registry/stop.go index d5026d7..d416c9f 100644 --- a/internal/cli/registry/stop.go +++ b/internal/cli/registry/stop.go @@ -4,6 +4,7 @@ package registry import ( + "errors" "fmt" registrypkg "github.com/bootc-dev/bink/internal/registry" @@ -12,24 +13,38 @@ import ( ) func newStopCmd() *cobra.Command { + var authOnly bool + cmd := &cobra.Command{ Use: "stop", - Short: "Stop and remove the local registry", - Long: "Stop the shared local registry container and remove its data volume", + Short: "Stop and remove the local registries", + Long: "Stop both local registry containers and remove the shared data volume. Use --auth to stop only the authenticated registry.", RunE: func(cmd *cobra.Command, args []string) error { mgr, err := registrypkg.NewManager() if err != nil { return fmt.Errorf("creating registry manager: %w", err) } - if err := mgr.StopRegistry(cmd.Context()); err != nil { - return fmt.Errorf("stopping registry: %w", err) + authErr := mgr.StopAuthRegistry(cmd.Context()) + if authOnly { + if authErr != nil { + return fmt.Errorf("stopping auth registry: %w", authErr) + } + logrus.Info("Auth registry stopped and removed") + return nil + } + + registryErr := mgr.StopRegistry(cmd.Context()) + if err := errors.Join(authErr, registryErr); err != nil { + return fmt.Errorf("stopping registries: %w", err) } - logrus.Info("Registry stopped and data removed") + logrus.Info("All registries stopped and data removed") return nil }, } + cmd.Flags().BoolVar(&authOnly, "auth", false, "Stop only the authenticated registry") + return cmd } From 7fed47b17ed5457b4af5087b2264801d43a9b264 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Wed, 9 Sep 2026 16:02:23 +0530 Subject: [PATCH 4/5] test: add integration test for authenticated registry pull Start an authenticated registry with test-specific credentials, push an image through the shared storage via the unauthenticated registry, verify anonymous access is rejected, and confirm a pod can pull the image using imagePullSecrets. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- test/integration/helpers/cluster.go | 6 +- test/integration/registry_test.go | 155 ++++++++++++++++++++++++++-- 2 files changed, 148 insertions(+), 13 deletions(-) diff --git a/test/integration/helpers/cluster.go b/test/integration/helpers/cluster.go index 7a7e206..ba02373 100644 --- a/test/integration/helpers/cluster.go +++ b/test/integration/helpers/cluster.go @@ -54,9 +54,11 @@ func RunCommand(cmd *exec.Cmd, timeout ...time.Duration) *gexec.Session { // CreateCluster creates a cluster with the given name // This is a high-level helper that expects success // Uses auto-assigned ports (--api-port 0) to avoid port conflicts in tests -func CreateCluster(name string) { +func CreateCluster(name string, extraArgs ...string) { GinkgoWriter.Printf("Creating cluster: %s (with auto-assigned API port)\n", name) - cmd := BinkCmd("cluster", "start", "--cluster-name", name, "--api-port", "0", "--memory", "1900", "--max-memory", "4096", "--node-image", NodeImage()) + args := []string{"cluster", "start", "--cluster-name", name, "--api-port", "0", "--memory", "1900", "--max-memory", "4096", "--node-image", NodeImage()} + args = append(args, extraArgs...) + cmd := BinkCmd(args...) session := RunCommand(cmd, 10*time.Minute) Expect(session.ExitCode()).To(Equal(0), "Failed to create cluster: %s", string(session.Err.Contents())) } diff --git a/test/integration/registry_test.go b/test/integration/registry_test.go index c6949f3..4835126 100644 --- a/test/integration/registry_test.go +++ b/test/integration/registry_test.go @@ -5,7 +5,10 @@ package integration_test import ( "context" + "encoding/base64" + "encoding/json" "fmt" + "net/http" "time" . "github.com/onsi/ginkgo/v2" @@ -17,6 +20,13 @@ import ( "github.com/bootc-dev/bink/test/integration/helpers" ) +const ( + registryTestNamespace = metav1.NamespaceDefault + registryTestPodName = "registry-test" + authRegistryTestPodName = "auth-registry-test" + podExecEchoMessage = "hello" +) + var _ = Describe("Local Registry", func() { var clusterName string @@ -56,8 +66,8 @@ var _ = Describe("Local Registry", func() { config.RegistryHostname, config.ClusterDomain, config.RegistryPort) pod := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ - Name: "registry-test", - Labels: map[string]string{"run": "registry-test"}, + Name: registryTestPodName, + Labels: map[string]string{"run": registryTestPodName}, }, Spec: corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyNever, @@ -68,24 +78,147 @@ var _ = Describe("Local Registry", func() { }}, }, } - helpers.CreatePod(kubeClient, "default", pod, 5*time.Minute) + helpers.CreatePod(kubeClient, registryTestNamespace, pod, 5*time.Minute) By("Verifying the pod is running with the registry image") - runningPod, err := kubeClient.CoreV1().Pods("default").Get( - context.Background(), "registry-test", metav1.GetOptions{}) + runningPod, err := kubeClient.CoreV1().Pods(registryTestNamespace).Get( + context.Background(), registryTestPodName, metav1.GetOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(runningPod.Status.Phase).To(Equal(corev1.PodRunning)) Expect(runningPod.Spec.Containers[0].Image).To(Equal(registryImage)) By("Verifying the container is functional by running a command inside it") - Eventually(func() string { - result, _ := helpers.PodExec(kubeconfigPath, "default", "registry-test", - []string{"echo", "hello"}) - return result - }, 1*time.Minute, 5*time.Second).Should(ContainSubstring("hello")) + Eventually(func() (string, error) { + return helpers.PodExec(kubeconfigPath, registryTestNamespace, registryTestPodName, + []string{"echo", podExecEchoMessage}) + }, 1*time.Minute, 5*time.Second).Should(ContainSubstring(podExecEchoMessage)) By("Cleaning up the pod") - helpers.DeletePod(kubeClient, "default", "registry-test") + helpers.DeletePod(kubeClient, registryTestNamespace, registryTestPodName) + + By("Cleaning up the local registry tag") + helpers.ImageRemove(registryTag) + }) + + It("should pull from the authenticated registry using imagePullSecrets", func() { + authRegistryUser := "integration-" + clusterName + authRegistryPassword := "password-" + clusterName + + By("Requiring an unused authenticated registry") + Expect(helpers.ContainerExists(config.AuthRegistryContainerName)).To(BeFalse(), + "authenticated registry already exists; stop it before running this test") + + By("Starting an authenticated registry with test-specific credentials") + startSession := helpers.RunCommand(helpers.BinkCmd( + "registry", "start", "--auth", + "--registry-user", authRegistryUser, + "--registry-password", authRegistryPassword, + )) + Expect(startSession.ExitCode()).To(Equal(0), "Failed to start authenticated registry") + + DeferCleanup(func() { + session := helpers.RunCommand(helpers.BinkCmd("registry", "stop", "--auth")) + Expect(session.ExitCode()).To(Equal(0), "Failed to clean up authenticated registry") + }) + + By("Creating a single-node cluster") + helpers.CreateCluster(clusterName, + "--registry-user", authRegistryUser, + "--registry-password", authRegistryPassword) + + By("Pulling busybox image locally") + helpers.ImagePull(config.TestBusyboxImage) + + registryTag := fmt.Sprintf("localhost:%d/busybox:auth-registry-test", config.RegistryPort) + + By("Tagging busybox for the local registry") + helpers.ImageTag(config.TestBusyboxImage, "auth-registry-test", + fmt.Sprintf("localhost:%d/busybox", config.RegistryPort)) + + By("Pushing busybox to the unauthenticated registry") + helpers.ImagePush(registryTag, registryTag) + + By("Verifying the authenticated registry rejects anonymous requests") + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Get(fmt.Sprintf("http://localhost:%d/v2/", config.AuthRegistryPort)) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(response.Body.Close) + Expect(response.StatusCode).To(Equal(http.StatusUnauthorized)) + + By("Exposing API and creating Kubernetes client") + kubeClient, kubeconfigPath := helpers.SetupKubeClient(clusterName) + defer helpers.CleanupKubeconfig(kubeconfigPath) + + By("Removing control-plane taint to allow scheduling on single-node cluster") + helpers.RemoveControlPlaneTaint(kubeClient, "node1") + + By("Creating docker-registry secret with auth registry credentials") + authServer := fmt.Sprintf("%s.%s:%d", + config.AuthRegistryHostname, config.ClusterDomain, config.AuthRegistryPort) + authEncoded := base64.StdEncoding.EncodeToString( + []byte(authRegistryUser + ":" + authRegistryPassword)) + dockerConfig := map[string]any{ + "auths": map[string]any{ + authServer: map[string]any{ + "username": authRegistryUser, + "password": authRegistryPassword, + "auth": authEncoded, + }, + }, + } + dockerConfigJSON, err := json.Marshal(dockerConfig) + Expect(err).ToNot(HaveOccurred()) + + secretName := "auth-registry-secret" + _, err = kubeClient.CoreV1().Secrets(registryTestNamespace).Create( + context.Background(), + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: dockerConfigJSON}, + }, + metav1.CreateOptions{}, + ) + Expect(err).ToNot(HaveOccurred()) + + By("Deploying a pod that pulls from the authenticated registry") + authRegistryImage := fmt.Sprintf("%s.%s:%d/busybox:auth-registry-test", + config.AuthRegistryHostname, config.ClusterDomain, config.AuthRegistryPort) + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: authRegistryTestPodName, + Labels: map[string]string{"run": authRegistryTestPodName}, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + ImagePullSecrets: []corev1.LocalObjectReference{{Name: secretName}}, + Containers: []corev1.Container{{ + Name: "busybox", + Image: authRegistryImage, + ImagePullPolicy: corev1.PullAlways, + Command: []string{"sh", "-c", "echo 'auth-registry-pull-success' && sleep 3600"}, + }}, + }, + } + helpers.CreatePod(kubeClient, registryTestNamespace, pod, 5*time.Minute) + + By("Verifying the pod is running with the auth registry image") + runningPod, err := kubeClient.CoreV1().Pods(registryTestNamespace).Get( + context.Background(), authRegistryTestPodName, metav1.GetOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(runningPod.Status.Phase).To(Equal(corev1.PodRunning)) + Expect(runningPod.Spec.Containers[0].Image).To(Equal(authRegistryImage)) + + By("Verifying the container is functional by running a command inside it") + Eventually(func() (string, error) { + return helpers.PodExec(kubeconfigPath, registryTestNamespace, authRegistryTestPodName, + []string{"echo", podExecEchoMessage}) + }, 1*time.Minute, 5*time.Second).Should(ContainSubstring(podExecEchoMessage)) + + By("Cleaning up the pod and secret") + helpers.DeletePod(kubeClient, registryTestNamespace, authRegistryTestPodName) + Expect(kubeClient.CoreV1().Secrets(registryTestNamespace).Delete( + context.Background(), secretName, metav1.DeleteOptions{})).To(Succeed()) By("Cleaning up the local registry tag") helpers.ImageRemove(registryTag) From 9d321aa057154dd2efb2f0324d8e223e92f179d7 Mon Sep 17 00:00:00 2001 From: HarshwardhanPatil07 Date: Sat, 29 Aug 2026 11:55:42 +0530 Subject: [PATCH 5/5] ci: Build checkout images after restoring cache Cached or pre-pulled images currently replace the cluster and DNS images built from the pull request under the same tags. Build checkout-owned images last so integration tests consistently exercise the submitted source on both cache hits and misses. Assisted-by: AI Signed-off-by: HarshwardhanPatil07 --- .github/actions/setup-bink/action.yml | 30 ++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/.github/actions/setup-bink/action.yml b/.github/actions/setup-bink/action.yml index 0049f9b..b1dcc41 100644 --- a/.github/actions/setup-bink/action.yml +++ b/.github/actions/setup-bink/action.yml @@ -80,20 +80,6 @@ runs: shell: bash run: make build-bink - - name: Build cluster and DNS images - shell: bash - run: | - make build-cluster-image - make build-dns-image - - - name: Verify prerequisites - shell: bash - run: | - test -f ./bink - podman images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}" - df -h / - free -h - - name: Get image digests id: digests shell: bash @@ -139,3 +125,19 @@ runs: with: path: /tmp/podman-image-cache key: podman-images-v2-${{ inputs.cache-key-prefix }}-${{ steps.digests.outputs.hash }} + + # Build checkout-owned images after loading the cache so published images + # cannot replace changes made by the pull request under the same tags. + - name: Build cluster and DNS images + shell: bash + run: | + make build-cluster-image + make build-dns-image + + - name: Verify prerequisites + shell: bash + run: | + test -f ./bink + podman images --format "table {{.Repository}}:{{.Tag}}\t{{.Size}}" + df -h / + free -h