feat: add private Docker registry authentication for base images
All checks were successful
Check / check (pull_request) Successful in 3m34s

Add per-app registry credentials that are passed to Docker during image
builds, allowing apps to use base images from private registries.

- New registry_credentials table (migration 007)
- RegistryCredential model with full CRUD operations
- Docker client passes AuthConfigs to ImageBuild when credentials exist
- Deploy service fetches app registry credentials before builds
- Web UI section for managing registry credentials (add/edit/delete)
- Comprehensive unit tests for model and auth config builder
- README updated to list the feature
This commit is contained in:
user
2026-03-17 02:14:39 -07:00
parent fd110e69db
commit 0f4acb554e
11 changed files with 649 additions and 13 deletions

View File

@@ -0,0 +1,96 @@
package docker //nolint:testpackage // tests unexported buildAuthConfigs
import (
"testing"
)
func TestBuildAuthConfigsEmpty(t *testing.T) {
t.Parallel()
result := buildAuthConfigs(nil)
if len(result) != 0 {
t.Errorf("expected empty map, got %d entries", len(result))
}
}
func TestBuildAuthConfigsSingle(t *testing.T) {
t.Parallel()
auths := []RegistryAuth{
{
Registry: "registry.example.com",
Username: "user",
Password: "pass",
},
}
result := buildAuthConfigs(auths)
if len(result) != 1 {
t.Fatalf("expected 1 entry, got %d", len(result))
}
cfg, ok := result["registry.example.com"]
if !ok {
t.Fatal("expected registry.example.com key")
}
if cfg.Username != "user" {
t.Errorf("expected username 'user', got %q", cfg.Username)
}
if cfg.Password != "pass" {
t.Errorf("expected password 'pass', got %q", cfg.Password)
}
if cfg.ServerAddress != "registry.example.com" {
t.Errorf("expected server address 'registry.example.com', got %q", cfg.ServerAddress)
}
}
func TestBuildAuthConfigsMultiple(t *testing.T) {
t.Parallel()
auths := []RegistryAuth{
{Registry: "ghcr.io", Username: "ghuser", Password: "ghtoken"},
{Registry: "docker.io", Username: "dkuser", Password: "dktoken"},
}
result := buildAuthConfigs(auths)
if len(result) != 2 {
t.Fatalf("expected 2 entries, got %d", len(result))
}
ghcr := result["ghcr.io"]
if ghcr.Username != "ghuser" || ghcr.Password != "ghtoken" {
t.Errorf("unexpected ghcr.io config: %+v", ghcr)
}
dkr := result["docker.io"]
if dkr.Username != "dkuser" || dkr.Password != "dktoken" {
t.Errorf("unexpected docker.io config: %+v", dkr)
}
}
func TestRegistryAuthStruct(t *testing.T) {
t.Parallel()
auth := RegistryAuth{
Registry: "registry.example.com",
Username: "testuser",
Password: "testpass",
}
if auth.Registry != "registry.example.com" {
t.Errorf("expected registry 'registry.example.com', got %q", auth.Registry)
}
if auth.Username != "testuser" {
t.Errorf("expected username 'testuser', got %q", auth.Username)
}
if auth.Password != "testpass" {
t.Errorf("expected password 'testpass', got %q", auth.Password)
}
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/registry"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/docker/go-connections/nat"
@@ -105,12 +106,20 @@ func (c *Client) IsConnected() bool {
return c.docker != nil
}
// RegistryAuth contains authentication credentials for a Docker registry.
type RegistryAuth struct {
Registry string
Username string
Password string //nolint:gosec // credential field required for registry auth
}
// BuildImageOptions contains options for building an image.
type BuildImageOptions struct {
ContextDir string
DockerfilePath string
Tags []string
LogWriter io.Writer // Optional writer for build output
LogWriter io.Writer // Optional writer for build output
RegistryAuths []RegistryAuth // Optional registry credentials for pulling private base images
}
// BuildImage builds a Docker image from a context directory.
@@ -161,6 +170,21 @@ type PortMapping struct {
Protocol string // "tcp" or "udp"
}
// buildAuthConfigs converts RegistryAuth slices into Docker's AuthConfigs map.
func buildAuthConfigs(auths []RegistryAuth) map[string]registry.AuthConfig {
configs := make(map[string]registry.AuthConfig, len(auths))
for _, auth := range auths {
configs[auth.Registry] = registry.AuthConfig{
Username: auth.Username,
Password: auth.Password,
ServerAddress: auth.Registry,
}
}
return configs
}
// buildPortConfig converts port mappings to Docker port configuration.
func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
exposedPorts := make(nat.PortSet)
@@ -513,12 +537,18 @@ func (c *Client) performBuild(
}()
// Build image
resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{
buildOpts := dockertypes.ImageBuildOptions{
Dockerfile: opts.DockerfilePath,
Tags: opts.Tags,
Remove: true,
NoCache: false,
})
}
if len(opts.RegistryAuths) > 0 {
buildOpts.AuthConfigs = buildAuthConfigs(opts.RegistryAuths)
}
resp, err := c.docker.ImageBuild(ctx, tarArchive, buildOpts)
if err != nil {
return "", fmt.Errorf("failed to build image: %w", err)
}