mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-12 02:15:01 +00:00
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:
@@ -9,6 +9,7 @@ certs/
|
||||
data/
|
||||
config/*.yaml
|
||||
node/*.yaml
|
||||
kube/*-kubeconfig
|
||||
*.log
|
||||
.auth/
|
||||
!config/resources/*.yaml
|
||||
|
||||
+6
-5
@@ -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.
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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[] }>({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user