This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch 
gh-readonly-queue/main/pr-7077-3bda8fab59e2581f0f4260cd91503157be51c80d
in repository https://gitbox.apache.org/repos/asf/texera.git

commit 0f846092c61c2e9b3a0739be7507dc643a97607d
Author: Yicong Huang <[email protected]>
AuthorDate: Wed Jul 29 23:48:30 2026 -0400

    feat(local-dev): support Linux (#7077)
    
    ### What changes were proposed in this PR?
    
    Makes `bin/local-dev.sh` work on Linux, not just macOS.
    
    The script already carried some Linux support — `_find_jdk17` globs
    `/usr/lib/jvm/*`, SDKMAN and asdf are probed, the docker install hint
    has a `Linux:` line — but the three platform probes underneath it were
    macOS-only. The first was fatal, the other two degraded silently.
    
    **1. Host LAN IP detection (the hard blocker).** `_detect_host_lan_ip`
    probed `route get default` and `ipconfig getifaddr en0..en10`. Neither
    exists on Linux: `route` is net-tools with different syntax, `ipconfig`
    is macOS/Windows, and the interfaces are `eth0`/`enp*`. So
    `_require_host_lan_ip` aborted every `up`/`auto`:
    
    ```
    Before:  linux + up -> FATAL: could not detect a host LAN IP
    After:   linux + up -> LAN IP from `ip route` / `ip -4 addr`, stack comes up
    ```
    
    Split into `_detect_host_lan_ip_darwin` / `_detect_host_lan_ip_linux`
    behind a `uname` dispatcher, so the macOS path is byte-for-byte what it
    was. The Linux probe follows the same two steps — interface backing the
    default route first, then a scan — and the scan's exclusion list is the
    interesting part: this address exists to be reachable from **both** the
    host JVMs and the lakekeeper container, so a container bridge
    (`docker0`, `br-*`, `veth*`) is wrong even though it is a perfectly good
    local IPv4, and an overlay/VPN address (tailscale, zerotier, wireguard)
    is not reachable from the docker bridge at all. On a typical box
    `hostname -I` prints `10.10.10.30 172.17.0.1`, and picking the second
    would defeat the whole point of the variable.
    
    The FATAL message also named only the macOS probes; on Linux none of
    them had run, which made the message actively misleading. It now names
    the probes that actually executed.
    
    **2. Artifact mtime.** `svc_artifact_mtime` used BSD `stat -f "%Sm" -t
    FMT`. GNU coreutils reads `-f` as "file system info" and the format
    strings as filenames — it writes a diagnostic to stderr, prints
    filesystem fields on stdout and exits 1 — so `watch`'s ARTIFACT MTIME
    column rendered that garbage (the JVM call site had no `2>/dev/null`
    guard at all). Replaced with `_file_mtime_str`, which probes for the
    dialect rather than branching on `uname`, so GNU coreutils on macOS also
    works.
    
    **3. lsof dependency.** `listen_pid_for_port` required lsof. It ships
    with macOS but is not a default package on Debian/Ubuntu/Fedora, and
    without it every native service read as `stopped` — `up` relaunched
    services that were already running, and `down` silently no-opped. Falls
    back to `ss` from iproute2, matching on the local-address column so
    `:18080` doesn't answer for `:8080`. `-H` is deliberately not used (it
    postdates the iproute2 in older distros), and the "empty output means
    nothing is listening" contract that `svc_running_pid` / `wait_for_port`
    / the status table rely on is preserved.
    
    Two smaller `_install_hint` fixes: the docker line named
    `docker-compose-plugin`, which only lives in Docker's own apt repo
    rather than stock Ubuntu (`docker-compose-v2`), and the node / yarn /
    bun cases had no `Linux:` line at all.
    
    **Docs.** `bin/local-dev/README.md` gains a *Supported platforms*
    section: a table of which probe is used on which platform, the Linux
    prerequisites (iproute2, docker + compose v2, and the `docker`-group
    re-login that trips everyone up once), and why a docker-bridge address
    is not a valid `HOST_LAN_IP` even though it looks like a fine local
    IPv4. The bash suite's header comment also stopped claiming a real stack
    "needs a Mac", and now states that platform-specific checks report a
    skip rather than a pass.
    
    ### Any related issues, documentation, discussions?
    
    Closes #7065
    
    ### How was this PR tested?
    
    New coverage in the existing `infra`-job suite. The LAN-IP cases run
    against a fake `ip` so the assertions don't depend on the test machine's
    interfaces, and the port-listener cases run against a real listener with
    lsof shimmed out:
    
    ```
    $ bash bin/local-dev/tests/test_local_dev_sh.sh
    ...
      ✓ linux LAN IP: default route interface wins → 10.10.10.30
      ✓ linux LAN IP: no default route: skips the docker bridge → 10.10.10.30
      ✓ linux LAN IP: no default route: skips bridges, veth and tailscale → 
192.168.1.42
      ✓ linux LAN IP: default route interface has no global v4: falls back to 
scan → 192.168.1.42
      ✓ linux LAN IP: nothing but loopback (refuses)
      ✓ linux LAN IP: only excluded interfaces (refuses)
      ✓ linux LAN IP: no `ip` on PATH (refuses)
      ✓ _detect_host_lan_ip dispatches per platform
      ✓ host-LAN-IP failure message is not macOS-only
      ✓ _file_mtime_str formats YYYY-MM-DD HH:MM (2026-07-29 22:17)
      ✓ _file_mtime_str refuses a missing file quietly
      ✓ _file_mtime_str refuses a missing argument quietly
      ✓ BSD stat is confined to _file_mtime_str
      ✓ listen_pid_for_port finds the listener (46263 → 193102)
      ✓ listen_pid_for_port works without lsof (193102)
      ✓ listen_pid_for_port: unused port yields nothing
      ✓ docker hint names the distro compose package
      ✓ every install hint has a Linux line
    
    68 passed, 0 failed
    ```
    
    ```
    $ python -m pytest bin/local-dev/tests/ -q
    1 failed, 42 passed
    ```
    
    That failure is `test_is_dirty_after_seed_then_edit`, **pre-existing and
    unrelated** — it reproduces identically on a pristine `upstream/main`
    checkout on this machine, and this PR touches neither `tui.py` nor that
    test. Filed as #7075 and fixed there.
    
    End-to-end on Ubuntu 24.04.4 / x86_64, docker 29.1.3, with `HOST_LAN_IP`
    deliberately **not** exported — i.e. the exact case that used to abort:
    
    ```
    [step] env HOST_LAN_IP='<unset>'
    [step] down
    [rc] down=0
    [step] up (unfiltered)
    ...
      ✓  postgres                         :5432    healthy
      ✓  minio                            :9000    healthy
      ✓  lakefs                           :8000    healthy
      ✓  lakekeeper                       :8181    healthy
      ✓  litellm                          :4000    healthy
      ✓  config-service                   :9094    healthy
      ✓  access-control-service           :9096    healthy
      ✓  file-service                     :9092    healthy
      ✓  workflow-compiling-service       :9090    healthy
      ✓  computing-unit-master            :8085    healthy
      ✓  computing-unit-managing-service  :8082    healthy
      ✓  texera-web                       :8080    healthy
      ✓  agent-service                    :3001    healthy
      ✓  frontend                         :4200    healthy
    
      ✓ 14 of 14 services healthy
    [rc] up=0
    ```
    
    The detected address matches the kernel's own answer and is reachable at
    the MinIO port, which is what the variable is for:
    
    ```
    detected=10.10.10.30
    $ ip -4 route get 1.1.1.1 | awk '{print $7; exit}'
    10.10.10.30
    $ curl http://10.10.10.30:9000/minio/health/live   ->  HTTP 200
    ```
    
    lsof removed from `PATH` (shimmed to `exit 127`) now reports the same
    state rather than "everything stopped" — same shell, same PIDs, so the
    comparison is apples to apples:
    
    ```
    ########## status: real lsof ##########
      ● config-service   9094  150646  running
      ● texera-web       8080  151007  running
      Status: 14 of 14 services running
    
    ########## status: lsof shimmed out (exit 127) ##########
      ● config-service   9094  150646  running
      ● texera-web       8080  151007  running
      Status: 14 of 14 services running
    ```
    
    And `watch`'s ARTIFACT MTIME column renders a timestamp instead of GNU
    stat's diagnostics:
    
    ```
        SERVICE                          PORT    PID       ARTIFACT MTIME     
SRC STATE
      ● config-service                  :9094   150646    2026-07-29 20:33      
 running
      ● access-control-service          :9096   150670    2026-07-29 20:33      
 running
    ```
    
    macOS is unchanged by construction: the Darwin probe body is untouched,
    and a test asserts the dispatcher still routes to it. I have no Mac to
    run the suite on, so CI's `infra (macos-latest)` job is what covers that
    side. The checks that can only hold on one platform — the `ss` fallback
    in particular, since `ss` is Linux-only and macOS ships lsof in the base
    system — are guarded and report a skip there rather than a pass.
    
    ### Was this PR authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Code (Claude Opus 5)
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
 bin/local-dev/README.md                  |  34 ++++
 bin/local-dev/main.sh                    | 130 +++++++++++++--
 bin/local-dev/tests/test_local_dev_sh.sh | 262 ++++++++++++++++++++++++++++++-
 3 files changed, 406 insertions(+), 20 deletions(-)

diff --git a/bin/local-dev/README.md b/bin/local-dev/README.md
index a560f9098b..e1a1f175fc 100644
--- a/bin/local-dev/README.md
+++ b/bin/local-dev/README.md
@@ -35,6 +35,40 @@ bin/local-dev.sh --help     # full reference
 Everything in this folder is wired up by the wrapper at `bin/local-dev.sh`;
 you should not need to invoke any file inside `bin/local-dev/` directly.
 
+## Supported platforms
+
+macOS and Linux. Where the two differ, `main.sh` picks the right probe by
+platform — there is nothing to configure:
+
+| Concern | macOS | Linux |
+| --- | --- | --- |
+| Host LAN IP (the MinIO endpoint) | `route get default`, `ipconfig getifaddr` 
| `ip route show default`, `ip -4 addr show scope global` |
+| Artifact mtime (`watch`'s ARTIFACT MTIME column) | BSD `stat -f` | GNU `stat 
-c` |
+| Port → PID | `lsof` | `lsof`, falling back to `ss` |
+
+On top of the toolchain in [AGENTS.md](../../AGENTS.md) — JDK 17, Scala 2.13,
+Python 3.12, Node 24 — plus sbt and docker, Linux needs:
+
+* **iproute2** (`ip`, `ss`). Essential on every mainstream distro, so normally
+  already installed. `ip` is required: without it the host LAN IP cannot be
+  detected and `up` refuses to start rather than publish an endpoint that only
+  works from one side.
+* **docker with the compose v2 plugin.** On stock Ubuntu that is
+  `apt install docker.io docker-compose-v2`; `docker-compose-plugin` only
+  exists in Docker's own apt repository.
+* **your user in the `docker` group** — `sudo usermod -aG docker "$USER"`,
+  then log in again. Group membership does not apply to shells that are
+  already running, so a `docker compose` permission error right after that
+  command is expected.
+
+`HOST_LAN_IP` is detected for you; export it only to override the choice, e.g.
+to pin one interface on a multi-homed host. The address has to be reachable
+from **both** the host JVMs and the containers, which is why the scan skips
+container bridges (`docker0`, `br-*`, `veth*`) and overlay/VPN interfaces
+(tailscale, zerotier, wireguard): `172.17.0.1` looks like a perfectly good
+local IPv4 but is not reachable from inside another container's network
+namespace.
+
 ## Layout
 
 ```
diff --git a/bin/local-dev/main.sh b/bin/local-dev/main.sh
index a1240208ac..58fa687f0d 100755
--- a/bin/local-dev/main.sh
+++ b/bin/local-dev/main.sh
@@ -462,22 +462,68 @@ fi
 # `localhost` only works for the host. `texera-minio` only works inside the
 # docker network. The host's LAN IP works from BOTH (host loopback for the
 # host, docker NAT'd out-and-back for the container).
-_detect_host_lan_ip() {
+#
+# Both platform probes follow the same two steps: the interface backing the
+# default route first (most reliable on a laptop that may have wifi +
+# thunderbolt + tailscale all active), then a scan as a fallback.
+_detect_host_lan_ip_darwin() {
     local iface="" ip=""
-    # 1. The interface backing the default route — most reliable on a
-    #    laptop that may have wifi + thunderbolt + tailscale all active.
     iface=$(route get default 2>/dev/null | awk '/interface:/{print $2; exit}')
     if [[ -n "$iface" ]]; then
         ip=$(ipconfig getifaddr "$iface" 2>/dev/null)
         [[ -n "$ip" && "$ip" != 127.* ]] && { printf '%s\n' "$ip"; return 0; }
     fi
-    # 2. Fallback: linux `hostname -I`-equivalent walk over en*.
     for iface in en0 en1 en2 en3 en4 en5 en6 en7 en8 en9 en10; do
         ip=$(ipconfig getifaddr "$iface" 2>/dev/null)
         [[ -n "$ip" && "$ip" != 127.* ]] && { printf '%s\n' "$ip"; return 0; }
     done
     return 1
 }
+
+_detect_host_lan_ip_linux() {
+    command -v ip >/dev/null 2>&1 || return 1
+    local iface="" addr="" idx="" dev="" fam="" cidr=""
+    # 1. Interface backing the default route — take its first global IPv4,
+    #    stripping the /prefix that `ip -o addr` appends.
+    iface=$(ip -4 route show default 2>/dev/null \
+        | awk '{ for (i = 1; i < NF; i++) if ($i == "dev") { print $(i+1); 
exit } }')
+    # A default route over a container bridge or an overlay/VPN tunnel points 
at
+    # an address the containers can't reach back, which is the whole thing this
+    # variable must avoid — drop such an interface so the scan below wins.
+    case "$iface" in
+        
docker*|br-*|bridge*|virbr*|veth*|tun*|tap*|cni*|flannel*|cali*|kube*|tailscale*|zt*|wg*)
+            iface="" ;;
+    esac
+    if [[ -n "$iface" ]]; then
+        addr=$(ip -4 -o addr show dev "$iface" scope global 2>/dev/null \
+            | awk '{ split($4, a, "/"); print a[1]; exit }')
+        [[ -n "$addr" && "$addr" != 127.* ]] && { printf '%s\n' "$addr"; 
return 0; }
+    fi
+    # 2. Scan every global IPv4, skipping the interfaces that would defeat the
+    #    purpose of this address. A container bridge (docker0, br-*, veth*) is
+    #    reachable from the host but not from inside another container's
+    #    network namespace the way MinIO needs; an overlay/VPN address
+    #    (tailscale, zerotier) is not reachable from the docker bridge at all.
+    while read -r idx dev fam cidr _rest; do
+        [[ "$fam" == "inet" ]] || continue
+        case "$dev" in
+            
lo|docker*|br-*|bridge*|virbr*|veth*|tun*|tap*|cni*|flannel*|cali*|kube*|tailscale*|zt*|wg*)
+                continue ;;
+        esac
+        addr="${cidr%%/*}"
+        [[ -n "$addr" && "$addr" != 127.* ]] && { printf '%s\n' "$addr"; 
return 0; }
+    done < <(ip -4 -o addr show scope global 2>/dev/null)
+    return 1
+}
+
+_detect_host_lan_ip() {
+    case "$(uname -s 2>/dev/null)" in
+        Darwin) _detect_host_lan_ip_darwin ;;
+        Linux)  _detect_host_lan_ip_linux ;;
+        # Anything else (BSD, WSL oddities): try both rather than give up.
+        *)      _detect_host_lan_ip_darwin || _detect_host_lan_ip_linux ;;
+    esac
+}
 # Lazy resolver — called from subcommands that actually need to publish a
 # host-reachable S3 endpoint (cmd_up, cmd_auto). Subcommands like
 # `version`, `status`, `--help`, or `-i` don't talk to MinIO and shouldn't
@@ -486,10 +532,18 @@ _require_host_lan_ip() {
     [[ -n "${HOST_LAN_IP:-}" ]] && return 0
     HOST_LAN_IP="$(_detect_host_lan_ip)" || HOST_LAN_IP=""
     if [[ -z "$HOST_LAN_IP" ]]; then
+        local probes="" bridge_note=""
+        case "$(uname -s 2>/dev/null)" in
+            Darwin) probes="\`route get default\` / en0-en10" ;;
+            Linux)  probes="\`ip route show default\` / \`ip -4 addr show 
scope global\`"
+                    bridge_note=" outside the container bridges" ;;
+            *)      probes="the macOS and Linux probes"
+                    bridge_note=" outside the container bridges" ;;
+        esac
         echo "FATAL: could not detect a host LAN IP." >&2
         echo "       MinIO needs an address reachable from both docker 
(lakekeeper" >&2
         echo "       does S3 ops) and the host (JVMs read signed URLs back); 
none" >&2
-        echo "       of \`route get default\` / en0-en10 had a non-loopback 
IPv4." >&2
+        echo "       of $probes offered a non-loopback IPv4${bridge_note}." >&2
         echo "       Connect to a network or export HOST_LAN_IP=<your-IP> 
explicitly." >&2
         exit 1
     fi
@@ -1133,20 +1187,23 @@ _install_hint() {
         node)
             printf "  ${BOLD}install Node 20+ (needed for frontend & 
agent-service):${RESET}\n"
             printf "    macOS:   brew install node\n"
+            printf "    Linux:   use a version manager below — distro 
packages\n"
+            printf "             (apt/dnf nodejs) are older than the frontend 
needs\n"
             printf "    nvm:     nvm install --lts && nvm use --lts\n"
             printf "    fnm:     fnm install --lts\n"
             printf "    volta:   volta install node\n"
             ;;
         yarn)
             printf "  ${BOLD}install yarn (needed for the frontend):${RESET}\n"
+            printf "    any OS:  corepack enable    ${DIM}# ships with Node; 
frontend pins yarn@4${RESET}\n"
             printf "    macOS:   brew install yarn\n"
-            printf "    npm:     npm install -g yarn\n"
-            printf "    corepack: corepack enable\n"
+            printf "    Linux:   npm install -g yarn\n"
             ;;
         bun)
             printf "  ${BOLD}install bun (needed for agent-service):${RESET}\n"
             printf "    macOS:   brew install oven-sh/bun/bun\n"
-            printf "    curl:    curl -fsSL https://bun.sh/install | bash\n"
+            printf "    Linux:   curl -fsSL https://bun.sh/install | bash\n"
+            printf "    npm:     npm install -g bun\n"
             ;;
         sbt)
             printf "  ${BOLD}install sbt (needed to build the JVM 
services):${RESET}\n"
@@ -1156,7 +1213,8 @@ _install_hint() {
         docker)
             printf "  ${BOLD}install Docker (needed for 
postgres/minio/lakefs/lakekeeper/litellm):${RESET}\n"
             printf "    macOS:   download Docker Desktop from 
https://docker.com/products/docker-desktop\n";
-            printf "    Linux:   apt install docker.io docker-compose-plugin\n"
+            printf "    Linux:   apt install docker.io docker-compose-v2    
${DIM}# dnf: moby-engine docker-compose${RESET}\n"
+            printf "             then sudo usermod -aG docker \"\$USER\" and 
log back in\n"
             ;;
         *)
             printf "  ${DIM}no install hint for: %s${RESET}\n" "$tool"
@@ -1194,9 +1252,53 @@ _diagnose_node() {
 }
 
 # --------- helpers ---------
+# PID of whatever is listening on a TCP port, or nothing. Always exits 0 —
+# "empty output" is the contract for "nothing is listening", and callers
+# (svc_running_pid, wait_for_port, the status table) rely on that.
+#
+# lsof first: it ships with macOS and is what this has always used. But it is
+# not a default package on Debian/Ubuntu/Fedora, and when it is missing every
+# native service reads as stopped — `up` then relaunches services that are
+# already running and `down` silently no-ops. So fall back to `ss` from
+# iproute2, which is essential on any modern Linux.
 listen_pid_for_port() {
-    # || true so pipefail doesn't kill us when nothing is listening
-    lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -1 || true
+    local port="$1" pid=""
+    if command -v lsof >/dev/null 2>&1; then
+        # || true so pipefail doesn't kill us when nothing is listening
+        pid=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | head -1 || 
true)
+        [[ -n "$pid" ]] && { printf '%s\n' "$pid"; return 0; }
+    fi
+    if command -v ss >/dev/null 2>&1; then
+        # `ss -lntp` prints e.g.
+        #   LISTEN 0 511 127.0.0.1:8080 0.0.0.0:* 
users:(("java",pid=4827,fd=9))
+        # Match on the local-address column ending in :<port> so :18080 doesn't
+        # answer for :8080. No -H: it postdates the iproute2 in older distros.
+        pid=$(ss -lntp 2>/dev/null \
+            | awk -v suffix=":$port" '$4 ~ suffix"$" { print; exit }' \
+            | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2 || true)
+        [[ -n "$pid" ]] && { printf '%s\n' "$pid"; return 0; }
+    fi
+    return 0
+}
+
+# Modification time of a file as "YYYY-MM-DD HH:MM", in whichever stat dialect
+# is present. Non-zero and silent if the file is missing or unreadable, so
+# callers can substitute their own placeholder.
+#
+# GNU coreutils and BSD disagree completely here: BSD's `stat -f FMT -t 
TIMEFMT`
+# reads `-f` as "file system info" under GNU, which then treats the format
+# strings as filenames — it writes a diagnostic to stderr, prints filesystem
+# fields on stdout and exits 1. Probe rather than branch on `uname` so a
+# GNU-coreutils macOS also works.
+_file_mtime_str() {
+    local f="${1:-}"
+    [[ -n "$f" && -e "$f" ]] || return 1
+    if stat -c '%y' "$f" >/dev/null 2>&1; then
+        # GNU: "2026-07-29 20:06:12.123456789 +0000" → keep date + HH:MM
+        stat -c '%y' "$f" 2>/dev/null | cut -c1-16
+    else
+        stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$f" 2>/dev/null
+    fi
 }
 
 # Branch + short-sha of the deploy source ($REPO_ROOT), tab-separated, each
@@ -1671,14 +1773,14 @@ svc_artifact_mtime() {
                     done <<< "$globbed"
                 fi
                 if [[ ${#main_jars[@]} -gt 0 ]]; then
-                    stat -f "%Sm" -t "%Y-%m-%d %H:%M" "${main_jars[0]}"
+                    _file_mtime_str "${main_jars[0]}" || echo "—"
                     return
                 fi
             fi
             echo "—"
             ;;
-        bun)    stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$(amap_get SVC_CWD 
"$svc")/bun.lock" 2>/dev/null || echo "—" ;;
-        yarn)   stat -f "%Sm" -t "%Y-%m-%d %H:%M" "$(amap_get SVC_CWD 
"$svc")/yarn.lock" 2>/dev/null || echo "—" ;;
+        bun)    _file_mtime_str "$(amap_get SVC_CWD "$svc")/bun.lock" || echo 
"—" ;;
+        yarn)   _file_mtime_str "$(amap_get SVC_CWD "$svc")/yarn.lock" || echo 
"—" ;;
         docker) echo "—" ;;
     esac
 }
diff --git a/bin/local-dev/tests/test_local_dev_sh.sh 
b/bin/local-dev/tests/test_local_dev_sh.sh
index 06a340b372..fbcd48a87f 100755
--- a/bin/local-dev/tests/test_local_dev_sh.sh
+++ b/bin/local-dev/tests/test_local_dev_sh.sh
@@ -20,10 +20,14 @@
 #   bash bin/local-dev/tests/test_local_dev_sh.sh
 # Exits 0 if every check passes, 1 otherwise.
 #
-# Kept deliberately small: bringing up the actual stack needs Docker /
-# sbt / a Mac and is out of scope for CI here. We cover the things that
-# regress quietly — script syntax, version-detection, the subcommand
-# dispatch, and graceful failure on garbage input.
+# Kept deliberately small: bringing up the actual stack needs Docker, sbt and
+# the rest of the toolchain, which is out of scope for CI here. We cover the
+# things that regress quietly — script syntax, version-detection, the
+# subcommand dispatch, and graceful failure on garbage input.
+#
+# Runs on macOS and Linux alike (CI's `infra` job covers both). Anything that
+# can only hold on one of them is guarded by a `uname` check or by probing for
+# the tool involved, and reports a skip rather than a pass.
 
 set -u
 
@@ -593,7 +597,253 @@ else
     _fail "changelog references missing files:$missing_files"
 fi
 
-# 29) Regression for #7075: `find -newer` is *strictly* newer, so a source 
whose
+# 29) Host LAN IP detection on Linux. The macOS probes (`route get default`,
+#     `ipconfig getifaddr en0..en10`) do not exist there, so `up`/`auto` used 
to
+#     abort with "could not detect a host LAN IP" on every Linux box (#7065).
+#     Driven against a fake `ip` so the assertions don't depend on the test
+#     machine's interfaces. The docker-bridge case is the one that matters 
most:
+#     the whole point of HOST_LAN_IP is an address reachable from both the host
+#     and the lakekeeper container, and 172.17.0.1 is reachable from neither
+#     side the way we need.
+lanip_fn=$(awk '/^_detect_host_lan_ip_linux\(\)/{f=1} f{print} f&&/^}/{exit}' 
"$MAIN_SH")
+if [[ -z "$lanip_fn" ]]; then
+    _fail "_detect_host_lan_ip_linux helper missing"
+else
+    _lan_dir=$(mktemp -d 2>/dev/null || mktemp -d -t ldlan)
+    # Fake `ip`: answers `route show default` from ip.route and
+    # `-o addr show [dev X] scope global` from ip.addr.
+    cat > "$_lan_dir/ip" <<'FAKE_IP'
+#!/usr/bin/env bash
+mode=""; want_dev=""
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        route) mode=route ;;
+        addr)  mode=addr ;;
+        dev)   shift; want_dev="${1:-}" ;;
+    esac
+    shift
+done
+case "$mode" in
+    route) cat "$0.route" 2>/dev/null ;;
+    addr)
+        if [[ -n "$want_dev" ]]; then
+            awk -v d="$want_dev" '$2 == d' "$0.addr" 2>/dev/null
+        else
+            cat "$0.addr" 2>/dev/null
+        fi
+        ;;
+esac
+FAKE_IP
+    chmod +x "$_lan_dir/ip"
+    _lan_check() {  # $1=label  $2=expected stdout ("" = must fail)  $3=route  
$4=addr
+        printf '%s\n' "$3" > "$_lan_dir/ip.route"
+        printf '%s\n' "$4" > "$_lan_dir/ip.addr"
+        local got="" rc=0
+        got=$(PATH="$_lan_dir:$PATH"; eval "$lanip_fn"; 
_detect_host_lan_ip_linux) || rc=$?
+        if [[ -z "$2" ]]; then
+            if (( rc != 0 )) && [[ -z "$got" ]]; then
+                _pass "linux LAN IP: $1 (refuses)"
+            else
+                _fail "linux LAN IP: $1 should refuse" "rc=$rc got='$got'"
+            fi
+        elif [[ "$got" == "$2" ]]; then
+            _pass "linux LAN IP: $1 → $got"
+        else
+            _fail "linux LAN IP: $1" "expected '$2' got '$got' (rc=$rc)"
+        fi
+    }
+    _lan_check "default route interface wins" "10.10.10.30" \
+        'default via 10.10.10.1 dev eth0 proto static metric 100' \
+        '2: eth0    inet 10.10.10.30/24 brd 10.10.10.255 scope global eth0
+3: docker0    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0'
+    _lan_check "no default route: skips the docker bridge" "10.10.10.30" \
+        '' \
+        '3: docker0    inet 172.17.0.1/16 brd 172.17.255.255 scope global 
docker0
+2: eth0    inet 10.10.10.30/24 brd 10.10.10.255 scope global eth0'
+    _lan_check "no default route: skips bridges, veth and tailscale" 
"192.168.1.42" \
+        '' \
+        '4: br-abc123    inet 172.18.0.1/16 scope global br-abc123
+5: veth9f2    inet 172.19.0.1/16 scope global veth9f2
+6: tailscale0    inet 100.101.102.103/32 scope global tailscale0
+2: enp5s0    inet 192.168.1.42/24 scope global enp5s0'
+    _lan_check "default route interface has no global v4: falls back to scan" 
"192.168.1.42" \
+        'default via 10.0.0.1 dev ppp0 proto static' \
+        '2: enp5s0    inet 192.168.1.42/24 scope global enp5s0'
+    _lan_check "default route over a bridge/VPN is skipped, not trusted" 
"10.10.10.30" \
+        'default via 172.17.0.1 dev docker0 proto static' \
+        '3: docker0    inet 172.17.0.1/16 scope global docker0
+2: eth0    inet 10.10.10.30/24 scope global eth0'
+    _lan_check "nothing but loopback" "" '' ''
+    _lan_check "only excluded interfaces" "" \
+        '' \
+        '3: docker0    inet 172.17.0.1/16 scope global docker0'
+    # Negative: no iproute2 at all must fail cleanly, not emit a bogus address.
+    _lan_no_ip=$(mktemp -d 2>/dev/null || mktemp -d -t ldlan2)
+    got=""; rc=0
+    got=$(PATH="$_lan_no_ip"; eval "$lanip_fn"; _detect_host_lan_ip_linux) || 
rc=$?
+    if (( rc != 0 )) && [[ -z "$got" ]]; then
+        _pass "linux LAN IP: no \`ip\` on PATH (refuses)"
+    else
+        _fail "linux LAN IP: no \`ip\` should refuse" "rc=$rc got='$got'"
+    fi
+    rm -rf "$_lan_dir" "$_lan_no_ip"
+fi
+
+# 30) The dispatcher must keep the macOS probes for Darwin and only use the
+#     Linux ones on Linux — the fix must not regress the platform that worked.
+dispatch_fn=$(awk '/^_detect_host_lan_ip\(\)/{f=1} f{print} f&&/^}/{exit}' 
"$MAIN_SH")
+if [[ "$dispatch_fn" == *"Darwin"* ]] \
+   && [[ "$dispatch_fn" == *"_detect_host_lan_ip_darwin"* ]] \
+   && [[ "$dispatch_fn" == *"_detect_host_lan_ip_linux"* ]]; then
+    _pass "_detect_host_lan_ip dispatches per platform"
+else
+    _fail "_detect_host_lan_ip doesn't dispatch to both platform probes"
+fi
+# And the FATAL must stop naming only the macOS probes, since on Linux none of
+# them ever ran.
+if ! grep -q 'of \\`route get default\\` / en0-en10 had a non-loopback IPv4' 
"$MAIN_SH"; then
+    _pass "host-LAN-IP failure message is not macOS-only"
+else
+    _fail "host-LAN-IP failure message still blames route get/en0-en10 on 
every platform"
+fi
+
+# 31) Artifact mtime must be readable with either stat dialect. GNU coreutils
+#     rejects BSD's `stat -f "%Sm" -t FMT` outright (it reads -f as "file 
system
+#     info" and the format strings as filenames), so on Linux the ARTIFACT 
MTIME
+#     column rendered stat's error text instead of a timestamp.
+mtime_fn=$(awk '/^_file_mtime_str\(\)/{f=1} f{print} f&&/^}/{exit}' "$MAIN_SH")
+if [[ -z "$mtime_fn" ]]; then
+    _fail "_file_mtime_str helper missing"
+else
+    _mt_dir=$(mktemp -d 2>/dev/null || mktemp -d -t ldmt)
+    : > "$_mt_dir/artifact.jar"
+    got=$(eval "$mtime_fn"; _file_mtime_str "$_mt_dir/artifact.jar")
+    if [[ "$got" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}$ ]]; then
+        _pass "_file_mtime_str formats YYYY-MM-DD HH:MM ($got)"
+    else
+        _fail "_file_mtime_str wrong format" "got '$got'"
+    fi
+    # Negative: a missing file must fail rather than print stat's diagnostics.
+    got=""; rc=0
+    got=$(eval "$mtime_fn"; _file_mtime_str "$_mt_dir/nope.jar" 2>&1) || rc=$?
+    if (( rc != 0 )) && [[ -z "$got" ]]; then
+        _pass "_file_mtime_str refuses a missing file quietly"
+    else
+        _fail "_file_mtime_str should fail quietly on a missing file" "rc=$rc 
got='$got'"
+    fi
+    got=""; rc=0
+    got=$(eval "$mtime_fn"; _file_mtime_str 2>&1) || rc=$?
+    if (( rc != 0 )) && [[ -z "$got" ]]; then
+        _pass "_file_mtime_str refuses a missing argument quietly"
+    else
+        _fail "_file_mtime_str should fail quietly with no argument" "rc=$rc 
got='$got'"
+    fi
+    rm -rf "$_mt_dir"
+fi
+# The BSD spelling is allowed to survive inside _file_mtime_str — that's the
+# whole point of the helper — but nowhere else, or the next call site silently
+# breaks on Linux again.
+main_outside_helper=$(awk '
+    /^_file_mtime_str\(\)/ { skip = 1 }
+    skip { if ($0 == "}") skip = 0; next }
+    { print }
+' "$MAIN_SH")
+if ! printf '%s\n' "$main_outside_helper" | grep -qE 'stat -f "%Sm" -t'; then
+    _pass "BSD stat is confined to _file_mtime_str"
+else
+    _fail "main.sh still calls BSD-only \`stat -f \"%Sm\" -t\` outside 
_file_mtime_str" \
+        "$(printf '%s\n' "$main_outside_helper" | grep -nE 'stat -f "%Sm" -t' 
| head -3 | tr '\n' '|')"
+fi
+
+# 32) Port→PID lookup must not depend on lsof. It is standard on macOS but not
+#     installed by default on Debian/Ubuntu/Fedora, and without it every native
+#     service read as "stopped" — so `up` relaunched live services and `down`
+#     silently no-opped. Tested against a real listener, with lsof shimmed out.
+listen_fn=$(awk '/^listen_pid_for_port\(\)/{f=1} f{print} f&&/^}/{exit}' 
"$MAIN_SH")
+if [[ -z "$listen_fn" ]]; then
+    _fail "listen_pid_for_port helper missing"
+elif ! command -v python3 >/dev/null 2>&1; then
+    _pass "skip: python3 not on PATH (port listener check)"
+else
+    _lp_dir=$(mktemp -d 2>/dev/null || mktemp -d -t ldlp)
+    # Bind an ephemeral port, print it, then hold it open until killed.
+    python3 -c '
+import socket, sys, time
+s = socket.socket(); s.bind(("127.0.0.1", 0)); s.listen(1)
+print(s.getsockname()[1], flush=True)
+time.sleep(120)
+' > "$_lp_dir/port" 2>/dev/null &
+    _lp_pid=$!
+    _lp_port=""
+    for _ in 1 2 3 4 5 6 7 8 9 10; do
+        _lp_port=$(head -1 "$_lp_dir/port" 2>/dev/null)
+        [[ -n "$_lp_port" ]] && break
+        sleep 0.3
+    done
+    if [[ -z "$_lp_port" ]]; then
+        _fail "port listener check: could not start a listener"
+    else
+        got=$(eval "$listen_fn"; listen_pid_for_port "$_lp_port")
+        if [[ "$got" == "$_lp_pid" ]]; then
+            _pass "listen_pid_for_port finds the listener ($_lp_port → $got)"
+        else
+            _fail "listen_pid_for_port didn't find the listener" \
+                "port=$_lp_port expected=$_lp_pid got='$got'"
+        fi
+        # Same answer with lsof unavailable — the Linux-without-lsof box. The
+        # fallback is `ss` from iproute2, which only exists on Linux; macOS has
+        # no equivalent and ships lsof in the base system, so there is nothing
+        # to fall back to (or to test) there.
+        if command -v ss >/dev/null 2>&1; then
+            printf '#!/bin/sh\nexit 127\n' > "$_lp_dir/lsof"; chmod +x 
"$_lp_dir/lsof"
+            got=$(PATH="$_lp_dir:$PATH"; eval "$listen_fn"; 
listen_pid_for_port "$_lp_port")
+            if [[ "$got" == "$_lp_pid" ]]; then
+                _pass "listen_pid_for_port works without lsof ($got)"
+            else
+                _fail "listen_pid_for_port needs lsof" \
+                    "port=$_lp_port expected=$_lp_pid got='$got'"
+            fi
+        else
+            _pass "skip: no \`ss\` on this platform (lsof fallback is 
Linux-only)"
+        fi
+        # Negative: a port nobody listens on yields empty output, not noise.
+        got=$(eval "$listen_fn"; listen_pid_for_port 1 2>&1)
+        if [[ -z "$got" ]]; then
+            _pass "listen_pid_for_port: unused port yields nothing"
+        else
+            _fail "listen_pid_for_port: unused port produced output" 
"got='$got'"
+        fi
+    fi
+    kill "$_lp_pid" 2>/dev/null
+    wait "$_lp_pid" 2>/dev/null
+    rm -rf "$_lp_dir"
+fi
+
+# 33) Install hints have to be actionable on Linux: `docker-compose-plugin`
+#     only exists in Docker's own apt repo (stock Ubuntu ships
+#     `docker-compose-v2`), and node/yarn/bun had no Linux line at all.
+hint_body=$(awk '/^_install_hint\(\)/{f=1} f{print} f&&/^}/{exit}' "$MAIN_SH")
+if [[ "$hint_body" != *"docker-compose-plugin"* ]] \
+   && [[ "$hint_body" == *"docker-compose-v2"* ]]; then
+    _pass "docker hint names the distro compose package"
+else
+    _fail "docker hint still points at docker-compose-plugin"
+fi
+hint_missing=""
+for tool in java python node yarn bun sbt docker; do
+    case_body=$(printf '%s\n' "$hint_body" | awk -v t="$tool" '
+        $0 ~ "^[[:space:]]*"t"\\)" {f=1}
+        f {print}
+        f && /;;/ {exit}')
+    [[ "$case_body" == *"Linux"* ]] || hint_missing+=" $tool"
+done
+if [[ -z "$hint_missing" ]]; then
+    _pass "every install hint has a Linux line"
+else
+    _fail "install hints with no Linux line:$hint_missing"
+fi
+
+# 34) Regression for #7075: `find -newer` is *strictly* newer, so a source 
whose
 #     mtime equals the build stamp's is invisible to the fast filter and `auto`
 #     reports "everything up-to-date" without rebuilding it. The read side
 #     therefore compares against a throwaway marker one second behind the 
stamp.
@@ -649,7 +899,7 @@ else
     rm -rf "$_sd"
 fi
 
-# 30) Wiring: the jvm dirty check must compare against the backdated marker
+# 35) Wiring: the jvm dirty check must compare against the backdated marker
 #     rather than the stamp, or #7075 is only half fixed (the shell path is the
 #     one that gates the rebuild; tui.py only colours the SRC column).
 src_changed_body=$(awk 'index($0, "svc_src_changed()") == 1 {f=1} f{print} f 
&& /^}/{exit}' "$MAIN_SH")

Reply via email to