From 1b8529ab052bdcf780582f18cf1d8c6e9872fe9b Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 6 Oct 2026 09:05:21 +0000 Subject: [PATCH 1/2] registry: Add update-password command Allow updating auth registry credentials on a running container by overwriting the htpasswd file via exec. Assisted-by: AI Signed-off-by: Alice Frosi --- internal/cli/registry/registry.go | 1 + internal/cli/registry/update_password.go | 45 +++++++++++++++++++ internal/cli/registry/update_password_test.go | 31 +++++++++++++ internal/registry/registry.go | 40 ++++++++++++++++- 4 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 internal/cli/registry/update_password.go create mode 100644 internal/cli/registry/update_password_test.go diff --git a/internal/cli/registry/registry.go b/internal/cli/registry/registry.go index 587e7fc..55fbc62 100644 --- a/internal/cli/registry/registry.go +++ b/internal/cli/registry/registry.go @@ -17,6 +17,7 @@ func NewRegistryCmd() *cobra.Command { cmd.AddCommand(newStartCmd()) cmd.AddCommand(newStopCmd()) cmd.AddCommand(newInfoCmd()) + cmd.AddCommand(newUpdatePasswordCmd()) return cmd } diff --git a/internal/cli/registry/update_password.go b/internal/cli/registry/update_password.go new file mode 100644 index 0000000..cfc28ea --- /dev/null +++ b/internal/cli/registry/update_password.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 The bink Authors +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "fmt" + + registrypkg "github.com/bootc-dev/bink/internal/registry" + "github.com/spf13/cobra" +) + +func newUpdatePasswordCmd() *cobra.Command { + var registryUser string + var registryPassword string + + cmd := &cobra.Command{ + Use: "update-password", + Short: "Update the authenticated registry password", + Long: "Update the credentials for the running authenticated registry without restarting it", + RunE: func(cmd *cobra.Command, args []string) error { + if err := registrypkg.ValidateAuthCredentials(registryUser, registryPassword); err != nil { + return fmt.Errorf("invalid credentials: %w", err) + } + + mgr, err := registrypkg.NewManager() + if err != nil { + return fmt.Errorf("creating registry manager: %w", err) + } + + if err := mgr.UpdateAuthRegistryPassword(cmd.Context(), registryUser, registryPassword); err != nil { + return fmt.Errorf("updating auth registry password: %w", err) + } + + return nil + }, + } + + cmd.Flags().StringVar(®istryUser, "registry-user", "", "Username for the authenticated registry") + cmd.Flags().StringVar(®istryPassword, "registry-password", "", "Password for the authenticated registry") + _ = cmd.MarkFlagRequired("registry-user") + _ = cmd.MarkFlagRequired("registry-password") + + return cmd +} diff --git a/internal/cli/registry/update_password_test.go b/internal/cli/registry/update_password_test.go new file mode 100644 index 0000000..76b1750 --- /dev/null +++ b/internal/cli/registry/update_password_test.go @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2026 The bink Authors +// SPDX-License-Identifier: Apache-2.0 + +package registry + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestUpdatePasswordRequiresCredentials(t *testing.T) { + g := NewWithT(t) + cmd := newUpdatePasswordCmd() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + g.Expect(cmd.Execute()).To(MatchError(ContainSubstring("required flag"))) +} + +func TestUpdatePasswordFlagsDefaultToEmpty(t *testing.T) { + g := NewWithT(t) + cmd := newUpdatePasswordCmd() + + 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/registry/registry.go b/internal/registry/registry.go index 4076ed2..06caaaf 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -202,7 +202,7 @@ func (m *Manager) RegistryInfo(ctx context.Context) (*RegistryStatus, error) { // 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. +// container: to change them, use UpdateAuthRegistryPassword. 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 { @@ -290,6 +290,44 @@ func (m *Manager) createAuthContainer(ctx context.Context, username, password st return nil } +func (m *Manager) UpdateAuthRegistryPassword(ctx context.Context, username, password string) error { + if err := ValidateAuthCredentials(username, password); err != nil { + return err + } + + exists, err := m.podman.ContainerExists(ctx, config.AuthRegistryContainerName) + if err != nil { + return fmt.Errorf("checking auth registry container: %w", err) + } + if !exists { + return fmt.Errorf("auth registry container %q does not exist", config.AuthRegistryContainerName) + } + + status, err := m.podman.ContainerStatus(ctx, config.AuthRegistryContainerName) + if err != nil { + return fmt.Errorf("checking auth registry status: %w", err) + } + if status != define.ContainerStateRunning.String() { + return fmt.Errorf("auth registry is not running (status: %s)", status) + } + + htpasswdEntry, err := generateHtpasswd(username, password) + if err != nil { + return fmt.Errorf("generating htpasswd: %w", err) + } + + escaped := strings.ReplaceAll(htpasswdEntry, "'", "'\\''") + _, err = m.podman.ContainerExec(ctx, config.AuthRegistryContainerName, []string{ + "/bin/sh", "-c", fmt.Sprintf("printf '%%s\\n' '%s' > /auth/htpasswd", escaped), + }) + if err != nil { + return fmt.Errorf("updating htpasswd in auth registry: %w", err) + } + + logrus.Infof("Auth registry password updated for user %q", username) + return nil +} + func (m *Manager) StopAuthRegistry(ctx context.Context) error { exists, err := m.podman.ContainerExists(ctx, config.AuthRegistryContainerName) if err != nil { From 4838de0c3a5eb8bf1e7e6c2602d7f8f1d4d3f46b Mon Sep 17 00:00:00 2001 From: Alice Frosi Date: Tue, 6 Oct 2026 09:05:28 +0000 Subject: [PATCH 2/2] test: Add credential rotation to auth registry test Extend the auth registry integration test to verify password rotation with both user and password change, and add a CreateAuthPod helper to reduce duplication. Assisted-by: AI Signed-off-by: Alice Frosi --- test/integration/helpers/kubectl.go | 21 ++++++ test/integration/registry_test.go | 101 ++++++++++++++++++++++------ 2 files changed, 100 insertions(+), 22 deletions(-) diff --git a/test/integration/helpers/kubectl.go b/test/integration/helpers/kubectl.go index 3300cc8..8d9480a 100644 --- a/test/integration/helpers/kubectl.go +++ b/test/integration/helpers/kubectl.go @@ -137,6 +137,27 @@ func CreatePod(client *kubernetes.Clientset, namespace string, pod *corev1.Pod, WaitForPodReady(client, namespace, fmt.Sprintf("run=%s", pod.Name), timeout) } +// CreateAuthPod creates a pod that pulls from the authenticated registry using imagePullSecrets. +func CreateAuthPod(client *kubernetes.Clientset, namespace, podName, image, secretName string, timeout time.Duration) { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Labels: map[string]string{"run": podName}, + }, + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + ImagePullSecrets: []corev1.LocalObjectReference{{Name: secretName}}, + Containers: []corev1.Container{{ + Name: "busybox", + Image: image, + ImagePullPolicy: corev1.PullAlways, + Command: []string{"sh", "-c", "echo 'auth-registry-pull-success' && sleep 3600"}, + }}, + }, + } + CreatePod(client, namespace, pod, timeout) +} + // DeletePod deletes a pod. Does not fail if already gone. func DeletePod(client *kubernetes.Clientset, namespace, name string) { _ = client.CoreV1().Pods(namespace).Delete(context.Background(), name, metav1.DeleteOptions{}) diff --git a/test/integration/registry_test.go b/test/integration/registry_test.go index 4835126..3425fa9 100644 --- a/test/integration/registry_test.go +++ b/test/integration/registry_test.go @@ -21,10 +21,11 @@ import ( ) const ( - registryTestNamespace = metav1.NamespaceDefault - registryTestPodName = "registry-test" - authRegistryTestPodName = "auth-registry-test" - podExecEchoMessage = "hello" + registryTestNamespace = metav1.NamespaceDefault + registryTestPodName = "registry-test" + authRegistryTestPodName = "auth-registry-test" + authRegistryRotatedTestPodName = "auth-registry-rotated-test" + podExecEchoMessage = "hello" ) var _ = Describe("Local Registry", func() { @@ -184,23 +185,8 @@ var _ = Describe("Local Registry", func() { 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) + helpers.CreateAuthPod(kubeClient, registryTestNamespace, authRegistryTestPodName, + authRegistryImage, secretName, 5*time.Minute) By("Verifying the pod is running with the auth registry image") runningPod, err := kubeClient.CoreV1().Pods(registryTestNamespace).Get( @@ -215,8 +201,79 @@ var _ = Describe("Local Registry", func() { []string{"echo", podExecEchoMessage}) }, 1*time.Minute, 5*time.Second).Should(ContainSubstring(podExecEchoMessage)) - By("Cleaning up the pod and secret") + By("Verifying the original credentials are accepted before rotation") + authURL := fmt.Sprintf("http://localhost:%d/v2/", config.AuthRegistryPort) + client = &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", authURL, nil) + Expect(err).ToNot(HaveOccurred()) + req.SetBasicAuth(authRegistryUser, authRegistryPassword) + response, err = client.Do(req) + Expect(err).ToNot(HaveOccurred()) + response.Body.Close() + Expect(response.StatusCode).To(Equal(http.StatusOK)) + + newRegistryUser := "rotated-" + clusterName + newRegistryPassword := "rotated-password-" + clusterName + + By("Updating the auth registry password") + updateSession := helpers.RunCommand(helpers.BinkCmd( + "registry", "update-password", + "--registry-user", newRegistryUser, + "--registry-password", newRegistryPassword, + )) + Expect(updateSession.ExitCode()).To(Equal(0), "Failed to update auth registry password") + + By("Verifying the old credentials are rejected after rotation") + req, err = http.NewRequest("GET", authURL, nil) + Expect(err).ToNot(HaveOccurred()) + req.SetBasicAuth(authRegistryUser, authRegistryPassword) + response, err = client.Do(req) + Expect(err).ToNot(HaveOccurred()) + response.Body.Close() + Expect(response.StatusCode).To(Equal(http.StatusUnauthorized)) + + By("Verifying the new credentials are accepted after rotation") + req, err = http.NewRequest("GET", authURL, nil) + Expect(err).ToNot(HaveOccurred()) + req.SetBasicAuth(newRegistryUser, newRegistryPassword) + response, err = client.Do(req) + Expect(err).ToNot(HaveOccurred()) + response.Body.Close() + Expect(response.StatusCode).To(Equal(http.StatusOK)) + + By("Updating the Kubernetes pull secret with new credentials") + newAuthEncoded := base64.StdEncoding.EncodeToString( + []byte(newRegistryUser + ":" + newRegistryPassword)) + newDockerConfig := map[string]any{ + "auths": map[string]any{ + authServer: map[string]any{ + "username": newRegistryUser, + "password": newRegistryPassword, + "auth": newAuthEncoded, + }, + }, + } + newDockerConfigJSON, err := json.Marshal(newDockerConfig) + Expect(err).ToNot(HaveOccurred()) + + _, err = kubeClient.CoreV1().Secrets(registryTestNamespace).Update( + context.Background(), + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: secretName}, + Type: corev1.SecretTypeDockerConfigJson, + Data: map[string][]byte{corev1.DockerConfigJsonKey: newDockerConfigJSON}, + }, + metav1.UpdateOptions{}, + ) + Expect(err).ToNot(HaveOccurred()) + + By("Creating a new pod to verify pull works with new credentials") + helpers.CreateAuthPod(kubeClient, registryTestNamespace, authRegistryRotatedTestPodName, + authRegistryImage, secretName, 5*time.Minute) + + By("Cleaning up the pods and secret") helpers.DeletePod(kubeClient, registryTestNamespace, authRegistryTestPodName) + helpers.DeletePod(kubeClient, registryTestNamespace, authRegistryRotatedTestPodName) Expect(kubeClient.CoreV1().Secrets(registryTestNamespace).Delete( context.Background(), secretName, metav1.DeleteOptions{})).To(Succeed())