From 467a242c951418adb2299d994164e9c3aaead06b Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Fri, 7 Aug 2026 10:18:23 +0100 Subject: [PATCH 1/6] Identify credential errors --- integration/docker_test.go | 61 ++++++++++++++++++++++++--- internal/docker/application.go | 21 +++++---- internal/docker/errors.go | 43 +++++++++++++++++++ internal/docker/errors_test.go | 49 +++++++++++++++++++++ internal/docker/progress.go | 7 +++ internal/docker/progress_test.go | 13 ++++++ internal/docker/registry_auth.go | 14 ++++++ internal/docker/registry_auth_test.go | 8 ++++ internal/ui/install_test.go | 14 ++++++ 9 files changed, 213 insertions(+), 17 deletions(-) diff --git a/integration/docker_test.go b/integration/docker_test.go index 29212b5..dbcb3af 100644 --- a/integration/docker_test.go +++ b/integration/docker_test.go @@ -26,6 +26,7 @@ import ( "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" "github.com/docker/go-connections/nat" + "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/mutate" @@ -258,6 +259,32 @@ func TestUpdateDetectsLocalImageChange(t *testing.T) { assert.NotEqual(t, firstContainer, secondContainer, "container should change after update") } +func TestDeployDetectsMissingRegistryCredentials(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + registryURL := startLocalAuthRegistry(t, ctx) + imageTag := registryURL + "/private-test:latest" + auth := remote.WithAuth(&authn.Basic{Username: "testuser", Password: "testpass"}) + buildAndPushImage(t, ctx, imageTag, "v1", auth) + + t.Setenv("DOCKER_CONFIG", t.TempDir()) + + ns := newTestNamespace(t, "once-auth-test") + + err := docker.NewApplication(ns, docker.ApplicationSettings{ + Name: "authapp", + Image: imageTag, + Host: "authapp.localhost", + }).Deploy(ctx, nil) + + var authErr *docker.RegistryAuthError + require.ErrorAs(t, err, &authErr) + assert.Equal(t, registryURL, authErr.Registry) + assert.ErrorIs(t, err, docker.ErrPullFailed) + assert.Equal(t, "Log in to "+registryURL+" first. This image can't be downloaded without registry credentials.", docker.ErrorMessage(err)) +} + func TestLargeLabelData(t *testing.T) { t.Parallel() @@ -1268,6 +1295,27 @@ func collectPauseEvents(t *testing.T, ctx context.Context, containerName string) } func startLocalRegistry(t *testing.T, ctx context.Context) string { + return startRegistryContainer(t, ctx, nil, nil, http.StatusOK) +} + +func startLocalAuthRegistry(t *testing.T, ctx context.Context) string { + t.Helper() + + authDir := t.TempDir() + htpasswd := "testuser:$2a$04$MpM8fR.Xgy8DLIwkC85y/eyL7GYvMxHPMcfv7JJCJElJuTjezW.Xa\n" // bcrypt of "testpass" + require.NoError(t, os.WriteFile(filepath.Join(authDir, "htpasswd"), []byte(htpasswd), 0644)) + + env := []string{ + "REGISTRY_AUTH=htpasswd", + "REGISTRY_AUTH_HTPASSWD_REALM=test", + "REGISTRY_AUTH_HTPASSWD_PATH=/auth/htpasswd", + } + binds := []string{authDir + ":/auth:ro"} + + return startRegistryContainer(t, ctx, env, binds, http.StatusUnauthorized) +} + +func startRegistryContainer(t *testing.T, ctx context.Context, env, binds []string, readyStatus int) string { t.Helper() c, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) require.NoError(t, err) @@ -1282,8 +1330,9 @@ func startLocalRegistry(t *testing.T, ctx context.Context) string { portStr := strconv.Itoa(port) resp, err := c.ContainerCreate(ctx, - &container.Config{Image: "registry:2"}, + &container.Config{Image: "registry:2", Env: env}, &container.HostConfig{ + Binds: binds, PortBindings: nat.PortMap{ "5000/tcp": []nat.PortBinding{{HostPort: portStr}}, }, @@ -1304,7 +1353,7 @@ func startLocalRegistry(t *testing.T, ctx context.Context) string { return false } resp.Body.Close() - return resp.StatusCode == http.StatusOK + return resp.StatusCode == readyStatus }, 10*time.Second, 200*time.Millisecond, "registry did not become ready") return registryURL @@ -1349,12 +1398,12 @@ func baseImage(t *testing.T, ctx context.Context) v1.Image { return img } -func pushToRegistry(t *testing.T, ctx context.Context, tag string, img v1.Image) { +func pushToRegistry(t *testing.T, ctx context.Context, tag string, img v1.Image, opts ...remote.Option) { t.Helper() ref, err := name.ParseReference(tag) require.NoError(t, err) - require.NoError(t, remote.Write(ref, img, remote.WithContext(ctx))) + require.NoError(t, remote.Write(ref, img, append([]remote.Option{remote.WithContext(ctx)}, opts...)...)) } func buildTestBackup(t *testing.T, imageName string) []byte { @@ -1454,7 +1503,7 @@ func extractTarGz(t *testing.T, r io.Reader) map[string][]byte { return entries } -func buildAndPushImage(t *testing.T, ctx context.Context, tag, version string) { +func buildAndPushImage(t *testing.T, ctx context.Context, tag, version string, opts ...remote.Option) { t.Helper() base := baseImage(t, ctx) @@ -1470,5 +1519,5 @@ func buildAndPushImage(t *testing.T, ctx context.Context, tag, version string) { img, err := mutate.ConfigFile(base, cfg) require.NoError(t, err) - pushToRegistry(t, ctx, tag, img) + pushToRegistry(t, ctx, tag, img, opts...) } diff --git a/internal/docker/application.go b/internal/docker/application.go index 351d0f5..d0929d3 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "io" "log/slog" "net/http" "strconv" @@ -292,19 +291,12 @@ func (a *Application) pullImage(ctx context.Context, progress DeployProgressCall opts := image.PullOptions{RegistryAuth: registryAuthFor(a.Settings.Image)} reader, err := a.namespace.client.ImagePull(ctx, a.Settings.Image, opts) if err != nil { - return false, fmt.Errorf("%w: %w", ErrPullFailed, err) + return false, a.pullError(err) } defer reader.Close() - if progress != nil { - tracker := newPullProgressTracker(progress) - if err := tracker.Track(reader); err != nil { - return false, fmt.Errorf("%w: %w", ErrPullFailed, err) - } - } else { - if _, err := io.Copy(io.Discard, reader); err != nil { - return false, fmt.Errorf("%w: %w", ErrPullFailed, err) - } + if err := newPullProgressTracker(progress).Track(reader); err != nil { + return false, a.pullError(err) } pulledInspect, err := a.namespace.client.ImageInspect(ctx, a.Settings.Image) @@ -315,6 +307,13 @@ func (a *Application) pullImage(ctx context.Context, progress DeployProgressCall return pulledInspect.ID != a.runningImageID(ctx), nil } +func (a *Application) pullError(err error) error { + if isRegistryAuthError(err) { + return &RegistryAuthError{Registry: registryHostFor(a.Settings.Image), Cause: err} + } + return fmt.Errorf("%w: %w", ErrPullFailed, err) +} + func (a *Application) runningImageID(ctx context.Context) string { name, err := a.ContainerName(ctx) if err != nil { diff --git a/internal/docker/errors.go b/internal/docker/errors.go index 7969471..b11bc88 100644 --- a/internal/docker/errors.go +++ b/internal/docker/errors.go @@ -2,7 +2,10 @@ package docker import ( "errors" + "fmt" "strings" + + "github.com/containerd/errdefs" ) type DescribedError interface { @@ -21,6 +24,23 @@ var ( } ) +type RegistryAuthError struct { + Registry string + Cause error +} + +func (e *RegistryAuthError) Error() string { + return fmt.Sprintf("registry authentication required for %s: %v", e.Registry, e.Cause) +} + +func (e *RegistryAuthError) Description() string { + return fmt.Sprintf("Log in to %s first. This image can't be downloaded without registry credentials.", e.Registry) +} + +func (e *RegistryAuthError) Unwrap() error { return e.Cause } + +func (e *RegistryAuthError) Is(target error) bool { return target == ErrPullFailed } + func ErrorMessage(err error) string { var de DescribedError if errors.As(err, &de) { @@ -49,3 +69,26 @@ func isPortConflict(err error) bool { return strings.Contains(msg, "bind: address already in use") || strings.Contains(msg, "port is already allocated") } + +func isRegistryAuthError(err error) bool { + if err == nil { + return false + } + if errdefs.IsUnauthorized(err) { + return true + } + msg := strings.ToLower(err.Error()) + for _, indicator := range []string{ + "unauthorized", + "authentication required", + "pull access denied", + "no basic auth credentials", + "insufficient_scope", + "failed to authorize", + } { + if strings.Contains(msg, indicator) { + return true + } + } + return false +} diff --git a/internal/docker/errors_test.go b/internal/docker/errors_test.go index 16dd3e8..6f0fc94 100644 --- a/internal/docker/errors_test.go +++ b/internal/docker/errors_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsPortConflict(t *testing.T) { @@ -30,3 +31,51 @@ func TestErrorMessage(t *testing.T) { assert.Equal(t, "something broke", ErrorMessage(err)) }) } + +func TestIsRegistryAuthError(t *testing.T) { + authErrors := []string{ + "Error response from daemon: pull access denied for acme/private, repository does not exist or may require 'docker login': denied: requested access to the resource is denied", + "Error response from daemon: unauthorized: incorrect username or password", + "unauthorized: authentication required", + `Error response from daemon: Get "https://registry.example.com/v2/": no basic auth credentials`, + "pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed", + `failed to resolve reference "ghcr.io/acme/app:latest": failed to authorize: failed to fetch anonymous token: unexpected status: 401 Unauthorized`, + } + for _, msg := range authErrors { + assert.True(t, isRegistryAuthError(errors.New(msg)), msg) + } + + otherErrors := []string{ + "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", + "Error response from daemon: manifest for acme/app:latest not found: manifest unknown: manifest unknown", + `Get "https://registry.example.com/v2/": dial tcp: lookup registry.example.com: no such host`, + "context deadline exceeded", + "open /var/lib/docker/tmp/foo: permission denied", + } + for _, msg := range otherErrors { + assert.False(t, isRegistryAuthError(errors.New(msg)), msg) + } + + assert.False(t, isRegistryAuthError(nil)) + assert.True(t, isRegistryAuthError(fmt.Errorf("pull: %w", unauthorizedStub{}))) +} + +func TestRegistryAuthError(t *testing.T) { + cause := errors.New("no basic auth credentials") + err := fmt.Errorf("%w: %w", ErrDeployFailed, &RegistryAuthError{Registry: "ghcr.io", Cause: cause}) + + assert.ErrorIs(t, err, ErrPullFailed) + + var authErr *RegistryAuthError + require.ErrorAs(t, err, &authErr) + assert.Equal(t, "ghcr.io", authErr.Registry) + assert.Equal(t, cause, authErr.Unwrap()) + assert.Equal(t, "Log in to ghcr.io first. This image can't be downloaded without registry credentials.", ErrorMessage(err)) +} + +// Helpers + +type unauthorizedStub struct{} + +func (unauthorizedStub) Error() string { return "the registry said no" } +func (unauthorizedStub) Unauthorized() {} diff --git a/internal/docker/progress.go b/internal/docker/progress.go index 55734b9..4299de7 100644 --- a/internal/docker/progress.go +++ b/internal/docker/progress.go @@ -60,6 +60,10 @@ func (t *pullProgressTracker) Track(reader io.Reader) error { return err } + if msg.Error != nil { + return msg.Error + } + t.processMessage(msg) } @@ -115,6 +119,9 @@ func (t *pullProgressTracker) reportProgress() { } func (t *pullProgressTracker) report(percentage int) { + if t.callback == nil { + return + } t.callback(DeployProgress{ Stage: DeployStageDownloading, Percentage: percentage, diff --git a/internal/docker/progress_test.go b/internal/docker/progress_test.go index 36cfa79..3fd7064 100644 --- a/internal/docker/progress_test.go +++ b/internal/docker/progress_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestPullProgressTracker(t *testing.T) { @@ -61,6 +62,18 @@ func TestPullProgressTrackerWithCachedLayers(t *testing.T) { assert.Equal(t, 100, lastUpdate.Percentage) } +func TestPullProgressTrackerReturnsStreamError(t *testing.T) { + events := `{"status":"Pulling from acme/private","id":"latest"} +{"errorDetail":{"message":"unauthorized: authentication required"},"error":"unauthorized: authentication required"} +` + + tracker := newPullProgressTracker(nil) + err := tracker.Track(strings.NewReader(events)) + + require.Error(t, err) + assert.Contains(t, err.Error(), "unauthorized: authentication required") +} + // Helpers func assertNeverDecreases(t *testing.T, updates []DeployProgress) { diff --git a/internal/docker/registry_auth.go b/internal/docker/registry_auth.go index 11c418e..1eea3cf 100644 --- a/internal/docker/registry_auth.go +++ b/internal/docker/registry_auth.go @@ -30,3 +30,17 @@ func registryAuthFor(imageName string) string { } return base64.URLEncoding.EncodeToString(data) } + +// registryHostFor returns the registry hostname for the given image, in the +// form a user would pass to `docker login`. +func registryHostFor(imageName string) string { + ref, err := name.ParseReference(imageName) + if err != nil { + return "the registry" + } + registry := ref.Context().RegistryStr() + if registry == name.DefaultRegistry { + return "docker.io" + } + return registry +} diff --git a/internal/docker/registry_auth_test.go b/internal/docker/registry_auth_test.go index 65ef65a..5ccf53a 100644 --- a/internal/docker/registry_auth_test.go +++ b/internal/docker/registry_auth_test.go @@ -12,6 +12,14 @@ import ( "github.com/stretchr/testify/require" ) +func TestRegistryHostFor(t *testing.T) { + assert.Equal(t, "docker.io", registryHostFor("nginx")) + assert.Equal(t, "docker.io", registryHostFor("acme/private:latest")) + assert.Equal(t, "ghcr.io", registryHostFor("ghcr.io/basecamp/once-campfire:latest")) + assert.Equal(t, "localhost:5000", registryHostFor("localhost:5000/foo")) + assert.Equal(t, "the registry", registryHostFor(":::bad")) +} + func TestRegistryAuthFor(t *testing.T) { t.Run("invalid image string", func(t *testing.T) { isolateDockerConfig(t) diff --git a/internal/ui/install_test.go b/internal/ui/install_test.go index 1bdd8d7..736fe4f 100644 --- a/internal/ui/install_test.go +++ b/internal/ui/install_test.go @@ -261,6 +261,20 @@ func TestInstall_PullFailureReturnsToAppList(t *testing.T) { assert.Equal(t, pullErr, m.err) } +func TestInstall_AuthFailureShowsLoginMessage(t *testing.T) { + ns := newTestNamespace() + m := NewInstall(ns, "") + m, _ = updateInstall(m, tea.WindowSizeMsg{Width: 120, Height: 24}) + m, _ = updateInstall(m, InstallCustomSelectedMsg{}) + m, _ = updateInstall(m, InstallImageSubmitMsg{ImageRef: "ghcr.io/acme/private"}) + m, _ = updateInstall(m, InstallFormSubmitMsg{ImageRef: "ghcr.io/acme/private", Hostname: "app.example.com"}) + + authErr := &docker.RegistryAuthError{Registry: "ghcr.io", Cause: errors.New("no basic auth credentials")} + m, _ = updateInstall(m, InstallActivityFailedMsg{Err: fmt.Errorf("%w: %w", docker.ErrDeployFailed, authErr)}) + assert.Equal(t, installStateImageForm, m.state) + assert.Contains(t, m.View(), "Log in to ghcr.io first") +} + func TestInstall_NonPullDeployFailureReturnsToHostname(t *testing.T) { ns := newTestNamespace() m := NewInstall(ns, "") From 0eb904f5097828cadf276f446a8eacca69dd656d Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Fri, 7 Aug 2026 15:03:39 +0100 Subject: [PATCH 2/6] Support registry credentials in app settings Add RegistrySettings (username/password) to ApplicationSettings and use them when pulling images. Credentials stored in the settings take precedence over the Docker credential store. The deploy and update commands accept the new credentials with the --registry-username and --registry-password flags. To keep the password out of shell history and terminal logs, it can also be read from stdin with --registry-password-stdin. --- integration/docker_test.go | 32 +++++++++ internal/command/deploy.go | 2 +- internal/command/deploy_test.go | 35 +++++++++- internal/command/settings_flags.go | 71 +++++++++++++++----- internal/command/update_test.go | 22 ++++++ internal/docker/application.go | 2 +- internal/docker/application_settings.go | 13 ++++ internal/docker/application_settings_test.go | 23 +++++++ internal/docker/registry_auth.go | 27 ++++++-- internal/docker/registry_auth_test.go | 42 +++++++++--- 10 files changed, 232 insertions(+), 37 deletions(-) diff --git a/integration/docker_test.go b/integration/docker_test.go index dbcb3af..2217f05 100644 --- a/integration/docker_test.go +++ b/integration/docker_test.go @@ -6,6 +6,7 @@ import ( "compress/gzip" "context" "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -285,6 +286,37 @@ func TestDeployDetectsMissingRegistryCredentials(t *testing.T) { assert.Equal(t, "Log in to "+registryURL+" first. This image can't be downloaded without registry credentials.", docker.ErrorMessage(err)) } +func TestDeployWithRegistryCredentials(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + registryURL := startLocalAuthRegistry(t, ctx) + imageTag := registryURL + "/private-deploy-test:latest" + auth := remote.WithAuth(&authn.Basic{Username: "testuser", Password: "testpass"}) + buildAndPushImage(t, ctx, imageTag, "v1", auth) + + // The Docker credential store holds wrong credentials for this registry, + // to prove that the credentials in the app settings take precedence. + configDir := t.TempDir() + t.Setenv("DOCKER_CONFIG", configDir) + wrongAuth := base64.StdEncoding.EncodeToString([]byte("wronguser:wrongpass")) + config := fmt.Sprintf(`{"auths":{%q:{"auth":%q}}}`, registryURL, wrongAuth) + require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.json"), []byte(config), 0600)) + + ns := newTestNamespace(t, "once-auth-deploy-test") + require.NoError(t, ns.EnsureNetwork(ctx)) + require.NoError(t, ns.Proxy().Boot(ctx, getProxyPorts(t))) + + settings := docker.ApplicationSettings{ + Name: "authapp", + Image: imageTag, + Host: "authapp.localhost", + Registry: docker.RegistrySettings{Username: "testuser", Password: "testpass"}, + } + app := deployApp(t, ctx, ns, settings) + assert.Equal(t, settings.Registry, app.Settings.Registry, "registry credentials should persist in the app label") +} + func TestLargeLabelData(t *testing.T) { t.Parallel() diff --git a/internal/command/deploy.go b/internal/command/deploy.go index ec90559..1702f29 100644 --- a/internal/command/deploy.go +++ b/internal/command/deploy.go @@ -47,7 +47,7 @@ func (d *deployCommand) run(ctx context.Context, ns *docker.Namespace, cmd *cobr return docker.ErrHostnameInUse } - settings, err := d.flags.buildSettings(imageRef, host) + settings, err := d.flags.buildSettings(cmd, imageRef, host) if err != nil { return err } diff --git a/internal/command/deploy_test.go b/internal/command/deploy_test.go index d60c7ae..e22ca60 100644 --- a/internal/command/deploy_test.go +++ b/internal/command/deploy_test.go @@ -1,8 +1,10 @@ package command import ( + "strings" "testing" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -60,20 +62,47 @@ func TestParseEnvVars(t *testing.T) { func TestBuildSettingsImageRequired(t *testing.T) { f := &settingsFlags{} - _, err := f.buildSettings("", "app.example.com") + _, err := f.buildSettings(&cobra.Command{}, "", "app.example.com") assert.ErrorIs(t, err, docker.ErrImageRequired) } +func TestBuildSettingsRegistryCredentials(t *testing.T) { + f := &settingsFlags{registryUsername: "user", registryPassword: "pass"} + s, err := f.buildSettings(&cobra.Command{}, "image:latest", "app.example.com") + require.NoError(t, err) + assert.Equal(t, docker.RegistrySettings{Username: "user", Password: "pass"}, s.Registry) +} + +func TestBuildSettingsRegistryPasswordFromStdin(t *testing.T) { + cmd := &cobra.Command{} + cmd.SetIn(strings.NewReader("stdin-pass\n")) + + f := &settingsFlags{registryUsername: "user", registryPasswordStdin: true} + s, err := f.buildSettings(cmd, "image:latest", "app.example.com") + require.NoError(t, err) + assert.Equal(t, docker.RegistrySettings{Username: "user", Password: "stdin-pass"}, s.Registry) +} + +func TestRegistryPasswordFlagsAreMutuallyExclusive(t *testing.T) { + cmd := &cobra.Command{} + f := &settingsFlags{} + f.register(cmd) + require.NoError(t, cmd.Flags().Set("registry-password", "pass")) + require.NoError(t, cmd.Flags().Set("registry-password-stdin", "true")) + + assert.Error(t, cmd.ValidateFlagGroups()) +} + func TestBuildSettingsAutoBackupRequiresPath(t *testing.T) { t.Run("auto-backup without path", func(t *testing.T) { f := &settingsFlags{autoBackup: true} - _, err := f.buildSettings("image:latest", "app.example.com") + _, err := f.buildSettings(&cobra.Command{}, "image:latest", "app.example.com") assert.ErrorIs(t, err, docker.ErrAutoBackupWithoutPath) }) t.Run("auto-backup with path", func(t *testing.T) { f := &settingsFlags{autoBackup: true, backupPath: "/backups"} - _, err := f.buildSettings("image:latest", "app.example.com") + _, err := f.buildSettings(&cobra.Command{}, "image:latest", "app.example.com") require.NoError(t, err) }) } diff --git a/internal/command/settings_flags.go b/internal/command/settings_flags.go index 2ec77a4..a3200ee 100644 --- a/internal/command/settings_flags.go +++ b/internal/command/settings_flags.go @@ -2,6 +2,7 @@ package command import ( "fmt" + "io" "path/filepath" "strings" @@ -11,25 +12,32 @@ import ( ) type settingsFlags struct { - host string - disableTLS bool - env []string - smtpServer string - smtpPort string - smtpUsername string - smtpPassword string - smtpFrom string - cpus int - memory int - autoUpdate bool - backupPath string - autoBackup bool + host string + disableTLS bool + env []string + registryUsername string + registryPassword string + registryPasswordStdin bool + smtpServer string + smtpPort string + smtpUsername string + smtpPassword string + smtpFrom string + cpus int + memory int + autoUpdate bool + backupPath string + autoBackup bool } func (f *settingsFlags) register(cmd *cobra.Command) { cmd.Flags().StringVar(&f.host, "host", "", "hostname for the application") cmd.Flags().BoolVar(&f.disableTLS, "disable-tls", false, "disable TLS for this application") cmd.Flags().StringArrayVar(&f.env, "env", nil, "environment variable in KEY=VALUE format (can be repeated)") + cmd.Flags().StringVar(&f.registryUsername, "registry-username", "", "username for the image's registry") + cmd.Flags().StringVar(&f.registryPassword, "registry-password", "", "password for the image's registry") + cmd.Flags().BoolVar(&f.registryPasswordStdin, "registry-password-stdin", false, "read the registry password from stdin") + cmd.MarkFlagsMutuallyExclusive("registry-password", "registry-password-stdin") cmd.Flags().StringVar(&f.smtpServer, "smtp-server", "", "SMTP server address") cmd.Flags().StringVar(&f.smtpPort, "smtp-port", "", "SMTP server port") cmd.Flags().StringVar(&f.smtpUsername, "smtp-username", "", "SMTP username") @@ -42,7 +50,11 @@ func (f *settingsFlags) register(cmd *cobra.Command) { cmd.Flags().BoolVar(&f.autoBackup, "auto-backup", false, "enable automatic backups") } -func (f *settingsFlags) buildSettings(image, host string) (docker.ApplicationSettings, error) { +func (f *settingsFlags) buildSettings(cmd *cobra.Command, image, host string) (docker.ApplicationSettings, error) { + if err := f.resolveRegistryPassword(cmd); err != nil { + return docker.ApplicationSettings{}, err + } + envVars, err := f.parseEnvVars() if err != nil { return docker.ApplicationSettings{}, err @@ -53,7 +65,11 @@ func (f *settingsFlags) buildSettings(image, host string) (docker.ApplicationSet } s := docker.ApplicationSettings{ - Image: image, + Image: image, + Registry: docker.RegistrySettings{ + Username: f.registryUsername, + Password: f.registryPassword, + }, Host: host, DisableTLS: f.disableTLS, EnvVars: envVars, @@ -83,6 +99,10 @@ func (f *settingsFlags) buildSettings(image, host string) (docker.ApplicationSet } func (f *settingsFlags) applyChanges(cmd *cobra.Command, existing docker.ApplicationSettings, image string) (docker.ApplicationSettings, error) { + if err := f.resolveRegistryPassword(cmd); err != nil { + return existing, err + } + s := existing s.Image = image @@ -100,6 +120,12 @@ func (f *settingsFlags) applyChanges(cmd *cobra.Command, existing docker.Applica } s.EnvVars = envVars } + if cmd.Flags().Changed("registry-username") { + s.Registry.Username = f.registryUsername + } + if cmd.Flags().Changed("registry-password") || f.registryPasswordStdin { + s.Registry.Password = f.registryPassword + } if cmd.Flags().Changed("smtp-server") { s.SMTP.Server = f.smtpServer } @@ -141,6 +167,21 @@ func (f *settingsFlags) applyChanges(cmd *cobra.Command, existing docker.Applica return s, nil } +// resolveRegistryPassword reads the registry password from stdin when the +// --registry-password-stdin flag is set. +func (f *settingsFlags) resolveRegistryPassword(cmd *cobra.Command) error { + if !f.registryPasswordStdin { + return nil + } + + data, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return fmt.Errorf("reading registry password from stdin: %w", err) + } + f.registryPassword = strings.TrimRight(string(data), "\r\n") + return nil +} + func (f *settingsFlags) parseEnvVars() (map[string]string, error) { if f.env == nil { return nil, nil diff --git a/internal/command/update_test.go b/internal/command/update_test.go index 869712f..90e507a 100644 --- a/internal/command/update_test.go +++ b/internal/command/update_test.go @@ -1,6 +1,7 @@ package command import ( + "strings" "testing" "github.com/spf13/cobra" @@ -78,6 +79,27 @@ func TestApplyChanges(t *testing.T) { assert.Equal(t, existing.Resources.MemoryMB, result.Resources.MemoryMB) }) + t.Run("registry credentials changed", func(t *testing.T) { + cmd, f := newCmd() + require.NoError(t, cmd.Flags().Set("registry-username", "reguser")) + require.NoError(t, cmd.Flags().Set("registry-password", "regpass")) + + result, err := f.applyChanges(cmd, existing, existing.Image) + require.NoError(t, err) + assert.Equal(t, docker.RegistrySettings{Username: "reguser", Password: "regpass"}, result.Registry) + assert.Equal(t, existing.SMTP, result.SMTP) + }) + + t.Run("registry password from stdin", func(t *testing.T) { + cmd, f := newCmd() + cmd.SetIn(strings.NewReader("stdin-pass\n")) + require.NoError(t, cmd.Flags().Set("registry-password-stdin", "true")) + + result, err := f.applyChanges(cmd, existing, existing.Image) + require.NoError(t, err) + assert.Equal(t, "stdin-pass", result.Registry.Password) + }) + t.Run("env replaces all vars", func(t *testing.T) { cmd, f := newCmd() require.NoError(t, cmd.Flags().Set("env", "NEW=val")) diff --git a/internal/docker/application.go b/internal/docker/application.go index d0929d3..3e44c2d 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -288,7 +288,7 @@ func (a *Application) saveOperationResult(ctx context.Context, record func(*Stat } func (a *Application) pullImage(ctx context.Context, progress DeployProgressCallback) (bool, error) { - opts := image.PullOptions{RegistryAuth: registryAuthFor(a.Settings.Image)} + opts := image.PullOptions{RegistryAuth: registryAuthFor(a.Settings.Image, a.Settings.Registry)} reader, err := a.namespace.client.ImagePull(ctx, a.Settings.Image, opts) if err != nil { return false, a.pullError(err) diff --git a/internal/docker/application_settings.go b/internal/docker/application_settings.go index 613b525..c9ae5fc 100644 --- a/internal/docker/application_settings.go +++ b/internal/docker/application_settings.go @@ -75,6 +75,15 @@ func (s SMTPSettings) BuildEnv() []string { } } +type RegistrySettings struct { + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` +} + +func (r RegistrySettings) Empty() bool { + return r == RegistrySettings{} +} + type ContainerResources struct { CPUs int `json:"cpus,omitempty"` MemoryMB int `json:"memoryMB,omitempty"` @@ -88,6 +97,7 @@ type BackupSettings struct { type ApplicationSettings struct { Name string `json:"name"` Image string `json:"image"` + Registry RegistrySettings `json:"registry"` Host string `json:"host"` DisableTLS bool `json:"disableTLS"` EnvVars map[string]string `json:"env"` @@ -133,6 +143,9 @@ func (s ApplicationSettings) Equal(other ApplicationSettings) bool { if s.SMTP != other.SMTP { return false } + if s.Registry != other.Registry { + return false + } if s.AutoUpdate != other.AutoUpdate { return false } diff --git a/internal/docker/application_settings_test.go b/internal/docker/application_settings_test.go index 5d51f5d..0af0eaa 100644 --- a/internal/docker/application_settings_test.go +++ b/internal/docker/application_settings_test.go @@ -224,6 +224,29 @@ func TestBackupSettingsEqualDiffers(t *testing.T) { assert.False(t, base.Equal(noBackup)) } +func TestRegistrySettingsEqualDiffers(t *testing.T) { + base := ApplicationSettings{Name: "app", Registry: RegistrySettings{Username: "user", Password: "pass"}} + + different := ApplicationSettings{Name: "app", Registry: RegistrySettings{Username: "user", Password: "changed"}} + assert.False(t, base.Equal(different)) + + same := ApplicationSettings{Name: "app", Registry: RegistrySettings{Username: "user", Password: "pass"}} + assert.True(t, base.Equal(same)) +} + +func TestRegistrySettingsMarshalRoundTrip(t *testing.T) { + original := ApplicationSettings{ + Name: "app", + Image: "img:latest", + Registry: RegistrySettings{Username: "user", Password: "pass"}, + } + restored, err := UnmarshalApplicationSettings(original.Marshal()) + require.NoError(t, err) + assert.Equal(t, "user", restored.Registry.Username) + assert.Equal(t, "pass", restored.Registry.Password) + assert.True(t, original.Equal(restored)) +} + func TestKeysEqualDiffers(t *testing.T) { base := ApplicationSettings{Name: "app", Keys: Keys{SecretKeyBase: "secret"}} diff --git a/internal/docker/registry_auth.go b/internal/docker/registry_auth.go index 1eea3cf..94350d7 100644 --- a/internal/docker/registry_auth.go +++ b/internal/docker/registry_auth.go @@ -10,8 +10,17 @@ import ( // registryAuthFor returns a base64-encoded JSON auth string for the registry // that hosts the given image, suitable for use in image.PullOptions.RegistryAuth. -// Returns "" on any error or missing credentials, falling back to anonymous access. -func registryAuthFor(imageName string) string { +// Credentials in the given RegistrySettings take precedence over the Docker +// credential store. Returns "" on any error or missing credentials, falling +// back to anonymous access. +func registryAuthFor(imageName string, registry RegistrySettings) string { + if !registry.Empty() { + return encodeAuthConfig(&authn.AuthConfig{ + Username: registry.Username, + Password: registry.Password, + }) + } + ref, err := name.ParseReference(imageName) if err != nil { return "" @@ -24,11 +33,7 @@ func registryAuthFor(imageName string) string { if err != nil { return "" } - data, err := json.Marshal(cfg) - if err != nil { - return "" - } - return base64.URLEncoding.EncodeToString(data) + return encodeAuthConfig(cfg) } // registryHostFor returns the registry hostname for the given image, in the @@ -44,3 +49,11 @@ func registryHostFor(imageName string) string { } return registry } + +func encodeAuthConfig(cfg *authn.AuthConfig) string { + data, err := json.Marshal(cfg) + if err != nil { + return "" + } + return base64.URLEncoding.EncodeToString(data) +} diff --git a/internal/docker/registry_auth_test.go b/internal/docker/registry_auth_test.go index 5ccf53a..db90e6e 100644 --- a/internal/docker/registry_auth_test.go +++ b/internal/docker/registry_auth_test.go @@ -23,12 +23,12 @@ func TestRegistryHostFor(t *testing.T) { func TestRegistryAuthFor(t *testing.T) { t.Run("invalid image string", func(t *testing.T) { isolateDockerConfig(t) - assert.Equal(t, "", registryAuthFor(":::bad")) + assert.Equal(t, "", registryAuthFor(":::bad", RegistrySettings{})) }) t.Run("no docker config present", func(t *testing.T) { isolateDockerConfig(t) - assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{})) }) t.Run("reads config from DOCKER_CONFIG directory", func(t *testing.T) { @@ -36,7 +36,7 @@ func TestRegistryAuthFor(t *testing.T) { encoded := base64.StdEncoding.EncodeToString([]byte("myuser:mypass")) writeDockerConfig(t, dir, map[string]string{"ghcr.io": encoded}, nil, "") - token := registryAuthFor("ghcr.io/basecamp/once:main") + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{}) require.NotEmpty(t, token) ac := decodeAuthToken(t, token) assert.Equal(t, "myuser", ac.Username) @@ -47,7 +47,7 @@ func TestRegistryAuthFor(t *testing.T) { dir := isolateDockerConfig(t) require.NoError(t, os.WriteFile(filepath.Join(dir, "config.json"), []byte("{not json}"), 0600)) - assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{})) }) t.Run("config has credHelpers for host", func(t *testing.T) { @@ -55,7 +55,7 @@ func TestRegistryAuthFor(t *testing.T) { installFakeCredHelper(t, "myhelper", credHelperScript("helper-user", "helper-pass")) writeDockerConfig(t, dir, nil, map[string]string{"ghcr.io": "myhelper"}, "") - token := registryAuthFor("ghcr.io/basecamp/once:main") + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{}) require.NotEmpty(t, token) ac := decodeAuthToken(t, token) assert.Equal(t, "helper-user", ac.Username) @@ -67,7 +67,7 @@ func TestRegistryAuthFor(t *testing.T) { installFakeCredHelper(t, "mystore", credHelperScript("store-user", "store-pass")) writeDockerConfig(t, dir, nil, nil, "mystore") - token := registryAuthFor("ghcr.io/basecamp/once:main") + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{}) require.NotEmpty(t, token) ac := decodeAuthToken(t, token) assert.Equal(t, "store-user", ac.Username) @@ -90,7 +90,7 @@ echo '{"ServerURL":"","Username":"store-user","Secret":"store-pass"}' `) writeDockerConfig(t, dir, nil, map[string]string{"ghcr.io": "specific-helper"}, "global-store") - token := registryAuthFor("ghcr.io/basecamp/once:main") + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{}) require.NotEmpty(t, token) ac := decodeAuthToken(t, token) assert.Equal(t, "helper-user", ac.Username) @@ -102,7 +102,7 @@ echo '{"ServerURL":"","Username":"store-user","Secret":"store-pass"}' encoded := base64.StdEncoding.EncodeToString([]byte("inline-user:inline-pass")) writeDockerConfig(t, dir, map[string]string{"ghcr.io": encoded}, nil, "") - token := registryAuthFor("ghcr.io/basecamp/once:main") + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{}) require.NotEmpty(t, token) ac := decodeAuthToken(t, token) assert.Equal(t, "inline-user", ac.Username) @@ -114,7 +114,7 @@ echo '{"ServerURL":"","Username":"store-user","Secret":"store-pass"}' installFakeCredHelper(t, "failing-helper", "#!/bin/sh\nexit 1\n") writeDockerConfig(t, dir, nil, map[string]string{"ghcr.io": "failing-helper"}, "") - assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{})) }) t.Run("no matching entry for host", func(t *testing.T) { @@ -122,7 +122,29 @@ echo '{"ServerURL":"","Username":"store-user","Secret":"store-pass"}' encoded := base64.StdEncoding.EncodeToString([]byte("user:pass")) writeDockerConfig(t, dir, map[string]string{"docker.io": encoded}, nil, "") - assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main")) + assert.Equal(t, "", registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{})) + }) + + t.Run("settings credentials need no docker config", func(t *testing.T) { + isolateDockerConfig(t) + + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{Username: "settings-user", Password: "settings-pass"}) + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "settings-user", ac.Username) + assert.Equal(t, "settings-pass", ac.Password) + }) + + t.Run("settings credentials win over docker config", func(t *testing.T) { + dir := isolateDockerConfig(t) + encoded := base64.StdEncoding.EncodeToString([]byte("config-user:config-pass")) + writeDockerConfig(t, dir, map[string]string{"ghcr.io": encoded}, nil, "") + + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{Username: "settings-user", Password: "settings-pass"}) + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "settings-user", ac.Username) + assert.Equal(t, "settings-pass", ac.Password) }) } From 69d0d435c614fe1e971b15eefd39eff05122a02a Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Fri, 7 Aug 2026 15:03:39 +0100 Subject: [PATCH 3/6] Prompt for registry credentials on auth failure When a custom image pull fails with a registry credentials error, return to the image form with username and password fields revealed. The retry keeps the image ref and hostname from the failed attempt. Built-in images are always public, so the app list path keeps its existing error handling. In TUI mode started with --install, the same form appears, and esc quits as usual. --- internal/ui/install.go | 28 +++++++++- internal/ui/install_activity.go | 5 +- internal/ui/install_hostname_form.go | 4 ++ internal/ui/install_image_form.go | 72 +++++++++++++++++++------- internal/ui/install_image_form_test.go | 50 ++++++++++++++++++ internal/ui/install_test.go | 72 ++++++++++++++++++++++++-- 6 files changed, 205 insertions(+), 26 deletions(-) diff --git a/internal/ui/install.go b/internal/ui/install.go index b7d0de4..62c4707 100644 --- a/internal/ui/install.go +++ b/internal/ui/install.go @@ -52,6 +52,9 @@ type Install struct { cliMode bool customImage bool installFlag string + imageRef string + hostname string + registry docker.RegistrySettings } func NewInstall(ns *docker.Namespace, imageRef string) Install { @@ -71,6 +74,7 @@ func NewInstall(ns *docker.Namespace, imageRef string) Install { } m.state = installStateHostname m.hostnameForm = NewInstallHostnameForm(imageRef, m.installFlag) + m.imageRef = imageRef } else { m.state = installStateAppList m.appList = NewInstallAppList() @@ -168,21 +172,33 @@ func (m Install) Update(msg tea.Msg) (Component, tea.Cmd) { case InstallAppSelectedMsg: m.hostnameForm = NewInstallHostnameForm(msg.ImageRef, "") m.customImage = false + m.imageRef = msg.ImageRef + m.hostname = "" + m.registry = docker.RegistrySettings{} m.state = installStateHostname return m, m.initScreenWithSize() case InstallCustomSelectedMsg: m.imageForm = NewInstallImageForm() + m.imageRef = "" + m.hostname = "" + m.registry = docker.RegistrySettings{} m.state = installStateImageForm return m, m.initScreenWithSize() case InstallImageSubmitMsg: m.hostnameForm = NewInstallHostnameForm(msg.ImageRef, "") + m.hostnameForm.SetHostname(m.hostname) m.customImage = true + m.imageRef = msg.ImageRef + m.registry = docker.RegistrySettings{Username: msg.Username, Password: msg.Password} m.state = installStateHostname return m, m.initScreenWithSize() case InstallImageBackMsg: + if m.cliMode { + return m, m.cancelFromScreen() + } m.state = installStateAppList return m, nil @@ -202,8 +218,9 @@ func (m Install) Update(msg tea.Msg) (Component, tea.Cmd) { m.err = docker.ErrHostnameInUse return m, nil } + m.hostname = msg.Hostname m.state = installStateActivity - m.activity = NewInstallActivity(m.namespace, msg.ImageRef, msg.Hostname) + m.activity = NewInstallActivity(m.namespace, msg.ImageRef, msg.Hostname, m.registry) m.activity.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.activity.Init() @@ -211,6 +228,12 @@ func (m Install) Update(msg tea.Msg) (Component, tea.Cmd) { _ = m.namespace.Refresh(context.Background()) m.activity = nil m.err = msg.Err + var authErr *docker.RegistryAuthError + if errors.As(msg.Err, &authErr) && (m.customImage || m.cliMode) { + m.imageForm = NewInstallImageFormWithCredentials(m.imageRef, m.registry.Username) + m.state = installStateImageForm + return m, m.initScreenWithSize() + } if errors.Is(msg.Err, docker.ErrPullFailed) { m.state = m.imageErrorState() } else { @@ -326,6 +349,9 @@ func (m Install) handleBack() (Install, tea.Cmd) { case installStateAppList: return m, m.cancelFromScreen() case installStateImageForm: + if m.cliMode { + return m, m.cancelFromScreen() + } m.state = installStateAppList return m, nil case installStateHostname: diff --git a/internal/ui/install_activity.go b/internal/ui/install_activity.go index f4a4632..207b5c4 100644 --- a/internal/ui/install_activity.go +++ b/internal/ui/install_activity.go @@ -42,6 +42,7 @@ type InstallActivity struct { namespace *docker.Namespace imageRef string hostname string + registry docker.RegistrySettings width, height int stage installStage percentage int @@ -52,12 +53,13 @@ type InstallActivity struct { cancel context.CancelFunc } -func NewInstallActivity(ns *docker.Namespace, imageRef, hostname string) *InstallActivity { +func NewInstallActivity(ns *docker.Namespace, imageRef, hostname string, registry docker.RegistrySettings) *InstallActivity { ctx, cancel := context.WithCancel(context.Background()) return &InstallActivity{ namespace: ns, imageRef: imageRef, hostname: hostname, + registry: registry, stage: stagePreparing, progress: NewProgress(0, Colors.Primary), progressChan: make(chan installProgressMsg, 10), @@ -178,6 +180,7 @@ func (m *InstallActivity) runInstall(ctx context.Context) { app := docker.NewApplication(m.namespace, docker.ApplicationSettings{ Name: appName, Image: m.imageRef, + Registry: m.registry, Host: hostname, AutoUpdate: true, }) diff --git a/internal/ui/install_hostname_form.go b/internal/ui/install_hostname_form.go index 8626c9f..18a0982 100644 --- a/internal/ui/install_hostname_form.go +++ b/internal/ui/install_hostname_form.go @@ -70,3 +70,7 @@ func (m InstallHostnameForm) View() string { func (m InstallHostnameForm) Hostname() string { return m.form.TextField(0).Value() } + +func (m InstallHostnameForm) SetHostname(v string) { + m.form.TextField(0).SetValue(v) +} diff --git a/internal/ui/install_image_form.go b/internal/ui/install_image_form.go index bce0b7e..df6a74d 100644 --- a/internal/ui/install_image_form.go +++ b/internal/ui/install_image_form.go @@ -4,34 +4,36 @@ import ( tea "charm.land/bubbletea/v2" ) -type InstallImageSubmitMsg struct{ ImageRef string } +type InstallImageSubmitMsg struct { + ImageRef string + Username string + Password string +} type InstallImageBackMsg struct{} type InstallImageForm struct { - form Form + form Form + hasCredentials bool } func NewInstallImageForm() InstallImageForm { - m := InstallImageForm{ - form: NewForm("Next", FormItem{ - Label: "Image", - Field: NewTextField("user/repo:tag"), - Required: true, - }), - } + return newInstallImageForm(NewForm("Next", imageFormItem("")), false) +} - m.form.OnSubmit(func(f *Form) tea.Cmd { - ref := f.TextField(0).Value() - if expanded, ok := expandAlias(ref); ok { - ref = expanded - } - return func() tea.Msg { return InstallImageSubmitMsg{ImageRef: ref} } - }) - m.form.OnCancel(func(f *Form) tea.Cmd { - return func() tea.Msg { return InstallImageBackMsg{} } - }) +// NewInstallImageFormWithCredentials builds the image form with username and +// password fields revealed, for retrying after a registry credentials error. +func NewInstallImageFormWithCredentials(imageRef, username string) InstallImageForm { + usernameField := NewTextField("") + usernameField.SetValue(username) + passwordField := NewTextField("") + passwordField.SetEchoPassword() - return m + form := NewForm("Next", + imageFormItem(imageRef), + FormItem{Label: "Username", Field: usernameField}, + FormItem{Label: "Password", Field: passwordField}, + ) + return newInstallImageForm(form, true) } func (m InstallImageForm) Init() tea.Cmd { @@ -47,3 +49,33 @@ func (m InstallImageForm) Update(msg tea.Msg) (InstallImageForm, tea.Cmd) { func (m InstallImageForm) View() string { return m.form.View() } + +// Helpers + +func newInstallImageForm(form Form, hasCredentials bool) InstallImageForm { + m := InstallImageForm{form: form, hasCredentials: hasCredentials} + + m.form.OnSubmit(func(f *Form) tea.Cmd { + ref := f.TextField(0).Value() + if expanded, ok := expandAlias(ref); ok { + ref = expanded + } + msg := InstallImageSubmitMsg{ImageRef: ref} + if hasCredentials { + msg.Username = f.TextField(1).Value() + msg.Password = f.TextField(2).Value() + } + return func() tea.Msg { return msg } + }) + m.form.OnCancel(func(f *Form) tea.Cmd { + return func() tea.Msg { return InstallImageBackMsg{} } + }) + + return m +} + +func imageFormItem(imageRef string) FormItem { + field := NewTextField("user/repo:tag") + field.SetValue(imageRef) + return FormItem{Label: "Image", Field: field, Required: true} +} diff --git a/internal/ui/install_image_form_test.go b/internal/ui/install_image_form_test.go index de712dd..2537ca1 100644 --- a/internal/ui/install_image_form_test.go +++ b/internal/ui/install_image_form_test.go @@ -49,6 +49,56 @@ func TestInstallImageForm_Cancel(t *testing.T) { assert.True(t, ok, "expected InstallImageBackMsg, got %T", msg) } +func TestInstallImageForm_SubmitWithoutCredentialFields(t *testing.T) { + form := NewInstallImageForm() + + imageFormTypeText(&form, "ghcr.io/acme/private") + imageFormPressTab(&form) + form, cmd := form.Update(keyPressMsg("enter")) + require.NotNil(t, cmd) + + submit := cmd().(InstallImageSubmitMsg) + assert.Empty(t, submit.Username) + assert.Empty(t, submit.Password) +} + +func TestInstallImageForm_SubmitWithCredentials(t *testing.T) { + form := NewInstallImageFormWithCredentials("ghcr.io/acme/private", "olduser") + assert.Equal(t, "olduser", form.form.TextField(1).Value()) + + form.form.TextField(1).SetValue("") + imageFormPressTab(&form) + imageFormTypeText(&form, "myuser") + imageFormPressTab(&form) + imageFormTypeText(&form, "mypass") + imageFormPressTab(&form) + form, cmd := form.Update(keyPressMsg("enter")) + require.NotNil(t, cmd) + + msg := cmd() + submit, ok := msg.(InstallImageSubmitMsg) + require.True(t, ok, "expected InstallImageSubmitMsg, got %T", msg) + assert.Equal(t, "ghcr.io/acme/private", submit.ImageRef) + assert.Equal(t, "myuser", submit.Username) + assert.Equal(t, "mypass", submit.Password) +} + +func TestInstallImageForm_CredentialsAreOptional(t *testing.T) { + form := NewInstallImageFormWithCredentials("ghcr.io/acme/private", "") + + imageFormPressTab(&form) + imageFormPressTab(&form) + imageFormPressTab(&form) + form, cmd := form.Update(keyPressMsg("enter")) + require.NotNil(t, cmd) + + submit, ok := cmd().(InstallImageSubmitMsg) + require.True(t, ok, "expected InstallImageSubmitMsg") + assert.Equal(t, "ghcr.io/acme/private", submit.ImageRef) + assert.Empty(t, submit.Username) + assert.Empty(t, submit.Password) +} + func TestInstallImageForm_RequiresImage(t *testing.T) { form := NewInstallImageForm() diff --git a/internal/ui/install_test.go b/internal/ui/install_test.go index 736fe4f..1c2162e 100644 --- a/internal/ui/install_test.go +++ b/internal/ui/install_test.go @@ -261,18 +261,67 @@ func TestInstall_PullFailureReturnsToAppList(t *testing.T) { assert.Equal(t, pullErr, m.err) } -func TestInstall_AuthFailureShowsLoginMessage(t *testing.T) { +func TestInstall_AuthFailureShowsCredentialFields(t *testing.T) { + m := installWithAuthFailure(t) + + assert.Equal(t, installStateImageForm, m.state) + view := ansi.Strip(m.View()) + assert.Contains(t, view, "Log in to ghcr.io first") + assert.Contains(t, view, "Username") + assert.Contains(t, view, "Password") + assert.Equal(t, "ghcr.io/acme/private", m.imageForm.form.TextField(0).Value()) +} + +func TestInstall_AuthFailureRetryCarriesCredentials(t *testing.T) { + m := installWithAuthFailure(t) + + m, _ = updateInstall(m, InstallImageSubmitMsg{ImageRef: "ghcr.io/acme/private", Username: "myuser", Password: "mypass"}) + assert.Equal(t, installStateHostname, m.state) + assert.Equal(t, "app.example.com", m.hostnameForm.Hostname(), "hostname should carry over to the retry") + + m, _ = updateInstall(m, InstallFormSubmitMsg{ImageRef: "ghcr.io/acme/private", Hostname: "app.example.com"}) + assert.Equal(t, installStateActivity, m.state) + assert.Equal(t, docker.RegistrySettings{Username: "myuser", Password: "mypass"}, m.activity.registry) +} + +func TestInstall_SecondAuthFailurePrefillsUsername(t *testing.T) { + m := installWithAuthFailure(t) + m, _ = updateInstall(m, InstallImageSubmitMsg{ImageRef: "ghcr.io/acme/private", Username: "myuser", Password: "wrong"}) + m, _ = updateInstall(m, InstallFormSubmitMsg{ImageRef: "ghcr.io/acme/private", Hostname: "app.example.com"}) + + authErr := &docker.RegistryAuthError{Registry: "ghcr.io", Cause: errors.New("no basic auth credentials")} + m, _ = updateInstall(m, InstallActivityFailedMsg{Err: fmt.Errorf("%w: %w", docker.ErrDeployFailed, authErr)}) + assert.Equal(t, installStateImageForm, m.state) + assert.Equal(t, "myuser", m.imageForm.form.TextField(1).Value()) +} + +func TestInstall_AuthFailureOnKnownAppReturnsToAppList(t *testing.T) { ns := newTestNamespace() m := NewInstall(ns, "") m, _ = updateInstall(m, tea.WindowSizeMsg{Width: 120, Height: 24}) - m, _ = updateInstall(m, InstallCustomSelectedMsg{}) - m, _ = updateInstall(m, InstallImageSubmitMsg{ImageRef: "ghcr.io/acme/private"}) + m, _ = updateInstall(m, InstallAppSelectedMsg{ImageRef: "ghcr.io/basecamp/once-campfire"}) + m, _ = updateInstall(m, InstallFormSubmitMsg{ImageRef: "ghcr.io/basecamp/once-campfire", Hostname: "chat.example.com"}) + + authErr := &docker.RegistryAuthError{Registry: "ghcr.io", Cause: errors.New("no basic auth credentials")} + m, _ = updateInstall(m, InstallActivityFailedMsg{Err: fmt.Errorf("%w: %w", docker.ErrDeployFailed, authErr)}) + assert.Equal(t, installStateAppList, m.state) +} + +func TestInstall_AuthFailureInCLIModeShowsCredentialFields(t *testing.T) { + m := NewInstall(newTestNamespace(), "ghcr.io/acme/private") + m, _ = updateInstall(m, tea.WindowSizeMsg{Width: 120, Height: 24}) m, _ = updateInstall(m, InstallFormSubmitMsg{ImageRef: "ghcr.io/acme/private", Hostname: "app.example.com"}) authErr := &docker.RegistryAuthError{Registry: "ghcr.io", Cause: errors.New("no basic auth credentials")} m, _ = updateInstall(m, InstallActivityFailedMsg{Err: fmt.Errorf("%w: %w", docker.ErrDeployFailed, authErr)}) assert.Equal(t, installStateImageForm, m.state) - assert.Contains(t, m.View(), "Log in to ghcr.io first") + assert.Equal(t, "ghcr.io/acme/private", m.imageForm.form.TextField(0).Value()) + + // Esc from the image form quits in CLI mode. + _, cmd := updateInstall(m, keyPressMsg("esc")) + require.NotNil(t, cmd) + _, ok := cmd().(QuitMsg) + assert.True(t, ok, "expected QuitMsg") } func TestInstall_NonPullDeployFailureReturnsToHostname(t *testing.T) { @@ -412,3 +461,18 @@ func updateInstall(m Install, msg tea.Msg) (Install, tea.Cmd) { comp, cmd := m.Update(msg) return comp.(Install), cmd } + +// installWithAuthFailure drives a custom image install up to a registry +// credentials failure. +func installWithAuthFailure(t *testing.T) Install { + t.Helper() + m := newTestInstall() + m, _ = updateInstall(m, tea.WindowSizeMsg{Width: 120, Height: 24}) + m, _ = updateInstall(m, InstallCustomSelectedMsg{}) + m, _ = updateInstall(m, InstallImageSubmitMsg{ImageRef: "ghcr.io/acme/private"}) + m, _ = updateInstall(m, InstallFormSubmitMsg{ImageRef: "ghcr.io/acme/private", Hostname: "app.example.com"}) + + authErr := &docker.RegistryAuthError{Registry: "ghcr.io", Cause: errors.New("no basic auth credentials")} + m, _ = updateInstall(m, InstallActivityFailedMsg{Err: fmt.Errorf("%w: %w", docker.ErrDeployFailed, authErr)}) + return m +} From 771fa813e7a02a1035a7caee8a8265b62ea06074 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Sat, 8 Aug 2026 12:20:32 +0100 Subject: [PATCH 4/6] Narrow "failed to authorize" auth detection containerd wraps transport failures during token fetch in the same "failed to authorize" phrase as credential rejections. A registry outage was therefore classified as an auth error, prompting for credentials instead of reporting the pull failure. Count "failed to authorize" as an auth error only when the message also carries a credential-style status (401, 403, denied). --- internal/docker/errors.go | 6 +++++- internal/docker/errors_test.go | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/docker/errors.go b/internal/docker/errors.go index b11bc88..4687a34 100644 --- a/internal/docker/errors.go +++ b/internal/docker/errors.go @@ -84,11 +84,15 @@ func isRegistryAuthError(err error) bool { "pull access denied", "no basic auth credentials", "insufficient_scope", - "failed to authorize", } { if strings.Contains(msg, indicator) { return true } } + // "failed to authorize" also wraps transport failures during token + // fetch, so it only counts when paired with a credential-style status. + if strings.Contains(msg, "failed to authorize") { + return strings.Contains(msg, "401") || strings.Contains(msg, "403") || strings.Contains(msg, "denied") + } return false } diff --git a/internal/docker/errors_test.go b/internal/docker/errors_test.go index 6f0fc94..26c2c27 100644 --- a/internal/docker/errors_test.go +++ b/internal/docker/errors_test.go @@ -40,6 +40,7 @@ func TestIsRegistryAuthError(t *testing.T) { `Error response from daemon: Get "https://registry.example.com/v2/": no basic auth credentials`, "pull access denied, repository does not exist or may require authorization: server message: insufficient_scope: authorization failed", `failed to resolve reference "ghcr.io/acme/app:latest": failed to authorize: failed to fetch anonymous token: unexpected status: 401 Unauthorized`, + `failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token: 403 Forbidden`, } for _, msg := range authErrors { assert.True(t, isRegistryAuthError(errors.New(msg)), msg) @@ -51,6 +52,8 @@ func TestIsRegistryAuthError(t *testing.T) { `Get "https://registry.example.com/v2/": dial tcp: lookup registry.example.com: no such host`, "context deadline exceeded", "open /var/lib/docker/tmp/foo: permission denied", + `failed to authorize: failed to fetch anonymous token: Get "https://auth.example.com/token": dial tcp: connection refused`, + `failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://auth.example.com/token: 500 Internal Server Error`, } for _, msg := range otherErrors { assert.False(t, isRegistryAuthError(errors.New(msg)), msg) From b389dc7263154fb91d31ec560c5a346675c9feae Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Sat, 8 Aug 2026 15:47:29 +0100 Subject: [PATCH 5/6] Scope registry credentials to their registry host Stored credentials were sent on every pull, whatever registry hosted the image. Changing an app's image to a different registry would send the old password to the new host. Record the registry host with the credentials, and only apply them to pulls from that host. Other pulls fall back to the Docker credential store or anonymous access, and a private image then triggers the usual credentials prompt. --- integration/docker_test.go | 2 +- internal/command/deploy_test.go | 4 +-- internal/command/settings_flags.go | 10 +++--- internal/command/update_test.go | 35 +++++++++++++++++++- internal/docker/application.go | 2 +- internal/docker/application_settings.go | 20 ++++++++++- internal/docker/application_settings_test.go | 25 +++++++++++++- internal/docker/registry_auth.go | 10 +++--- internal/docker/registry_auth_test.go | 33 ++++++++++++++---- internal/ui/install.go | 2 +- internal/ui/install_test.go | 2 +- 11 files changed, 119 insertions(+), 26 deletions(-) diff --git a/integration/docker_test.go b/integration/docker_test.go index 2217f05..4a20484 100644 --- a/integration/docker_test.go +++ b/integration/docker_test.go @@ -311,7 +311,7 @@ func TestDeployWithRegistryCredentials(t *testing.T) { Name: "authapp", Image: imageTag, Host: "authapp.localhost", - Registry: docker.RegistrySettings{Username: "testuser", Password: "testpass"}, + Registry: docker.NewRegistrySettings(imageTag, "testuser", "testpass"), } app := deployApp(t, ctx, ns, settings) assert.Equal(t, settings.Registry, app.Settings.Registry, "registry credentials should persist in the app label") diff --git a/internal/command/deploy_test.go b/internal/command/deploy_test.go index e22ca60..0601328 100644 --- a/internal/command/deploy_test.go +++ b/internal/command/deploy_test.go @@ -70,7 +70,7 @@ func TestBuildSettingsRegistryCredentials(t *testing.T) { f := &settingsFlags{registryUsername: "user", registryPassword: "pass"} s, err := f.buildSettings(&cobra.Command{}, "image:latest", "app.example.com") require.NoError(t, err) - assert.Equal(t, docker.RegistrySettings{Username: "user", Password: "pass"}, s.Registry) + assert.Equal(t, docker.RegistrySettings{Host: "docker.io", Username: "user", Password: "pass"}, s.Registry) } func TestBuildSettingsRegistryPasswordFromStdin(t *testing.T) { @@ -80,7 +80,7 @@ func TestBuildSettingsRegistryPasswordFromStdin(t *testing.T) { f := &settingsFlags{registryUsername: "user", registryPasswordStdin: true} s, err := f.buildSettings(cmd, "image:latest", "app.example.com") require.NoError(t, err) - assert.Equal(t, docker.RegistrySettings{Username: "user", Password: "stdin-pass"}, s.Registry) + assert.Equal(t, docker.RegistrySettings{Host: "docker.io", Username: "user", Password: "stdin-pass"}, s.Registry) } func TestRegistryPasswordFlagsAreMutuallyExclusive(t *testing.T) { diff --git a/internal/command/settings_flags.go b/internal/command/settings_flags.go index a3200ee..b5b906c 100644 --- a/internal/command/settings_flags.go +++ b/internal/command/settings_flags.go @@ -65,11 +65,8 @@ func (f *settingsFlags) buildSettings(cmd *cobra.Command, image, host string) (d } s := docker.ApplicationSettings{ - Image: image, - Registry: docker.RegistrySettings{ - Username: f.registryUsername, - Password: f.registryPassword, - }, + Image: image, + Registry: docker.NewRegistrySettings(image, f.registryUsername, f.registryPassword), Host: host, DisableTLS: f.disableTLS, EnvVars: envVars, @@ -126,6 +123,9 @@ func (f *settingsFlags) applyChanges(cmd *cobra.Command, existing docker.Applica if cmd.Flags().Changed("registry-password") || f.registryPasswordStdin { s.Registry.Password = f.registryPassword } + if cmd.Flags().Changed("registry-username") || cmd.Flags().Changed("registry-password") || f.registryPasswordStdin { + s.Registry = docker.NewRegistrySettings(s.Image, s.Registry.Username, s.Registry.Password) + } if cmd.Flags().Changed("smtp-server") { s.SMTP.Server = f.smtpServer } diff --git a/internal/command/update_test.go b/internal/command/update_test.go index 90e507a..6e5f676 100644 --- a/internal/command/update_test.go +++ b/internal/command/update_test.go @@ -86,10 +86,43 @@ func TestApplyChanges(t *testing.T) { result, err := f.applyChanges(cmd, existing, existing.Image) require.NoError(t, err) - assert.Equal(t, docker.RegistrySettings{Username: "reguser", Password: "regpass"}, result.Registry) + assert.Equal(t, docker.RegistrySettings{Host: "docker.io", Username: "reguser", Password: "regpass"}, result.Registry) assert.Equal(t, existing.SMTP, result.SMTP) }) + t.Run("new registry credentials are scoped to the new image", func(t *testing.T) { + cmd, f := newCmd() + require.NoError(t, cmd.Flags().Set("registry-username", "reguser")) + require.NoError(t, cmd.Flags().Set("registry-password", "regpass")) + + result, err := f.applyChanges(cmd, existing, "ghcr.io/acme/app:latest") + require.NoError(t, err) + assert.Equal(t, "ghcr.io", result.Registry.Host) + }) + + t.Run("image change alone leaves credentials scoped to the old host", func(t *testing.T) { + cmd, f := newCmd() + withCreds := existing + withCreds.Registry = docker.NewRegistrySettings(existing.Image, "reguser", "regpass") + + result, err := f.applyChanges(cmd, withCreds, "ghcr.io/acme/app:latest") + require.NoError(t, err) + assert.Equal(t, withCreds.Registry, result.Registry) + assert.False(t, result.Registry.AppliesTo(result.Image)) + }) + + t.Run("cleared registry credentials drop the host", func(t *testing.T) { + cmd, f := newCmd() + withCreds := existing + withCreds.Registry = docker.NewRegistrySettings(existing.Image, "reguser", "regpass") + require.NoError(t, cmd.Flags().Set("registry-username", "")) + require.NoError(t, cmd.Flags().Set("registry-password", "")) + + result, err := f.applyChanges(cmd, withCreds, withCreds.Image) + require.NoError(t, err) + assert.Equal(t, docker.RegistrySettings{}, result.Registry) + }) + t.Run("registry password from stdin", func(t *testing.T) { cmd, f := newCmd() cmd.SetIn(strings.NewReader("stdin-pass\n")) diff --git a/internal/docker/application.go b/internal/docker/application.go index 3e44c2d..de7f331 100644 --- a/internal/docker/application.go +++ b/internal/docker/application.go @@ -309,7 +309,7 @@ func (a *Application) pullImage(ctx context.Context, progress DeployProgressCall func (a *Application) pullError(err error) error { if isRegistryAuthError(err) { - return &RegistryAuthError{Registry: registryHostFor(a.Settings.Image), Cause: err} + return &RegistryAuthError{Registry: RegistryHostFor(a.Settings.Image), Cause: err} } return fmt.Errorf("%w: %w", ErrPullFailed, err) } diff --git a/internal/docker/application_settings.go b/internal/docker/application_settings.go index c9ae5fc..213a1c7 100644 --- a/internal/docker/application_settings.go +++ b/internal/docker/application_settings.go @@ -76,12 +76,30 @@ func (s SMTPSettings) BuildEnv() []string { } type RegistrySettings struct { + Host string `json:"host,omitempty"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` } +// NewRegistrySettings builds credentials scoped to the registry that hosts +// the given image. Empty credentials produce the zero value. +func NewRegistrySettings(image, username, password string) RegistrySettings { + if username == "" && password == "" { + return RegistrySettings{} + } + return RegistrySettings{ + Host: RegistryHostFor(image), + Username: username, + Password: password, + } +} + func (r RegistrySettings) Empty() bool { - return r == RegistrySettings{} + return r.Username == "" && r.Password == "" +} + +func (r RegistrySettings) AppliesTo(image string) bool { + return r.Host == RegistryHostFor(image) } type ContainerResources struct { diff --git a/internal/docker/application_settings_test.go b/internal/docker/application_settings_test.go index 0af0eaa..cab92a8 100644 --- a/internal/docker/application_settings_test.go +++ b/internal/docker/application_settings_test.go @@ -238,15 +238,38 @@ func TestRegistrySettingsMarshalRoundTrip(t *testing.T) { original := ApplicationSettings{ Name: "app", Image: "img:latest", - Registry: RegistrySettings{Username: "user", Password: "pass"}, + Registry: NewRegistrySettings("img:latest", "user", "pass"), } restored, err := UnmarshalApplicationSettings(original.Marshal()) require.NoError(t, err) + assert.Equal(t, "docker.io", restored.Registry.Host) assert.Equal(t, "user", restored.Registry.Username) assert.Equal(t, "pass", restored.Registry.Password) assert.True(t, original.Equal(restored)) } +func TestNewRegistrySettings(t *testing.T) { + scoped := NewRegistrySettings("ghcr.io/acme/app:latest", "user", "pass") + assert.Equal(t, RegistrySettings{Host: "ghcr.io", Username: "user", Password: "pass"}, scoped) + + assert.Equal(t, RegistrySettings{}, NewRegistrySettings("ghcr.io/acme/app:latest", "", "")) +} + +func TestRegistrySettingsEmpty(t *testing.T) { + assert.True(t, RegistrySettings{}.Empty()) + assert.True(t, RegistrySettings{Host: "ghcr.io"}.Empty()) + assert.False(t, RegistrySettings{Username: "user", Password: "pass"}.Empty()) +} + +func TestRegistrySettingsAppliesTo(t *testing.T) { + registry := NewRegistrySettings("ghcr.io/acme/app:latest", "user", "pass") + + assert.True(t, registry.AppliesTo("ghcr.io/acme/app:v2")) + assert.True(t, registry.AppliesTo("ghcr.io/acme/other")) + assert.False(t, registry.AppliesTo("acme/app:latest")) + assert.False(t, registry.AppliesTo("registry.example.com/acme/app")) +} + func TestKeysEqualDiffers(t *testing.T) { base := ApplicationSettings{Name: "app", Keys: Keys{SecretKeyBase: "secret"}} diff --git a/internal/docker/registry_auth.go b/internal/docker/registry_auth.go index 94350d7..29c7fe3 100644 --- a/internal/docker/registry_auth.go +++ b/internal/docker/registry_auth.go @@ -11,10 +11,10 @@ import ( // registryAuthFor returns a base64-encoded JSON auth string for the registry // that hosts the given image, suitable for use in image.PullOptions.RegistryAuth. // Credentials in the given RegistrySettings take precedence over the Docker -// credential store. Returns "" on any error or missing credentials, falling -// back to anonymous access. +// credential store, but only for the registry they are scoped to. Returns "" +// on any error or missing credentials, falling back to anonymous access. func registryAuthFor(imageName string, registry RegistrySettings) string { - if !registry.Empty() { + if !registry.Empty() && registry.AppliesTo(imageName) { return encodeAuthConfig(&authn.AuthConfig{ Username: registry.Username, Password: registry.Password, @@ -36,9 +36,9 @@ func registryAuthFor(imageName string, registry RegistrySettings) string { return encodeAuthConfig(cfg) } -// registryHostFor returns the registry hostname for the given image, in the +// RegistryHostFor returns the registry hostname for the given image, in the // form a user would pass to `docker login`. -func registryHostFor(imageName string) string { +func RegistryHostFor(imageName string) string { ref, err := name.ParseReference(imageName) if err != nil { return "the registry" diff --git a/internal/docker/registry_auth_test.go b/internal/docker/registry_auth_test.go index db90e6e..931ecf7 100644 --- a/internal/docker/registry_auth_test.go +++ b/internal/docker/registry_auth_test.go @@ -13,11 +13,11 @@ import ( ) func TestRegistryHostFor(t *testing.T) { - assert.Equal(t, "docker.io", registryHostFor("nginx")) - assert.Equal(t, "docker.io", registryHostFor("acme/private:latest")) - assert.Equal(t, "ghcr.io", registryHostFor("ghcr.io/basecamp/once-campfire:latest")) - assert.Equal(t, "localhost:5000", registryHostFor("localhost:5000/foo")) - assert.Equal(t, "the registry", registryHostFor(":::bad")) + assert.Equal(t, "docker.io", RegistryHostFor("nginx")) + assert.Equal(t, "docker.io", RegistryHostFor("acme/private:latest")) + assert.Equal(t, "ghcr.io", RegistryHostFor("ghcr.io/basecamp/once-campfire:latest")) + assert.Equal(t, "localhost:5000", RegistryHostFor("localhost:5000/foo")) + assert.Equal(t, "the registry", RegistryHostFor(":::bad")) } func TestRegistryAuthFor(t *testing.T) { @@ -128,7 +128,7 @@ echo '{"ServerURL":"","Username":"store-user","Secret":"store-pass"}' t.Run("settings credentials need no docker config", func(t *testing.T) { isolateDockerConfig(t) - token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{Username: "settings-user", Password: "settings-pass"}) + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{Host: "ghcr.io", Username: "settings-user", Password: "settings-pass"}) require.NotEmpty(t, token) ac := decodeAuthToken(t, token) assert.Equal(t, "settings-user", ac.Username) @@ -140,12 +140,31 @@ echo '{"ServerURL":"","Username":"store-user","Secret":"store-pass"}' encoded := base64.StdEncoding.EncodeToString([]byte("config-user:config-pass")) writeDockerConfig(t, dir, map[string]string{"ghcr.io": encoded}, nil, "") - token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{Username: "settings-user", Password: "settings-pass"}) + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{Host: "ghcr.io", Username: "settings-user", Password: "settings-pass"}) require.NotEmpty(t, token) ac := decodeAuthToken(t, token) assert.Equal(t, "settings-user", ac.Username) assert.Equal(t, "settings-pass", ac.Password) }) + + t.Run("settings credentials for another host are not sent", func(t *testing.T) { + isolateDockerConfig(t) + + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{Host: "docker.io", Username: "settings-user", Password: "settings-pass"}) + assert.Equal(t, "", token) + }) + + t.Run("settings credentials for another host fall back to docker config", func(t *testing.T) { + dir := isolateDockerConfig(t) + encoded := base64.StdEncoding.EncodeToString([]byte("config-user:config-pass")) + writeDockerConfig(t, dir, map[string]string{"ghcr.io": encoded}, nil, "") + + token := registryAuthFor("ghcr.io/basecamp/once:main", RegistrySettings{Host: "docker.io", Username: "settings-user", Password: "settings-pass"}) + require.NotEmpty(t, token) + ac := decodeAuthToken(t, token) + assert.Equal(t, "config-user", ac.Username) + assert.Equal(t, "config-pass", ac.Password) + }) } // Helpers diff --git a/internal/ui/install.go b/internal/ui/install.go index 62c4707..9088360 100644 --- a/internal/ui/install.go +++ b/internal/ui/install.go @@ -191,7 +191,7 @@ func (m Install) Update(msg tea.Msg) (Component, tea.Cmd) { m.hostnameForm.SetHostname(m.hostname) m.customImage = true m.imageRef = msg.ImageRef - m.registry = docker.RegistrySettings{Username: msg.Username, Password: msg.Password} + m.registry = docker.NewRegistrySettings(msg.ImageRef, msg.Username, msg.Password) m.state = installStateHostname return m, m.initScreenWithSize() diff --git a/internal/ui/install_test.go b/internal/ui/install_test.go index 1c2162e..5e9c033 100644 --- a/internal/ui/install_test.go +++ b/internal/ui/install_test.go @@ -281,7 +281,7 @@ func TestInstall_AuthFailureRetryCarriesCredentials(t *testing.T) { m, _ = updateInstall(m, InstallFormSubmitMsg{ImageRef: "ghcr.io/acme/private", Hostname: "app.example.com"}) assert.Equal(t, installStateActivity, m.state) - assert.Equal(t, docker.RegistrySettings{Username: "myuser", Password: "mypass"}, m.activity.registry) + assert.Equal(t, docker.RegistrySettings{Host: "ghcr.io", Username: "myuser", Password: "mypass"}, m.activity.registry) } func TestInstall_SecondAuthFailurePrefillsUsername(t *testing.T) { From 5fb271b32b3466541626b6dfbc30fcd9680a1a5b Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Sat, 8 Aug 2026 20:36:52 +0100 Subject: [PATCH 6/6] Do not re-scope stored credentials to a new registry When an update changes the image's registry, carry over an existing credential field only if the stored credentials already apply to the new image. This stops a password saved for one registry from being sent to a different registry when only one credential flag is given. --- internal/command/settings_flags.go | 22 ++++++++++++++-------- internal/command/update_test.go | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/internal/command/settings_flags.go b/internal/command/settings_flags.go index b5b906c..61ea258 100644 --- a/internal/command/settings_flags.go +++ b/internal/command/settings_flags.go @@ -117,14 +117,20 @@ func (f *settingsFlags) applyChanges(cmd *cobra.Command, existing docker.Applica } s.EnvVars = envVars } - if cmd.Flags().Changed("registry-username") { - s.Registry.Username = f.registryUsername - } - if cmd.Flags().Changed("registry-password") || f.registryPasswordStdin { - s.Registry.Password = f.registryPassword - } - if cmd.Flags().Changed("registry-username") || cmd.Flags().Changed("registry-password") || f.registryPasswordStdin { - s.Registry = docker.NewRegistrySettings(s.Image, s.Registry.Username, s.Registry.Password) + usernameChanged := cmd.Flags().Changed("registry-username") + passwordChanged := cmd.Flags().Changed("registry-password") || f.registryPasswordStdin + if usernameChanged || passwordChanged { + var username, password string + if s.Registry.AppliesTo(s.Image) { + username, password = s.Registry.Username, s.Registry.Password + } + if usernameChanged { + username = f.registryUsername + } + if passwordChanged { + password = f.registryPassword + } + s.Registry = docker.NewRegistrySettings(s.Image, username, password) } if cmd.Flags().Changed("smtp-server") { s.SMTP.Server = f.smtpServer diff --git a/internal/command/update_test.go b/internal/command/update_test.go index 6e5f676..c3a834b 100644 --- a/internal/command/update_test.go +++ b/internal/command/update_test.go @@ -111,6 +111,28 @@ func TestApplyChanges(t *testing.T) { assert.False(t, result.Registry.AppliesTo(result.Image)) }) + t.Run("image change does not re-scope existing credentials", func(t *testing.T) { + cmd, f := newCmd() + withCreds := existing + withCreds.Registry = docker.NewRegistrySettings(existing.Image, "reguser", "regpass") + require.NoError(t, cmd.Flags().Set("registry-username", "reguser")) + + result, err := f.applyChanges(cmd, withCreds, "ghcr.io/acme/app:latest") + require.NoError(t, err) + assert.Equal(t, docker.RegistrySettings{Host: "ghcr.io", Username: "reguser"}, result.Registry) + }) + + t.Run("username change keeps the password for the same registry", func(t *testing.T) { + cmd, f := newCmd() + withCreds := existing + withCreds.Registry = docker.NewRegistrySettings(existing.Image, "reguser", "regpass") + require.NoError(t, cmd.Flags().Set("registry-username", "newuser")) + + result, err := f.applyChanges(cmd, withCreds, withCreds.Image) + require.NoError(t, err) + assert.Equal(t, docker.RegistrySettings{Host: "docker.io", Username: "newuser", Password: "regpass"}, result.Registry) + }) + t.Run("cleared registry credentials drop the host", func(t *testing.T) { cmd, f := newCmd() withCreds := existing