[UI] authorize tensorboard actions (#3639)
* Authorization service proto * implement auth service * Add unit tests * Generate auth api client * Authorization checks for tensorboard apis * UI Server authorization checks * Clean up error parsing * Revert changes * Fix portable-fetch not found bug * Fix unit test * Include portable-fetch required by api client * Fix portable-fetch module import error * Fix portable-fetch again * Add unit tests * Address CR comments * add unit test for header * Update readme
This commit is contained in:
parent
a5b3e7e3f0
commit
cb71eed5f9
|
|
@ -61,6 +61,7 @@ Then it depends on what you want to develop:
|
|||
| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| Client UI | `NAMESPACE=kubeflow npm run start:proxy` | |
|
||||
| Client UI + Node server | `NAMESPACE=kubeflow npm run start:proxy-and-server` | You need to rerun the script every time you edit node server code. |
|
||||
| Client UI + Node server (debug mode) | `NAMESPACE=kubeflow npm run start:proxy-and-server-inspect` | Same as above, and you can use chrome to debug the server. |
|
||||
|
||||
## Unit testing FAQ
|
||||
There are a few typees of tests during presubmit:
|
||||
|
|
|
|||
|
|
@ -29,13 +29,14 @@
|
|||
},
|
||||
"scripts": {
|
||||
"analyze-bundle": "node analyze_bundle.js",
|
||||
"apis": "npm run apis:experiment && npm run apis:job && npm run apis:pipeline && npm run apis:run && npm run apis:filter && npm run apis:visualization",
|
||||
"apis": "npm run apis:experiment && npm run apis:job && npm run apis:pipeline && npm run apis:run && npm run apis:filter && npm run apis:visualization && npm run apis:auth",
|
||||
"apis:experiment": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/experiment.swagger.json -l typescript-fetch -o ./src/apis/experiment -c ./swagger-config.json",
|
||||
"apis:job": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/job.swagger.json -l typescript-fetch -o ./src/apis/job -c ./swagger-config.json",
|
||||
"apis:pipeline": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/pipeline.swagger.json -l typescript-fetch -o ./src/apis/pipeline -c ./swagger-config.json",
|
||||
"apis:run": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/run.swagger.json -l typescript-fetch -o ./src/apis/run -c ./swagger-config.json",
|
||||
"apis:filter": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/filter.swagger.json -l typescript-fetch -o ./src/apis/filter -c ./swagger-config.json",
|
||||
"apis:visualization": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/visualization.swagger.json -l typescript-fetch -o ./src/apis/visualization -c ./swagger-config.json",
|
||||
"apis:auth": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/auth.swagger.json -l typescript-fetch -o ./server/src/generated/apis/auth -c ./swagger-config.json",
|
||||
"build": "npm run lint && EXTEND_ESLINT=true react-scripts build",
|
||||
"docker": "COMMIT_HASH=`git rev-parse HEAD`; docker build -q -t ml-pipelines-frontend:${COMMIT_HASH} --build-arg COMMIT_HASH=${COMMIT_HASH} --build-arg DATE=\"`date -u`\" -f Dockerfile ..",
|
||||
"eject": "react-scripts eject",
|
||||
|
|
@ -45,9 +46,11 @@
|
|||
"lint": "eslint --ext js,ts,tsx src",
|
||||
"mock:api": "ts-node-dev -O '{\"module\": \"commonjs\"}' --project mock-backend/tsconfig.json mock-backend/mock-api-server.ts 3001",
|
||||
"mock:server": "node server/dist/server.js build",
|
||||
"mock:server:inspect": "node inspect server/dist/server.js build",
|
||||
"postinstall": "cd ./server && npm ci && cd ../mock-backend && npm ci",
|
||||
"start:proxy": "./scripts/start-proxy.sh",
|
||||
"start:proxy-and-server": "./scripts/start-proxy-and-server.sh",
|
||||
"start:proxy-and-server-inspect": "./scripts/start-proxy-and-server.sh --inspect",
|
||||
"start": "EXTEND_ESLINT=true react-scripts start",
|
||||
"sync-backend-sample-config": "node scripts/sync-backend-sample-config.js",
|
||||
"test": "react-scripts test",
|
||||
|
|
|
|||
|
|
@ -37,4 +37,8 @@ kubectl port-forward -n $NAMESPACE svc/ml-pipeline 3002:8888 &
|
|||
kubectl port-forward -n $NAMESPACE svc/minio-service 9000:9000 &
|
||||
export MINIO_HOST=localhost
|
||||
export MINIO_NAMESPACE=
|
||||
ML_PIPELINE_SERVICE_PORT=3002 npm run mock:server 3001
|
||||
if [ "$1" == "--inspect" ]; then
|
||||
ML_PIPELINE_SERVICE_PORT=3002 npm run mock:server:inspect 3001
|
||||
else
|
||||
ML_PIPELINE_SERVICE_PORT=3002 npm run mock:server 3001
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ jest.mock('node-fetch');
|
|||
jest.mock('@google-cloud/storage');
|
||||
jest.mock('./minio-helper');
|
||||
|
||||
// TODO: move sections of tests here to individual files in `frontend/server/integration-tests/`
|
||||
// for better organization and shorter/more focused tests.
|
||||
|
||||
const mockedFetch: jest.Mock = fetch as any;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -627,10 +630,11 @@ describe('UIServer apis', () => {
|
|||
});
|
||||
|
||||
describe('/apps/tensorboard', () => {
|
||||
let request: requests.SuperTest<requests.Test>;
|
||||
let k8sGetCustomObjectSpy: jest.SpyInstance;
|
||||
let k8sDeleteCustomObjectSpy: jest.SpyInstance;
|
||||
let k8sCreateCustomObjectSpy: jest.SpyInstance;
|
||||
let kfpApiServer: Server;
|
||||
|
||||
function newGetTensorboardResponse({
|
||||
name = 'viewer-example',
|
||||
logDir = 'log-dir-example',
|
||||
|
|
@ -655,9 +659,8 @@ describe('UIServer apis', () => {
|
|||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
request = requests(app.start());
|
||||
k8sGetCustomObjectSpy = jest.spyOn(
|
||||
K8S_TEST_EXPORT.k8sV1CustomObjectClient,
|
||||
'getNamespacedCustomObject',
|
||||
|
|
@ -672,18 +675,29 @@ describe('UIServer apis', () => {
|
|||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (kfpApiServer) {
|
||||
kfpApiServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('requires logdir for get tensorboard', done => {
|
||||
request.get('/apps/tensorboard').expect(404, 'logdir argument is required', done);
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.get('/apps/tensorboard')
|
||||
.expect(400, 'logdir argument is required', done);
|
||||
});
|
||||
|
||||
it('requires namespace for get tensorboard', done => {
|
||||
request
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.get('/apps/tensorboard?logdir=some-log-dir')
|
||||
.expect(404, 'namespace argument is required', done);
|
||||
.expect(400, 'namespace argument is required', done);
|
||||
});
|
||||
|
||||
it('does not crash with a weird query', done => {
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
k8sGetCustomObjectSpy.mockImplementation(() =>
|
||||
Promise.resolve(newGetTensorboardResponse()),
|
||||
);
|
||||
|
|
@ -691,10 +705,79 @@ describe('UIServer apis', () => {
|
|||
// exception, so this can verify handler doesn't do extra
|
||||
// decodeURIComponent on queries.
|
||||
const weirdLogDir = encodeURIComponent('%2');
|
||||
request.get(`/apps/tensorboard?logdir=${weirdLogDir}&namespace=test-ns`).expect(200, done);
|
||||
requests(app.start())
|
||||
.get(`/apps/tensorboard?logdir=${weirdLogDir}&namespace=test-ns`)
|
||||
.expect(200, done);
|
||||
});
|
||||
|
||||
it('authorizes user requests from KFP auth api', done => {
|
||||
app = new UIServer(loadConfigs(argv, { ENABLE_AUTHZ: 'true' }));
|
||||
const appKfpApi = express();
|
||||
const receivedHeaders: any[] = [];
|
||||
appKfpApi.get('/apis/v1beta1/auth', (req, res) => {
|
||||
receivedHeaders.push(req.headers);
|
||||
res.status(200).send('{}'); // Authorized
|
||||
});
|
||||
kfpApiServer = appKfpApi.listen(3001);
|
||||
k8sGetCustomObjectSpy.mockImplementation(() =>
|
||||
Promise.resolve(newGetTensorboardResponse()),
|
||||
);
|
||||
requests(app.start())
|
||||
.get(`/apps/tensorboard?logdir=some-log-dir&namespace=test-ns`)
|
||||
.set('x-goog-authenticated-user-email', 'accounts.google.com:user@google.com')
|
||||
.expect(200, err => {
|
||||
expect(receivedHeaders).toHaveLength(1);
|
||||
expect(receivedHeaders[0]).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"accept": "*/*",
|
||||
"accept-encoding": "gzip,deflate",
|
||||
"connection": "close",
|
||||
"host": "localhost:3001",
|
||||
"user-agent": "node-fetch/1.0 (+https://github.com/bitinn/node-fetch)",
|
||||
"x-goog-authenticated-user-email": "accounts.google.com:user@google.com",
|
||||
}
|
||||
`);
|
||||
done(err);
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects user requests when KFP auth api rejected', done => {
|
||||
const errorSpy = jest.spyOn(console, 'error');
|
||||
errorSpy.mockImplementation();
|
||||
app = new UIServer(loadConfigs(argv, { ENABLE_AUTHZ: 'true' }));
|
||||
const appKfpApi = express();
|
||||
appKfpApi.get('/apis/v1beta1/auth', (_, res) => {
|
||||
res.status(400).send(
|
||||
JSON.stringify({
|
||||
error: 'User xxx is not unauthorized to list viewers',
|
||||
details: ['unauthorized', 'callstack'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
kfpApiServer = appKfpApi.listen(3001);
|
||||
k8sGetCustomObjectSpy.mockImplementation(() =>
|
||||
Promise.resolve(newGetTensorboardResponse()),
|
||||
);
|
||||
requests(app.start())
|
||||
.get(`/apps/tensorboard?logdir=some-log-dir&namespace=test-ns`)
|
||||
.expect(
|
||||
401,
|
||||
'User is not authorized to GET VIEWERS in namespace test-ns: User xxx is not unauthorized to list viewers',
|
||||
err => {
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
errorSpy,
|
||||
).toHaveBeenCalledWith(
|
||||
'User is not authorized to GET VIEWERS in namespace test-ns: User xxx is not unauthorized to list viewers',
|
||||
['unauthorized', 'callstack'],
|
||||
);
|
||||
done(err);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('gets tensorboard url and version', done => {
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
k8sGetCustomObjectSpy.mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
newGetTensorboardResponse({
|
||||
|
|
@ -705,7 +788,7 @@ describe('UIServer apis', () => {
|
|||
),
|
||||
);
|
||||
|
||||
request
|
||||
requests(app.start())
|
||||
.get(`/apps/tensorboard?logdir=${encodeURIComponent('log-dir-1')}&namespace=test-ns`)
|
||||
.expect(
|
||||
200,
|
||||
|
|
@ -732,19 +815,24 @@ describe('UIServer apis', () => {
|
|||
|
||||
describe('post (create)', () => {
|
||||
it('requires logdir', done => {
|
||||
request.post('/apps/tensorboard').expect(404, 'logdir argument is required', done);
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.post('/apps/tensorboard')
|
||||
.expect(400, 'logdir argument is required', done);
|
||||
});
|
||||
|
||||
it('requires namespace', done => {
|
||||
request
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.post('/apps/tensorboard?logdir=some-log-dir')
|
||||
.expect(404, 'namespace argument is required', done);
|
||||
.expect(400, 'namespace argument is required', done);
|
||||
});
|
||||
|
||||
it('requires tfversion', done => {
|
||||
request
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.post('/apps/tensorboard?logdir=some-log-dir&namespace=test-ns')
|
||||
.expect(404, 'tfversion (tensorflow version) argument is required', done);
|
||||
.expect(400, 'tfversion (tensorflow version) argument is required', done);
|
||||
});
|
||||
|
||||
it('creates tensorboard viewer custom object and waits for it', done => {
|
||||
|
|
@ -768,7 +856,8 @@ describe('UIServer apis', () => {
|
|||
});
|
||||
k8sCreateCustomObjectSpy.mockImplementation(() => Promise.resolve());
|
||||
|
||||
request
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.post(
|
||||
`/apps/tensorboard?logdir=${encodeURIComponent(
|
||||
'log-dir-1',
|
||||
|
|
@ -845,7 +934,8 @@ describe('UIServer apis', () => {
|
|||
);
|
||||
k8sCreateCustomObjectSpy.mockImplementation(() => Promise.resolve());
|
||||
|
||||
request
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.post(
|
||||
`/apps/tensorboard?logdir=${encodeURIComponent(
|
||||
'log-dir-1',
|
||||
|
|
@ -853,7 +943,7 @@ describe('UIServer apis', () => {
|
|||
)
|
||||
.expect(
|
||||
500,
|
||||
`Failed to start Tensorboard app: Error: There's already an existing tensorboard instance with a different version 2.1.0`,
|
||||
`Failed to start Tensorboard app: There's already an existing tensorboard instance with a different version 2.1.0`,
|
||||
err => {
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
done(err);
|
||||
|
|
@ -873,7 +963,8 @@ describe('UIServer apis', () => {
|
|||
);
|
||||
k8sCreateCustomObjectSpy.mockImplementation(() => Promise.resolve());
|
||||
|
||||
request
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.post(
|
||||
`/apps/tensorboard?logdir=${encodeURIComponent(
|
||||
'log-dir-1',
|
||||
|
|
@ -889,13 +980,17 @@ describe('UIServer apis', () => {
|
|||
|
||||
describe('delete', () => {
|
||||
it('requires logdir', done => {
|
||||
request.delete('/apps/tensorboard').expect(404, 'logdir argument is required', done);
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.delete('/apps/tensorboard')
|
||||
.expect(400, 'logdir argument is required', done);
|
||||
});
|
||||
|
||||
it('requires namespace', done => {
|
||||
request
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.delete('/apps/tensorboard?logdir=some-log-dir')
|
||||
.expect(404, 'namespace argument is required', done);
|
||||
.expect(400, 'namespace argument is required', done);
|
||||
});
|
||||
|
||||
it('deletes tensorboard viewer custom object', done => {
|
||||
|
|
@ -910,7 +1005,8 @@ describe('UIServer apis', () => {
|
|||
);
|
||||
k8sDeleteCustomObjectSpy.mockImplementation(() => Promise.resolve());
|
||||
|
||||
request
|
||||
app = new UIServer(loadConfigs(argv, {}));
|
||||
requests(app.start())
|
||||
.delete(`/apps/tensorboard?logdir=${encodeURIComponent('log-dir-1')}&namespace=test-ns`)
|
||||
.expect(200, 'Tensorboard deleted.', err => {
|
||||
expect(k8sDeleteCustomObjectSpy.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
|
|
|
|||
|
|
@ -24,11 +24,7 @@ import {
|
|||
getArtifactsProxyHandler,
|
||||
getArtifactServiceGetter,
|
||||
} from './handlers/artifacts';
|
||||
import {
|
||||
getCreateTensorboardHandler,
|
||||
getTensorboardHandler,
|
||||
deleteTensorboardHandler,
|
||||
} from './handlers/tensorboard';
|
||||
import { getTensorboardHandlers } from './handlers/tensorboard';
|
||||
import { getPodLogsHandler } from './handlers/pod-logs';
|
||||
import { podInfoHandler, podEventsHandler } from './handlers/pod-info';
|
||||
import { getClusterNameHandler, getProjectIdHandler } from './handlers/gke-metadata';
|
||||
|
|
@ -129,13 +125,17 @@ function createUIServer(options: UIConfigs) {
|
|||
registerHandler(app.get, '/artifacts/get', getArtifactsHandler(options.artifacts));
|
||||
|
||||
/** Tensorboard viewer */
|
||||
registerHandler(app.get, '/apps/tensorboard', getTensorboardHandler);
|
||||
registerHandler(app.delete, '/apps/tensorboard', deleteTensorboardHandler);
|
||||
registerHandler(
|
||||
app.post,
|
||||
'/apps/tensorboard',
|
||||
getCreateTensorboardHandler(options.viewer.tensorboard),
|
||||
);
|
||||
const {
|
||||
get: tensorboardGetHandler,
|
||||
create: tensorboardCreateHandler,
|
||||
delete: tensorboardDeleteHandler,
|
||||
} = getTensorboardHandlers(options.viewer.tensorboard, {
|
||||
apiServerAddress,
|
||||
authzEnabled: options.auth.enabled,
|
||||
});
|
||||
registerHandler(app.get, '/apps/tensorboard', tensorboardGetHandler);
|
||||
registerHandler(app.delete, '/apps/tensorboard', tensorboardDeleteHandler);
|
||||
registerHandler(app.post, '/apps/tensorboard', tensorboardCreateHandler);
|
||||
|
||||
/** Pod logs */
|
||||
registerHandler(app.get, '/k8s/pod/logs', getPodLogsHandler(options.argo, options.artifacts));
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ export function loadConfigs(argv: string[], env: ProcessEnv): UIConfigs {
|
|||
ARGO_ARCHIVE_PREFIX = 'logs',
|
||||
/** Disables GKE metadata endpoint. */
|
||||
DISABLE_GKE_METADATA = 'false',
|
||||
/** Enable authorization checks for multi user mode. */
|
||||
ENABLE_AUTHZ = 'false',
|
||||
/** Deployment type. */
|
||||
DEPLOYMENT: DEPLOYMENT_STR = '',
|
||||
} = env;
|
||||
|
|
@ -158,6 +160,9 @@ export function loadConfigs(argv: string[], env: ProcessEnv): UIConfigs {
|
|||
gkeMetadata: {
|
||||
disabled: asBool(DISABLE_GKE_METADATA),
|
||||
},
|
||||
auth: {
|
||||
enabled: asBool(ENABLE_AUTHZ),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -216,6 +221,9 @@ export interface ServerConfigs {
|
|||
export interface GkeMetadataConfigs {
|
||||
disabled: boolean;
|
||||
}
|
||||
export interface AuthorizationConfigs {
|
||||
enabled: boolean;
|
||||
}
|
||||
export interface UIConfigs {
|
||||
server: ServerConfigs;
|
||||
artifacts: {
|
||||
|
|
@ -230,4 +238,5 @@ export interface UIConfigs {
|
|||
viewer: ViewerConfigs;
|
||||
pipeline: PipelineConfigs;
|
||||
gkeMetadata: GkeMetadataConfigs;
|
||||
auth: AuthorizationConfigs;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,7 +239,6 @@ function getGCSArtifactHandler(options: { key: string; bucket: string }, peek: n
|
|||
};
|
||||
}
|
||||
|
||||
const AUTH_EMAIL_HEADER = 'x-goog-authenticated-user-email';
|
||||
const ARTIFACTS_PROXY_DEFAULTS = {
|
||||
serviceName: 'ml-pipeline-ui-artifact',
|
||||
servicePort: '80',
|
||||
|
|
|
|||
|
|
@ -11,58 +11,127 @@
|
|||
// 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 { Handler, Request, Response } from 'express';
|
||||
import * as k8sHelper from '../k8s-helper';
|
||||
import { ViewerTensorboardConfig } from '../configs';
|
||||
import {
|
||||
AuthServiceApi,
|
||||
AuthorizeRequestResources,
|
||||
AuthorizeRequestVerb,
|
||||
} from '../src/generated/apis/auth';
|
||||
import portableFetch from 'portable-fetch';
|
||||
import { parseError } from '../utils';
|
||||
|
||||
/**
|
||||
* A handler which retrieve the endpoint for a tensorboard instance. The
|
||||
* handler expects a query string `logdir`.
|
||||
*/
|
||||
export const getTensorboardHandler: Handler = async (req, res) => {
|
||||
const { logdir, namespace } = req.query;
|
||||
if (!logdir) {
|
||||
res.status(404).send('logdir argument is required');
|
||||
return;
|
||||
}
|
||||
if (!namespace) {
|
||||
res.status(404).send('namespace argument is required');
|
||||
return;
|
||||
}
|
||||
|
||||
async function authorize(
|
||||
req: Request,
|
||||
res: Response,
|
||||
authService: AuthServiceApi,
|
||||
{
|
||||
resources,
|
||||
verb,
|
||||
namespace,
|
||||
}: { resources: AuthorizeRequestResources; verb: AuthorizeRequestVerb; namespace: string },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
res.send(await k8sHelper.getTensorboardInstance(logdir, namespace));
|
||||
// Resources and verb are string enums, they are used as string here, that
|
||||
// requires a force type conversion. If we generated client should accept
|
||||
// enums instead.
|
||||
await authService.authorize(namespace, resources as any, verb as any, {
|
||||
// Pass authentication header.
|
||||
// TODO: parameterize the header.
|
||||
headers: { [AUTH_EMAIL_HEADER]: req.headers[AUTH_EMAIL_HEADER] },
|
||||
});
|
||||
console.debug(`Authorized to ${verb} ${resources} in namespace ${namespace}.`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to list Tensorboard pods: ', err?.body || err);
|
||||
res.status(500).send(`Failed to list Tensorboard pods: ${err}`);
|
||||
const details = await parseError(err);
|
||||
const message = `User is not authorized to ${verb} ${resources} in namespace ${namespace}: ${details.message}`;
|
||||
console.error(message, details.additionalInfo);
|
||||
res.status(401).send(message);
|
||||
}
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a handler which will create a tensorboard viewer CRD, waits for the
|
||||
* tensorboard instance to be ready, and return the endpoint to the instance.
|
||||
* The handler expects the following query strings in the request:
|
||||
* - `logdir`
|
||||
* - `tfversion`
|
||||
* @param tensorboardConfig The configuration for Tensorboard.
|
||||
*/
|
||||
export function getCreateTensorboardHandler(tensorboardConfig: ViewerTensorboardConfig): Handler {
|
||||
return async (req, res) => {
|
||||
const { logdir, namespace, tfversion } = req.query;
|
||||
export const getTensorboardHandlers = (
|
||||
tensorboardConfig: ViewerTensorboardConfig,
|
||||
otherConfig: { apiServerAddress: string; authzEnabled: boolean },
|
||||
): { get: Handler; create: Handler; delete: Handler } => {
|
||||
const { apiServerAddress, authzEnabled } = otherConfig;
|
||||
console.log('api server address ' + apiServerAddress);
|
||||
// TODO: Use portable-fetch instead of node-fetch in other parts too. The generated api here only
|
||||
// supports portable-fetch.
|
||||
const authService = new AuthServiceApi(
|
||||
{ basePath: apiServerAddress },
|
||||
undefined,
|
||||
portableFetch as any,
|
||||
);
|
||||
/**
|
||||
* A handler which retrieve the endpoint for a tensorboard instance. The
|
||||
* handler expects a query string `logdir`.
|
||||
*/
|
||||
const get: Handler = async (req, res) => {
|
||||
const { logdir, namespace } = req.query;
|
||||
if (!logdir) {
|
||||
res.status(404).send('logdir argument is required');
|
||||
res.status(400).send('logdir argument is required');
|
||||
return;
|
||||
}
|
||||
if (!namespace) {
|
||||
res.status(404).send('namespace argument is required');
|
||||
return;
|
||||
}
|
||||
if (!tfversion) {
|
||||
res.status(404).send('tfversion (tensorflow version) argument is required');
|
||||
res.status(400).send('namespace argument is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (authzEnabled) {
|
||||
const authorized = await authorize(req, res, authService, {
|
||||
verb: AuthorizeRequestVerb.GET,
|
||||
resources: AuthorizeRequestResources.VIEWERS,
|
||||
namespace,
|
||||
});
|
||||
if (!authorized) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.send(await k8sHelper.getTensorboardInstance(logdir, namespace));
|
||||
} catch (err) {
|
||||
const details = await parseError(err);
|
||||
console.error(`Failed to list Tensorboard pods: ${details.message}`, details.additionalInfo);
|
||||
res.status(500).send(`Failed to list Tensorboard pods: ${details.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* A handler which will create a tensorboard viewer CRD, waits for the
|
||||
* tensorboard instance to be ready, and return the endpoint to the instance.
|
||||
* The handler expects the following query strings in the request:
|
||||
* - `logdir`
|
||||
* - `tfversion`
|
||||
*/
|
||||
const create: Handler = async (req, res) => {
|
||||
const { logdir, namespace, tfversion } = req.query;
|
||||
if (!logdir) {
|
||||
res.status(400).send('logdir argument is required');
|
||||
return;
|
||||
}
|
||||
if (!namespace) {
|
||||
res.status(400).send('namespace argument is required');
|
||||
return;
|
||||
}
|
||||
if (!tfversion) {
|
||||
res.status(400).send('tfversion (tensorflow version) argument is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (authzEnabled) {
|
||||
const authorized = await authorize(req, res, authService, {
|
||||
verb: AuthorizeRequestVerb.CREATE,
|
||||
resources: AuthorizeRequestResources.VIEWERS,
|
||||
namespace,
|
||||
});
|
||||
if (!authorized) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await k8sHelper.newTensorboardInstance(
|
||||
logdir,
|
||||
namespace,
|
||||
|
|
@ -77,32 +146,52 @@ export function getCreateTensorboardHandler(tensorboardConfig: ViewerTensorboard
|
|||
);
|
||||
res.send(tensorboardAddress);
|
||||
} catch (err) {
|
||||
console.error('Failed to start Tensorboard app: ', err?.body || err);
|
||||
res.status(500).send(`Failed to start Tensorboard app: ${err}`);
|
||||
const details = await parseError(err);
|
||||
console.error(`Failed to start Tensorboard app: ${details.message}`, details.additionalInfo);
|
||||
res.status(500).send(`Failed to start Tensorboard app: ${details.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A handler that deletes a tensorboard viewer. The handler expects query string
|
||||
* `logdir` in the request.
|
||||
*/
|
||||
export const deleteTensorboardHandler: Handler = async (req, res) => {
|
||||
const { logdir, namespace } = req.query;
|
||||
if (!logdir) {
|
||||
res.status(404).send('logdir argument is required');
|
||||
return;
|
||||
}
|
||||
if (!namespace) {
|
||||
res.status(404).send('namespace argument is required');
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* A handler that deletes a tensorboard viewer. The handler expects query string
|
||||
* `logdir` in the request.
|
||||
*/
|
||||
const deleteHandler: Handler = async (req, res) => {
|
||||
const { logdir, namespace } = req.query;
|
||||
if (!logdir) {
|
||||
res.status(400).send('logdir argument is required');
|
||||
return;
|
||||
}
|
||||
if (!namespace) {
|
||||
res.status(400).send('namespace argument is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await k8sHelper.deleteTensorboardInstance(logdir, namespace);
|
||||
res.send('Tensorboard deleted.');
|
||||
} catch (err) {
|
||||
console.error('Failed to delete Tensorboard app: ', err?.body || err);
|
||||
res.status(500).send(`Failed to delete Tensorboard app: ${err}`);
|
||||
}
|
||||
try {
|
||||
if (authzEnabled) {
|
||||
const authorized = await authorize(req, res, authService, {
|
||||
verb: AuthorizeRequestVerb.DELETE,
|
||||
resources: AuthorizeRequestResources.VIEWERS,
|
||||
namespace,
|
||||
});
|
||||
if (!authorized) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await k8sHelper.deleteTensorboardInstance(logdir, namespace);
|
||||
res.send('Tensorboard deleted.');
|
||||
} catch (err) {
|
||||
const details = await parseError(err);
|
||||
console.error(`Failed to delete Tensorboard app: ${details.message}`, details.additionalInfo);
|
||||
res.status(500).send(`Failed to delete Tensorboard app: ${details.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get,
|
||||
create,
|
||||
delete: deleteHandler,
|
||||
};
|
||||
};
|
||||
|
||||
const AUTH_EMAIL_HEADER = 'x-goog-authenticated-user-email';
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
import * as crypto from 'crypto-js';
|
||||
import * as fs from 'fs';
|
||||
import { PartialArgoWorkflow } from './workflow-helper';
|
||||
import { parseError } from './utils';
|
||||
|
||||
// If this is running inside a k8s Pod, its namespace should be written at this
|
||||
// path, this is also how we can tell whether we're running in the cluster.
|
||||
|
|
@ -233,7 +234,7 @@ export async function getPod(
|
|||
const { body } = await k8sV1Client.readNamespacedPod(podName, podNamespace);
|
||||
return [body, undefined];
|
||||
} catch (error) {
|
||||
const { message, additionalInfo } = parseK8sError(error);
|
||||
const { message, additionalInfo } = await parseError(error);
|
||||
const userMessage = `Could not get pod ${podName} in namespace ${podNamespace}: ${message}`;
|
||||
return [undefined, { message: userMessage, additionalInfo }];
|
||||
}
|
||||
|
|
@ -258,7 +259,7 @@ export async function listPodEvents(
|
|||
);
|
||||
return [body, undefined];
|
||||
} catch (error) {
|
||||
const { message, additionalInfo } = parseK8sError(error);
|
||||
const { message, additionalInfo } = await parseError(error);
|
||||
const userMessage = `Error when listing pod events for pod "${podName}" in "${podNamespace}" namespace: ${message}`;
|
||||
return [undefined, { message: userMessage, additionalInfo }];
|
||||
}
|
||||
|
|
@ -307,48 +308,6 @@ export async function getK8sSecret(name: string, key: string) {
|
|||
return buff.toString('ascii');
|
||||
}
|
||||
|
||||
const UNKOWN_ERROR = {
|
||||
message: 'Unknown error',
|
||||
additionalInfo: 'Unknown error',
|
||||
};
|
||||
function parseK8sError(error: any): { message: string; additionalInfo: any } {
|
||||
try {
|
||||
if (!error) {
|
||||
return UNKOWN_ERROR;
|
||||
} else if (typeof error === 'string') {
|
||||
return {
|
||||
message: error,
|
||||
additionalInfo: error,
|
||||
};
|
||||
} else if (error.body) {
|
||||
// Kubernetes client http error has body with all the info.
|
||||
// Example error.body
|
||||
// {
|
||||
// kind: 'Status',
|
||||
// apiVersion: 'v1',
|
||||
// metadata: {},
|
||||
// status: 'Failure',
|
||||
// message: 'pods "test-pod" not found',
|
||||
// reason: 'NotFound',
|
||||
// details: { name: 'test-pod', kind: 'pods' },
|
||||
// code: 404
|
||||
// }
|
||||
return {
|
||||
message: error.body.message || UNKOWN_ERROR.message,
|
||||
additionalInfo: error.body,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
message: error.message || UNKOWN_ERROR.message,
|
||||
additionalInfo: error,
|
||||
};
|
||||
}
|
||||
} catch (parsingError) {
|
||||
console.error('There was a parsing error: ', parsingError);
|
||||
return UNKOWN_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
export const TEST_ONLY = {
|
||||
k8sV1Client,
|
||||
k8sV1CustomObjectClient,
|
||||
|
|
|
|||
|
|
@ -2694,6 +2694,14 @@
|
|||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
|
||||
},
|
||||
"encoding": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
|
||||
"integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=",
|
||||
"requires": {
|
||||
"iconv-lite": "~0.4.13"
|
||||
}
|
||||
},
|
||||
"end-of-stream": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
|
||||
|
|
@ -3784,8 +3792,7 @@
|
|||
"is-stream": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
|
||||
"integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=",
|
||||
"dev": true
|
||||
"integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
|
||||
},
|
||||
"is-stream-ended": {
|
||||
"version": "0.1.4",
|
||||
|
|
@ -7036,6 +7043,26 @@
|
|||
"integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==",
|
||||
"dev": true
|
||||
},
|
||||
"portable-fetch": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/portable-fetch/-/portable-fetch-3.0.0.tgz",
|
||||
"integrity": "sha1-PL9KptvFpXNLQcBBnJJzMTv9mtg=",
|
||||
"requires": {
|
||||
"node-fetch": "^1.0.1",
|
||||
"whatwg-fetch": ">=0.10.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"node-fetch": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
|
||||
"integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
|
||||
"requires": {
|
||||
"encoding": "^0.1.11",
|
||||
"is-stream": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"posix-character-classes": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
|
||||
|
|
@ -7046,6 +7073,12 @@
|
|||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
|
||||
"integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ="
|
||||
},
|
||||
"prettier": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
|
||||
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
|
||||
"dev": true
|
||||
},
|
||||
"pretty-format": {
|
||||
"version": "24.9.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz",
|
||||
|
|
@ -8390,6 +8423,11 @@
|
|||
"iconv-lite": "0.4.24"
|
||||
}
|
||||
},
|
||||
"whatwg-fetch": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz",
|
||||
"integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q=="
|
||||
},
|
||||
"whatwg-mimetype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"minio": "^7.0.0",
|
||||
"node-fetch": "^2.1.2",
|
||||
"peek-stream": "^1.1.3",
|
||||
"portable-fetch": "^3.0.0",
|
||||
"tar-stream": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -28,6 +29,7 @@
|
|||
"@types/tar": "^4.0.3",
|
||||
"@types/tar-stream": "^1.6.1",
|
||||
"jest": "^25.3.0",
|
||||
"prettier": "1.19.1",
|
||||
"supertest": "^4.0.2",
|
||||
"ts-jest": "^25.2.1",
|
||||
"tslint": "^5.20.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
wwwroot/*.js
|
||||
node_modules
|
||||
typings
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# Swagger Codegen Ignore
|
||||
# Generated by swagger-codegen https://github.com/swagger-api/swagger-codegen
|
||||
|
||||
# Use this file to prevent files from being overwritten by the generator.
|
||||
# The patterns follow closely to .gitignore or .dockerignore.
|
||||
|
||||
# As an example, the C# client generator defines ApiClient.cs.
|
||||
# You can make changes and tell Swagger Codgen to ignore just this file by uncommenting the following line:
|
||||
#ApiClient.cs
|
||||
|
||||
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
|
||||
#foo/*/qux
|
||||
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
|
||||
|
||||
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
|
||||
#foo/**/qux
|
||||
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
|
||||
|
||||
# You can also negate patterns with an exclamation (!).
|
||||
# For example, you can ignore all files in a docs folder with the file extension .md:
|
||||
#docs/*.md
|
||||
# Then explicitly reverse the ignore rule for a single file:
|
||||
#!docs/README.md
|
||||
|
|
@ -0,0 +1 @@
|
|||
2.4.7
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
/// <reference path="./custom.d.ts" />
|
||||
// tslint:disable
|
||||
/**
|
||||
* backend/api/auth.proto
|
||||
* No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
|
||||
*
|
||||
* OpenAPI spec version: version not set
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by the swagger code generator program.
|
||||
* https://github.com/swagger-api/swagger-codegen.git
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
import * as url from 'url';
|
||||
import * as portableFetch from 'portable-fetch';
|
||||
import { Configuration } from './configuration';
|
||||
|
||||
const BASE_PATH = 'http://localhost'.replace(/\/+$/, '');
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const COLLECTION_FORMATS = {
|
||||
csv: ',',
|
||||
ssv: ' ',
|
||||
tsv: '\t',
|
||||
pipes: '|',
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface FetchAPI
|
||||
*/
|
||||
export interface FetchAPI {
|
||||
(url: string, init?: any): Promise<Response>;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface FetchArgs
|
||||
*/
|
||||
export interface FetchArgs {
|
||||
url: string;
|
||||
options: any;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @class BaseAPI
|
||||
*/
|
||||
export class BaseAPI {
|
||||
protected configuration: Configuration;
|
||||
|
||||
constructor(
|
||||
configuration?: Configuration,
|
||||
protected basePath: string = BASE_PATH,
|
||||
protected fetch: FetchAPI = portableFetch,
|
||||
) {
|
||||
if (configuration) {
|
||||
this.configuration = configuration;
|
||||
this.basePath = configuration.basePath || this.basePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @class RequiredError
|
||||
* @extends {Error}
|
||||
*/
|
||||
export class RequiredError extends Error {
|
||||
name: 'RequiredError';
|
||||
constructor(public field: string, msg?: string) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface ApiStatus
|
||||
*/
|
||||
export interface ApiStatus {
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ApiStatus
|
||||
*/
|
||||
error?: string;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof ApiStatus
|
||||
*/
|
||||
code?: number;
|
||||
/**
|
||||
*
|
||||
* @type {Array<ProtobufAny>}
|
||||
* @memberof ApiStatus
|
||||
*/
|
||||
details?: Array<ProtobufAny>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of resources in pipelines system.
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
export enum AuthorizeRequestResources {
|
||||
UNASSIGNEDRESOURCES = <any>'UNASSIGNED_RESOURCES',
|
||||
VIEWERS = <any>'VIEWERS',
|
||||
}
|
||||
|
||||
/**
|
||||
* Type of verbs that act on the resources.
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
export enum AuthorizeRequestVerb {
|
||||
UNASSIGNEDVERB = <any>'UNASSIGNED_VERB',
|
||||
CREATE = <any>'CREATE',
|
||||
GET = <any>'GET',
|
||||
DELETE = <any>'DELETE',
|
||||
}
|
||||
|
||||
/**
|
||||
* `Any` contains an arbitrary serialized protocol buffer message along with a URL that describes the type of the serialized message. Protobuf library provides support to pack/unpack Any values in the form of utility functions or additional generated methods of the Any type. Example 1: Pack and unpack a message in C++. Foo foo = ...; Any any; any.PackFrom(foo); ... if (any.UnpackTo(&foo)) { ... } Example 2: Pack and unpack a message in Java. Foo foo = ...; Any any = Any.pack(foo); ... if (any.is(Foo.class)) { foo = any.unpack(Foo.class); } Example 3: Pack and unpack a message in Python. foo = Foo(...) any = Any() any.Pack(foo) ... if any.Is(Foo.DESCRIPTOR): any.Unpack(foo) ... Example 4: Pack and unpack a message in Go foo := &pb.Foo{...} any, err := ptypes.MarshalAny(foo) ... foo := &pb.Foo{} if err := ptypes.UnmarshalAny(any, foo); err != nil { ... } The pack methods provided by protobuf library will by default use 'type.googleapis.com/full.type.name' as the type URL and the unpack methods only use the fully qualified type name after the last '/' in the type URL, for example \"foo.bar.com/x/y.z\" will yield type name \"y.z\". JSON ==== The JSON representation of an `Any` value uses the regular representation of the deserialized, embedded message, with an additional field `@type` which contains the type URL. Example: package google.profile; message Person { string first_name = 1; string last_name = 2; } { \"@type\": \"type.googleapis.com/google.profile.Person\", \"firstName\": <string>, \"lastName\": <string> } If the embedded message type is well-known and has a custom JSON representation, that representation will be embedded adding a field `value` which holds the custom JSON in addition to the `@type` field. Example (for message [google.protobuf.Duration][]): { \"@type\": \"type.googleapis.com/google.protobuf.Duration\", \"value\": \"1.212s\" }
|
||||
* @export
|
||||
* @interface ProtobufAny
|
||||
*/
|
||||
export interface ProtobufAny {
|
||||
/**
|
||||
* A URL/resource name that uniquely identifies the type of the serialized protocol buffer message. The last segment of the URL's path must represent the fully qualified name of the type (as in `path/google.protobuf.Duration`). The name should be in a canonical form (e.g., leading \".\" is not accepted). In practice, teams usually precompile into the binary all types that they expect it to use in the context of Any. However, for URLs which use the scheme `http`, `https`, or no scheme, one can optionally set up a type server that maps type URLs to message definitions as follows: * If no scheme is provided, `https` is assumed. * An HTTP GET on the URL must yield a [google.protobuf.Type][] value in binary format, or produce an error. * Applications are allowed to cache lookup results based on the URL, or have them precompiled into a binary to avoid any lookup. Therefore, binary compatibility needs to be preserved on changes to types. (Use versioned type names to manage breaking changes.) Note: this functionality is not currently available in the official protobuf release, and it is not used for type URLs beginning with type.googleapis.com. Schemes other than `http`, `https` (or the empty scheme) might be used with implementation specific semantics.
|
||||
* @type {string}
|
||||
* @memberof ProtobufAny
|
||||
*/
|
||||
type_url?: string;
|
||||
/**
|
||||
* Must be a valid serialized protocol buffer of the above specified type.
|
||||
* @type {string}
|
||||
* @memberof ProtobufAny
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthServiceApi - fetch parameter creator
|
||||
* @export
|
||||
*/
|
||||
export const AuthServiceApiFetchParamCreator = function(configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {string} [namespace]
|
||||
* @param {'UNASSIGNED_RESOURCES' | 'VIEWERS'} [resources]
|
||||
* @param {'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE'} [verb]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
authorize(
|
||||
namespace?: string,
|
||||
resources?: 'UNASSIGNED_RESOURCES' | 'VIEWERS',
|
||||
verb?: 'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE',
|
||||
options: any = {},
|
||||
): FetchArgs {
|
||||
const localVarPath = `/apis/v1beta1/auth`;
|
||||
const localVarUrlObj = url.parse(localVarPath, true);
|
||||
const localVarRequestOptions = Object.assign({ method: 'GET' }, options);
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication Bearer required
|
||||
if (configuration && configuration.apiKey) {
|
||||
const localVarApiKeyValue =
|
||||
typeof configuration.apiKey === 'function'
|
||||
? configuration.apiKey('authorization')
|
||||
: configuration.apiKey;
|
||||
localVarHeaderParameter['authorization'] = localVarApiKeyValue;
|
||||
}
|
||||
|
||||
if (namespace !== undefined) {
|
||||
localVarQueryParameter['namespace'] = namespace;
|
||||
}
|
||||
|
||||
if (resources !== undefined) {
|
||||
localVarQueryParameter['resources'] = resources;
|
||||
}
|
||||
|
||||
if (verb !== undefined) {
|
||||
localVarQueryParameter['verb'] = verb;
|
||||
}
|
||||
|
||||
localVarUrlObj.query = Object.assign(
|
||||
{},
|
||||
localVarUrlObj.query,
|
||||
localVarQueryParameter,
|
||||
options.query,
|
||||
);
|
||||
// fix override query string Detail: https://stackoverflow.com/a/7517673/1077943
|
||||
delete localVarUrlObj.search;
|
||||
localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers);
|
||||
|
||||
return {
|
||||
url: url.format(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* AuthServiceApi - functional programming interface
|
||||
* @export
|
||||
*/
|
||||
export const AuthServiceApiFp = function(configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {string} [namespace]
|
||||
* @param {'UNASSIGNED_RESOURCES' | 'VIEWERS'} [resources]
|
||||
* @param {'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE'} [verb]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
authorize(
|
||||
namespace?: string,
|
||||
resources?: 'UNASSIGNED_RESOURCES' | 'VIEWERS',
|
||||
verb?: 'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE',
|
||||
options?: any,
|
||||
): (fetch?: FetchAPI, basePath?: string) => Promise<any> {
|
||||
const localVarFetchArgs = AuthServiceApiFetchParamCreator(configuration).authorize(
|
||||
namespace,
|
||||
resources,
|
||||
verb,
|
||||
options,
|
||||
);
|
||||
return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => {
|
||||
return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then(response => {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
return response.json();
|
||||
} else {
|
||||
throw response;
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* AuthServiceApi - factory interface
|
||||
* @export
|
||||
*/
|
||||
export const AuthServiceApiFactory = function(
|
||||
configuration?: Configuration,
|
||||
fetch?: FetchAPI,
|
||||
basePath?: string,
|
||||
) {
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {string} [namespace]
|
||||
* @param {'UNASSIGNED_RESOURCES' | 'VIEWERS'} [resources]
|
||||
* @param {'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE'} [verb]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
authorize(
|
||||
namespace?: string,
|
||||
resources?: 'UNASSIGNED_RESOURCES' | 'VIEWERS',
|
||||
verb?: 'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE',
|
||||
options?: any,
|
||||
) {
|
||||
return AuthServiceApiFp(configuration).authorize(
|
||||
namespace,
|
||||
resources,
|
||||
verb,
|
||||
options,
|
||||
)(fetch, basePath);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* AuthServiceApi - object-oriented interface
|
||||
* @export
|
||||
* @class AuthServiceApi
|
||||
* @extends {BaseAPI}
|
||||
*/
|
||||
export class AuthServiceApi extends BaseAPI {
|
||||
/**
|
||||
*
|
||||
* @param {string} [namespace]
|
||||
* @param {'UNASSIGNED_RESOURCES' | 'VIEWERS'} [resources]
|
||||
* @param {'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE'} [verb]
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof AuthServiceApi
|
||||
*/
|
||||
public authorize(
|
||||
namespace?: string,
|
||||
resources?: 'UNASSIGNED_RESOURCES' | 'VIEWERS',
|
||||
verb?: 'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE',
|
||||
options?: any,
|
||||
) {
|
||||
return AuthServiceApiFp(this.configuration).authorize(
|
||||
namespace,
|
||||
resources,
|
||||
verb,
|
||||
options,
|
||||
)(this.fetch, this.basePath);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
// tslint:disable
|
||||
/**
|
||||
* backend/api/auth.proto
|
||||
* No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
|
||||
*
|
||||
* OpenAPI spec version: version not set
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by the swagger code generator program.
|
||||
* https://github.com/swagger-api/swagger-codegen.git
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
export interface ConfigurationParameters {
|
||||
apiKey?: string | ((name: string) => string);
|
||||
username?: string;
|
||||
password?: string;
|
||||
accessToken?: string | ((name: string, scopes?: string[]) => string);
|
||||
basePath?: string;
|
||||
}
|
||||
|
||||
export class Configuration {
|
||||
/**
|
||||
* parameter for apiKey security
|
||||
* @param name security name
|
||||
* @memberof Configuration
|
||||
*/
|
||||
apiKey?: string | ((name: string) => string);
|
||||
/**
|
||||
* parameter for basic security
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof Configuration
|
||||
*/
|
||||
username?: string;
|
||||
/**
|
||||
* parameter for basic security
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof Configuration
|
||||
*/
|
||||
password?: string;
|
||||
/**
|
||||
* parameter for oauth2 security
|
||||
* @param name security name
|
||||
* @param scopes oauth2 scope
|
||||
* @memberof Configuration
|
||||
*/
|
||||
accessToken?: string | ((name: string, scopes?: string[]) => string);
|
||||
/**
|
||||
* override base path
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof Configuration
|
||||
*/
|
||||
basePath?: string;
|
||||
|
||||
constructor(param: ConfigurationParameters = {}) {
|
||||
this.apiKey = param.apiKey;
|
||||
this.username = param.username;
|
||||
this.password = param.password;
|
||||
this.accessToken = param.accessToken;
|
||||
this.basePath = param.basePath;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
declare module 'portable-fetch';
|
||||
declare module 'url';
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
#!/bin/sh
|
||||
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
|
||||
#
|
||||
# Usage example: /bin/sh ./git_push.sh wing328 swagger-petstore-perl "minor update"
|
||||
|
||||
git_user_id=$1
|
||||
git_repo_id=$2
|
||||
release_note=$3
|
||||
|
||||
if [ "$git_user_id" = "" ]; then
|
||||
git_user_id="GIT_USER_ID"
|
||||
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
|
||||
fi
|
||||
|
||||
if [ "$git_repo_id" = "" ]; then
|
||||
git_repo_id="GIT_REPO_ID"
|
||||
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
|
||||
fi
|
||||
|
||||
if [ "$release_note" = "" ]; then
|
||||
release_note="Minor update"
|
||||
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
|
||||
fi
|
||||
|
||||
# Initialize the local directory as a Git repository
|
||||
git init
|
||||
|
||||
# Adds the files in the local repository and stages them for commit.
|
||||
git add .
|
||||
|
||||
# Commits the tracked changes and prepares them to be pushed to a remote repository.
|
||||
git commit -m "$release_note"
|
||||
|
||||
# Sets the new remote
|
||||
git_remote=`git remote`
|
||||
if [ "$git_remote" = "" ]; then # git remote not defined
|
||||
|
||||
if [ "$GIT_TOKEN" = "" ]; then
|
||||
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
|
||||
git remote add origin https://github.com/${git_user_id}/${git_repo_id}.git
|
||||
else
|
||||
git remote add origin https://${git_user_id}:${GIT_TOKEN}@github.com/${git_user_id}/${git_repo_id}.git
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
git pull origin master
|
||||
|
||||
# Pushes (Forces) the changes in the local repository up to the remote repository
|
||||
echo "Git pushing to https://github.com/${git_user_id}/${git_repo_id}.git"
|
||||
git push origin master 2>&1 | grep -v 'To https'
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
// tslint:disable
|
||||
/**
|
||||
* backend/api/auth.proto
|
||||
* No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
|
||||
*
|
||||
* OpenAPI spec version: version not set
|
||||
*
|
||||
*
|
||||
* NOTE: This class is auto generated by the swagger code generator program.
|
||||
* https://github.com/swagger-api/swagger-codegen.git
|
||||
* Do not edit the class manually.
|
||||
*/
|
||||
|
||||
export * from './api';
|
||||
export * from './configuration';
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
"strictNullChecks": true,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"skipLibCheck": true,
|
||||
"strictPropertyInitialization": false, // Workaround: swagger generated client breaks this.
|
||||
"strict": true
|
||||
},
|
||||
"exclude": ["dist", "coverage", "node_modules"]
|
||||
|
|
|
|||
|
|
@ -94,3 +94,91 @@ export class PreviewStream extends Transform {
|
|||
super({ ...opts, transform });
|
||||
}
|
||||
}
|
||||
|
||||
export interface ErrorDetails {
|
||||
message: string;
|
||||
additionalInfo: any;
|
||||
}
|
||||
const UNKOWN_ERROR = 'Unknown error';
|
||||
export async function parseError(error: any): Promise<ErrorDetails> {
|
||||
return (
|
||||
parseK8sError(error) ||
|
||||
(await parseKfpApiError(error)) ||
|
||||
parseGenericError(error) || { message: UNKOWN_ERROR, additionalInfo: error }
|
||||
);
|
||||
}
|
||||
|
||||
function parseGenericError(error: any): ErrorDetails | undefined {
|
||||
if (!error) {
|
||||
return undefined;
|
||||
} else if (typeof error === 'string') {
|
||||
return {
|
||||
message: error,
|
||||
additionalInfo: error,
|
||||
};
|
||||
} else if (error instanceof Error) {
|
||||
return { message: error.message, additionalInfo: error };
|
||||
} else if (error.message && typeof error.message === 'string') {
|
||||
return { message: error.message, additionalInfo: error };
|
||||
} else if (
|
||||
error.url &&
|
||||
typeof error.url === 'string' &&
|
||||
error.status &&
|
||||
typeof error.status === 'number' &&
|
||||
error.statusText &&
|
||||
typeof error.statusText === 'string'
|
||||
) {
|
||||
const { url, status, statusText } = error;
|
||||
return {
|
||||
message: `Fetching ${url} failed with status code ${status} and message: ${statusText}`,
|
||||
additionalInfo: { url, status, statusText },
|
||||
};
|
||||
}
|
||||
// Cannot understand error type
|
||||
return undefined;
|
||||
}
|
||||
async function parseKfpApiError(error: any): Promise<ErrorDetails | undefined> {
|
||||
if (!error || !error.json || typeof error.json !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const json = await error.json();
|
||||
const { error: message, details } = json;
|
||||
if (message && details && typeof message === 'string' && typeof details === 'object') {
|
||||
return {
|
||||
message,
|
||||
additionalInfo: details,
|
||||
};
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} catch (err) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function parseK8sError(error: any): ErrorDetails | undefined {
|
||||
if (!error || !error.body || typeof error.body !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof error.body.message !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Kubernetes client http error has body with all the info.
|
||||
// Example error.body
|
||||
// {
|
||||
// kind: 'Status',
|
||||
// apiVersion: 'v1',
|
||||
// metadata: {},
|
||||
// status: 'Failure',
|
||||
// message: 'pods "test-pod" not found',
|
||||
// reason: 'NotFound',
|
||||
// details: { name: 'test-pod', kind: 'pods' },
|
||||
// code: 404
|
||||
// }
|
||||
return {
|
||||
message: error.body.message,
|
||||
additionalInfo: error.body,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import BusyButton from '../../atoms/BusyButton';
|
|||
import Button from '@material-ui/core/Button';
|
||||
import Viewer, { ViewerConfig } from './Viewer';
|
||||
import { Apis } from '../../lib/Apis';
|
||||
import { commonCss, padding } from '../../Css';
|
||||
import { commonCss, padding, color } from '../../Css';
|
||||
import InputLabel from '@material-ui/core/InputLabel';
|
||||
import Input from '@material-ui/core/Input';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
|
|
@ -47,6 +47,12 @@ export const css = stylesheet({
|
|||
shortButton: {
|
||||
width: 50,
|
||||
},
|
||||
warningText: {
|
||||
color: color.warningText,
|
||||
},
|
||||
errorText: {
|
||||
color: color.errorText,
|
||||
},
|
||||
});
|
||||
|
||||
export interface TensorboardViewerConfig extends ViewerConfig {
|
||||
|
|
@ -67,6 +73,7 @@ interface TensorboardViewerState {
|
|||
tensorflowVersion: string;
|
||||
// When podAddress is not null, we need to further tell whether the TensorBoard pod is accessible or not
|
||||
tensorboardReady: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// TODO(jingzhang36): we'll later parse Tensorboard version from mlpipeline-ui-metadata.json file.
|
||||
|
|
@ -84,6 +91,7 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
|
|||
podAddress: '',
|
||||
tensorflowVersion: DEFAULT_TENSORBOARD_VERSION,
|
||||
tensorboardReady: false,
|
||||
errorMessage: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +125,7 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
|
|||
public render(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
{this.state.errorMessage && <div className={css.errorText}>{this.state.errorMessage}</div>}
|
||||
{this.state.podAddress && (
|
||||
<div>
|
||||
<div
|
||||
|
|
@ -138,7 +147,9 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
|
|||
{this.state.tensorboardReady ? (
|
||||
``
|
||||
) : (
|
||||
<div>Tensorboard is starting, and you may need to wait for a few minutes.</div>
|
||||
<div className={css.warningText}>
|
||||
Tensorboard is starting, and you may need to wait for a few minutes.
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
|
||||
|
|
@ -264,44 +275,56 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
|
|||
|
||||
private async _checkTensorboardApp(): Promise<void> {
|
||||
this.setState({ busy: true }, async () => {
|
||||
const { podAddress, tfVersion } = await Apis.getTensorboardApp(
|
||||
this._buildUrl(),
|
||||
this._getNamespace(),
|
||||
);
|
||||
if (podAddress) {
|
||||
this.setState({ busy: false, podAddress, tensorflowVersion: tfVersion });
|
||||
} else {
|
||||
// No existing pod
|
||||
this.setState({ busy: false });
|
||||
try {
|
||||
const { podAddress, tfVersion } = await Apis.getTensorboardApp(
|
||||
this._buildUrl(),
|
||||
this._getNamespace(),
|
||||
);
|
||||
if (podAddress) {
|
||||
this.setState({ busy: false, podAddress, tensorflowVersion: tfVersion });
|
||||
} else {
|
||||
// No existing pod
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
} catch (err) {
|
||||
this.setState({ busy: false, errorMessage: err?.message || 'Unknown error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _startTensorboard = async () => {
|
||||
this.setState({ busy: true }, async () => {
|
||||
await Apis.startTensorboardApp(
|
||||
this._buildUrl(),
|
||||
this.state.tensorflowVersion,
|
||||
this._getNamespace(),
|
||||
);
|
||||
this.setState({ busy: false, tensorboardReady: false }, () => {
|
||||
this._checkTensorboardApp();
|
||||
});
|
||||
this.setState({ busy: true, errorMessage: undefined }, async () => {
|
||||
try {
|
||||
await Apis.startTensorboardApp(
|
||||
this._buildUrl(),
|
||||
this.state.tensorflowVersion,
|
||||
this._getNamespace(),
|
||||
);
|
||||
this.setState({ busy: false, tensorboardReady: false }, () => {
|
||||
this._checkTensorboardApp();
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({ busy: false, errorMessage: err?.message || 'Unknown error' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private _deleteTensorboard = async () => {
|
||||
// delete the already opened Tensorboard, clear the podAddress recorded in frontend,
|
||||
// and return to the select & start tensorboard page
|
||||
this.setState({ busy: true }, async () => {
|
||||
await Apis.deleteTensorboardApp(this._buildUrl(), this._getNamespace());
|
||||
this.setState({
|
||||
busy: false,
|
||||
deleteDialogOpen: false,
|
||||
podAddress: '',
|
||||
tensorflowVersion: DEFAULT_TENSORBOARD_VERSION,
|
||||
tensorboardReady: false,
|
||||
});
|
||||
this.setState({ busy: true, errorMessage: undefined }, async () => {
|
||||
try {
|
||||
await Apis.deleteTensorboardApp(this._buildUrl(), this._getNamespace());
|
||||
this.setState({
|
||||
busy: false,
|
||||
deleteDialogOpen: false,
|
||||
podAddress: '',
|
||||
tensorflowVersion: DEFAULT_TENSORBOARD_VERSION,
|
||||
tensorboardReady: false,
|
||||
});
|
||||
} catch (err) {
|
||||
this.setState({ busy: false, errorMessage: err?.message || 'Unknown error' });
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue