Stop demo diagnostics at failed tailnet readiness

Failed setup now yields local-only, topology-free diagnostics. Require recognised Running state and successful tailnet ping before TCP, retain probe exits without raw private output or fallback probes, and exercise twenty synthetic readiness/privacy cases. Parent controls expose false success and private output on both channels. Keep workflow identities, secrets, action pins, mutation gates and release scope unchanged.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot] 2026-10-01 17:36:26 +01:00
parent ee2fb78d05
commit bb1903e48d
5 changed files with 203 additions and 43 deletions

View file

@ -8,21 +8,29 @@ TCP_PORT="${DEMO_SERVER_PORT:-22}"
TCP_ATTEMPTS="${DEMO_TCP_ATTEMPTS:-6}"
TCP_RETRY_SECONDS="${DEMO_TCP_RETRY_SECONDS:-5}"
if [ "$MODE" != "check" ] && [ "$MODE" != "diagnose" ]; then
echo "Usage: $0 [check|diagnose]" >&2
exit 2
fi
# Only local daemon state is collected here. Never print tailnet addresses,
# names, tags, relay locations or raw CLI errors into public workflow logs.
# Success means the daemon is running, not that the demo is reachable.
print_safe_status() {
local status_file
local status_file status_result=0
if ! command -v tailscale >/dev/null 2>&1; then
echo "Tailscale CLI is not available."
return 0
return 1
fi
status_file="$(mktemp)"
if ! tailscale status --json >"$status_file" 2>/dev/null; then
echo "Tailscale status JSON is unavailable."
rm -f "$status_file"
return 0
return 1
fi
python3 - "$DEMO_SERVER_HOST" "$status_file" <<'PY' || true
python3 - "$DEMO_SERVER_HOST" "$status_file" <<'PY' || status_result=$?
import json
import sys
@ -30,66 +38,69 @@ host = sys.argv[1]
try:
with open(sys.argv[2], encoding="utf-8") as status_file:
status = json.load(status_file)
except (json.JSONDecodeError, OSError):
except (ValueError, OSError):
print("Tailscale status JSON is unavailable.")
raise SystemExit(0)
raise SystemExit(1)
states = {"NoState", "InUseOtherUser", "NeedsLogin", "NeedsMachineAuth", "Stopped", "Starting", "Running"}
state = status.get("BackendState") if isinstance(status, dict) else None
if not isinstance(state, str) or state not in states:
print("Tailscale backend: unknown")
raise SystemExit(1)
print(f"Tailscale backend: {state}")
if state != "Running":
raise SystemExit(1)
self_node = status.get("Self") or {}
self_ips = [ip for ip in self_node.get("TailscaleIPs") or [] if ":" not in ip]
self_dns = (self_node.get("DNSName") or "").rstrip(".")
self_tags = sorted(self_node.get("Tags") or [])
target = None
for peer in (status.get("Peer") or {}).values():
peer_ips = peer.get("TailscaleIPs") or []
peer_dns = (peer.get("DNSName") or "").rstrip(".")
peers = status.get("Peer")
for peer in peers.values() if isinstance(peers, dict) else ():
if not isinstance(peer, dict):
continue
peer_ips = peer.get("TailscaleIPs")
peer_ips = peer_ips if isinstance(peer_ips, list) else []
peer_dns = peer.get("DNSName")
peer_dns = peer_dns.rstrip(".") if isinstance(peer_dns, str) else ""
if host in peer_ips or host.rstrip(".") == peer_dns:
target = peer
break
print(f"Tailscale backend: {status.get('BackendState', 'unknown')}")
print(f"Runner Tailscale IPv4: {self_ips[0] if self_ips else 'unavailable'}")
print(f"Runner Tailscale DNS: {self_dns or 'unavailable'}")
print(f"Runner Tailscale tags: {','.join(self_tags) if self_tags else 'none'}")
if target is None:
print("Demo peer is not present in the runner peer map yet.")
else:
print(
"Demo peer state: "
f"online={bool(target.get('Online'))} "
f"active={bool(target.get('Active'))} "
f"relay={target.get('Relay') or 'none'}"
f"online={target.get('Online') is True} "
f"active={target.get('Active') is True}"
)
PY
rm -f "$status_file"
}
diagnose() {
print_safe_status
if command -v tailscale >/dev/null 2>&1; then
tailscale ping --c 1 --timeout 5s "$DEMO_SERVER_HOST" || true
fi
nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT" || true
return "$status_result"
}
if [ "$MODE" = "diagnose" ]; then
diagnose
# Both callers use this after the setup action fails. A provider refusal must
# not become another network attempt or a direct-host fallback diagnostic.
print_safe_status || true
echo "Diagnostic mode is local-only; demo connectivity and installed state remain unverified."
exit 0
fi
if [ "$MODE" != "check" ]; then
echo "Usage: $0 [check|diagnose]" >&2
exit 2
if ! print_safe_status; then
echo "::error::Tailscale setup has not established a running backend; no demo reachability probes attempted. This is not evidence that the demo host is down."
exit 1
fi
print_safe_status
if ! tailscale ping --c 3 --timeout 10s "$DEMO_SERVER_HOST"; then
# Probe output can contain private peer names/addresses or raw client errors.
# Retain the exit verdict, not that topology, in public workflow diagnostics.
if ! tailscale ping --c 3 --timeout 10s "$DEMO_SERVER_HOST" >/dev/null 2>&1; then
echo "::error::Tailscale cannot reach the demo peer. Verify that the workflow tag is authorized to reach the demo host tag and that the peer is online."
diagnose
# Never try TCP after a failed tailnet readiness probe, or replay that probe
# as a diagnostic. Preserve the failed result for the workflow owner.
exit 1
fi
for attempt in $(seq 1 "$TCP_ATTEMPTS"); do
if nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT"; then
if nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT" >/dev/null 2>&1; then
echo "Demo SSH transport is reachable over Tailscale."
exit 0
fi
@ -101,5 +112,4 @@ for attempt in $(seq 1 "$TCP_ATTEMPTS"); do
done
echo "::error::Tailscale reached the demo peer, but TCP/${TCP_PORT} remained closed. Verify sshd and the host firewall on tailscale0."
diagnose
exit 1

View file

@ -1029,6 +1029,14 @@ Companion drill:
complete missing-package list is a failed boundary check, not qualification.
`cd scripts/release_control && python3 -m unittest resolve_release_promotion_test release_promotion_policy_test`
`go test ./scripts/installtests -run 'Test(Demo|DeployDemo|UpdateDemo|Release)' -count=1`
- `TestDemoReachabilityStopsBeforeUnreadyOrFailedTailnetProbes` executes
synthetic local daemon/probe commands: failed setup diagnostics never
contact the host, non-running/malformed status cannot pass readiness,
failed tailnet ping cannot fall back to TCP, and workflow output excludes
private topology, including stdout/stderr from both probes on success or
failure. Running-state success still requires both ping and SSH
transport, with bounded TCP failures retained. This is offline helper
correctness, not live OAuth access or installed demo acceptance.
- Manual scenario:
- Compare the exact selected release notes with the install-metadata verdict.
Authored grouped notes must retain version identity, unsigned-Windows and

View file

@ -4114,8 +4114,23 @@ hostnames or Tailscale IPs, rather than silently depending on public SSH
reachability from GitHub-hosted runners. The workflow must use the current
pinned Tailscale GitHub Action, its target `ping` readiness gate, and the shared
`.github/scripts/check-demo-reachability.sh` TCP/22 diagnostic before SSH key
capture. A successful tailnet join alone is not connectivity proof. After that
network preflight, shared SSH
capture. A successful tailnet join alone is not connectivity proof.
The shared helper's `diagnose` mode is local-only after a failed setup action:
it must not ping the peer or attempt direct TCP as a fallback. Its `check` mode
requires recognised local `Running` state before any probe, then a successful
tailnet ping before TCP/22. Missing, malformed or non-running daemon state is
an incomplete setup result, not proof that the demo is down. Failed ping is
retained without another diagnostic ping or TCP attempt. Status diagnostics
report only allowlisted backend states and peer-presence/online/active booleans;
they never print tailnet addresses, DNS names, tags, relay locations, raw JSON
or local CLI errors. Tailnet and TCP probe stdout/stderr are also suppressed
on both success and failure; their exits and bounded, topology-free verdicts
remain visible. Diagnostic success cannot establish connectivity or
installed acceptance, and neither mode changes credentials or authorises
replaying a provider refusal.
After that network preflight, shared SSH
setup must wait for configured demo hostnames to resolve, accept configured IP
literals without a DNS precheck, and then capture host keys with bounded
short retries before any installer or binary copy runs; a long `ssh-keyscan`

View file

@ -2599,8 +2599,8 @@ func TestDemoReachabilityHelperSeparatesTailnetAndSshTransportProof(t *testing.T
`tailscale status --json`,
`tailscale ping --c 3 --timeout 10s "$DEMO_SERVER_HOST"`,
`nc -z -w 5 "$DEMO_SERVER_HOST" "$TCP_PORT"`,
`Runner Tailscale DNS:`,
`Runner Tailscale tags:`,
`Tailscale backend:`,
`Diagnostic mode is local-only`,
`Demo peer is not present in the runner peer map yet.`,
`Verify sshd and the host firewall on tailscale0.`,
} {
@ -2641,7 +2641,7 @@ exit 1
if err != nil {
t.Fatalf("demo reachability helper failed: %v\n%s", err, output)
}
for _, needle := range []string{"Tailscale backend: Running", "Demo peer state: online=True active=True relay=lhr", "Demo SSH transport is reachable over Tailscale."} {
for _, needle := range []string{"Tailscale backend: Running", "Demo peer state: online=True active=True", "Demo SSH transport is reachable over Tailscale."} {
if !strings.Contains(string(output), needle) {
t.Fatalf("demo reachability output missing %q: %s", needle, output)
}

View file

@ -0,0 +1,127 @@
package installtests
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestDemoReachabilityStopsBeforeUnreadyOrFailedTailnetProbes(t *testing.T) {
for _, tc := range []struct {
name, mode, state, rawStatus string
statusExit, pingExit, tcpExit int
wantExit, pings, tcpProbes int
want string
}{
{name: "needs-login", state: "NeedsLogin", wantExit: 1, want: "no demo reachability probes attempted"},
{name: "machine-auth", state: "NeedsMachineAuth", wantExit: 1, want: "no demo reachability probes attempted"},
{name: "stopped", state: "Stopped", wantExit: 1},
{name: "starting", state: "Starting", wantExit: 1},
{name: "unknown-state", state: "private-data-never-log", wantExit: 1, want: "Tailscale backend: unknown"},
{name: "unavailable-status", statusExit: 1, wantExit: 1, want: "status JSON is unavailable"},
{name: "invalid-json", rawStatus: "private-data-never-log", wantExit: 1},
{name: "array-status", rawStatus: "[]", wantExit: 1},
{name: "null-status", rawStatus: "null", wantExit: 1},
{name: "missing-state", rawStatus: "{}", wantExit: 1},
{name: "invalid-state-type", rawStatus: `{"BackendState":[]}`, wantExit: 1},
{name: "diagnose-after-login-failure", mode: "diagnose", state: "NeedsLogin", want: "Diagnostic mode is local-only"},
{name: "diagnose-after-running-setup-failure", mode: "diagnose", state: "Running", want: "installed state remain unverified"},
{name: "diagnose-unavailable-status", mode: "diagnose", statusExit: 1, want: "Diagnostic mode is local-only"},
{name: "failed-tailnet-ping", state: "Running", pingExit: 1, wantExit: 1, pings: 1, want: "Tailscale cannot reach the demo peer"},
{name: "closed-ssh", state: "Running", tcpExit: 1, wantExit: 1, pings: 1, tcpProbes: 2, want: "TCP/22 remained closed"},
{name: "ready", state: "Running", pings: 1, tcpProbes: 1, want: "Demo SSH transport is reachable over Tailscale"},
{name: "malformed-peer-map", rawStatus: `{"BackendState":"Running","Peer":[]}`, pings: 1, tcpProbes: 1},
{name: "malformed-peer", rawStatus: `{"BackendState":"Running","Peer":{"a":null,"b":{"DNSName":[],"TailscaleIPs":3}}}`, pings: 1, tcpProbes: 1},
{name: "invalid-mode", mode: "unsupported", state: "Running", wantExit: 2, want: "Usage:"},
} {
t.Run(tc.name, func(t *testing.T) {
tmp := t.TempDir()
bin := filepath.Join(tmp, "bin")
if err := os.Mkdir(bin, 0o755); err != nil {
t.Fatal(err)
}
status := tc.rawStatus
if status == "" {
data, err := json.Marshal(map[string]any{
"BackendState": tc.state,
"Self": map[string]any{"TailscaleIPs": []string{"100.100.100.1"}, "DNSName": "private-data-never-log.test.", "Tags": []string{"tag:private-data-never-log"}},
"Peer": map[string]any{"demo": map[string]any{"DNSName": "demo.synthetic.test.", "Online": true, "Active": true, "Relay": "private-data-never-log"}},
})
if err != nil {
t.Fatal(err)
}
status = string(data)
}
statusFile := filepath.Join(tmp, "status.json")
if err := os.WriteFile(statusFile, []byte(status), 0o600); err != nil {
t.Fatal(err)
}
callsFile := filepath.Join(tmp, "calls")
for name, script := range map[string]string{
"tailscale": `#!/bin/sh
printf 'tailscale %s\n' "$*" >> "$CALLS_FILE"
case "$1" in
status) cat "$STATUS_FILE"; echo 'private-data-never-log' >&2; exit "$STATUS_EXIT" ;;
ping) echo 'private-data-never-log'; echo 'private-data-never-log' >&2; exit "$PING_EXIT" ;;
*) exit 97 ;;
esac
`,
"nc": `#!/bin/sh
printf 'nc %s\n' "$*" >> "$CALLS_FILE"
echo 'private-data-never-log'
echo 'private-data-never-log' >&2
exit "$TCP_EXIT"
`,
} {
if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
}
mode := tc.mode
if mode == "" {
mode = "check"
}
cmd := exec.Command("bash", repoFile(".github", "scripts", "check-demo-reachability.sh"), mode)
cmd.Env = append(os.Environ(),
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"DEMO_SERVER_HOST=demo.synthetic.test", "DEMO_SERVER_PORT=22", "DEMO_TCP_ATTEMPTS=2", "DEMO_TCP_RETRY_SECONDS=0",
"CALLS_FILE="+callsFile, "STATUS_FILE="+statusFile,
"STATUS_EXIT="+strconv.Itoa(tc.statusExit), "PING_EXIT="+strconv.Itoa(tc.pingExit), "TCP_EXIT="+strconv.Itoa(tc.tcpExit),
)
output, err := cmd.CombinedOutput()
if cmd.ProcessState == nil || cmd.ProcessState.ExitCode() != tc.wantExit {
t.Fatalf("helper exit: %v, want %d; output=%s", err, tc.wantExit, output)
}
calls, err := os.ReadFile(callsFile)
if err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
if got := strings.Count(string(calls), "tailscale ping "); got != tc.pings {
t.Fatalf("tailnet probes=%d, want %d; calls=%s", got, tc.pings, calls)
}
if got := strings.Count(string(calls), "nc "); got != tc.tcpProbes {
t.Fatalf("TCP probes=%d, want %d; calls=%s", got, tc.tcpProbes, calls)
}
if tc.mode == "unsupported" && len(calls) != 0 {
t.Fatalf("invalid mode attempted a diagnostic: %s", calls)
}
if !strings.Contains(string(output), tc.want) {
t.Fatalf("output missing %q: %s", tc.want, output)
}
for _, private := range []string{"private-data-never-log", "100.100.100.1", "demo.synthetic.test", "Traceback"} {
if strings.Contains(string(output), private) {
t.Fatalf("helper exposed private or raw diagnostic content %q: %s", private, output)
}
}
if tc.wantExit != 0 || tc.mode == "diagnose" {
if strings.Contains(string(output), "Demo SSH transport is reachable") {
t.Fatalf("helper claimed connectivity without successful check: %s", output)
}
}
})
}
}