diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 4a96ea2..32b7df1 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -8,9 +8,11 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: build: + if: github.event_name != 'workflow_dispatch' runs-on: ubuntu-latest permissions: contents: read @@ -93,6 +95,12 @@ jobs: working-directory: cmd run: go test -race ./... + - name: Test (cmd, bridge networking) + working-directory: cmd + env: + JAWSAUTH_TEST_FORCE_BRIDGE: "1" + run: go test -race ./... + - name: Calculate code coverage id: coverage run: | @@ -113,6 +121,30 @@ jobs: working-directory: cmd run: go build -v ./... + cmd: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: stable + cache-dependency-path: cmd/go.sum + + - name: Test (cmd) + working-directory: cmd + run: go test -race -v ./... + + - name: Test (cmd, bridge networking) + working-directory: cmd + env: + JAWSAUTH_TEST_FORCE_BRIDGE: "1" + run: go test -race -v ./... + coverage: needs: build runs-on: ubuntu-latest diff --git a/cmd/demo/integration_test.go b/cmd/demo/integration_test.go index edacb4a..c92f612 100644 --- a/cmd/demo/integration_test.go +++ b/cmd/demo/integration_test.go @@ -15,23 +15,62 @@ import ( "strings" "testing" "time" + + "github.com/linkdata/jawsauth/cmd/internal/testdocker" + "github.com/moby/moby/api/types/container" + "github.com/testcontainers/testcontainers-go" ) func TestIntegrationLoginWorks(t *testing.T) { - t.Parallel() - ctx, cancel := context.WithTimeout(t.Context(), 4*time.Minute) defer cancel() + opts := demoOptions{ + ListenAddr: "127.0.0.1:0", + PasswordFile: filepath.Join(t.TempDir(), "password.txt"), + Realm: "jawsauth-demo-it", + ClientID: "jawsauth-demo-it-client", + Username: "demouser", + UserEmail: "demouser@example.com", + KeycloakImage: testdocker.KeycloakImage, + } + oldDefaultHost := keycloakDefaultHost + keycloakDefaultHost = func() (string, error) { return "127.0.0.1", nil } + t.Cleanup(func() { keycloakDefaultHost = oldDefaultHost }) + hostNetwork := testdocker.HostNetworkAvailable(ctx, opts.KeycloakImage) + t.Logf("Docker host network available: %t", hostNetwork) + portInUse := errors.New("keycloak port already in use") + attempts := 1 + if hostNetwork { + attempts = 3 + oldHTTPSPort := keycloakHTTPSPort + t.Cleanup(func() { keycloakHTTPSPort = oldHTTPSPort }) + oldContainer := keycloakContainer + keycloakContainer = func(ctx context.Context, req testcontainers.GenericContainerRequest) (testcontainers.Container, error) { + req.Cmd = append(req.Cmd, "--http-host=127.0.0.1") + req.HostConfigModifier = func(hc *container.HostConfig) { hc.NetworkMode = "host" } + started, err := oldContainer(ctx, req) + if err != nil && testdocker.PortInUse(ctx, started) { + err = errors.Join(err, portInUse) + } + return started, err + } + t.Cleanup(func() { keycloakContainer = oldContainer }) + } - passwordFile := filepath.Join(t.TempDir(), "password.txt") - demo, err := startDemo(ctx, demoOptions{ - ListenAddr: "127.0.0.1:0", - PasswordFile: passwordFile, - Realm: "jawsauth-demo-it", - ClientID: "jawsauth-demo-it-client", - Username: "demouser", - UserEmail: "demouser@example.com", - }) + var demo *demoServer + var err error + for range attempts { + if hostNetwork { + keycloakHTTPSPort, err = testdocker.FreePort() + if err != nil { + t.Fatal(err) + } + } + demo, err = startDemo(ctx, opts) + if !errors.Is(err, portInUse) { + break + } + } if err != nil { if isDockerUnavailableError(err) { t.Skipf("docker unavailable: %v", err) @@ -46,7 +85,7 @@ func TestIntegrationLoginWorks(t *testing.T) { } }) - passwordBytes, err := os.ReadFile(passwordFile) + passwordBytes, err := os.ReadFile(opts.PasswordFile) if err != nil { t.Fatal(err) } diff --git a/cmd/demo/keycloak.go b/cmd/demo/keycloak.go index 8f99e3b..f8dcb74 100644 --- a/cmd/demo/keycloak.go +++ b/cmd/demo/keycloak.go @@ -56,6 +56,7 @@ var ( keycloakGeneratePEM = generateSelfSignedCertificatePEM keycloakContainer = testcontainers.GenericContainer keycloakDefaultHost = defaultInterfaceAddress + keycloakHTTPSPort = "8443" ) func startKeycloakServer(ctx context.Context, image, adminPassword string) (*keycloakServer, error) { @@ -90,23 +91,23 @@ func startKeycloakServer(ctx context.Context, image, adminPassword string) (*key req := testcontainers.ContainerRequest{ Image: image, - ExposedPorts: []string{"8443/tcp"}, + ExposedPorts: []string{keycloakHTTPSPort + "/tcp"}, Env: map[string]string{ "KC_BOOTSTRAP_ADMIN_USERNAME": defaultKeycloakAdminUser, "KC_BOOTSTRAP_ADMIN_PASSWORD": adminPassword, "KC_HTTP_ENABLED": "false", - "KC_HTTPS_PORT": "8443", + "KC_HTTPS_PORT": keycloakHTTPSPort, "KC_HTTPS_CERTIFICATE_FILE": "/opt/keycloak/conf/server.crt", "KC_HTTPS_CERTIFICATE_KEY_FILE": "/opt/keycloak/conf/server.key", "KC_HOSTNAME_STRICT": "false", }, - Cmd: []string{"start-dev", "--https-port=8443", "--http-enabled=false"}, + Cmd: []string{"start-dev", "--https-port=" + keycloakHTTPSPort, "--http-enabled=false"}, Files: []testcontainers.ContainerFile{ {HostFilePath: certPath, ContainerFilePath: "/opt/keycloak/conf/server.crt", FileMode: 0o644}, {HostFilePath: keyPath, ContainerFilePath: "/opt/keycloak/conf/server.key", FileMode: 0o644}, }, WaitingFor: wait.ForHTTP("/"). - WithPort("8443/tcp"). + WithPort(keycloakHTTPSPort + "/tcp"). WithTLS(true). WithAllowInsecure(true). WithStartupTimeout(2 * time.Minute), @@ -117,6 +118,11 @@ func startKeycloakServer(ctx context.Context, image, adminPassword string) (*key Started: true, }) if err != nil { + if container != nil { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + _ = container.Terminate(cleanupCtx, testcontainers.StopTimeout(0)) + cancel() + } cleanupTempDir() return nil, fmt.Errorf("start keycloak container: %w", err) } @@ -128,7 +134,7 @@ func startKeycloakServer(ctx context.Context, image, adminPassword string) (*key return nil, fmt.Errorf("resolve keycloak host: %w", err) } - port, err := container.MappedPort(ctx, "8443/tcp") + port, err := container.MappedPort(ctx, keycloakHTTPSPort+"/tcp") if err != nil { _ = container.Terminate(ctx) cleanupTempDir() diff --git a/cmd/demo/keycloak_unit_test.go b/cmd/demo/keycloak_unit_test.go index 7f2f8aa..f15d12f 100644 --- a/cmd/demo/keycloak_unit_test.go +++ b/cmd/demo/keycloak_unit_test.go @@ -55,6 +55,7 @@ type fakeContainer struct { hostErr error portErr error terminateErr error + terminated *bool } func (container fakeContainer) Host(context.Context) (string, error) { @@ -69,6 +70,9 @@ func (container fakeContainer) MappedPort(context.Context, string) (network.Port } func (container fakeContainer) Terminate(context.Context, ...testcontainers.TerminateOption) error { + if container.terminated != nil { + *container.terminated = true + } return container.terminateErr } @@ -146,6 +150,20 @@ func TestStartKeycloakServerErrorsAndSuccess(t *testing.T) { }) } + t.Run("container start failure cleans up", func(t *testing.T) { + resetKeycloakDeps(t) + var terminated bool + keycloakContainer = func(context.Context, testcontainers.GenericContainerRequest) (testcontainers.Container, error) { + return fakeContainer{terminated: &terminated}, errors.New("start failure") + } + if _, err := startKeycloakServer(t.Context(), "image", "admin-pass"); err == nil { + t.Fatal("expected container startup failure") + } + if !terminated { + t.Fatal("failed container was not terminated") + } + }) + t.Run("success", func(t *testing.T) { resetKeycloakDeps(t) keycloakContainer = func(context.Context, testcontainers.GenericContainerRequest) (testcontainers.Container, error) { diff --git a/cmd/integration/keycloak_test.go b/cmd/integration/keycloak_test.go index c70faeb..00d91f8 100644 --- a/cmd/integration/keycloak_test.go +++ b/cmd/integration/keycloak_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/linkdata/jawsauth/cmd/internal/testdocker" + "github.com/moby/moby/api/types/container" "github.com/testcontainers/testcontainers-go" ) @@ -18,7 +20,7 @@ func TestKeycloakFlow(t *testing.T) { ctx := t.Context() // Start Keycloak container - keycloakContainer, err := startKeycloakContainer(t, ctx) + keycloakContainer, containerPort, err := startKeycloakContainer(t, ctx) defer func() { if keycloakContainer != nil { if t.Failed() { @@ -39,7 +41,7 @@ func TestKeycloakFlow(t *testing.T) { t.Fatalf("Failed to get container host: %v", err) } - port, err := keycloakContainer.MappedPort(ctx, "8080/tcp") + port, err := keycloakContainer.MappedPort(ctx, containerPort) if err != nil { t.Fatalf("Failed to get container port: %v", err) } @@ -95,7 +97,7 @@ func TestKeycloakFlow(t *testing.T) { serverHandlerTest(t, baseURL, realm, "testclient", clientSecret) } -func startKeycloakContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) { +func startKeycloakContainer(t *testing.T, ctx context.Context) (testcontainers.Container, string, error) { t.Helper() defer func() { if x := recover(); x != nil { @@ -103,7 +105,7 @@ func startKeycloakContainer(t *testing.T, ctx context.Context) (testcontainers.C } }() req := testcontainers.ContainerRequest{ - Image: "quay.io/keycloak/keycloak:latest", + Image: testdocker.KeycloakImage, ExposedPorts: []string{"8080/tcp"}, Env: map[string]string{ "KC_BOOTSTRAP_ADMIN_USERNAME": "admin", @@ -111,16 +113,42 @@ func startKeycloakContainer(t *testing.T, ctx context.Context) (testcontainers.C }, Cmd: []string{"start-dev"}, } - - container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: true, - }) - if err != nil { - return nil, fmt.Errorf("failed to start container: %w", err) + hostNetwork := testdocker.HostNetworkAvailable(ctx, req.Image) + t.Logf("Docker host network available: %t", hostNetwork) + attempts := 1 + if hostNetwork { + attempts = 3 + req.HostConfigModifier = func(hc *container.HostConfig) { hc.NetworkMode = "host" } + } + containerPort := "8080/tcp" + var lastErr error + for range attempts { + if hostNetwork { + port, err := testdocker.FreePort() + if err != nil { + return nil, "", err + } + containerPort = port + "/tcp" + req.Cmd = []string{"start-dev", "--http-host=127.0.0.1", "--http-port=" + port} + } + started, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err == nil { + err = waitForKeycloak(ctx, started, containerPort) + } + if err == nil { + return started, containerPort, nil + } + if hostNetwork && testdocker.PortInUse(ctx, started) { + lastErr = err + _ = started.Terminate(context.WithoutCancel(ctx)) + continue + } + return started, containerPort, fmt.Errorf("failed to start container: %w", err) } - - return container, waitForKeycloak(ctx, container) + return nil, "", fmt.Errorf("keycloak port was occupied on all %d attempts: %w", attempts, lastErr) } func printLogs(ctx context.Context, container testcontainers.Container) { @@ -404,13 +432,13 @@ func getUserInfoEmail(ctx context.Context, baseURL, realm, accessToken string) ( return email, nil } -func waitForKeycloak(ctx context.Context, container testcontainers.Container) error { +func waitForKeycloak(ctx context.Context, container testcontainers.Container, containerPort string) error { host, err := container.Host(ctx) if err != nil { return fmt.Errorf("failed to get container host: %w", err) } - mappedPort, err := container.MappedPort(ctx, "8080/tcp") + mappedPort, err := container.MappedPort(ctx, containerPort) if err != nil { return fmt.Errorf("failed to get mapped port: %w", err) } @@ -418,6 +446,13 @@ func waitForKeycloak(ctx context.Context, container testcontainers.Container) er url := fmt.Sprintf("http://%s:%s/", host, mappedPort.Port()) for range 20 { // Retry for ~60 seconds (20 attempts, 3 seconds each) + state, err := container.State(ctx) + if err != nil { + return err + } + if !state.Running { + return fmt.Errorf("keycloak container exited with code %d", state.ExitCode) + } resp, err := http.Get(url) if err == nil { statusCode := resp.StatusCode diff --git a/cmd/internal/testdocker/network.go b/cmd/internal/testdocker/network.go new file mode 100644 index 0000000..20e62fb --- /dev/null +++ b/cmd/internal/testdocker/network.go @@ -0,0 +1,83 @@ +package testdocker + +import ( + "context" + "io" + "net" + "os" + "strconv" + "strings" + "time" + + "github.com/moby/moby/api/types/container" + "github.com/testcontainers/testcontainers-go" + tcexec "github.com/testcontainers/testcontainers-go/exec" +) + +const KeycloakImage = "quay.io/keycloak/keycloak:26.7.4@sha256:82a77884f3af238beab1e7afd63b5f530e1b5c0590bd7aa60b40a40463e29b2c" + +// HostNetworkAvailable checks that the test process and Docker host share a network namespace. +func HostNetworkAvailable(ctx context.Context, image string) bool { + if os.Getenv("JAWSAUTH_TEST_FORCE_BRIDGE") == "1" { + return false + } + bootID, err := os.ReadFile("/proc/sys/kernel/random/boot_id") + if err != nil { + return false + } + netNS, err := os.Readlink("/proc/self/ns/net") + if err != nil { + return false + } + probe, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: image, + Entrypoint: []string{"/bin/sh", "-c"}, + Cmd: []string{"sleep 300"}, + HostConfigModifier: func(hc *container.HostConfig) { hc.NetworkMode = "host" }, + }, + Started: true, + }) + if probe != nil { + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + _ = probe.Terminate(cleanupCtx, testcontainers.StopTimeout(0)) + }() + } + if err != nil { + return false + } + host, err := probe.Host(ctx) + if err != nil || (host != "localhost" && host != "127.0.0.1" && host != "::1") { + return false + } + exitCode, output, err := probe.Exec(ctx, []string{"/bin/sh", "-c", "cat /proc/sys/kernel/random/boot_id; readlink /proc/self/ns/net"}, tcexec.Multiplexed()) + if err != nil || exitCode != 0 { + return false + } + actual, err := io.ReadAll(output) + return err == nil && strings.TrimSpace(string(actual)) == strings.TrimSpace(string(bootID))+"\n"+netNS +} + +func FreePort() (string, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", err + } + defer func() { _ = listener.Close() }() + return strconv.Itoa(listener.Addr().(*net.TCPAddr).Port), nil +} + +func PortInUse(ctx context.Context, c testcontainers.Container) bool { + if c == nil { + return false + } + logs, err := c.Logs(ctx) + if err != nil { + return false + } + defer func() { _ = logs.Close() }() + output, err := io.ReadAll(logs) + return err == nil && strings.Contains(strings.ToLower(string(output)), "already in use") +}