utils/utils_test.go
Rama main · réplica de lectura del repositorio original.
package utils
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"go.uber.org/zap"
)
func TestContainerNameFromDockerRun(t *testing.T) {
cases := []struct {
name string
cmd string
want string
}{
{"space form", "docker run --rm --name dedup-go-test dedup-go:latest", "dedup-go-test"},
{"equals form", "docker run --name=my-app img", "my-app"},
{"name mid-flags", "docker run -d --name x --network y img", "x"},
{"no name", "docker run --rm img", ""},
{"empty", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ContainerNameFromDockerRun(tc.cmd); got != tc.want {
t.Fatalf("ContainerNameFromDockerRun(%q) = %q, want %q", tc.cmd, got, tc.want)
}
})
}
}
// GetAvailablePort must not hand back a port from the kernel's local port
// range.
//
// The oracle is read straight from /proc, NOT from ephemeralPortRange: using
// the production helper as its own oracle makes the test self-referential, and
// a helper that mis-reports the range (say 32768-40000) would then pass while
// handing out 40001-65534 — most of it inside the real range, i.e. exactly the
// bug this test exists to catch.
func TestGetAvailablePortAvoidsTheEphemeralRange(t *testing.T) {
raw, err := os.ReadFile("/proc/sys/net/ipv4/ip_local_port_range")
if err != nil {
t.Skipf("no ip_local_port_range on this platform (%v); GetAvailablePort "+
"correctly falls back to :0 there and there is nothing to assert", err)
}
f := strings.Fields(string(raw))
if len(f) != 2 {
t.Fatalf("unexpected ip_local_port_range content %q", raw)
}
lo64, err1 := strconv.ParseUint(f[0], 10, 32)
hi64, err2 := strconv.ParseUint(f[1], 10, 32)
if err1 != nil || err2 != nil {
t.Fatalf("unparseable ip_local_port_range %q", raw)
}
lo, hi := uint32(lo64), uint32(hi64)
if hi >= 65535 {
t.Skipf("kernel range reaches %d, leaving no space above it; the fallback is "+
"correct here and there is nothing to assert", hi)
}
for i := 0; i < 50; i++ {
port, err := GetAvailablePort()
if err != nil {
t.Fatalf("GetAvailablePort: %v", err)
}
if port == 0 {
t.Fatal("GetAvailablePort returned port 0")
}
if port >= lo && port <= hi {
t.Fatalf("GetAvailablePort returned %d, inside the kernel's local port range "+
"%d-%d read from /proc: every other bind(0) allocator on the machine — a "+
"second keploy process, Docker's host-port publisher — draws from there "+
"and can take it before the agent binds", port, lo, hi)
}
}
}
// The returned port must be one the function VERIFIED, not merely a number in
// the right band.
//
// Asserting "the returned port is bindable" is not enough: on an idle box
// almost any port above the ephemeral range binds, so that assertion holds by
// luck even if the bindability probe is deleted entirely. Instead, hold a block
// of candidates and require the function to return something outside it.
func TestGetAvailablePortSkipsPortsAlreadyHeld(t *testing.T) {
if _, _, known := ephemeralPortRange(); !known {
t.Skip("platform range unknown; GetAvailablePort falls back to :0 here")
}
_, hi, _ := ephemeralPortRange()
if hi >= 65535 {
t.Skip("no space above the ephemeral range on this host")
}
// Occupy a contiguous block so a probe-less implementation, which returns
// whatever candidate it lands on, has a high chance of returning a held one.
held := map[uint32]bool{}
var lns []net.Listener
for p := hi + 1; p <= 65535 && len(lns) < 2000; p++ {
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", p))
if err != nil {
continue
}
lns = append(lns, ln)
held[p] = true
}
defer func() {
for _, ln := range lns {
_ = ln.Close()
}
}()
if len(held) < 100 {
t.Skipf("could only hold %d ports; too few to discriminate", len(held))
}
for i := 0; i < 200; i++ {
port, err := GetAvailablePort()
if err != nil {
t.Fatalf("GetAvailablePort: %v", err)
}
if held[port] {
t.Fatalf("GetAvailablePort returned %d, a port THIS TEST is holding: the "+
"candidate was never verified bindable, so callers are handed ports "+
"that are already in use", port)
}
}
}
// Successive callers must not all be handed the SAME port.
//
// Scanning upward from a fixed floor returns the lowest free port every time,
// so two keploy processes started moments apart — the ordinary CI pattern —
// are handed an identical number, each having verified it free. Whichever
// binds second loses, and the agent's bind retry cannot recover because the
// winner is a live holder, not a departing one.
//
// Sequential rather than concurrent: concurrent callers are separated anyway
// by the brief hold of the verification listener, so a concurrent test passes
// even with a fixed floor and proves nothing.
func TestGetAvailablePortDoesNotHandEveryCallerTheSamePort(t *testing.T) {
const n = 12
seen := map[uint32]int{}
for i := 0; i < n; i++ {
p, err := GetAvailablePort()
if err != nil {
t.Fatalf("GetAvailablePort: %v", err)
}
seen[p]++
}
if len(seen) < 2 {
t.Fatalf("%d successive calls returned %d distinct port(s): candidates are not "+
"randomised, so keploy processes started moments apart are handed the same "+
"port and one of them fails to bind", n, len(seen))
}
}
func TestEphemeralPortRangeIsSane(t *testing.T) {
lo, hi, known := ephemeralPortRange()
if !known {
// Correct on any platform without ip_local_port_range. It MUST report
// unknown rather than guessing: macOS and Windows both default to
// 49152-65535, so assuming the Linux range would allocate entirely
// inside the real ephemeral range there.
if lo != 0 || hi != 0 {
t.Fatalf("unknown range still reported %d-%d; callers must not act on it", lo, hi)
}
return
}
if lo == 0 || hi == 0 || hi < lo || hi > 65535 {
t.Fatalf("ephemeralPortRange returned %d-%d, want a sane range", lo, hi)
}
}
// The fallback branches are unreachable on a normal Linux box — the /proc read
// always succeeds — so they are tested through the pure parser instead. Without
// this, a broken default would be dead code that no test can reach.
func TestParseEphemeralPortRange(t *testing.T) {
cases := []struct {
name string
in string
lo, hi uint32
}{
{"normal", "32768\t60999\n", 32768, 60999},
{"widened", "1024 65000\n", 1024, 65000},
{"one field", "32768\n", defaultEphemeralLo, defaultEphemeralHi},
{"three fields", "1 2 3\n", defaultEphemeralLo, defaultEphemeralHi},
{"empty", "", defaultEphemeralLo, defaultEphemeralHi},
{"non-numeric", "lo hi\n", defaultEphemeralLo, defaultEphemeralHi},
{"inverted", "60999 32768\n", defaultEphemeralLo, defaultEphemeralHi},
{"zero low", "0 60999\n", defaultEphemeralLo, defaultEphemeralHi},
{"out of range high", "32768 70000\n", defaultEphemeralLo, defaultEphemeralHi},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
lo, hi, ok := parseEphemeralPortRange(tc.in)
if !ok {
t.Fatal("parseEphemeralPortRange reported unknown; it is only called " +
"when the file was read, so it must always resolve to a range")
}
if lo != tc.lo || hi != tc.hi {
t.Fatalf("parseEphemeralPortRange(%q) = %d-%d, want %d-%d", tc.in, lo, hi, tc.lo, tc.hi)
}
})
}
// The Linux default must leave usable space above it, or GetAvailablePort
// has nowhere to draw from on a host whose file is malformed and silently
// falls back to the racy :0 path. This constrains the LINUX default only —
// unknown platforms report known=false and fall back deliberately.
if defaultEphemeralHi >= 65535 {
t.Fatalf("Linux default high-water mark %d leaves no room above it",
defaultEphemeralHi)
}
}
// The agent, proxy and DNS ports are all allocated before ANY of them is bound,
// so a later draw can legitimately return a port an earlier one already
// claimed — isPortAvailable says yes, because nothing is listening on it yet.
// Narrowing allocation to above the ephemeral range shrinks the pool and makes
// that coincidence correspondingly more likely.
func TestEnsureAvailablePortsHonoursTheExclusionSet(t *testing.T) {
// A port that IS free: without the exclusion set it is returned unchanged.
free, err := GetAvailablePort()
if err != nil {
t.Fatalf("GetAvailablePort: %v", err)
}
if got, err := EnsureAvailablePorts(free); err != nil || got != free {
t.Fatalf("precondition: a free port must be returned unchanged, got %d (%v)", got, err)
}
got, err := EnsureAvailablePorts(free, free)
if err != nil {
t.Fatalf("EnsureAvailablePorts: %v", err)
}
if got == free {
t.Fatalf("EnsureAvailablePorts returned %d, which the caller had already claimed: "+
"nothing is bound to it yet, so isPortAvailable says free and two subsystems "+
"end up racing for the same port", got)
}
}
// A platform without ip_local_port_range must report UNKNOWN, not guess.
//
// This is the branch that decides whether the allocator acts at all, and on
// Linux the read always succeeds so it is dead code no ordinary test reaches.
// Guessing the Linux range there is actively harmful: macOS and Windows both
// default to 49152-65535, so 61000-65534 would be entirely INSIDE their real
// ephemeral range while shrinking the pool from 16384 to 4535 — strictly worse
// than the plain :0 the fallback gives them.
func TestEphemeralPortRangeReportsUnknownWhenUnreadable(t *testing.T) {
lo, hi, known := ephemeralPortRangeFrom(func(string) ([]byte, error) {
return nil, os.ErrNotExist
})
if known {
t.Fatalf("reported a known range %d-%d on a platform with no "+
"ip_local_port_range; GetAvailablePort would then allocate from a band that "+
"is inside the real ephemeral range on macOS and Windows", lo, hi)
}
if lo != 0 || hi != 0 {
t.Fatalf("unknown range still returned %d-%d; callers must not act on it", lo, hi)
}
// A readable-but-malformed file is a different case: the platform IS Linux,
// so the documented default is the right answer.
lo, hi, known = ephemeralPortRangeFrom(func(string) ([]byte, error) {
return []byte("garbage\n"), nil
})
if !known || lo != defaultEphemeralLo || hi != defaultEphemeralHi {
t.Fatalf("malformed content gave %d-%d known=%v, want the Linux default %d-%d known=true",
lo, hi, known, defaultEphemeralLo, defaultEphemeralHi)
}
}
func TestNetworkNameFromDockerRun(t *testing.T) {
cases := []struct {
name string
cmd string
want string
}{
{"space form", "docker run --name app --network keploy-network img", "keploy-network"},
{"equals form", "docker run --name=app --network=keploy-network img", "keploy-network"},
{"net alias, space form", "docker run --net keploy-network img", "keploy-network"},
{"net alias, equals form", "docker run --net=keploy-network img", "keploy-network"},
{"no network", "docker run --name app img", ""},
// An empty "--flag=" must not be taken as the value and stop the scan.
{"empty equals then a real flag", "docker run --net= --network mynet img", "mynet"},
{"flag with no value", "docker run --network", ""},
{"empty", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := NetworkNameFromDockerRun(tc.cmd); got != tc.want {
t.Fatalf("NetworkNameFromDockerRun(%q) = %q, want %q", tc.cmd, got, tc.want)
}
})
}
}
// uniqueProcessGroups returns each process's group, as read, once: the
// interrupt signals groups, and a group signalled twice is waited on twice.
func TestUniqueProcessGroups(t *testing.T) {
groups := map[int]int{10: 10, 11: 10, 12: 12, 13: 10}
got, err := uniqueProcessGroups([]int{11, 12, 13, 10}, func(pid int) (int, error) { return groups[pid], nil })
if err != nil || fmt.Sprint(got) != "[10 12]" {
t.Fatalf("uniqueProcessGroups = %v, %v; want [10 12]", got, err)
}
failed := fmt.Errorf("process 12 is not in the process table")
if _, err := uniqueProcessGroups([]int{10, 12}, func(pid int) (int, error) {
if pid == 12 {
return 0, failed
}
return groups[pid], nil
}); err != failed {
t.Fatalf("uniqueProcessGroups with an unreadable group returned %v, want %v", err, failed)
}
}
// The shell runs `docker run` and `docker<TAB>run` as `docker run`, so the
// kind is the same.
func TestFindDockerCmdReadsWordsNotSpacing(t *testing.T) {
for cmd, want := range map[string]CmdType{
"docker run --name app img": DockerRun,
"docker\trun --name app img": DockerRun,
" sudo docker compose up ": DockerCompose,
"podman\n run app": DockerRun,
"docker start -a app": DockerStart,
"python app.py": Native,
} {
if got := FindDockerCmd(cmd); got != want {
t.Errorf("FindDockerCmd(%q) = %q, want %q", cmd, got, want)
}
}
}
// keploy's flags for the container go right after its engine's run
// subcommand, wherever that is.
func TestRunSubcommandEnd(t *testing.T) {
for cmd, want := range map[string]string{
"docker run img": "docker run",
"sudo docker run img": "sudo docker run",
"docker container run img": "docker container run",
"sudo -u docker docker run img": "sudo -u docker docker run",
"DOCKER_CONFIG=/srv/docker docker run img": "DOCKER_CONFIG=/srv/docker docker run",
"/run/current-system/sw/bin/docker run img": "/run/current-system/sw/bin/docker run",
"docker pull img && docker run img": "docker pull img && docker run",
"podman\n run img": "podman\n run",
`C:\Docker\docker.exe run img`: `C:\Docker\docker.exe run`,
"docker start -a app": "",
"docker --log-level debug run img": "",
"./run.sh": "",
} {
at := RunSubcommandEnd(cmd)
got := ""
if at >= 0 {
got = cmd[:at]
}
if got != want {
t.Errorf("RunSubcommandEnd(%q) ends after %q, want after %q", cmd, got, want)
}
}
}
// A release lookup that GitHub did not answer with a release is an error, not
// an empty release: a rate-limited answer decoded to a tag of "", which read
// as a newer version than the running one, and the update went ahead.
func TestGetGitHubRelease_RefusesAnythingButARelease(t *testing.T) {
for name, answer := range map[string]func(http.ResponseWriter){
"rate limited": func(w http.ResponseWriter) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"API rate limit exceeded"}`))
},
"no tag": func(w http.ResponseWriter) { _, _ = w.Write([]byte(`{"body":"notes"}`)) },
"an error that looks like a release": func(w http.ResponseWriter) {
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"tag_name":"v9.9.9"}`))
},
} {
t.Run(name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { answer(w) }))
defer srv.Close()
if got, err := getGitHubRelease(context.Background(), zap.NewNop(), srv.URL); err == nil {
t.Errorf("getGitHubRelease = %+v; want an error", got)
}
})
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"tag_name":"v3.6.200","assets":[{"name":"keploy_linux_amd64.tar.gz","digest":"sha256:ab"}]}`))
}))
defer srv.Close()
got, err := getGitHubRelease(context.Background(), zap.NewNop(), srv.URL)
if err != nil || got.TagName != "v3.6.200" || len(got.Assets) != 1 || got.Assets[0].Digest != "sha256:ab" {
t.Errorf("getGitHubRelease = %+v, %v; want the release with its asset digest", got, err)
}
}