diff --git a/integration/docker_test.go b/integration/docker_test.go index 29212b5..4a20484 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" @@ -26,6 +27,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 +260,63 @@ 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 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.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") +} + func TestLargeLabelData(t *testing.T) { t.Parallel() @@ -1268,6 +1327,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 +1362,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 +1385,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 +1430,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 +1535,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 +1551,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/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..0601328 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{Host: "docker.io", 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{Host: "docker.io", 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..61ea258 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 @@ -54,6 +66,7 @@ func (f *settingsFlags) buildSettings(image, host string) (docker.ApplicationSet s := docker.ApplicationSettings{ Image: image, + Registry: docker.NewRegistrySettings(image, f.registryUsername, f.registryPassword), Host: host, DisableTLS: f.disableTLS, EnvVars: envVars, @@ -83,6 +96,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 +117,21 @@ func (f *settingsFlags) applyChanges(cmd *cobra.Command, existing docker.Applica } s.EnvVars = envVars } + 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 } @@ -141,6 +173,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..c3a834b 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,82 @@ 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{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("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 + 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")) + 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 351d0f5..de7f331 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" @@ -289,22 +288,15 @@ 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, 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/application_settings.go b/internal/docker/application_settings.go index 613b525..213a1c7 100644 --- a/internal/docker/application_settings.go +++ b/internal/docker/application_settings.go @@ -75,6 +75,33 @@ 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.Username == "" && r.Password == "" +} + +func (r RegistrySettings) AppliesTo(image string) bool { + return r.Host == RegistryHostFor(image) +} + type ContainerResources struct { CPUs int `json:"cpus,omitempty"` MemoryMB int `json:"memoryMB,omitempty"` @@ -88,6 +115,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 +161,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..cab92a8 100644 --- a/internal/docker/application_settings_test.go +++ b/internal/docker/application_settings_test.go @@ -224,6 +224,52 @@ 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: 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/errors.go b/internal/docker/errors.go index 7969471..4687a34 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,30 @@ 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", + } { + 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 16dd3e8..26c2c27 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,54 @@ 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`, + `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) + } + + 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", + `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) + } + + 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..29c7fe3 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, 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() && registry.AppliesTo(imageName) { + return encodeAuthConfig(&authn.AuthConfig{ + Username: registry.Username, + Password: registry.Password, + }) + } + ref, err := name.ParseReference(imageName) if err != nil { return "" @@ -24,6 +33,24 @@ func registryAuthFor(imageName string) string { if err != nil { return "" } + return encodeAuthConfig(cfg) +} + +// 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 +} + +func encodeAuthConfig(cfg *authn.AuthConfig) string { data, err := json.Marshal(cfg) if err != nil { return "" diff --git a/internal/docker/registry_auth_test.go b/internal/docker/registry_auth_test.go index 65ef65a..931ecf7 100644 --- a/internal/docker/registry_auth_test.go +++ b/internal/docker/registry_auth_test.go @@ -12,15 +12,23 @@ 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) - 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) { @@ -28,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) @@ -39,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) { @@ -47,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) @@ -59,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) @@ -82,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) @@ -94,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) @@ -106,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) { @@ -114,7 +122,48 @@ 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{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 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{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) }) } diff --git a/internal/ui/install.go b/internal/ui/install.go index b7d0de4..9088360 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.NewRegistrySettings(msg.ImageRef, msg.Username, 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 1bdd8d7..5e9c033 100644 --- a/internal/ui/install_test.go +++ b/internal/ui/install_test.go @@ -261,6 +261,69 @@ func TestInstall_PullFailureReturnsToAppList(t *testing.T) { assert.Equal(t, pullErr, m.err) } +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{Host: "ghcr.io", 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, 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.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) { ns := newTestNamespace() m := NewInstall(ns, "") @@ -398,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 +}