Skip to content

Commit

Permalink
[UI] Show step pod yaml and events in RunDetails page (#3304)
Browse files Browse the repository at this point in the history
* [UI Server] Pod info handler

* [UI] Pod info tab in run details page

* Change pod info preview to use yaml editor

* Fix namespace

* Adds error handling for PodInfo

* Adjust to warning message

* [UI] Pod events in RunDetails page

* Adjust error message

* Refactor k8s helper to get rid of in cluster limit

* Tests for pod info handler

* Tests for pod event list handler

* Move pod yaml viewer related components to separate file.

* Unit tests for PodYaml component

* Fix react unit tests

* Fix error message

* Address CR comments

* Add permission to ui role
  • Loading branch information
Bobgy authored Mar 20, 2020
1 parent ec41c69 commit f882f36
Show file tree
Hide file tree
Showing 17 changed files with 765 additions and 130 deletions.
141 changes: 128 additions & 13 deletions frontend/server/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,26 @@ import { Storage as GCSStorage } from '@google-cloud/storage';
import { UIServer } from './app';
import { loadConfigs } from './configs';
import * as minioHelper from './minio-helper';
import * as k8sHelper from './k8s-helper';
import { TEST_ONLY as K8S_TEST_EXPORT } from './k8s-helper';

jest.mock('minio');
jest.mock('node-fetch');
jest.mock('@google-cloud/storage');
jest.mock('./minio-helper');
jest.mock('./k8s-helper');

const mockedFetch: jest.Mock = fetch as any;
const mockedK8sHelper: jest.Mock = k8sHelper as any;

beforeEach(() => {
const consoleInfoSpy = jest.spyOn(global.console, 'info');
consoleInfoSpy.mockImplementation(() => null);
const consoleLogSpy = jest.spyOn(global.console, 'log');
consoleLogSpy.mockImplementation(() => null);
});

afterEach(() => {
jest.restoreAllMocks();
jest.resetAllMocks();
});

describe('UIServer apis', () => {
let app: UIServer;
Expand Down Expand Up @@ -373,9 +383,6 @@ describe('UIServer apis', () => {

describe('/system', () => {
describe('/cluster-name', () => {
beforeEach(() => {
mockedK8sHelper.isInCluster = true;
});
it('responds with cluster name data from gke metadata', done => {
mockedFetch.mockImplementationOnce((url: string, _opts: any) =>
url === 'http://metadata/computeMetadata/v1/instance/attributes/cluster-name'
Expand Down Expand Up @@ -412,9 +419,6 @@ describe('UIServer apis', () => {
});
});
describe('/project-id', () => {
beforeEach(() => {
mockedK8sHelper.isInCluster = true;
});
it('responds with project id data from gke metadata', done => {
mockedFetch.mockImplementationOnce((url: string, _opts: any) =>
url === 'http://metadata/computeMetadata/v1/project/project-id'
Expand Down Expand Up @@ -446,10 +450,121 @@ describe('UIServer apis', () => {
});
});

// TODO: refractor k8s helper module so that api that interact with k8s can be
// mocked and tested. There is currently no way to mock k8s APIs as
// `k8s-helper.isInCluster` is a constant that is generated when the module is
// first loaded.
describe('/k8s/pod', () => {
let request: requests.SuperTest<requests.Test>;
beforeEach(() => {
app = new UIServer(loadConfigs(argv, {}));
request = requests(app.start());
});

it('asks for podname if not provided', done => {
request.get('/k8s/pod').expect(422, 'podname argument is required', done);
});

it('asks for podnamespace if not provided', done => {
request
.get('/k8s/pod?podname=test-pod')
.expect(422, 'podnamespace argument is required', done);
});

it('responds with pod info in JSON', done => {
const readPodSpy = jest.spyOn(K8S_TEST_EXPORT.k8sV1Client, 'readNamespacedPod');
readPodSpy.mockImplementation(() =>
Promise.resolve({
body: { kind: 'Pod' }, // only body is used
} as any),
);
request
.get('/k8s/pod?podname=test-pod&podnamespace=test-ns')
.expect(200, '{"kind":"Pod"}', err => {
expect(readPodSpy).toHaveBeenCalledWith('test-pod', 'test-ns');
done(err);
});
});

it('responds with error when failed to retrieve pod info', done => {
const readPodSpy = jest.spyOn(K8S_TEST_EXPORT.k8sV1Client, 'readNamespacedPod');
readPodSpy.mockImplementation(() =>
Promise.reject({
body: {
message: 'pod not found',
code: 404,
},
} as any),
);
const spyError = jest.spyOn(console, 'error').mockImplementation(() => null);
request
.get('/k8s/pod?podname=test-pod&podnamespace=test-ns')
.expect(500, 'Could not get pod test-pod in namespace test-ns: pod not found', () => {
expect(spyError).toHaveBeenCalledTimes(1);
done();
});
});
});

describe('/k8s/pod/events', () => {
let request: requests.SuperTest<requests.Test>;
beforeEach(() => {
app = new UIServer(loadConfigs(argv, {}));
request = requests(app.start());
});

it('asks for podname if not provided', done => {
request.get('/k8s/pod/events').expect(422, 'podname argument is required', done);
});

it('asks for podnamespace if not provided', done => {
request
.get('/k8s/pod/events?podname=test-pod')
.expect(422, 'podnamespace argument is required', done);
});

it('responds with pod info in JSON', done => {
const listEventSpy = jest.spyOn(K8S_TEST_EXPORT.k8sV1Client, 'listNamespacedEvent');
listEventSpy.mockImplementation(() =>
Promise.resolve({
body: { kind: 'EventList' }, // only body is used
} as any),
);
request
.get('/k8s/pod/events?podname=test-pod&podnamespace=test-ns')
.expect(200, '{"kind":"EventList"}', err => {
expect(listEventSpy).toHaveBeenCalledWith(
'test-ns',
undefined,
undefined,
undefined,
'involvedObject.namespace=test-ns,involvedObject.name=test-pod,involvedObject.kind=Pod',
);
done(err);
});
});

it('responds with error when failed to retrieve pod info', done => {
const listEventSpy = jest.spyOn(K8S_TEST_EXPORT.k8sV1Client, 'listNamespacedEvent');
listEventSpy.mockImplementation(() =>
Promise.reject({
body: {
message: 'no events',
code: 404,
},
} as any),
);
const spyError = jest.spyOn(console, 'error').mockImplementation(() => null);
request
.get('/k8s/pod/events?podname=test-pod&podnamespace=test-ns')
.expect(
500,
'Error when listing pod events for pod "test-pod" in "test-ns" namespace: no events',
err => {
expect(spyError).toHaveBeenCalledTimes(1);
done(err);
},
);
});
});

// TODO: Add integration tests for k8s helper related endpoints

// describe('/apps/tensorboard', () => {

Expand Down
4 changes: 4 additions & 0 deletions frontend/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
deleteTensorboardHandler,
} from './handlers/tensorboard';
import { getPodLogsHandler } from './handlers/pod-logs';
import { podInfoHandler, podEventsHandler } from './handlers/pod-info';
import { getClusterNameHandler, getProjectIdHandler } from './handlers/gke-metadata';
import { getAllowCustomVisualizationsHandler } from './handlers/vis';
import { getIndexHTMLHandler } from './handlers/index-html';
Expand Down Expand Up @@ -126,6 +127,9 @@ function createUIServer(options: UIConfigs) {

/** Pod logs */
registerHandler(app.get, '/k8s/pod/logs', getPodLogsHandler(options.argo, options.artifacts));
/** Pod info */
registerHandler(app.get, '/k8s/pod', podInfoHandler);
registerHandler(app.get, '/k8s/pod/events', podEventsHandler);

/** Cluster metadata (GKE only) */
registerHandler(app.get, '/system/cluster-name', getClusterNameHandler(options.gkeMetadata));
Expand Down
10 changes: 0 additions & 10 deletions frontend/server/handlers/gke-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,6 @@ export const getClusterNameHandler = (options: GkeMetadataConfigs) => {
};

const clusterNameHandler: Handler = async (_, res) => {
if (!k8sHelper.isInCluster) {
res.status(500).send('Not running in Kubernetes cluster.');
return;
}

const response = await fetch(
'http://metadata/computeMetadata/v1/instance/attributes/cluster-name',
{ headers: { 'Metadata-Flavor': 'Google' } },
Expand All @@ -52,11 +47,6 @@ export const getProjectIdHandler = (options: GkeMetadataConfigs) => {
};

const projectIdHandler: Handler = async (_, res) => {
if (!k8sHelper.isInCluster) {
res.status(500).send('Not running in Kubernetes cluster.');
return;
}

const response = await fetch('http://metadata/computeMetadata/v1/project/project-id', {
headers: { 'Metadata-Flavor': 'Google' },
});
Expand Down
66 changes: 66 additions & 0 deletions frontend/server/handlers/pod-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { Handler } from 'express';
import * as k8sHelper from '../k8s-helper';

/**
* podInfoHandler retrieves pod info and sends back as JSON format.
*/
export const podInfoHandler: Handler = async (req, res) => {
const { podname, podnamespace } = req.query;
if (!podname) {
// 422 status code "Unprocessable entity", refer to https://stackoverflow.com/a/42171674
res.status(422).send('podname argument is required');
return;
}
if (!podnamespace) {
res.status(422).send('podnamespace argument is required');
return;
}
const podName = decodeURIComponent(podname);
const podNamespace = decodeURIComponent(podnamespace);

const [pod, err] = await k8sHelper.getPod(podName, podNamespace);
if (err) {
const { message, additionalInfo } = err;
console.error(message, additionalInfo);
res.status(500).send(message);
return;
}
res.status(200).send(JSON.stringify(pod));
};

export const podEventsHandler: Handler = async (req, res) => {
const { podname, podnamespace } = req.query;
if (!podname) {
res.status(422).send('podname argument is required');
return;
}
if (!podnamespace) {
res.status(422).send('podnamespace argument is required');
return;
}
const podName = decodeURIComponent(podname);
const podNamespace = decodeURIComponent(podnamespace);

const [eventList, err] = await k8sHelper.listPodEvents(podName, podNamespace);
if (err) {
const { message, additionalInfo } = err;
console.error(message, additionalInfo);
res.status(500).send(message);
return;
}
res.status(200).send(JSON.stringify(eventList));
};
5 changes: 0 additions & 5 deletions frontend/server/handlers/pod-logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,6 @@ export function getPodLogsHandler(
);

return async (req, res) => {
if (!k8sHelper.isInCluster) {
res.status(500).send('Cannot talk to Kubernetes master');
return;
}

if (!req.query.podname) {
res.status(404).send('podname argument is required');
return;
Expand Down
15 changes: 0 additions & 15 deletions frontend/server/handlers/tensorboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,6 @@ import { ViewerTensorboardConfig } from '../configs';
* handler expects a query string `logdir`.
*/
export const getTensorboardHandler: Handler = async (req, res) => {
if (!k8sHelper.isInCluster) {
res.status(500).send('Cannot talk to Kubernetes master');
return;
}

if (!req.query.logdir) {
res.status(404).send('logdir argument is required');
return;
Expand All @@ -49,11 +44,6 @@ export const getTensorboardHandler: Handler = async (req, res) => {
*/
export function getCreateTensorboardHandler(tensorboardConfig: ViewerTensorboardConfig): Handler {
return async (req, res) => {
if (!k8sHelper.isInCluster) {
res.status(500).send('Cannot talk to Kubernetes master');
return;
}

if (!req.query.logdir) {
res.status(404).send('logdir argument is required');
return;
Expand Down Expand Up @@ -87,11 +77,6 @@ export function getCreateTensorboardHandler(tensorboardConfig: ViewerTensorboard
* `logdir` in the request.
*/
export const deleteTensorboardHandler: Handler = async (req, res) => {
if (!k8sHelper.isInCluster) {
res.status(500).send('Cannot talk to Kubernetes master');
return;
}

if (!req.query.logdir) {
res.status(404).send('logdir argument is required');
return;
Expand Down
Loading

0 comments on commit f882f36

Please sign in to comment.