248 lines
7.9 KiB
Go
248 lines
7.9 KiB
Go
package docker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kandev/kandev/internal/common/config"
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
"github.com/moby/moby/api/types/network"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
func TestNormalizeDockerHostIP(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in netip.Addr
|
|
want string
|
|
}{
|
|
{name: "unset", in: netip.Addr{}, want: "127.0.0.1"},
|
|
{name: "ipv4 wildcard", in: netip.MustParseAddr("0.0.0.0"), want: "127.0.0.1"},
|
|
{name: "ipv6 wildcard", in: netip.MustParseAddr("::"), want: "127.0.0.1"},
|
|
{name: "ipv4 loopback", in: netip.MustParseAddr("127.0.0.1"), want: "127.0.0.1"},
|
|
{name: "ipv4 host", in: netip.MustParseAddr("10.0.0.5"), want: "10.0.0.5"},
|
|
{name: "ipv6 loopback", in: netip.MustParseAddr("::1"), want: "::1"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := normalizeDockerHostIP(tc.in); got != tc.want {
|
|
t.Errorf("normalizeDockerHostIP(%v) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildDockerPortBindings_EmptyReturnsNil(t *testing.T) {
|
|
exposed, bindings, err := buildDockerPortBindings(nil)
|
|
if err != nil {
|
|
t.Fatalf("buildDockerPortBindings: %v", err)
|
|
}
|
|
if exposed != nil {
|
|
t.Errorf("expected nil exposed ports, got %v", exposed)
|
|
}
|
|
if bindings != nil {
|
|
t.Errorf("expected nil bindings, got %v", bindings)
|
|
}
|
|
}
|
|
|
|
func TestBuildDockerPortBindings_AssignsContainerAndHost(t *testing.T) {
|
|
in := []PortBindingConfig{
|
|
{ContainerPort: 8080, HostIP: "127.0.0.1", HostPort: "0"},
|
|
{ContainerPort: 9000, HostIP: "0.0.0.0", HostPort: "9001"},
|
|
}
|
|
exposed, bindings, err := buildDockerPortBindings(in)
|
|
if err != nil {
|
|
t.Fatalf("buildDockerPortBindings: %v", err)
|
|
}
|
|
|
|
if got := len(exposed); got != 2 {
|
|
t.Fatalf("exposed ports = %d, want 2", got)
|
|
}
|
|
for _, b := range in {
|
|
key := network.MustParsePort(fmt.Sprintf("%d/tcp", b.ContainerPort))
|
|
if _, ok := exposed[key]; !ok {
|
|
t.Errorf("exposed missing %s", key)
|
|
}
|
|
got := bindings[key]
|
|
if len(got) != 1 {
|
|
t.Fatalf("bindings[%s] = %d entries, want 1", key, len(got))
|
|
}
|
|
if got[0].HostIP.String() != b.HostIP || got[0].HostPort != b.HostPort {
|
|
t.Errorf("bindings[%s] = %+v, want host_ip=%q host_port=%q", key, got[0], b.HostIP, b.HostPort)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildDockerPortBindings_DeduplicatesContainerPort(t *testing.T) {
|
|
in := []PortBindingConfig{
|
|
{ContainerPort: 7000, HostIP: "127.0.0.1", HostPort: "0"},
|
|
{ContainerPort: 7000, HostIP: "10.0.0.5", HostPort: "7000"},
|
|
}
|
|
_, bindings, err := buildDockerPortBindings(in)
|
|
if err != nil {
|
|
t.Fatalf("buildDockerPortBindings: %v", err)
|
|
}
|
|
got := bindings[network.MustParsePort("7000/tcp")]
|
|
if len(got) != 2 {
|
|
t.Fatalf("want both bindings on port 7000, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestBuildDockerPortBindings_RejectsInvalidInput(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in PortBindingConfig
|
|
}{
|
|
{name: "container port zero", in: PortBindingConfig{ContainerPort: 0, HostPort: "1"}},
|
|
{name: "container port too large", in: PortBindingConfig{ContainerPort: 70000, HostPort: "1"}},
|
|
{name: "host ip not an address", in: PortBindingConfig{ContainerPort: 8080, HostIP: "localhost"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if _, _, err := buildDockerPortBindings([]PortBindingConfig{tc.in}); err == nil {
|
|
t.Fatal("expected an error, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildDockerPortBindings_EmptyHostIPPublishesOnAllInterfaces(t *testing.T) {
|
|
_, bindings, err := buildDockerPortBindings([]PortBindingConfig{
|
|
{ContainerPort: 8080, HostIP: "", HostPort: "8080"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("buildDockerPortBindings: %v", err)
|
|
}
|
|
got := bindings[network.MustParsePort("8080/tcp")]
|
|
if len(got) != 1 {
|
|
t.Fatalf("bindings = %d entries, want 1", len(got))
|
|
}
|
|
if got[0].HostIP.IsValid() {
|
|
t.Errorf("host IP = %v, want the zero address (all interfaces)", got[0].HostIP)
|
|
}
|
|
}
|
|
|
|
func TestParseHostPort(t *testing.T) {
|
|
if got, err := parseHostPort("9001"); err != nil || got != 9001 {
|
|
t.Fatalf("parseHostPort(9001) = %d, %v", got, err)
|
|
}
|
|
for _, in := range []string{"", "abc", "70000", "-1"} {
|
|
if _, err := parseHostPort(in); err == nil {
|
|
t.Errorf("parseHostPort(%q) = nil error, want an error", in)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestContainerTeardownTreatsMissingContainersAsSuccess(t *testing.T) {
|
|
dockerDaemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"message":"No such container: missing-container"}`))
|
|
}))
|
|
t.Cleanup(dockerDaemon.Close)
|
|
|
|
log, err := logger.NewFromZap(zap.NewNop())
|
|
if err != nil {
|
|
t.Fatalf("new logger: %v", err)
|
|
}
|
|
client, err := NewClient(config.DockerConfig{
|
|
Host: "tcp://" + strings.TrimPrefix(dockerDaemon.URL, "http://"),
|
|
APIVersion: "1.44",
|
|
}, log)
|
|
require.NoError(t, err)
|
|
|
|
require.NoError(t, client.StopContainer(context.Background(), "missing-container", time.Second))
|
|
require.NoError(t, client.KillContainer(context.Background(), "missing-container", "SIGKILL"))
|
|
require.NoError(t, client.RemoveContainer(context.Background(), "missing-container", true))
|
|
}
|
|
|
|
func TestRemoveContainerTreatsConcurrentRemovalAsSuccess(t *testing.T) {
|
|
dockerDaemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodHead && r.URL.Path == "/_ping" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusConflict)
|
|
_, _ = w.Write([]byte(`{"message":"removal of container abc is already in progress"}`))
|
|
}))
|
|
t.Cleanup(dockerDaemon.Close)
|
|
|
|
log, err := logger.NewFromZap(zap.NewNop())
|
|
require.NoError(t, err)
|
|
client, err := NewClient(config.DockerConfig{
|
|
Host: "tcp://" + strings.TrimPrefix(dockerDaemon.URL, "http://"),
|
|
APIVersion: "1.44",
|
|
}, log)
|
|
require.NoError(t, err)
|
|
|
|
// Docker reports a conflict when another owner is already removing the
|
|
// same container. Removal is a convergence operation, so the in-flight
|
|
// owner has already taken responsibility for reaching the desired state.
|
|
require.NoError(t, client.RemoveContainer(context.Background(), "abc", true))
|
|
}
|
|
|
|
func TestBuildHostConfig_SecurityOptNilWhenEmpty(t *testing.T) {
|
|
cfg := ContainerConfig{
|
|
Memory: 256,
|
|
CPUQuota: 100000,
|
|
NetworkMode: "bridge",
|
|
AutoRemove: true,
|
|
}
|
|
hc := buildHostConfig(cfg, nil, nil)
|
|
if hc.SecurityOpt != nil {
|
|
t.Errorf("SecurityOpt = %v, want nil", hc.SecurityOpt)
|
|
}
|
|
}
|
|
|
|
func TestBuildHostConfig_SecurityOptSetWhenProvided(t *testing.T) {
|
|
cfg := ContainerConfig{
|
|
Memory: 256,
|
|
CPUQuota: 100000,
|
|
NetworkMode: "bridge",
|
|
AutoRemove: true,
|
|
SecurityOpt: []string{
|
|
`seccomp={"defaultAction":"SCMP_ACT_ALLOW","architectures":["SCMP_ARCH_X86_64"]}`,
|
|
"apparmor=unconfined",
|
|
},
|
|
}
|
|
hc := buildHostConfig(cfg, nil, nil)
|
|
if len(hc.SecurityOpt) != 2 {
|
|
t.Fatalf("SecurityOpt = %v, want 2 entries", hc.SecurityOpt)
|
|
}
|
|
if hc.SecurityOpt[0] != cfg.SecurityOpt[0] {
|
|
t.Errorf("SecurityOpt[0] = %q, want %q", hc.SecurityOpt[0], cfg.SecurityOpt[0])
|
|
}
|
|
if hc.SecurityOpt[1] != cfg.SecurityOpt[1] {
|
|
t.Errorf("SecurityOpt[1] = %q, want %q", hc.SecurityOpt[1], cfg.SecurityOpt[1])
|
|
}
|
|
}
|
|
|
|
func TestBuildDockerMounts(t *testing.T) {
|
|
in := []MountConfig{
|
|
{Source: "/src", Target: "/dst", ReadOnly: true},
|
|
{Source: "/data", Target: "/var/data", ReadOnly: false},
|
|
}
|
|
mounts := buildDockerMounts(in)
|
|
if len(mounts) != 2 {
|
|
t.Fatalf("got %d mounts, want 2", len(mounts))
|
|
}
|
|
if mounts[0].Source != "/src" || mounts[0].Target != "/dst" || !mounts[0].ReadOnly {
|
|
t.Errorf("mount 0 = %+v, want source=/src target=/dst readOnly=true", mounts[0])
|
|
}
|
|
if mounts[1].Source != "/data" || mounts[1].Target != "/var/data" || mounts[1].ReadOnly {
|
|
t.Errorf("mount 1 = %+v, want source=/data target=/var/data readOnly=false", mounts[1])
|
|
}
|
|
}
|