Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions internal/state/ledger.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,19 @@ func (db *DB) ReleasePortsFor(owner string) error {
return nil
}

// ReleasePort removes the single (owner, purpose) allocation so the next
// AllocatePort re-picks a port. Used when a persisted port has become
// unpublishable (e.g. it now falls inside a Windows/Hyper-V excluded range).
// Hold the lock.
func (db *DB) ReleasePort(owner, purpose string) error {
_, err := db.Exec(`DELETE FROM port_alloc WHERE ctx=? AND owner=? AND purpose=?`,
db.Ctx, owner, purpose)
if err != nil {
return fmt.Errorf("release port %s/%s: %w", owner, purpose, err)
}
return nil
}

// --- provisioning ownership ledger ----------------------------------------

// RecordProvisioned ties a provisioned db/role/bucket/redis_index to a project
Expand Down
23 changes: 19 additions & 4 deletions internal/workspace/ports.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,34 @@ const (
// FreeHostPort allocates a stable host port for (owner, purpose), persisting it
// inside the lock. A port is considered free only if it is ALL of: not already
// persisted in the ledger, bindable on 127.0.0.1 (advisory), and not published
// by a live tool-managed container. The last check is essential on Docker Desktop
// (macOS/WSL2), where a host bind-test does not reflect the VM's port proxy, so
// the bind-test alone would hand out a port Docker already holds (spec 03/08).
// by a live tool-managed container. The published check is essential on Docker
// Desktop (macOS/WSL2), where a host bind-test does not reflect the VM's port
// proxy, so the bind-test alone would hand out a port Docker already holds (spec
// 03/08). On WSL2 it additionally skips Windows/Hyper-V excluded port ranges (see
// ports_excluded.go) that a Linux-side bind-test cannot see but Docker Desktop's
// Windows-side forward rejects with a 500.
func (m *Manager) FreeHostPort(ctx context.Context, owner, purpose string, base int) (int, error) {
published, err := m.publishedPorts(ctx)
if err != nil {
return 0, err
}
var port int
err = lock.WithLock(ctx, m.LockPath, func() error {
// A port persisted on a previous run may now sit inside a Windows/Hyper-V
// excluded range (those move across reboots on WSL2). AllocatePort returns
// a persisted port verbatim without re-checking, so an excluded one would
// be handed back forever and every publish would fail — release it first so
// a usable port is picked.
if p, ok, e := m.DB.PortFor(owner, purpose); e != nil {
return e
} else if ok && portExcluded(p) {
if e := m.DB.ReleasePort(owner, purpose); e != nil {
return e
}
}
var e error
port, e = m.DB.AllocatePort(owner, purpose, base, base+portRangeSpan, func(p int) bool {
return !published[p] && bindable(p)
return !published[p] && !portExcluded(p) && bindable(p)
})
return e
})
Expand Down
120 changes: 120 additions & 0 deletions internal/workspace/ports_excluded.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package workspace

import (
"bufio"
"context"
"os/exec"
"strconv"
"strings"
"sync"
"time"

"github.com/open-source-cloud/devstack/internal/xdg"
)

// netshTimeout bounds the netsh.exe call so a wedged Windows side never hangs
// the CLI; on timeout we fall back to "no exclusions" (the bind-test still runs).
const netshTimeout = 3 * time.Second

// portRange is an inclusive [start,end] host-port range that cannot be published.
type portRange struct{ start, end int }

// netshRunner returns the raw `netsh int ipv4 show excludedportrange` output.
// Overridable in tests; nil in production means "shell out to netsh.exe".
var netshRunner func() string

// wsl2Detect gates the exclusion query to WSL2. A package var so tests can force
// the WSL2 path deterministically on any platform.
var wsl2Detect = xdg.IsWSL2

var (
excludedMu sync.Mutex
excludedComputed bool
excludedCache []portRange
)

// excludedPortRanges returns the host-port ranges that cannot be bound as a
// published Docker port on this host. On WSL2 with Docker Desktop, Windows and
// Hyper-V DYNAMICALLY reserve TCP port ranges (`netsh int ipv4 show
// excludedportrange protocol=tcp`); the ranges change on every Windows reboot.
// A bind-test inside the Linux distro does NOT see them, so Docker Desktop's
// Windows-side port forward fails with:
//
// ports are not available: exposing port TCP 127.0.0.1:X -> 127.0.0.1:0:
// /forwards/expose returned unexpected status: 500
//
// Treating these ranges as unavailable during allocation is what keeps `expose`
// (and any other host-published port) working on WSL2. Non-WSL2 hosts have no
// such exclusions and return nil. Result is cached for this process' lifetime
// (the CLI is short-lived; ranges are stable within a boot).
func excludedPortRanges() []portRange {
excludedMu.Lock()
defer excludedMu.Unlock()
if !excludedComputed {
excludedComputed = true
if wsl2Detect() {
run := netshRunner
if run == nil {
run = runNetshExcluded
}
excludedCache = parseExcludedPortRanges(run())
}
}
return excludedCache
}

// resetExcludedCache clears the memoized ranges so a later call recomputes.
// Used by tests that inject a fake netsh output; a no-op cost in production.
func resetExcludedCache() {
excludedMu.Lock()
excludedComputed = false
excludedCache = nil
excludedMu.Unlock()
}

// runNetshExcluded shells out to the Windows netsh.exe (reachable from WSL2) for
// the TCP excluded-port-range table. A failure (netsh missing, non-Desktop WSL2)
// yields no exclusions rather than an error — the bind-test remains the backstop.
func runNetshExcluded() string {
ctx, cancel := context.WithTimeout(context.Background(), netshTimeout)
defer cancel()
out, err := exec.CommandContext(ctx, "netsh.exe", "int", "ipv4",
"show", "excludedportrange", "protocol=tcp").Output()
if err != nil {
return ""
}
return string(out)
}

// parseExcludedPortRanges extracts inclusive [start,end] pairs from netsh's
// excluded-port-range table. Each data row is two integers (start, end) with an
// optional trailing "*" note; the title, header, and separator lines have no
// leading integer pair. Matching on the "two integers begin the line" shape (not
// on column headings) keeps it locale-independent — netsh localizes its headers.
func parseExcludedPortRanges(text string) []portRange {
var ranges []portRange
sc := bufio.NewScanner(strings.NewReader(text))
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) < 2 {
continue
}
start, err1 := strconv.Atoi(fields[0])
end, err2 := strconv.Atoi(fields[1])
if err1 != nil || err2 != nil || start <= 0 || end < start {
continue
}
ranges = append(ranges, portRange{start, end})
}
return ranges
}

// portExcluded reports whether p falls inside any host-reserved excluded range.
func portExcluded(p int) bool {
for _, r := range excludedPortRanges() {
if p >= r.start && p <= r.end {
return true
}
}
return false
}
82 changes: 82 additions & 0 deletions internal/workspace/ports_excluded_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package workspace

import (
"context"
"testing"
)

// netshSample mirrors real `netsh int ipv4 show excludedportrange` output,
// including the header/separator lines (which the parser must skip regardless of
// locale) and the trailing "*" note on administered exclusions.
const netshSample = `
Protocol tcp Port Exclusion Ranges

Start Port End Port
---------- --------
50000 50059 *
54235 54235
58956 59055
59056 59155

* - Administered port exclusions.
`

func TestParseExcludedPortRanges(t *testing.T) {
got := parseExcludedPortRanges(netshSample)
want := []portRange{{50000, 50059}, {54235, 54235}, {58956, 59055}, {59056, 59155}}
if len(got) != len(want) {
t.Fatalf("parsed %d ranges, want %d: %+v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("range %d = %+v, want %+v", i, got[i], want[i])
}
}
}

func TestParseExcludedPortRangesIgnoresJunk(t *testing.T) {
if r := parseExcludedPortRanges(""); r != nil {
t.Errorf("empty input yielded %+v", r)
}
// Header-only (no data rows) → no ranges. A reversed/invalid pair is dropped.
if r := parseExcludedPortRanges("Start End\n---- ----\n900 100\n"); len(r) != 0 {
t.Errorf("invalid rows yielded %+v", r)
}
}

// withExcludedRanges forces the WSL2 exclusion path with a fake netsh output for
// one test (deterministic on any platform) and restores + clears the cache after.
func withExcludedRanges(t *testing.T, output string) {
t.Helper()
prevRunner, prevDetect := netshRunner, wsl2Detect
netshRunner = func() string { return output }
wsl2Detect = func() bool { return true }
resetExcludedCache()
t.Cleanup(func() {
netshRunner, wsl2Detect = prevRunner, prevDetect
resetExcludedCache()
})
}

func TestFreeHostPortReallocatesExcludedPersistedPort(t *testing.T) {
withExcludedRanges(t, netshSample)
m := newManager(t, nil)
ctx := context.Background()

// Pre-seed the ledger with a port that lands inside an excluded range,
// simulating an allocation made before Windows reserved that range.
if p, err := m.DB.AllocatePort("minio", "minio-expose", 59000, 59000, nil); err != nil || p != 59000 {
t.Fatalf("seed port: got %d, err %v", p, err)
}
port, err := m.FreeHostPort(ctx, "minio", "minio-expose", 59000)
if err != nil {
t.Fatal(err)
}
if portExcluded(port) {
t.Fatalf("re-allocated into an excluded range: %d", port)
}
// Stable afterward.
if again, _ := m.FreeHostPort(ctx, "minio", "minio-expose", 59000); again != port {
t.Errorf("port not stable after reallocation: %d vs %d", again, port)
}
}
Loading