From a6c3742c21c1efbbac22e9529278c4ad17a82c14 Mon Sep 17 00:00:00 2001 From: Lisa Kim Date: Fri, 4 Oct 2024 15:02:49 -0700 Subject: [PATCH] Web: Define fetch kube resource web api endpoint (#46741) * Define fetch kube resource web api endpoint * Remove unused endpoints and funcs --- lib/web/apiserver.go | 2 +- lib/web/apiserver_test.go | 145 +++++++++++++----- lib/web/servers.go | 25 ++- web/packages/teleport/src/config.ts | 22 +++ .../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 +- 10 files changed, 240 insertions(+), 51 deletions(-) diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 4885330a94102..0ec9f7f4efb75 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -872,7 +872,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 640b7395ea7ee..8fe04777a6513 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -4259,7 +4259,7 @@ func TestClusterKubesGet(t *testing.T) { } } -func TestClusterKubePodsGet(t *testing.T) { +func TestClusterKubeResourcesGet(t *testing.T) { t.Parallel() kubeClusterName := "kube_cluster" @@ -4278,6 +4278,10 @@ func TestClusterKubePodsGet(t *testing.T) { Namespace: types.Wildcard, Name: types.Wildcard, }, + { + Kind: types.KindKubeNamespace, + Name: types.Wildcard, + }, }, }, }) @@ -4296,11 +4300,15 @@ func TestClusterKubePodsGet(t *testing.T) { tt := []struct { name string user string + kind string + kubeCluster string expectedResponse []ui.KubeResource + wantErr bool }{ { - name: "get pods from gRPC server", - user: "test-user@example.com", + name: "get pods from gRPC server", + kind: types.KindKubePod, + kubeCluster: kubeClusterName, expectedResponse: []ui.KubeResource{ { Kind: types.KindKubePod, @@ -4318,6 +4326,38 @@ func TestClusterKubePodsGet(t *testing.T) { }, }, }, + { + name: "get namespaces", + kind: types.KindKubeNamespace, + kubeCluster: kubeClusterName, + expectedResponse: []ui.KubeResource{ + { + Kind: types.KindKubeNamespace, + Name: "default", + Namespace: "", + Labels: []ui.Label{{Name: "app", Value: "test"}}, + KubeCluster: kubeClusterName, + }, + }, + }, + { + name: "missing kind", + kind: "", + kubeCluster: kubeClusterName, + wantErr: true, + }, + { + name: "invalid kind", + kind: "invalid-kind", + kubeCluster: kubeClusterName, + wantErr: true, + }, + { + name: "missing kube cluster", + kind: types.KindKubeNamespace, + kubeCluster: "", + wantErr: true, + }, } proxy := env.proxies[0] listener, err := net.Listen("tcp", "127.0.0.1:0") @@ -4327,22 +4367,27 @@ func TestClusterKubePodsGet(t *testing.T) { addr := utils.MustParseAddr(listener.Addr().String()) proxy.handler.handler.cfg.ProxyWebAddr = *addr + user := "test-user@example.com" + pack := proxy.authPack(t, user, roleWithFullAccess(user)) + 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(), "pods") + endpoint := pack.clt.Endpoint("webapi", "sites", env.server.ClusterName(), "kubernetes", "resources") params := url.Values{} - params.Add("kubeCluster", kubeClusterName) + params.Add("kubeCluster", tc.kubeCluster) + 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.Len(t, resp.Items, 2) - require.Equal(t, 2, resp.TotalCount) - require.ElementsMatch(t, tc.expectedResponse, resp.Items) + if tc.wantErr { + require.True(t, trace.IsBadParameter(err)) + } else { + require.NoError(t, err) + resp := testResponse{} + require.NoError(t, json.Unmarshal(re.Bytes(), &resp)) + require.ElementsMatch(t, tc.expectedResponse, resp.Items) + } + }) } } @@ -9536,35 +9581,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/lib/web/servers.go b/lib/web/servers.go index 62919e6194637..da7a4d75ccb3a 100644 --- a/lib/web/servers.go +++ b/lib/web/servers.go @@ -20,6 +20,7 @@ package web import ( "net/http" + "slices" "github.com/gravitational/trace" "github.com/julienschmidt/httprouter" @@ -61,21 +62,35 @@ func (h *Handler) clusterKubesGet(w http.ResponseWriter, r *http.Request, p http }, nil } -// clusterKubePodsGet returns a list of Kubernetes Pods in a form the -// UI can present. -func (h *Handler) clusterKubePodsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { +// clusterKubeResourcesGet returns supported requested kubernetes subresources eg: pods, namespaces, secrets etc. +func (h *Handler) clusterKubeResourcesGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { + kind := r.URL.Query().Get("kind") + kubeCluster := r.URL.Query().Get("kubeCluster") + + if kubeCluster == "" { + return nil, trace.BadParameter("missing param %q", "kubeCluster") + } + + if kind == "" { + return nil, trace.BadParameter("missing param %q", "kind") + } + + if !slices.Contains(types.KubernetesResourcesKinds, kind) { + return nil, trace.BadParameter("kind is not valid, valid kinds %v", types.KubernetesResourcesKinds) + } + clt, err := sctx.NewKubernetesServiceClient(r.Context(), h.cfg.ProxyWebAddr.Addr) if err != nil { return nil, trace.Wrap(err) } - resp, err := listKubeResources(r.Context(), clt, r.URL.Query(), site.GetName(), types.KindKubePod) + resp, err := listKubeResources(r.Context(), clt, r.URL.Query(), site.GetName(), kind) if err != nil { return nil, trace.Wrap(err) } return listResourcesGetResponse{ - Items: ui.MakeKubeResources(resp.Resources, r.URL.Query().Get("kubeCluster")), + Items: ui.MakeKubeResources(resp.Resources, kubeCluster), StartKey: resp.NextKey, TotalCount: int(resp.TotalCount), }, nil diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index 14129fef320d7..977bfbc480dcd 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. */ @@ -249,6 +250,8 @@ const cfg = { 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 +883,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 +1242,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 {