mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-03 12:47:49 +00:00
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:
parent
ee2fb78d05
commit
bb1903e48d
5 changed files with 203 additions and 43 deletions
86
.github/scripts/check-demo-reachability.sh
vendored
86
.github/scripts/check-demo-reachability.sh
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
127
scripts/installtests/demo_reachability_test.go
Normal file
127
scripts/installtests/demo_reachability_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue