mirror of
https://github.com/docker/compose.git
synced 2026-10-12 02:13:08 +00:00
relay/ (github.com/docker/compose-relay) is a separate go.mod: root `go vet`/`golangci-lint`/`go test` never covered it, and the local pre-commit hook explicitly excludes any directory with its own go.mod for the same reason. Only the image build (relay-image-cross) was ever validated in CI -- the Go source itself had no quality gate. Adds relay-lint and relay-test bake targets, mirroring the root lint/ test Dockerfile stages: a build-base downloading go.mod's (currently empty) dependency set, a lint stage running the same pinned golangci-lint version as the root, and a test stage running go vet then go test. relay-lint joins the "validate" bake group; both are new matrix entries in the CI validate job. relay/ gets its own .golangci.yml (the lint stage's build context is "./relay" only, so it can't see the root config) with the same rule set as root, minus what doesn't apply to a dependency-free module (the e2e build tag, testify/gotest.tools-specific depguard entries). Turning lint on for the first time surfaced 8 real findings in relay/main.go and main_test.go (unchecked Close() errors, %v instead of %w on a wrapped error, a redundant embedded-field selector, and log.Fatalf after a defer that would never run) -- fixed here so the new job is green from the start, not merged red. Signed-off-by: Nicolas De Loof <[email protected]>
193 lines
5.3 KiB
Go
193 lines
5.3 KiB
Go
/*
|
|
Copyright 2026 Docker Compose CLI authors
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package main
|
|
|
|
import (
|
|
"io"
|
|
"net"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A provider announces endpoints as seen from its host; the relay, which
|
|
// knows it runs inside a container, rewrites host-local upstreams to
|
|
// host.docker.internal and leaves routable addresses untouched.
|
|
func TestParseRoutesTranslatesHostLocalUpstreams(t *testing.T) {
|
|
routes, err := parseRoutes("80=localhost:49152,81=127.0.0.1:5734,82=[::1]:5735,83=0.0.0.0:5736,443=192.168.1.10:8443")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := map[int]string{
|
|
80: "host.docker.internal:49152",
|
|
81: "host.docker.internal:5734",
|
|
82: "host.docker.internal:5735",
|
|
83: "host.docker.internal:5736",
|
|
443: "192.168.1.10:8443",
|
|
}
|
|
if !reflect.DeepEqual(routes, want) {
|
|
t.Fatalf("got %v, want %v", routes, want)
|
|
}
|
|
}
|
|
|
|
func TestParseRoutesRejectsMalformedEntries(t *testing.T) {
|
|
for _, spec := range []string{"", "80", "80=nohostport", "0=localhost:1", "x=localhost:1"} {
|
|
if _, err := parseRoutes(spec); err == nil {
|
|
t.Errorf("parseRoutes(%q): expected error", spec)
|
|
}
|
|
}
|
|
}
|
|
|
|
// tcpPair returns a connected (client, server) TCP pair.
|
|
func tcpPair(t *testing.T) (net.Conn, net.Conn) {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer func() { _ = ln.Close() }()
|
|
type accepted struct {
|
|
conn net.Conn
|
|
err error
|
|
}
|
|
ch := make(chan accepted, 1)
|
|
go func() {
|
|
conn, err := ln.Accept()
|
|
ch <- accepted{conn, err}
|
|
}()
|
|
client, err := net.Dial("tcp", ln.Addr().String())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
server := <-ch
|
|
if server.err != nil {
|
|
t.Fatal(server.err)
|
|
}
|
|
return client, server.conn
|
|
}
|
|
|
|
// silentUpstream accepts one connection, drains it, and sits silent with its
|
|
// write side open until closed.
|
|
func silentUpstream(t *testing.T) net.Listener {
|
|
t.Helper()
|
|
upstream, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = upstream.Close() })
|
|
go func() {
|
|
conn, err := upstream.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
_, _ = io.Copy(io.Discard, conn) // read the FIN, never answer, never close
|
|
time.Sleep(5 * time.Second)
|
|
}()
|
|
return upstream
|
|
}
|
|
|
|
// A peer that never closes after receiving our FIN must not pin the forward
|
|
// forever: once one direction is done, the surviving one may stream for as
|
|
// long as data flows but is reaped when idle past the grace.
|
|
func TestForwardReapsIdleHalfOpenPair(t *testing.T) {
|
|
restore := halfCloseIdleTimeout
|
|
halfCloseIdleTimeout = 200 * time.Millisecond
|
|
t.Cleanup(func() { halfCloseIdleTimeout = restore })
|
|
|
|
upstream := silentUpstream(t)
|
|
client, server := tcpPair(t)
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
|
|
finished := make(chan struct{})
|
|
go func() {
|
|
forward(server, upstream.Addr().String())
|
|
close(finished)
|
|
}()
|
|
|
|
if _, err := client.Write([]byte("request")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = client.Close() // downstream fully gone: down->up finishes, up->down survives
|
|
|
|
select {
|
|
case <-finished:
|
|
// reaped by the idle grace instead of hanging on the silent upstream
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("forward still blocked on a half-open pair after the idle grace")
|
|
}
|
|
}
|
|
|
|
// Half-close semantics survive the reaper: a response still flowing after the
|
|
// client half-closed its request side is relayed past the grace, not cut.
|
|
func TestForwardKeepsStreamingAfterHalfClose(t *testing.T) {
|
|
restore := halfCloseIdleTimeout
|
|
halfCloseIdleTimeout = 300 * time.Millisecond
|
|
t.Cleanup(func() { halfCloseIdleTimeout = restore })
|
|
|
|
upstream, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = upstream.Close() })
|
|
const chunks = 6
|
|
go func() {
|
|
conn, err := upstream.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer func() { _ = conn.Close() }()
|
|
// stream chunks for well past the idle grace, each within it
|
|
for range chunks {
|
|
time.Sleep(150 * time.Millisecond)
|
|
if _, err := conn.Write([]byte("chunk")); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
client, server := tcpPair(t)
|
|
t.Cleanup(func() { _ = client.Close() })
|
|
finished := make(chan struct{})
|
|
go func() {
|
|
forward(server, upstream.Addr().String())
|
|
close(finished)
|
|
}()
|
|
|
|
closeWrite(client) // request side done; the response keeps streaming
|
|
|
|
got := 0
|
|
buf := make([]byte, 64)
|
|
_ = client.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
for {
|
|
n, err := client.Read(buf)
|
|
got += n
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
if got < chunks*len("chunk") {
|
|
t.Fatalf("streaming was cut by the idle reaper: got %d bytes, want %d", got, chunks*len("chunk"))
|
|
}
|
|
// let forward return before the Cleanup restores the shared grace var
|
|
select {
|
|
case <-finished:
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("forward did not return after both directions ended")
|
|
}
|
|
}
|