Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 87 additions & 6 deletions integration/docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"compress/gzip"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
Expand All @@ -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"
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -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}},
},
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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...)
}
2 changes: 1 addition & 1 deletion internal/command/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
35 changes: 32 additions & 3 deletions internal/command/deploy_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package command

import (
"strings"
"testing"

"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand Down Expand Up @@ -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)
})
}
75 changes: 61 additions & 14 deletions internal/command/settings_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package command

import (
"fmt"
"io"
"path/filepath"
"strings"

Expand All @@ -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")
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading