Skip to content
Open
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
1 change: 1 addition & 0 deletions internal/cli/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func NewRegistryCmd() *cobra.Command {
cmd.AddCommand(newStartCmd())
cmd.AddCommand(newStopCmd())
cmd.AddCommand(newInfoCmd())
cmd.AddCommand(newUpdatePasswordCmd())

return cmd
}
45 changes: 45 additions & 0 deletions internal/cli/registry/update_password.go
Original file line number Diff line number Diff line change
@@ -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(&registryUser, "registry-user", "", "Username for the authenticated registry")
cmd.Flags().StringVar(&registryPassword, "registry-password", "", "Password for the authenticated registry")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This will show the password in the terminal right? We should probably use a hidden field for passwords

_ = cmd.MarkFlagRequired("registry-user")
_ = cmd.MarkFlagRequired("registry-password")

return cmd
}
31 changes: 31 additions & 0 deletions internal/cli/registry/update_password_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
40 changes: 39 additions & 1 deletion internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

we can use backticks in Sprintf which should also help with escaping quotes

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

when restart the container entrypoint re-executes this leading to file being overwritten with initial credentials

})
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 {
Expand Down
21 changes: 21 additions & 0 deletions test/integration/helpers/kubectl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down
101 changes: 79 additions & 22 deletions test/integration/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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(
Expand All @@ -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())

Expand Down
Loading