Skip to content
Merged
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
27 changes: 27 additions & 0 deletions internal/cli/registry/password.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: 2026 The bink Authors
// SPDX-License-Identifier: Apache-2.0

package registry

import (
"fmt"
"os"

"golang.org/x/term"
)

func readPassword() (string, error) {
fd := int(os.Stdin.Fd())
if !term.IsTerminal(fd) {
return "", fmt.Errorf("no password provided and stdin is not a terminal; use --registry-password")
}

fmt.Fprint(os.Stderr, "Registry password: ")
password, err := term.ReadPassword(fd)
fmt.Fprintln(os.Stderr)
if err != nil {
return "", fmt.Errorf("reading password: %w", err)
}

return string(password), nil
}
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(newUpdateCredentialsCmd())

return cmd
}
8 changes: 8 additions & 0 deletions internal/cli/registry/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ func newStartCmd() *cobra.Command {
Short: "Start the local container registry",
Long: "Start the shared local registry containers, creating them if they don't exist",
RunE: func(cmd *cobra.Command, args []string) error {
if registryUser != "" && registryPassword == "" {
p, err := readPassword()
if err != nil {
return err
}
registryPassword = p
}

authRequested, err := registrypkg.AuthRegistryRequested(registryUser, registryPassword)
if err != nil {
return fmt.Errorf("invalid auth registry credentials: %w", err)
Expand Down
52 changes: 52 additions & 0 deletions internal/cli/registry/update_credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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 newUpdateCredentialsCmd() *cobra.Command {
var registryUser string
var registryPassword string

cmd := &cobra.Command{
Use: "update-credentials",
Short: "Update the authenticated registry credentials",
Long: "Update the credentials for the running authenticated registry without restarting it",
RunE: func(cmd *cobra.Command, args []string) error {
if registryPassword == "" {
p, err := readPassword()
if err != nil {
return err
}
registryPassword = p
}

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.UpdateAuthRegistryCredentials(cmd.Context(), registryUser, registryPassword); err != nil {
return fmt.Errorf("updating auth registry credentials: %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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

True, but it is an issue we already have in the setting of the password which we should fix

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

return cmd
}
31 changes: 31 additions & 0 deletions internal/cli/registry/update_credentials_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 TestUpdateCredentialsRequiresUser(t *testing.T) {
g := NewWithT(t)
cmd := newUpdateCredentialsCmd()
cmd.SilenceErrors = true
cmd.SilenceUsage = true

g.Expect(cmd.Execute()).To(MatchError(ContainSubstring("required flag")))
}

func TestUpdateCredentialsFlagsDefaultToEmpty(t *testing.T) {
g := NewWithT(t)
cmd := newUpdateCredentialsCmd()

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())
}
27 changes: 26 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 UpdateAuthRegistryCredentials.
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,31 @@ func (m *Manager) createAuthContainer(ctx context.Context, username, password st
return nil
}

func (m *Manager) UpdateAuthRegistryCredentials(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)
}

if err := m.StopAuthRegistry(ctx); err != nil {
return fmt.Errorf("stopping auth registry for credential update: %w", err)
}

if err := m.EnsureAuthRegistry(ctx, username, password); err != nil {
return fmt.Errorf("restarting auth registry with new credentials: %w", err)
}

logrus.Infof("Auth registry credentials 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-credentials",
"--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