From d18358fb7b8ca9b628c779f2d63f6b17faf89bf7 Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Wed, 18 Sep 2024 15:12:21 -0700 Subject: [PATCH] Define fetch kube resource web api endpoint --- lib/web/apiserver.go | 1 + lib/web/apiserver_test.go | 181 +++++++++++++++--- web/packages/teleport/src/config.ts | 23 +++ .../teleport/src/generateResourcePath.test.ts | 18 +- .../teleport/src/generateResourcePath.ts | 3 + .../teleport/src/services/kube/kube.ts | 27 ++- .../teleport/src/services/kube/makeKube.ts | 17 +- .../teleport/src/services/kube/types.ts | 30 +++ .../services/resources/makeUnifiedResource.ts | 2 +- 9 files changed, 270 insertions(+), 32 deletions(-) diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index c53a988472851..97d93be5eecf8 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -869,6 +869,7 @@ func (h *Handler) bindDefaultEndpoints() { // Kube access handlers. h.GET("/webapi/sites/:site/kubernetes", h.WithClusterAuth(h.clusterKubesGet)) h.GET("/webapi/sites/:site/pods", h.WithClusterAuth(h.clusterKubePodsGet)) + h.GET("/webapi/sites/:site/kubernetes/resources", h.WithClusterAuth(h.clusterKubeResourcesGet)) // Github connector handlers h.GET("/webapi/github/login/web", h.WithRedirect(h.githubLoginWeb)) diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index b4d67945c9f62..c9638889e53ef 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -4259,6 +4259,113 @@ func TestClusterKubesGet(t *testing.T) { } } +func TestClusterKubeResourcesGet(t *testing.T) { + t.Parallel() + kubeClusterName := "kube_cluster" + + roleWithFullAccess := func(username string) []types.Role { + ret, err := types.NewRole(services.RoleNameForUser(username), types.RoleSpecV6{ + Allow: types.RoleConditions{ + Namespaces: []string{apidefaults.Namespace}, + KubernetesLabels: types.Labels{types.Wildcard: []string{types.Wildcard}}, + Rules: []types.Rule{ + types.NewRule(types.KindConnectionDiagnostic, services.RW()), + }, + KubeGroups: []string{"groups"}, + KubernetesResources: []types.KubernetesResource{ + { + Kind: types.KindKubePod, + Namespace: types.Wildcard, + Name: types.Wildcard, + }, + { + Kind: types.KindKubeNamespace, + Name: types.Wildcard, + }, + }, + }, + }) + require.NoError(t, err) + return []types.Role{ret} + } + require.NotNil(t, roleWithFullAccess) + + env := newWebPack(t, 1) + + type testResponse struct { + Items []ui.KubeResource `json:"items"` + TotalCount int `json:"totalCount"` + } + + tt := []struct { + name string + user string + kind string + expectedResponse []ui.KubeResource + }{ + { + name: "get pods from gRPC server", + user: "test-user@example.com", + kind: types.KindKubePod, + expectedResponse: []ui.KubeResource{ + { + Kind: types.KindKubePod, + Name: "test-pod", + Namespace: "default", + Labels: []ui.Label{{Name: "app", Value: "test"}}, + KubeCluster: kubeClusterName, + }, + { + Kind: types.KindKubePod, + Name: "test-pod2", + Namespace: "default", + Labels: []ui.Label{{Name: "app", Value: "test2"}}, + KubeCluster: kubeClusterName, + }, + }, + }, + { + name: "get namespaces", + user: "test-user2@example.com", + kind: types.KindKubeNamespace, + expectedResponse: []ui.KubeResource{ + { + Kind: types.KindKubeNamespace, + Name: "default", + Namespace: "", + Labels: []ui.Label{{Name: "app", Value: "test"}}, + KubeCluster: kubeClusterName, + }, + }, + }, + } + proxy := env.proxies[0] + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + // Init fake gRPC Kube service. + initGRPCServer(t, env, listener) + addr := utils.MustParseAddr(listener.Addr().String()) + proxy.handler.handler.cfg.ProxyWebAddr = *addr + + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + pack := proxy.authPack(t, tc.user, roleWithFullAccess(tc.user)) + + endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "kubernetes", "resources") + params := url.Values{} + params.Add("kubeCluster", kubeClusterName) + params.Add("kind", tc.kind) + re, err := pack.clt.Get(context.Background(), endpoint, params) + require.NoError(t, err) + + resp := testResponse{} + require.NoError(t, json.Unmarshal(re.Bytes(), &resp)) + require.ElementsMatch(t, tc.expectedResponse, resp.Items) + }) + } +} + func TestClusterKubePodsGet(t *testing.T) { t.Parallel() kubeClusterName := "kube_cluster" @@ -9513,35 +9620,59 @@ type fakeKubeService struct { } func (s *fakeKubeService) ListKubernetesResources(ctx context.Context, req *kubeproto.ListKubernetesResourcesRequest) (*kubeproto.ListKubernetesResourcesResponse, error) { - return &kubeproto.ListKubernetesResourcesResponse{ - Resources: []*types.KubernetesResourceV1{ - { - Kind: types.KindKubePod, - Metadata: types.Metadata{ - Name: "test-pod", - Labels: map[string]string{ - "app": "test", + switch req.GetResourceType() { + case types.KindKubePod: + { + return &kubeproto.ListKubernetesResourcesResponse{ + Resources: []*types.KubernetesResourceV1{ + { + Kind: types.KindKubePod, + Metadata: types.Metadata{ + Name: "test-pod", + Labels: map[string]string{ + "app": "test", + }, + }, + Spec: types.KubernetesResourceSpecV1{ + Namespace: "default", + }, }, - }, - Spec: types.KubernetesResourceSpecV1{ - Namespace: "default", - }, - }, - { - Kind: types.KindKubePod, - Metadata: types.Metadata{ - Name: "test-pod2", - Labels: map[string]string{ - "app": "test2", + { + Kind: types.KindKubePod, + Metadata: types.Metadata{ + Name: "test-pod2", + Labels: map[string]string{ + "app": "test2", + }, + }, + Spec: types.KubernetesResourceSpecV1{ + Namespace: "default", + }, }, }, - Spec: types.KubernetesResourceSpecV1{ - Namespace: "default", + TotalCount: 2, + }, nil + } + case types.KindKubeNamespace: + { + return &kubeproto.ListKubernetesResourcesResponse{ + Resources: []*types.KubernetesResourceV1{ + { + Kind: types.KindNamespace, + Metadata: types.Metadata{ + Name: "default", + Labels: map[string]string{ + "app": "test", + }, + }, + }, }, - }, - }, - TotalCount: 2, - }, nil + TotalCount: 1, + }, nil + } + default: + return nil, trace.BadParameter("kubernetes resource kind %q is not mocked", req.GetResourceType()) + } } func TestWebSocketAuthenticateRequest(t *testing.T) { diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 61800d5c97ac2..4856f34a5aeb7 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -38,6 +38,7 @@ import type { WebauthnAssertionResponse } from './services/auth'; import type { PluginKind, Regions } from './services/integrations'; import type { ParticipantMode } from 'teleport/services/session'; import type { YamlSupportedResourceKind } from './services/yaml/types'; +import type { KubeResourceKind } from './services/kube/types'; const cfg = { /** @deprecated Use cfg.edition instead. */ @@ -247,8 +248,11 @@ const cfg = { // TODO(zmb3): remove this when Assist is no longer using it sshPlaybackPrefix: '/v1/webapi/sites/:clusterId/sessions/:sid', // prefix because this is eventually concatenated with "/stream" or "/events" + kubernetesPath: '/v1/webapi/sites/:clusterId/kubernetes?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?', + kubernetesResourcesPath: + '/v1/webapi/sites/:clusterId/kubernetes/resources?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?&kubeCluster=:kubeCluster?&kubeNamespace=:kubeNamespace?&kind=:kind?', usersPath: '/v1/webapi/users', userWithUsernamePath: '/v1/webapi/users/:username', @@ -880,6 +884,13 @@ const cfg = { }); }, + getKubernetesResourcesUrl(clusterId: string, params: UrlKubeResourcesParams) { + return generateResourcePath(cfg.api.kubernetesResourcesPath, { + clusterId, + ...params, + }); + }, + getAuthnChallengeWithTokenUrl(tokenId: string) { return generatePath(cfg.api.mfaAuthnChallengeWithTokenPath, { tokenId, @@ -1232,6 +1243,18 @@ export interface UrlResourcesParams { kinds?: string[]; } +export interface UrlKubeResourcesParams { + query?: string; + search?: string; + sort?: SortType; + limit?: number; + startKey?: string; + searchAsRoles?: 'yes' | ''; + kubeNamespace?: string; + kubeCluster: string; + kind: KubeResourceKind; +} + export interface UrlDeployServiceIamConfigureScriptParams { integrationName: string; region: Regions; diff --git a/web/packages/teleport/src/generateResourcePath.test.ts b/web/packages/teleport/src/generateResourcePath.test.ts index f224dc024662c..cead245d24005 100644 --- a/web/packages/teleport/src/generateResourcePath.test.ts +++ b/web/packages/teleport/src/generateResourcePath.test.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import cfg, { UrlResourcesParams } from './config'; +import cfg, { UrlKubeResourcesParams, UrlResourcesParams } from './config'; import generateResourcePath from './generateResourcePath'; test('undefined params are set to empty string', () => { @@ -66,3 +66,19 @@ test('defined params but set to empty values are set to empty string', () => { '/v1/webapi/sites/cluster/resources?searchAsRoles=&limit=&startKey=&kinds=&query=&search=&sort=&pinnedOnly=&includedResourceMode=' ); }); + +test('defined kube related params are set', () => { + const params: UrlKubeResourcesParams = { + kind: 'namespace', + kubeCluster: 'kubecluster', + kubeNamespace: 'kubenamespace', + }; + expect( + generateResourcePath(cfg.api.kubernetesResourcesPath, { + clusterId: 'cluster', + ...params, + }) + ).toStrictEqual( + '/v1/webapi/sites/cluster/kubernetes/resources?searchAsRoles=&limit=&startKey=&query=&search=&sort=&kubeCluster=kubecluster&kubeNamespace=kubenamespace&kind=namespace' + ); +}); diff --git a/web/packages/teleport/src/generateResourcePath.ts b/web/packages/teleport/src/generateResourcePath.ts index a3a034af33d92..908299cde6d88 100644 --- a/web/packages/teleport/src/generateResourcePath.ts +++ b/web/packages/teleport/src/generateResourcePath.ts @@ -56,7 +56,10 @@ export default function generateResourcePath( .replace(':search?', processedParams.search || '') .replace(':searchAsRoles?', processedParams.searchAsRoles || '') .replace(':sort?', processedParams.sort || '') + .replace(':kind?', processedParams.kind || '') .replace(':kinds?', processedParams.kinds || '') + .replace(':kubeCluster?', processedParams.kubeCluster || '') + .replace(':kubeNamespace?', processedParams.kubeNamespace || '') .replace(':pinnedOnly?', processedParams.pinnedOnly || '') .replace( ':includedResourceMode?', diff --git a/web/packages/teleport/src/services/kube/kube.ts b/web/packages/teleport/src/services/kube/kube.ts index f81f46e22a0c8..82886438aba9c 100644 --- a/web/packages/teleport/src/services/kube/kube.ts +++ b/web/packages/teleport/src/services/kube/kube.ts @@ -17,11 +17,14 @@ */ import api from 'teleport/services/api'; -import cfg, { UrlResourcesParams } from 'teleport/config'; +import cfg, { + UrlKubeResourcesParams, + UrlResourcesParams, +} from 'teleport/config'; import { ResourcesResponse } from 'teleport/services/agents'; -import { Kube } from './types'; -import makeKube from './makeKube'; +import { Kube, KubeResourceResponse } from './types'; +import { makeKube, makeKubeResource } from './makeKube'; class KubeService { fetchKubernetes( @@ -41,6 +44,24 @@ class KubeService { }; }); } + + fetchKubernetesResources( + clusterId, + params: UrlKubeResourcesParams, + signal?: AbortSignal + ): Promise { + return api + .get(cfg.getKubernetesResourcesUrl(clusterId, params), signal) + .then(json => { + const items = json?.items || []; + + return { + items: items.map(makeKubeResource), + startKey: json?.startKey, + totalCount: json?.totalCount, + }; + }); + } } export default KubeService; diff --git a/web/packages/teleport/src/services/kube/makeKube.ts b/web/packages/teleport/src/services/kube/makeKube.ts index 7831cd097fc21..49c1957fe696d 100644 --- a/web/packages/teleport/src/services/kube/makeKube.ts +++ b/web/packages/teleport/src/services/kube/makeKube.ts @@ -16,9 +16,9 @@ * along with this program. If not, see . */ -import { Kube } from './types'; +import { Kube, KubeResource } from './types'; -export default function makeKube(json): Kube { +export function makeKube(json): Kube { const { name, requiresRequest } = json; const labels = json.labels || []; @@ -31,3 +31,16 @@ export default function makeKube(json): Kube { requiresRequest, }; } + +export function makeKubeResource(json): KubeResource { + const { kind, name, namespace, cluster } = json; + const labels = json.labels || []; + + return { + kind, + name, + namespace, + labels, + cluster, + }; +} diff --git a/web/packages/teleport/src/services/kube/types.ts b/web/packages/teleport/src/services/kube/types.ts index 4925517ef86d7..554249a287b64 100644 --- a/web/packages/teleport/src/services/kube/types.ts +++ b/web/packages/teleport/src/services/kube/types.ts @@ -25,3 +25,33 @@ export interface Kube { groups?: string[]; requiresRequest?: boolean; } + +/** + * Add kind consts as we go. + * Supported kube subresources: + * https://github.com/gravitational/teleport/blob/c86f46db17fe149240e30fa0748621239e36c72a/api/types/constants.go#L1233 + */ +export type KubeResourceKind = 'namespace'; + +/** + * Refers to kube_cluster's subresources like namespaces, pods, etc + */ +export type KubeResource = { + kind: KubeResourceKind; + name: string; + /** + * namespace will be left blank, if the field `kind` is `namespace` + */ + namespace?: string; + labels: ResourceLabel[]; + /** + * the kube cluster where this subresource belongs to + */ + cluster: string; +}; + +export interface KubeResourceResponse { + items: KubeResource[]; + startKey?: string; + totalCount?: number; +} diff --git a/web/packages/teleport/src/services/resources/makeUnifiedResource.ts b/web/packages/teleport/src/services/resources/makeUnifiedResource.ts index d0343d1f575fb..0dc4072c92719 100644 --- a/web/packages/teleport/src/services/resources/makeUnifiedResource.ts +++ b/web/packages/teleport/src/services/resources/makeUnifiedResource.ts @@ -20,7 +20,7 @@ import { UnifiedResource, UnifiedResourceKind } from '../agents'; import makeApp from '../apps/makeApps'; import { makeDatabase } from '../databases/makeDatabase'; import { makeDesktop } from '../desktops/makeDesktop'; -import makeKube from '../kube/makeKube'; +import { makeKube } from '../kube/makeKube'; import makeNode from '../nodes/makeNode'; export function makeUnifiedResource(json: any): UnifiedResource {