diff --git a/lib/service/service.go b/lib/service/service.go
index eadde605b5b7d..543baacc233a0 100644
--- a/lib/service/service.go
+++ b/lib/service/service.go
@@ -5761,6 +5761,7 @@ func (process *TeleportProcess) initProxyEndpoint(conn *Connector) error {
webConfig := web.Config{
Proxy: tsrv,
+ HighRateLimiterConfig: cfg.Proxy.WebUnauthenticatedHighRateLimiter,
AuthServers: cfg.AuthServerAddresses()[0],
ProxyClient: conn.Client,
ProxySSHAddr: proxySSHAddr,
diff --git a/lib/service/servicecfg/proxy.go b/lib/service/servicecfg/proxy.go
index e073de9265d28..cbc4473fbaf05 100644
--- a/lib/service/servicecfg/proxy.go
+++ b/lib/service/servicecfg/proxy.go
@@ -92,6 +92,11 @@ type ProxyConfig struct {
Limiter limiter.Config
+ // WebUnauthenticatedHighRateLimiter overrides the rate limiter the web API
+ // applies to unauthenticated endpoints that expect high request rates,
+ // such as /webapi/ping/:connector. Used only in tests.
+ WebUnauthenticatedHighRateLimiter *limiter.Config
+
// PublicAddrs is a list of the public addresses the proxy advertises
// for the HTTP endpoint. The hosts in PublicAddr are included in the
// list of host principals on the TLS and SSH certificate.
diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index 1816db70928cc..190d3c343b222 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -373,6 +373,11 @@ type Config struct {
// DatabaseREPLRegistry is used for retrieving database REPL.
DatabaseREPLRegistry dbrepl.REPLRegistry
+
+ // HighRateLimiterConfig overrides the rate limiter applied to
+ // unauthenticated endpoints that expect high request rates, such as
+ // /webapi/ping/:connector. Used only in tests.
+ HighRateLimiterConfig *limiter.Config
}
// SetDefaults ensures proper default values are set if
@@ -676,7 +681,7 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) {
return nil, trace.Wrap(err)
}
// highLimiter is used for endpoints which are only CPU constrained and require high request rates
- h.highLimiter, err = limiter.NewRateLimiter(limiter.Config{
+ highLimiterConfig := limiter.Config{
Rates: []limiter.Rate{
{
Period: defaults.LimiterHighPeriod,
@@ -685,7 +690,11 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*APIHandler, error) {
},
},
MaxConnections: defaults.LimiterMaxConnections,
- })
+ }
+ if cfg.HighRateLimiterConfig != nil {
+ highLimiterConfig = *cfg.HighRateLimiterConfig
+ }
+ h.highLimiter, err = limiter.NewRateLimiter(highLimiterConfig)
if err != nil {
return nil, trace.Wrap(err)
}
diff --git a/tool/tsh/common/kube_shared_fixture_test.go b/tool/tsh/common/kube_shared_fixture_test.go
new file mode 100644
index 0000000000000..41ee5e74ef6fc
--- /dev/null
+++ b/tool/tsh/common/kube_shared_fixture_test.go
@@ -0,0 +1,137 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package common
+
+import (
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/api/types"
+ kubeserver "github.com/gravitational/teleport/lib/kube/proxy/testing/kube_server"
+ "github.com/gravitational/teleport/lib/limiter"
+ "github.com/gravitational/teleport/lib/service/servicecfg"
+ "github.com/gravitational/teleport/lib/services"
+ "github.com/gravitational/teleport/lib/utils"
+)
+
+// Kube cluster names registered by the fixture.
+const (
+ sharedRootKubeCluster1 = "root-cluster"
+ sharedRootKubeCluster2 = "first-cluster"
+ sharedLeafKubeCluster = "leaf-cluster-some-suffix-added-by-discovery-service"
+ sharedLeafKubeClusterDiscoveredName = "leaf-cluster"
+)
+
+type kubeFixtureKey struct {
+ multiplexMode bool
+}
+
+var (
+ kubeFixtureMu sync.Mutex
+ kubeFixtures = map[kubeFixtureKey]*suite{}
+)
+
+// getKubeFixture returns the root+leaf Teleport suite for key, building it at most once per distinct key.
+// Bring-up costs ~4-5s and dominates TestKube/TestKubeLogin, whose bodies run in milliseconds,
+// so paying it once rather than per iteration is what keeps the package under the timeout at `-count 100`.
+func getKubeFixture(t *testing.T, key kubeFixtureKey) *suite {
+ t.Helper()
+ kubeFixtureMu.Lock()
+ defer kubeFixtureMu.Unlock()
+ if s, ok := kubeFixtures[key]; ok {
+ if s == nil {
+ t.Fatalf("shared kube fixture %+v failed to build earlier in this run", key)
+ }
+ return s
+ }
+ kubeFixtures[key] = nil // Prevent later -count iterations from rebuilding.
+
+ rootLabels := map[string]string{
+ "label1": "val1",
+ "ultra_long_label_for_teleport_kubernetes_service_list_kube_clusters_method": "ultra_long_label_value_for_teleport_kubernetes_service_list_kube_clusters_method",
+ }
+ leafLabels := map[string]string{
+ "label1": "val1",
+ "ultra_long_label_for_teleport_kubernetes_service_list_kube_clusters_method": "ultra_long_label_value_for_teleport_kubernetes_service_list_kube_clusters_method",
+ // mock a discovered kube cluster in the leaf Teleport cluster.
+ types.DiscoveredNameLabel: sharedLeafKubeClusterDiscoveredName,
+ }
+
+ s := newTestSuite(t,
+ withSharedFixture(),
+ withRootConfigFunc(func(cfg *servicecfg.Config) {
+ if key.multiplexMode {
+ cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
+ }
+ cfg.InsecureMode = true
+ cfg.Proxy.WebUnauthenticatedHighRateLimiter = &limiter.Config{} // no rate limiting
+ cfg.Kube.Enabled = true
+ cfg.Kube.ListenAddr = utils.MustParseAddr(localListenerAddr())
+ cfg.Kube.KubeconfigPath = newSharedKubeConfigFile(t, sharedRootKubeCluster1, sharedRootKubeCluster2)
+ cfg.Kube.StaticLabels = rootLabels
+ cfg.Proxy.Kube.Enabled = true
+ cfg.Proxy.Kube.ListenAddr = *utils.MustParseAddr(localListenerAddr())
+ cfg.SSH.Enabled = false
+ }),
+ withLeafCluster(),
+ withLeafConfigFunc(
+ func(cfg *servicecfg.Config) {
+ if key.multiplexMode {
+ cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
+ }
+ cfg.InsecureMode = true
+ cfg.Kube.Enabled = true
+ cfg.Kube.ListenAddr = utils.MustParseAddr(localListenerAddr())
+ cfg.Kube.KubeconfigPath = newSharedKubeConfigFile(t, sharedLeafKubeCluster)
+ cfg.Kube.StaticLabels = leafLabels
+ cfg.SSH.Enabled = false
+ },
+ ),
+ withValidationFunc(func(s *suite) bool {
+ // Wait for cache propagation of the kubernetes resources before proceeding with the tests.
+ var foundRoot1, foundRoot2, foundLeaf bool
+ for ks := range s.root.GetAuthServer().UnifiedResourceCache.KubernetesServers(t.Context(), services.UnifiedResourcesIterateParams{}) {
+ foundRoot1 = foundRoot1 || ks.GetCluster().GetName() == sharedRootKubeCluster1
+ foundRoot2 = foundRoot2 || ks.GetCluster().GetName() == sharedRootKubeCluster2
+ }
+
+ for ks := range s.leaf.GetAuthServer().UnifiedResourceCache.KubernetesServers(t.Context(), services.UnifiedResourcesIterateParams{}) {
+ foundLeaf = foundLeaf || ks.GetCluster().GetName() == sharedLeafKubeCluster
+ }
+
+ return foundRoot1 && foundRoot2 && foundLeaf
+ }),
+ )
+
+ kubeFixtures[key] = s
+ return s
+}
+
+func newSharedKubeConfigFile(t *testing.T, clusterNames ...string) string {
+ return buildKubeConfigFile(t, sharedTempDir(t), newSharedKubeSelfSubjectServer, clusterNames...)
+}
+
+func newSharedKubeSelfSubjectServer(t *testing.T) string {
+ srv, err := kubeserver.NewKubeAPIMock()
+ require.NoError(t, err)
+ registerSharedFixtureTeardown(func() { srv.Close() })
+ return srv.URL
+}
diff --git a/tool/tsh/common/kube_test.go b/tool/tsh/common/kube_test.go
index 627c2a86b84fe..f8d07de74c713 100644
--- a/tool/tsh/common/kube_test.go
+++ b/tool/tsh/common/kube_test.go
@@ -59,7 +59,7 @@ import (
)
func TestKube(t *testing.T) {
- pack := setupKubeTestPack(t, true)
+ pack := setupKubeTestPack(t, kubeFixtureKey{multiplexMode: true})
t.Run("list kube", pack.testListKube)
t.Run("proxy kube", pack.testProxyKube)
t.Run("proxy kube with exec-cmd", pack.testProxyKubeWithExecCmd)
@@ -90,14 +90,14 @@ func TestKubeLogin(t *testing.T) {
}
t.Run("kube login with multiplex mode", func(t *testing.T) {
- pack := setupKubeTestPack(t, true /* withMultiplexMode */)
+ pack := setupKubeTestPack(t, kubeFixtureKey{multiplexMode: true})
webProxyAddr, err := pack.root.ProxyWebAddr()
require.NoError(t, err)
testKubeLogin(t, pack.rootKubeCluster1, webProxyAddr.String())
})
t.Run("kube login without multiplex mode", func(t *testing.T) {
- pack := setupKubeTestPack(t, false /* withMultiplexMode */)
+ pack := setupKubeTestPack(t, kubeFixtureKey{multiplexMode: false})
proxyAddr, err := pack.root.ProxyKubeAddr()
require.NoError(t, err)
addr := net.JoinHostPort("localhost", fmt.Sprintf("%d", proxyAddr.Port(defaults.KubeListenPort)))
@@ -115,77 +115,22 @@ type kubeTestPack struct {
leafKubeCluster string
}
-func setupKubeTestPack(t *testing.T, withMultiplexMode bool) *kubeTestPack {
+// setupKubeTestPack returns a pack over the shared kube suite for key, logging
+// the current test in against it. The suite itself is built once per distinct
+// key and reused across -count iterations — see getKubeFixture.
+func setupKubeTestPack(t *testing.T, key kubeFixtureKey) *kubeTestPack {
t.Helper()
- ctx := context.Background()
- rootKubeCluster1 := "root-cluster"
- rootKubeCluster2 := "first-cluster"
- // mock a discovered kube cluster name in the leaf Teleport cluster.
- leafKubeCluster := "leaf-cluster-some-suffix-added-by-discovery-service"
- rootLabels := map[string]string{
- "label1": "val1",
- "ultra_long_label_for_teleport_kubernetes_service_list_kube_clusters_method": "ultra_long_label_value_for_teleport_kubernetes_service_list_kube_clusters_method",
- }
- leafLabels := map[string]string{
- "label1": "val1",
- "ultra_long_label_for_teleport_kubernetes_service_list_kube_clusters_method": "ultra_long_label_value_for_teleport_kubernetes_service_list_kube_clusters_method",
- // mock a discovered kube cluster in the leaf Teleport cluster.
- types.DiscoveredNameLabel: "leaf-cluster",
- }
-
- s := newTestSuite(t,
- withRootConfigFunc(func(cfg *servicecfg.Config) {
- if withMultiplexMode {
- cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
- }
- cfg.InsecureMode = true
- cfg.Kube.Enabled = true
- cfg.Kube.ListenAddr = utils.MustParseAddr(localListenerAddr())
- cfg.Kube.KubeconfigPath = newKubeConfigFile(t, rootKubeCluster1, rootKubeCluster2)
- cfg.Kube.StaticLabels = rootLabels
- cfg.Proxy.Kube.Enabled = true
- cfg.Proxy.Kube.ListenAddr = *utils.MustParseAddr(localListenerAddr())
- cfg.SSH.Enabled = false
- }),
- withLeafCluster(),
- withLeafConfigFunc(
- func(cfg *servicecfg.Config) {
- if withMultiplexMode {
- cfg.Auth.NetworkingConfig.SetProxyListenerMode(types.ProxyListenerMode_Multiplex)
- }
- cfg.InsecureMode = true
- cfg.Kube.Enabled = true
- cfg.Kube.ListenAddr = utils.MustParseAddr(localListenerAddr())
- cfg.Kube.KubeconfigPath = newKubeConfigFile(t, leafKubeCluster)
- cfg.Kube.StaticLabels = leafLabels
- cfg.SSH.Enabled = false
- },
- ),
- withValidationFunc(func(s *suite) bool {
- // Wait for cache propagation of the kubernetes resources before proceeding with the tests.
- var foundRoot1, foundRoot2, foundLeaf bool
- for ks := range s.root.GetAuthServer().UnifiedResourceCache.KubernetesServers(ctx, services.UnifiedResourcesIterateParams{}) {
- foundRoot1 = foundRoot1 || ks.GetCluster().GetName() == rootKubeCluster1
- foundRoot2 = foundRoot2 || ks.GetCluster().GetName() == rootKubeCluster2
- }
-
- for ks := range s.leaf.GetAuthServer().UnifiedResourceCache.KubernetesServers(ctx, services.UnifiedResourcesIterateParams{}) {
- foundLeaf = foundLeaf || ks.GetCluster().GetName() == leafKubeCluster
- }
-
- return foundRoot1 && foundRoot2 && foundLeaf
- }),
- )
+ s := getKubeFixture(t, key)
mustLoginSetEnvLegacy(t, s)
return &kubeTestPack{
suite: s,
rootClusterName: s.root.Config.Auth.ClusterName.GetClusterName(),
leafClusterName: s.leaf.Config.Auth.ClusterName.GetClusterName(),
- rootKubeCluster1: rootKubeCluster1,
- rootKubeCluster2: rootKubeCluster2,
- leafKubeCluster: leafKubeCluster,
+ rootKubeCluster1: sharedRootKubeCluster1,
+ rootKubeCluster2: sharedRootKubeCluster2,
+ leafKubeCluster: sharedLeafKubeCluster,
}
}
@@ -248,11 +193,11 @@ func (p *kubeTestPack) testListKube(t *testing.T) {
table := asciitable.MakeTableWithTruncatedColumn(
[]string{"Proxy", "Cluster", "Kube Cluster Name", "Labels", "Scope"},
[][]string{
- // "leaf-cluster" should be displayed instead of the
+ // The discovered name should be displayed instead of the
// full leaf cluster name, since it is mocked as a
// discovered resource and the discovered resource name
// is displayed in non-verbose mode.
- {p.root.Config.Proxy.WebAddr.String(), "leaf1", "leaf-cluster", formattedLeafLabels, ""},
+ {p.root.Config.Proxy.WebAddr.String(), "leaf1", sharedLeafKubeClusterDiscoveredName, formattedLeafLabels, ""},
{p.root.Config.Proxy.WebAddr.String(), "root", p.rootKubeCluster2, formattedRootLabels, ""},
{p.root.Config.Proxy.WebAddr.String(), "root", p.rootKubeCluster1, formattedRootLabels, ""},
},
@@ -279,7 +224,7 @@ func (p *kubeTestPack) testListKube(t *testing.T) {
args: []string{"--all", "--quiet"},
wantTable: func() string {
table := asciitable.MakeHeadlessTable(5)
- table.AddRow([]string{p.root.Config.Proxy.WebAddr.String(), "leaf1", "leaf-cluster", formattedLeafLabels, ""})
+ table.AddRow([]string{p.root.Config.Proxy.WebAddr.String(), "leaf1", sharedLeafKubeClusterDiscoveredName, formattedLeafLabels, ""})
table.AddRow([]string{p.root.Config.Proxy.WebAddr.String(), "root", p.rootKubeCluster2, formattedRootLabels, ""})
table.AddRow([]string{p.root.Config.Proxy.WebAddr.String(), "root", p.rootKubeCluster1, formattedRootLabels, ""})
return table.AsBuffer().String()
@@ -742,12 +687,14 @@ func TestKubeSelection(t *testing.T) {
}
func newKubeConfigFile(t *testing.T, clusterNames ...string) string {
- tmpDir := t.TempDir()
+ return buildKubeConfigFile(t, t.TempDir(), newKubeSelfSubjectServer, clusterNames...)
+}
+func buildKubeConfigFile(t *testing.T, dir string, newServer func(t *testing.T) string, clusterNames ...string) string {
kubeConf := clientcmdapi.NewConfig()
for _, name := range clusterNames {
kubeConf.Clusters[name] = &clientcmdapi.Cluster{
- Server: newKubeSelfSubjectServer(t),
+ Server: newServer(t),
InsecureSkipTLSVerify: true,
}
kubeConf.AuthInfos[name] = &clientcmdapi.AuthInfo{}
@@ -757,7 +704,7 @@ func newKubeConfigFile(t *testing.T, clusterNames ...string) string {
AuthInfo: name,
}
}
- kubeConfigLocation := filepath.Join(tmpDir, "kubeconfig")
+ kubeConfigLocation := filepath.Join(dir, "kubeconfig")
err := clientcmd.WriteToFile(*kubeConf, kubeConfigLocation)
require.NoError(t, err)
return kubeConfigLocation
diff --git a/tool/tsh/common/shared_fixture_test.go b/tool/tsh/common/shared_fixture_test.go
new file mode 100644
index 0000000000000..4b932e48e6cc2
--- /dev/null
+++ b/tool/tsh/common/shared_fixture_test.go
@@ -0,0 +1,67 @@
+/*
+ * Teleport
+ * Copyright (C) 2026 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package common
+
+import (
+ "os"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+var (
+ sharedFixtureMu sync.Mutex
+ sharedFixtureTeardowns []func()
+)
+
+func registerSharedFixtureTeardown(fn func()) {
+ sharedFixtureMu.Lock()
+ defer sharedFixtureMu.Unlock()
+ sharedFixtureTeardowns = append(sharedFixtureTeardowns, fn)
+}
+
+// teardownSharedFixtures runs the registered teardowns.
+// Called once from TestMain.
+func teardownSharedFixtures() {
+ sharedFixtureMu.Lock()
+ defer sharedFixtureMu.Unlock()
+ for i := len(sharedFixtureTeardowns) - 1; i >= 0; i-- {
+ sharedFixtureTeardowns[i]()
+ }
+ sharedFixtureTeardowns = nil
+}
+
+// dataDirFor returns a data dir for a cluster's file config.
+func dataDirFor(t *testing.T, shared bool) string {
+ t.Helper()
+ if !shared {
+ return t.TempDir()
+ }
+ return sharedTempDir(t)
+}
+
+// sharedTempDir returns a temp dir removed by TestMain.
+func sharedTempDir(t *testing.T) string {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "tsh-shared-fixture")
+ require.NoError(t, err)
+ registerSharedFixtureTeardown(func() { os.RemoveAll(dir) })
+ return dir
+}
diff --git a/tool/tsh/common/tsh_helper_test.go b/tool/tsh/common/tsh_helper_test.go
index adef1f2aba66c..633aa47aef356 100644
--- a/tool/tsh/common/tsh_helper_test.go
+++ b/tool/tsh/common/tsh_helper_test.go
@@ -65,7 +65,7 @@ func (s *suite) setupRootCluster(t *testing.T, options testSuiteOptions) {
fileConfig := &config.FileConfig{
Version: "v2",
Global: config.Global{
- DataDir: t.TempDir(),
+ DataDir: dataDirFor(t, options.shared),
NodeName: "rootnode",
},
SSH: config.SSH{
@@ -156,7 +156,7 @@ func (s *suite) setupRootCluster(t *testing.T, options testSuiteOptions) {
options.rootConfigFunc(cfg)
}
- s.root = runTeleport(t, cfg)
+ s.root = startTeleport(t, cfg, options.shared)
}
func (s *suite) setupLeafCluster(t *testing.T, options testSuiteOptions) {
@@ -164,7 +164,7 @@ func (s *suite) setupLeafCluster(t *testing.T, options testSuiteOptions) {
fileConfig := &config.FileConfig{
Version: "v2",
Global: config.Global{
- DataDir: t.TempDir(),
+ DataDir: dataDirFor(t, options.shared),
NodeName: "leafnode",
},
SSH: config.SSH{
@@ -254,7 +254,7 @@ func (s *suite) setupLeafCluster(t *testing.T, options testSuiteOptions) {
if options.leafConfigFunc != nil {
options.leafConfigFunc(cfg)
}
- s.leaf = runTeleport(t, cfg)
+ s.leaf = startTeleport(t, cfg, options.shared)
_, err = s.leaf.GetAuthServer().UpsertTrustedClusterV2(s.leaf.ExitContext(), tc)
require.NoError(t, err)
@@ -265,6 +265,7 @@ type testSuiteOptions struct {
leafConfigFunc func(cfg *servicecfg.Config)
leafCluster bool
validationFunc func(*suite) bool
+ shared bool
}
type testSuiteOptionFunc func(o *testSuiteOptions)
@@ -293,6 +294,13 @@ func withValidationFunc(f func(*suite) bool) testSuiteOptionFunc {
}
}
+// withSharedFixture marks the suite as a long-lived fixture.
+func withSharedFixture() testSuiteOptionFunc {
+ return func(o *testSuiteOptions) {
+ o.shared = true
+ }
+}
+
// deprecated: Use `tools/teleport/testenv.MakeTestServer` instead.
func newTestSuite(t *testing.T, opts ...testSuiteOptionFunc) *suite {
var options testSuiteOptions
@@ -305,10 +313,11 @@ func newTestSuite(t *testing.T, opts ...testSuiteOptionFunc) *suite {
if options.leafCluster || options.leafConfigFunc != nil {
s.setupLeafCluster(t, options)
- require.Eventually(t, func() bool {
- rt, err := s.root.GetAuthServer().GetTunnelConnections(t.Context(), s.leaf.Config.Auth.ClusterName.GetClusterName())
+ ctx := t.Context()
+ require.EventuallyWithT(t, func(t *assert.CollectT) {
+ rt, err := s.root.GetAuthServer().GetTunnelConnections(ctx, s.leaf.Config.Auth.ClusterName.GetClusterName())
require.NoError(t, err)
- return len(rt) == 1
+ require.Len(t, rt, 1)
}, 10*time.Second, 100*time.Millisecond)
}
@@ -322,6 +331,12 @@ func newTestSuite(t *testing.T, opts ...testSuiteOptionFunc) *suite {
}
func runTeleport(t *testing.T, cfg *servicecfg.Config) *service.TeleportProcess {
+ return startTeleport(t, cfg, false /* shared */)
+}
+
+// startTeleport starts cfg and waits for its configured services to become ready.
+func startTeleport(t *testing.T, cfg *servicecfg.Config, shared bool) *service.TeleportProcess {
+ t.Helper()
if cfg.InstanceMetadataClient == nil {
// Disables cloud auto-imported labels when running tests in cloud envs
// such as Github Actions.
@@ -338,10 +353,19 @@ func runTeleport(t *testing.T, cfg *servicecfg.Config) *service.TeleportProcess
process, err := service.NewTeleport(cfg)
require.NoError(t, err, trace.DebugReport(err))
require.NoError(t, process.Start())
- t.Cleanup(func() {
- require.NoError(t, process.Close())
- require.NoError(t, process.Wait())
- })
+ if shared {
+ // Unlike the t.Cleanup below, this can't assert on Close/Wait.
+ // It runs from TestMain after tests finished; calling t.Errorf that late panics.
+ registerSharedFixtureTeardown(func() {
+ _ = process.Close()
+ _ = process.Wait()
+ })
+ } else {
+ t.Cleanup(func() {
+ require.NoError(t, process.Close())
+ require.NoError(t, process.Wait())
+ })
+ }
var serviceReadyEvents []string
if cfg.Proxy.Enabled {
diff --git a/tool/tsh/common/tsh_test.go b/tool/tsh/common/tsh_test.go
index 8e434ccf42b15..00b582ff774a4 100644
--- a/tool/tsh/common/tsh_test.go
+++ b/tool/tsh/common/tsh_test.go
@@ -143,6 +143,7 @@ func TestMain(m *testing.M) {
ctx, cancel := context.WithCancel(context.Background())
cryptosuitestest.PrecomputeRSAKeys(ctx)
exitCode := m.Run()
+ teardownSharedFixtures()
cancel()
os.Exit(exitCode)
}