feat(setup): run telnet preflight in parallel with SSH probes

GetMigrationSummary now kicks off telnetPreflight in a goroutine at
entry and merges the four Telnet* fields into the main summary just
before returning. Wall time becomes max(ssh, telnet); the two transports
are queried independently and their results combined — SSH retains
visibility into /etc/hosts, /etc/resolv.conf and the on-device XML
config, while telnet contributes the live URL set readable via
`getpdo CurrentSystemConfiguration` without root.

Race-free by construction: the goroutine writes to its own
MigrationSummary instance and only the four telnet fields are copied
back. Verified with `go test -race`.

Tests cover telnet-only, ssh-only, and both-succeed paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-11 00:37:11 +02:00
co-authored by Claude Opus 4.7
parent c84cfeb757
commit 91ba28c52e
2 changed files with 160 additions and 0 deletions
@@ -0,0 +1,139 @@
package setup
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// telnetSummaryEnv builds a Manager whose:
// - SSH client is the supplied mockSSH (or a no-op if nil).
// - Telnet client is the supplied fakeTelnet.
// - Live :8090/info call hits an httptest server returning a minimal XML.
//
// The deviceIP returned is the httptest server's listener addr ("host:port"),
// so the live-info call works; the SSH and telnet clients ignore the addr
// and return whatever the fakes are scripted to return.
func telnetSummaryEnv(t *testing.T, ssh *mockSSH, ft *fakeTelnet) (*Manager, string, func()) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
_, _ = fmt.Fprint(w, `<info deviceID="123"><name>Test</name></info>`)
}))
m := NewManager("http://example:8000", nil, nil)
m.NewSSH = func(string) SSHClient {
if ssh != nil {
return ssh
}
return &mockSSH{runFunc: func(string) (string, error) { return "", errors.New("ssh disabled in test") }}
}
m.NewTelnet = func(string) TelnetClient { return ft }
return m, server.Listener.Addr().String(), server.Close
}
func TestGetMigrationSummary_TelnetSucceedsSSHFails(t *testing.T) {
target := "http://example:8000"
ft := &fakeTelnet{
banner: "BoseShell\n-> ",
responses: map[string]string{
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n",
},
}
m, host, cleanup := telnetSummaryEnv(t, nil, ft)
defer cleanup()
summary, err := m.GetMigrationSummary(host, "", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
if summary.SSHSuccess {
t.Errorf("SSHSuccess = true, want false")
}
if !summary.TelnetReachable {
t.Errorf("TelnetReachable = false, want true")
}
if !strings.Contains(summary.TelnetBanner, "BoseShell") {
t.Errorf("TelnetBanner = %q, want it to contain BoseShell", summary.TelnetBanner)
}
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
t.Errorf("TelnetVerifiedConfig = %q, want it to contain %q", summary.TelnetVerifiedConfig, target)
}
}
func TestGetMigrationSummary_TelnetFailsSSHFails(t *testing.T) {
ft := &fakeTelnet{dialErr: errors.New("connection refused")}
m, host, cleanup := telnetSummaryEnv(t, nil, ft)
defer cleanup()
summary, err := m.GetMigrationSummary(host, "", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
if summary.SSHSuccess {
t.Errorf("SSHSuccess = true, want false")
}
if summary.TelnetReachable {
t.Errorf("TelnetReachable = true, want false")
}
if !strings.Contains(summary.TelnetProbeError, "connection refused") {
t.Errorf("TelnetProbeError = %q, want connection refused", summary.TelnetProbeError)
}
}
func TestGetMigrationSummary_TelnetSucceedsSSHSucceeds(t *testing.T) {
target := "http://example:8000"
ft := &fakeTelnet{
responses: map[string]string{
"getpdo CurrentSystemConfiguration": "margeServerUrl=" + target + "\n",
},
}
// SSH mock returns enough for SSHSuccess to be true (cat /opt/Bose/etc/...).
ssh := &mockSSH{
runFunc: func(cmd string) (string, error) {
switch {
case strings.HasPrefix(cmd, "cat "+SoundTouchSdkPrivateCfgPath):
return `<?xml version="1.0"?><SoundTouchSdkPrivateCfg><margeServerUrl>` + target + `</margeServerUrl></SoundTouchSdkPrivateCfg>`, nil
case strings.HasPrefix(cmd, "[ -f"):
return "", errors.New("not found")
default:
return "", nil
}
},
}
m, host, cleanup := telnetSummaryEnv(t, ssh, ft)
defer cleanup()
summary, err := m.GetMigrationSummary(host, "", "", nil)
if err != nil {
t.Fatalf("GetMigrationSummary: %v", err)
}
if !summary.SSHSuccess {
t.Errorf("SSHSuccess = false, want true")
}
if !summary.TelnetReachable {
t.Errorf("TelnetReachable = false, want true")
}
if !strings.Contains(summary.TelnetVerifiedConfig, target) {
t.Errorf("TelnetVerifiedConfig = %q, want %q", summary.TelnetVerifiedConfig, target)
}
}
+21
View File
@@ -244,6 +244,20 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
SSHSuccess: false,
}
// Run the telnet preflight in parallel with the SSH-based probes below.
// Both transports are queried independently: SSH gives access to
// /etc/hosts, /etc/resolv.conf and the on-device XML config; telnet's
// `getpdo CurrentSystemConfiguration` reports the live URL set without
// needing root. They are complementary, so we wait for both and merge
// the results — total wall time = max(ssh, telnet).
telnetCh := make(chan MigrationSummary, 1)
go func() {
var local MigrationSummary
m.telnetPreflight(&local, deviceIP)
telnetCh <- local
}()
// Populate device info from datastore and live info
m.populateDeviceInfo(summary, deviceIP)
@@ -322,6 +336,13 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti
}
}
// 8. Merge telnet preflight results (started in parallel at the top).
telnetResult := <-telnetCh
summary.TelnetReachable = telnetResult.TelnetReachable
summary.TelnetBanner = telnetResult.TelnetBanner
summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig
summary.TelnetProbeError = telnetResult.TelnetProbeError
return summary, nil
}