Connect: add e2e tests for kube access (#65929)

* Add kube fixture

* Make items in unified resources more semantically accurate so they are easier to find

* Add shell utility that executes a command and waits for its completion

* Add kube tests

* Use unique pod name

* Use `tsh kubectl`

* Ignore kubeconfigs in git

* Set up kube cluster only for Connect

* Remove stale kind cluster before calling create

* Handle Docker networking

* Use stable image

* Fix race condition on launch

* Do not download `busybox` image, exec into kind's internal pod

* Disable tests to avoid hitting rate limiter

* Disable kube fixture for now

* Lint
This commit is contained in:
Grzegorz Zdunek
2026-08-13 10:11:50 +00:00
committed by GitHub
parent bbe840d165
commit 25a7be07df
17 changed files with 494 additions and 16 deletions
+1
View File
@@ -9,6 +9,7 @@ certs/
data/
config/*.yaml
node/*.yaml
kube/*-kubeconfig
*.log
.auth/
!config/resources/*.yaml
+6 -5
View File
@@ -60,11 +60,12 @@ automatically starts the required infrastructure.
Available fixtures:
| Fixture | Description |
|----------------|------------------------------------------------------------------------------------|
| `ssh-node` | Start and connect a Teleport SSH node (runs in Docker) |
| `ssh-node-bpf` | A second node, `docker-node-bpf`, with Enhanced Session Recording enabled. |
| `connect` | Build Teleport Connect. Auto-detected from Connect test helpers. |
| Fixture | Description |
|----------------|-----------------------------------------------------------------------------------------------|
| `ssh-node` | Start and connect a Teleport SSH node (runs in Docker) |
| `ssh-node-bpf` | A second node, `docker-node-bpf`, with Enhanced Session Recording enabled. |
| `kube` | Start a kind-backed Kubernetes fixture + enable Teleport `kubernetes_service` in main process |
| `connect` | Build Teleport Connect. Auto-detected from Connect test helpers. |
Fixtures can also be enabled manually with `--with-<name>` flags (e.g. `--with-ssh-node`, `--with-connect`),
which is useful for modes like `--codegen` or `--browse` where auto-detection does not run.
+7
View File
@@ -30,6 +30,13 @@ auth_service:
ssh_service:
enabled: "no"
{{- if .KubeConfigPath }}
kubernetes_service:
enabled: "yes"
listen_addr: 0.0.0.0:{{ .KubeServerPort }}
kubeconfig_file: {{ .KubeConfigPath }}
{{- end }}
proxy_service:
enabled: "yes"
public_addr: localhost:{{ .ProxyPort }}
+3
View File
@@ -99,6 +99,7 @@ export async function login(
.filter({ hasText: 'Add Cluster' });
// The 'Connect' button is visible only when no clusters are present.
await expect(connectButton.or(addClusterItem)).toBeVisible();
if (await connectButton.isVisible()) {
await connectButton.click();
} else {
@@ -120,6 +121,7 @@ export async function login(
export interface App {
electronApp: ElectronApplication;
page: Page;
userDataDir: string;
appConfigPath: string;
}
@@ -153,6 +155,7 @@ export const test = fixtureBase.extend<{
}
await use({
electronApp: launchedApp.electronApp,
userDataDir: temp.path,
page: launchedApp.page,
appConfigPath,
});
+1
View File
@@ -23,6 +23,7 @@ import { test as base } from '@playwright/test';
export type Fixture =
| 'ssh-node'
| 'ssh-node-bpf'
| 'kube'
| 'connect';
export const test = base.extend<{ fixtures: Fixture[] }>({
+54 -1
View File
@@ -28,7 +28,7 @@ export class TerminalPage {
constructor(private page: Page) {
this.input = page.getByRole('textbox', { name: 'Terminal input' });
this.terminal = page.getByTestId('terminal');
this.terminal = page.locator('.xterm');
}
async waitForReady() {
@@ -39,6 +39,59 @@ export class TerminalPage {
await this.input.pressSequentially(command + '\n');
}
/**
* Executes a shell command in the terminal and waits for its completion.
* Appends an exit-code marker so output can be matched to this specific command.
*
* @returns The trimmed output of the command.
* @throws {Error} If the exit code cannot be parsed or is not 0.
*/
async execAndWait(
command: string,
options: { timeout?: number } = {}
): Promise<string> {
// Generate a unique marker per command
const marker = `__EXIT_${crypto.randomUUID().split('-').at(0)}__=`;
const fullCommand = `${command}; echo "${marker}$?"`;
const terminalRows = this.terminal.locator('.xterm-rows');
await this.input.pressSequentially(`${fullCommand}\n`);
// Wait for the marker plus the exit code
const markerWithExitCodeRegex = new RegExp(`${marker}\\d+`);
await expect(terminalRows).toContainText(markerWithExitCodeRegex, {
timeout: options?.timeout || TERMINAL_TIMEOUT,
});
const text = (await terminalRows.textContent()) || '';
const fullCommandIndex = text.lastIndexOf(fullCommand);
const markerIndex = text.lastIndexOf(marker);
// Extract output between the typed command and the exit marker
const output = text
.slice(fullCommandIndex + fullCommand.length, markerIndex)
.trim();
// Extract the exit code immediately following the marker
const exitCodeMatch = text
.slice(markerIndex + marker.length)
.match(/^(\d+)/);
if (!exitCodeMatch) {
throw new Error(`Could not find exit code, output: ${output}`);
}
const exitCode = Number(exitCodeMatch[1]);
if (exitCode !== 0) {
throw new Error(
`Command failed with ${exitCode} exit code, output: ${output}`
);
}
return output;
}
async waitForText(text: string) {
await expect(this.terminal).toContainText(text, {
timeout: TERMINAL_TIMEOUT,
+11 -1
View File
@@ -34,7 +34,7 @@ export class UnifiedResourcesPage {
*/
async connect(serverName: string, login: string) {
const row = this.page
.locator('div')
.locator('li')
.filter({ has: this.page.getByText(serverName, { exact: true }) })
.filter({ has: this.page.getByRole('button', { name: 'Connect' }) })
.last();
@@ -47,4 +47,14 @@ export class UnifiedResourcesPage {
return new TerminalPage(popup);
}
async connectWithoutLogin(resourceName: string | RegExp): Promise<void> {
const locator = this.page
.locator('li')
.filter({ hasText: resourceName })
.filter({ has: this.page.getByRole('button', { name: 'Connect' }) })
.first();
await locator.getByRole('button', { name: 'Connect' }).click();
}
}
+1
View File
@@ -24,6 +24,7 @@ var (
SSHNode = register("ssh-node")
// SSHNodeBPF runs a second node, docker-node-bpf, with Enhanced Session Recording enabled.
SSHNodeBPF = register("ssh-node-bpf")
Kube = register("kube")
Connect = register("connect")
)
+7 -1
View File
@@ -20,9 +20,12 @@ require (
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.22.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/client-go v0.36.2
sigs.k8s.io/kind v0.32.0
)
require (
al.essio.dev/pkg/shellescape v1.6.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
@@ -171,6 +174,7 @@ require (
github.com/hinshun/vt10x v0.0.0-20220301184237-5011da428d02 // indirect
github.com/in-toto/attestation v1.2.0 // indirect
github.com/in-toto/in-toto-golang v0.11.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -210,6 +214,7 @@ require (
github.com/openai/openai-go/v3 v3.8.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/patrickmn/go-cache v2.1.1-0.20191004192108-46f407853014+incompatible // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkoukk/tiktoken-go v0.1.8 // indirect
@@ -234,6 +239,8 @@ require (
github.com/sigstore/timestamp-authority/v2 v2.1.2 // indirect
github.com/sijms/go-ora/v2 v2.9.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef // indirect
@@ -287,7 +294,6 @@ require (
gopkg.in/yaml.v2 v2.4.0 // indirect
k8s.io/api v0.36.2 // indirect
k8s.io/apimachinery v0.36.2 // indirect
k8s.io/client-go v0.36.2 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260427204847-8949caaa1199 // indirect
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect
+8
View File
@@ -243,6 +243,7 @@ github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo=
@@ -462,6 +463,8 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/safetext v0.0.0-20240104143208-7a7d9b3d812f h1:o2yGZLlsOj5H5uvtQNEdi6DeA0GbUP3lm0gWW5RvY0s=
github.com/google/safetext v0.0.0-20240104143208-7a7d9b3d812f/go.mod h1:H3K1Iu/utuCfa10JO+GsmKUYSWi7ug57Rk6GaDRHaaQ=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/trillian v1.7.3 h1:hziW+vo4czis48tzx2GK5xRBl/ZxBA9B0/UR5avXOro=
github.com/google/trillian v1.7.3/go.mod h1:qh8iy4x/GvnVXUBd5pK4oncuT1Y9vVYfibQVsR/WpKg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -685,6 +688,8 @@ github.com/oracle/oci-go-sdk/v65 v65.117.1/go.mod h1:oo33NDf2XPqx3/N6oLG4jFlrqJ0
github.com/patrickmn/go-cache v2.1.1-0.20191004192108-46f407853014+incompatible h1:IWzUvJ72xMjmrjR9q3H1PF+jwdN0uNQiR2t1BLNalyo=
github.com/patrickmn/go-cache v2.1.1-0.20191004192108-46f407853014+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -772,6 +777,7 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
@@ -1071,6 +1077,8 @@ sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9
sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/kind v0.32.0 h1:p9hscbj98u/qyrjVpjId86LI70nQmbSsipV7wCG10Xk=
sigs.k8s.io/kind v0.32.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8=
sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs=
sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI=
sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI=
+12
View File
@@ -31,6 +31,7 @@ type testInstance struct {
proxyPort int
authPort int
sshPorts []int
kubePort int
e2eDir string
dataDir string
tctlBin string
@@ -38,6 +39,7 @@ type testInstance struct {
teleportConfigPath string
teleport *teleportInstance
nodes []*dockerNode
kube *kubeCluster
}
// start starts the Teleport instance and SSH node for this test instance.
@@ -51,6 +53,12 @@ func (inst *testInstance) start(ctx context.Context) error {
}
}()
if inst.kube != nil {
if err = inst.kube.start(ctx); err != nil {
return fmt.Errorf("failed to start kube fixture for %s: %w", inst.browser, err)
}
}
if inst.teleport != nil {
if err = inst.teleport.start(ctx); err != nil {
return fmt.Errorf("failed to start Teleport for %s: %w", inst.browser, err)
@@ -85,6 +93,10 @@ func (inst *testInstance) stop() {
node.stop(context.Background())
}
if inst.kube != nil {
inst.kube.stop()
}
if inst.teleport != nil {
inst.teleport.stop()
}
+187
View File
@@ -0,0 +1,187 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/
package main
import (
"context"
"fmt"
"log/slog"
"net"
"net/url"
"os"
"path/filepath"
"k8s.io/client-go/tools/clientcmd"
kindv1alpha4 "sigs.k8s.io/kind/pkg/apis/config/v1alpha4"
kindcluster "sigs.k8s.io/kind/pkg/cluster"
kindlog "sigs.k8s.io/kind/pkg/log"
)
type kubeCluster struct {
log *slog.Logger
name string
kubeconfigPath string
// dockerEndpointHost is the hostname from a remote DOCKER_HOST (for example
// "docker" from tcp://docker:2375). Enables the kube cluster to be
// reachable outside the container.
dockerEndpointHost string
provider *kindcluster.Provider
}
func (k *kubeCluster) start(ctx context.Context) error {
if err := os.MkdirAll(filepath.Dir(k.kubeconfigPath), 0o755); err != nil {
return err
}
k.provider = kindcluster.NewProvider(
kindcluster.ProviderWithDocker(),
kindcluster.ProviderWithLogger(kindSlogLogger{log: k.log}),
)
k.log.InfoContext(ctx, "starting kube cluster", "name", k.name)
if err := k.provider.Delete(k.name, k.kubeconfigPath); err != nil {
return fmt.Errorf("failed to delete stale kube cluster %s: %w", k.name, err)
}
cfg := &kindv1alpha4.Cluster{}
if k.dockerEndpointHost != "" {
cfg.Networking.APIServerAddress = "0.0.0.0"
}
if err := k.provider.Create(
k.name,
kindcluster.CreateWithV1Alpha4Config(cfg),
kindcluster.CreateWithKubeconfigPath(k.kubeconfigPath),
); err != nil {
return fmt.Errorf("creating kube cluster %q: %w", k.name, err)
}
if k.dockerEndpointHost != "" {
if err := rewriteKubeconfigServerHost(k.kubeconfigPath, k.dockerEndpointHost); err != nil {
return fmt.Errorf("rewriting kubeconfig server host: %w", err)
}
}
k.log.InfoContext(ctx, "kube cluster is ready", "name", k.name, "kubeconfig", k.kubeconfigPath)
return nil
}
func rewriteKubeconfigServerHost(kubeconfigPath, targetHost string) error {
config, err := clientcmd.LoadFromFile(kubeconfigPath)
if err != nil {
return fmt.Errorf("loading kubeconfig %q: %w", kubeconfigPath, err)
}
for name, cluster := range config.Clusters {
serverURL, err := url.Parse(cluster.Server)
if err != nil {
return fmt.Errorf("parsing kubeconfig server URL for cluster %q: %w", name, err)
}
// Rewrite wildcard bind addresses.
if serverURL.Hostname() != "0.0.0.0" {
continue
}
port := serverURL.Port()
if port == "" {
serverURL.Host = targetHost
} else {
serverURL.Host = net.JoinHostPort(targetHost, port)
}
// Replace the address with the Docker endpoint host so the API server is reachable from the Teleport container.
cluster.Server = serverURL.String()
// kind's API server cert includes "localhost" in SANs, but not the Docker host name.
// Pinning TLSServerName to localhost avoids x509 hostname mismatch failures.
cluster.TLSServerName = "localhost"
}
return clientcmd.WriteToFile(*config, kubeconfigPath)
}
func (k *kubeCluster) stop() {
if k.provider == nil {
return
}
ctx := context.Background()
k.log.InfoContext(ctx, "deleting kube cluster", "name", k.name)
if err := k.provider.Delete(k.name, k.kubeconfigPath); err != nil {
k.log.WarnContext(ctx, "failed to delete kube cluster", "name", k.name, "error", err)
}
if err := os.Remove(k.kubeconfigPath); err != nil && !os.IsNotExist(err) {
k.log.WarnContext(ctx, "failed to remove kubeconfig", "path", k.kubeconfigPath, "error", err)
}
}
type kindSlogLogger struct {
log *slog.Logger
}
func (k kindSlogLogger) Warn(message string) {
//nolint:sloglint // message cannot be constant
k.log.WarnContext(context.Background(), message)
}
func (k kindSlogLogger) Warnf(format string, args ...interface{}) {
//nolint:sloglint // message cannot be constant
k.log.WarnContext(context.Background(), fmt.Sprintf(format, args...))
}
func (k kindSlogLogger) Error(message string) {
//nolint:sloglint // message cannot be constant
k.log.ErrorContext(context.Background(), message)
}
func (k kindSlogLogger) Errorf(format string, args ...interface{}) {
//nolint:sloglint // message cannot be constant
k.log.ErrorContext(context.Background(), fmt.Sprintf(format, args...))
}
func (k kindSlogLogger) V(level kindlog.Level) kindlog.InfoLogger {
return kindSlogInfoLogger{
log: k.log,
level: level,
}
}
type kindSlogInfoLogger struct {
log *slog.Logger
level kindlog.Level
}
func (k kindSlogInfoLogger) Enabled() bool {
return k.log.Enabled(context.Background(), k.slogLevel())
}
func (k kindSlogInfoLogger) Info(message string) {
//nolint:sloglint // message cannot be constant
k.log.Log(context.Background(), k.slogLevel(), message)
}
func (k kindSlogInfoLogger) Infof(format string, args ...interface{}) {
k.Info(fmt.Sprintf(format, args...))
}
func (k kindSlogInfoLogger) slogLevel() slog.Level {
if k.level == 0 {
return slog.LevelInfo
}
return slog.LevelDebug
}
+30 -3
View File
@@ -254,9 +254,12 @@ func run(flags *e2eFlags, mode runMode, e2eDir string, isCI bool) error {
portTargets = append(portTargets, &inst.sshPorts[i])
}
}
if fixtures.Kube.Enabled {
portTargets = append(portTargets, &inst.kubePort)
}
}
if ci := config.connectInstance; ci != nil {
portTargets = append(portTargets, &ci.proxyPort, &ci.authPort)
portTargets = append(portTargets, &ci.proxyPort, &ci.authPort, &ci.kubePort)
}
if err := allocatePorts(portTargets...); err != nil {
@@ -264,10 +267,10 @@ func run(flags *e2eFlags, mode runMode, e2eDir string, isCI bool) error {
}
for _, inst := range config.instances {
inst.log.DebugContext(ctx, "allocated ports", "proxy", inst.proxyPort, "auth", inst.authPort, "ssh", inst.sshPorts)
inst.log.DebugContext(ctx, "allocated ports", "proxy", inst.proxyPort, "auth", inst.authPort, "ssh", inst.sshPorts, "kube", inst.kubePort)
}
if ci := config.connectInstance; ci != nil {
ci.log.DebugContext(ctx, "allocated ports", "proxy", ci.proxyPort, "auth", ci.authPort)
ci.log.DebugContext(ctx, "allocated ports", "proxy", ci.proxyPort, "auth", ci.authPort, "kube", ci.kubePort)
}
if sshNodeEnabled() {
@@ -375,16 +378,40 @@ func run(flags *e2eFlags, mode runMode, e2eDir string, isCI bool) error {
slog.DebugContext(ctx, "generated bootstrap state", "path", stateFile)
for _, inst := range allInstances {
kubeConfigPath := ""
// TODO(gzdunek): Currently only Connect tests kube access, so skip setting up kube clusters
// for Web UI and slowing down the tests.
// Replace this with project/browser-scoped fixture discovery so each instance gets only the fixtures
// required by its test set.
if fixtures.Kube.Enabled && inst.browser == "connect" {
name := "teleport-e2e-kube-" + inst.browser
kubeConfigPath = filepath.Join(e2eDir, "kube", inst.browser+"-kubeconfig")
dockerEndpointHost, err := resolveDockerEndpointHost()
if err != nil {
return fmt.Errorf("resolving docker endpoint host: %w", err)
}
inst.kube = &kubeCluster{
log: inst.log,
name: name,
dockerEndpointHost: dockerEndpointHost,
kubeconfigPath: kubeConfigPath,
}
}
outPath := filepath.Join(e2eDir, "config", inst.browser+"-teleport.yaml")
tcfg, err := generateTeleportConfig(config.teleportConfigTemplate, outPath, &TeleportConfig{
ClusterName: clusterName,
DataDir: inst.dataDir,
AuthServerPort: inst.authPort,
ProxyPort: inst.proxyPort,
KubeServerPort: inst.kubePort,
KeyFilePath: filepath.Join(config.certsDir, keyFileName),
CertFilePath: filepath.Join(config.certsDir, certFileName),
LicenseFile: config.licenseFile,
LogLevel: config.teleportLogLevel,
KubeConfigPath: kubeConfigPath,
})
if err != nil {
return fmt.Errorf("failed to generate Teleport config for %s: %w", inst.browser, err)
+26
View File
@@ -159,10 +159,12 @@ type TeleportConfig struct {
DataDir string
AuthServerPort int
ProxyPort int
KubeServerPort int
KeyFilePath string
CertFilePath string
LicenseFile string
LogLevel string
KubeConfigPath string
}
func generateTeleportConfig(templatePath, outPath string, data *TeleportConfig) (string, error) {
@@ -211,6 +213,30 @@ func resolveDockerHost() (string, error) {
return conn.LocalAddr().(*net.UDPAddr).IP.String(), nil
}
// resolveDockerEndpointHost returns the hostname portion of a remote Docker
// endpoint (for example "docker" from DOCKER_HOST=tcp://docker:2375).
// Returns empty string when Docker is local (unix/npipe/default).
func resolveDockerEndpointHost() (string, error) {
dockerHost := os.Getenv("DOCKER_HOST")
if dockerHost == "" {
return "", nil
}
u, err := url.Parse(dockerHost)
if err != nil {
return "", fmt.Errorf("parsing DOCKER_HOST: %w", err)
}
if u.Scheme != "tcp" {
return "", nil
}
host := u.Hostname()
if host == "" {
return "", fmt.Errorf("tcp DOCKER_HOST has empty hostname: %q", dockerHost)
}
return host, nil
}
func generateStateFile(templatePath string, state *stateConfig) (string, error) {
return renderTemplate(templatePath, state)
}
+133
View File
@@ -0,0 +1,133 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
import fs from 'node:fs/promises';
import { test, expect } from '@gravitational/e2e/helpers/connect';
import { TerminalPage } from '@gravitational/e2e/helpers/pages/Terminal';
import { UnifiedResourcesPage } from '@gravitational/e2e/helpers/pages/UnifiedResources';
test.use({
autoLogin: true,
fixtures: [
// TODO(gzdunek): Uncomment when enabling these tests.
/*'kube'*/
],
user: {
roles: ['access'],
traits: { kubernetes_groups: ['system:masters'] },
},
});
const kubeResourceName = /teleport-e2e-kube-/i;
const kubePromptText = 'Try "kubectl version" to test the connection.';
async function openKubeTerminal(
resources: UnifiedResourcesPage,
terminal: TerminalPage
): Promise<void> {
await resources.connectWithoutLogin(kubeResourceName);
await terminal.waitForText(kubePromptText);
}
// TODO(gzdunek): Re-enable these tests once Connect E2E logins reuse authenticated state
// instead of hitting the rate limiter.
test.skip('creates kubeconfig under user data and reuses for the same kube session', async ({
app,
}) => {
const { page, userDataDir } = app;
const resources = new UnifiedResourcesPage(page);
const terminal = new TerminalPage(page);
await openKubeTerminal(resources, terminal);
const kubeTab = page.locator(
'[role="tab"][data-doc-kind="doc.gateway_kube"]'
);
await expect(kubeTab).toBeVisible();
const pathBeforeTabClose = await terminal.execAndWait('echo $KUBECONFIG');
expect(pathBeforeTabClose.startsWith(userDataDir)).toBe(true);
await expect(kubeTab).toBeVisible();
await kubeTab.locator('.close').click();
await expect(kubeTab).toHaveCount(0);
await openKubeTerminal(resources, terminal);
await expect(kubeTab).toBeVisible();
const pathAfterTabReopen = await terminal.execAndWait('echo $KUBECONFIG');
expect(pathBeforeTabClose).toBe(pathAfterTabReopen);
});
test.skip('closing connection removes kubeconfig file', async ({ app }) => {
const { page } = app;
const resources = new UnifiedResourcesPage(page);
const terminal = new TerminalPage(page);
await openKubeTerminal(resources, terminal);
const kubeconfigPath = await terminal.execAndWait('echo $KUBECONFIG');
expect(await fileExists(kubeconfigPath)).toBe(true);
await page.getByTitle(/Open Connections/).click();
const kubeConnection = page.locator('li').filter({ hasText: /KUBE/ }).first();
await expect(kubeConnection).toBeVisible();
const disconnectButton = kubeConnection.getByTitle(/Disconnect /).first();
await expect(disconnectButton).toBeVisible();
await disconnectButton.click();
const removeButton = kubeConnection.getByTitle(/Remove /).first();
await expect(removeButton).toBeVisible();
await removeButton.click();
await expect.poll(() => fileExists(kubeconfigPath)).toBe(false);
});
test.skip('exec into a pod', async ({ app }) => {
const { page } = app;
const resources = new UnifiedResourcesPage(page);
const terminal = new TerminalPage(page);
// Use `tsh kubectl` because `kubectl` is not available in CI.
const kubectlCommand = `"$E2E_CONNECT_TSH_BIN" kubectl`;
await openKubeTerminal(resources, terminal);
// Reuse kind's kube-proxy pod to test exec without pulling an additional image.
const versionOutput = await terminal.execAndWait(
`${kubectlCommand} exec --stdin --tty --namespace kube-system daemonset/kube-proxy -- /usr/local/bin/kube-proxy --version`
);
expect(versionOutput).toContain('Kubernetes v');
});
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch (e: unknown) {
if (
typeof e === 'object' &&
e !== null &&
'code' in e &&
(e as { code?: string }).code === 'ENOENT'
) {
return false;
}
throw e;
}
}
@@ -18,8 +18,6 @@
import styled from 'styled-components';
import { Flex } from 'design';
import { LoadingSkeleton } from '../shared/LoadingSkeleton';
import { ResourceViewProps } from '../types';
import { FETCH_MORE_SIZE } from '../UnifiedResources';
@@ -40,7 +38,7 @@ export function CardsView({
resourceLabelConfig,
}: ResourceViewProps) {
return (
<CardsContainer className="CardsContainer" gap={2}>
<CardsContainer className="CardsContainer">
{mappedResources.map(
({ item, key, onShowStatusInfo, showingStatusInfo }) => (
<ResourceCard
@@ -67,7 +65,10 @@ export function CardsView({
);
}
const CardsContainer = styled(Flex)`
const CardsContainer = styled.ul`
display: grid;
gap: ${props => props.theme.space[2]}px;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
margin: 0;
padding: 0;
`;
@@ -379,11 +379,12 @@ const WarningRightEdgeBadgeIcon = ({
* TODO(bl-nero): Known issue: this doesn't really work well with one-column
* layout;
*/
const CardContainer = styled(Box)<{
const CardContainer = styled.li<{
showingStatusInfo: boolean;
showHoverState: boolean;
}>`
height: 110px;
list-style: none;
position: relative;
.resource-health-status-svg {