// Copyright 2020 The Kubeflow Authors // // 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 { Storage as GCSStorage } from '@google-cloud/storage'; import * as fs from 'fs'; import * as minio from 'minio'; import fetch from 'node-fetch'; import * as path from 'path'; import { PassThrough } from 'stream'; import requests from 'supertest'; import { UIServer } from '../app'; import { loadConfigs } from '../configs'; import * as serverInfo from '../helpers/server-info'; import * as minioHelper from '../minio-helper'; import { commonSetup, mkTempDir } from './test-helper'; const MinioClient = minio.Client; jest.mock('minio'); jest.mock('node-fetch'); jest.mock('@google-cloud/storage'); const mockedFetch: jest.Mock = fetch as any; describe('/artifacts', () => { let app: UIServer; const { argv } = commonSetup(); let artifactContent: any = 'hello world'; beforeEach(() => { artifactContent = 'hello world'; // reset const mockedMinioClient: jest.Mock = MinioClient as any; mockedMinioClient.mockImplementation(function() { return { getObject: async (bucket: string, key: string) => { const objStream = new PassThrough(); objStream.end(artifactContent); if (bucket === 'ml-pipeline' && key === 'hello/world.txt') { return objStream; } else { throw new Error(`Unable to retrieve ${bucket}/${key} artifact.`); } }, }; }); }); afterEach(() => { if (app) { app.close(); } }); describe('/get', () => { it('responds with a minio artifact if source=minio', done => { const mockedMinioClient: jest.Mock = minio.Client as any; const configs = loadConfigs(argv, { MINIO_ACCESS_KEY: 'minio', MINIO_HOST: 'minio-service', MINIO_NAMESPACE: 'kubeflow', MINIO_PORT: '9000', MINIO_SECRET_KEY: 'minio123', MINIO_SSL: 'false', }); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=minio&bucket=ml-pipeline&key=hello%2Fworld.txt') .expect(200, artifactContent, err => { expect(mockedMinioClient).toBeCalledWith({ accessKey: 'minio', endPoint: 'minio-service.kubeflow', port: 9000, secretKey: 'minio123', useSSL: false, }); done(err); }); }); it('responds with a s3 artifact if source=s3', done => { const mockedMinioClient: jest.Mock = jest.spyOn(minioHelper, 'createMinioClient') as any; const configs = loadConfigs(argv, { AWS_ACCESS_KEY_ID: 'aws123', AWS_SECRET_ACCESS_KEY: 'awsSecret123', }); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=s3&bucket=ml-pipeline&key=hello%2Fworld.txt') .expect(200, artifactContent, err => { expect(mockedMinioClient).toBeCalledWith({ accessKey: 'aws123', endPoint: 's3.amazonaws.com', region: 'us-east-1', secretKey: 'awsSecret123', useSSL: true, }); done(err); }); }); it('responds with partial s3 artifact if peek=5 flag is set', done => { const mockedMinioClient = jest.spyOn(minioHelper, 'createMinioClient'); const configs = loadConfigs(argv, { AWS_ACCESS_KEY_ID: 'aws123', AWS_SECRET_ACCESS_KEY: 'awsSecret123', }); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=s3&bucket=ml-pipeline&key=hello%2Fworld.txt&peek=5') .expect(200, artifactContent.slice(0, 5), err => { expect(mockedMinioClient).toBeCalledWith({ accessKey: 'aws123', endPoint: 's3.amazonaws.com', region: 'us-east-1', secretKey: 'awsSecret123', useSSL: true, }); done(err); }); }); it('responds with a s3 artifact from bucket in non-default region if source=s3', done => { const mockedMinioClient: jest.Mock = jest.spyOn(minioHelper, 'createMinioClient') as any; const configs = loadConfigs(argv, { AWS_ACCESS_KEY_ID: 'aws123', AWS_SECRET_ACCESS_KEY: 'awsSecret123', AWS_REGION: 'eu-central-1', }); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=s3&bucket=ml-pipeline&key=hello%2Fworld.txt') .expect(200, artifactContent, err => { expect(mockedMinioClient).toBeCalledWith({ accessKey: 'aws123', endPoint: 's3.amazonaws.com', region: 'eu-central-1', secretKey: 'awsSecret123', useSSL: true, }); done(err); }); }); it('responds with a http artifact if source=http', done => { const artifactContent = 'hello world'; mockedFetch.mockImplementationOnce((url: string, opts: any) => url === 'http://foo.bar/ml-pipeline/hello/world.txt' ? Promise.resolve({ buffer: () => Promise.resolve(artifactContent), body: new PassThrough().end(artifactContent), }) : Promise.reject('Unable to retrieve http artifact.'), ); const configs = loadConfigs(argv, { HTTP_BASE_URL: 'foo.bar/', }); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=http&bucket=ml-pipeline&key=hello%2Fworld.txt') .expect(200, artifactContent, err => { expect(mockedFetch).toBeCalledWith('http://foo.bar/ml-pipeline/hello/world.txt', { headers: {}, }); done(err); }); }); it('responds with partial http artifact if peek=5 flag is set', done => { const artifactContent = 'hello world'; const mockedFetch: jest.Mock = fetch as any; mockedFetch.mockImplementationOnce((url: string, opts: any) => url === 'http://foo.bar/ml-pipeline/hello/world.txt' ? Promise.resolve({ buffer: () => Promise.resolve(artifactContent), body: new PassThrough().end(artifactContent), }) : Promise.reject('Unable to retrieve http artifact.'), ); const configs = loadConfigs(argv, { HTTP_BASE_URL: 'foo.bar/', }); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=http&bucket=ml-pipeline&key=hello%2Fworld.txt&peek=5') .expect(200, artifactContent.slice(0, 5), err => { expect(mockedFetch).toBeCalledWith('http://foo.bar/ml-pipeline/hello/world.txt', { headers: {}, }); done(err); }); }); it('responds with a https artifact if source=https', done => { const artifactContent = 'hello world'; mockedFetch.mockImplementationOnce((url: string, opts: any) => url === 'https://foo.bar/ml-pipeline/hello/world.txt' && opts.headers.Authorization === 'someToken' ? Promise.resolve({ buffer: () => Promise.resolve(artifactContent), body: new PassThrough().end(artifactContent), }) : Promise.reject('Unable to retrieve http artifact.'), ); const configs = loadConfigs(argv, { HTTP_AUTHORIZATION_DEFAULT_VALUE: 'someToken', HTTP_AUTHORIZATION_KEY: 'Authorization', HTTP_BASE_URL: 'foo.bar/', }); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=https&bucket=ml-pipeline&key=hello%2Fworld.txt') .expect(200, artifactContent, err => { expect(mockedFetch).toBeCalledWith('https://foo.bar/ml-pipeline/hello/world.txt', { headers: { Authorization: 'someToken', }, }); done(err); }); }); it('responds with a https artifact using the inherited header if source=https and http authorization key is provided.', done => { const artifactContent = 'hello world'; mockedFetch.mockImplementationOnce((url: string, _opts: any) => url === 'https://foo.bar/ml-pipeline/hello/world.txt' ? Promise.resolve({ buffer: () => Promise.resolve(artifactContent), body: new PassThrough().end(artifactContent), }) : Promise.reject('Unable to retrieve http artifact.'), ); const configs = loadConfigs(argv, { HTTP_AUTHORIZATION_KEY: 'Authorization', HTTP_BASE_URL: 'foo.bar/', }); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=https&bucket=ml-pipeline&key=hello%2Fworld.txt') .set('Authorization', 'inheritedToken') .expect(200, artifactContent, err => { expect(mockedFetch).toBeCalledWith('https://foo.bar/ml-pipeline/hello/world.txt', { headers: { Authorization: 'inheritedToken', }, }); done(err); }); }); it('responds with a gcs artifact if source=gcs', done => { const artifactContent = 'hello world'; const mockedGcsStorage: jest.Mock = GCSStorage as any; const stream = new PassThrough(); stream.write(artifactContent); stream.end(); mockedGcsStorage.mockImplementationOnce(() => ({ bucket: () => ({ getFiles: () => Promise.resolve([[{ name: 'hello/world.txt', createReadStream: () => stream }]]), }), })); const configs = loadConfigs(argv, {}); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=gcs&bucket=ml-pipeline&key=hello%2Fworld.txt') .expect(200, artifactContent + '\n', done); }); it('responds with a partial gcs artifact if peek=5 is set', done => { const artifactContent = 'hello world'; const mockedGcsStorage: jest.Mock = GCSStorage as any; const stream = new PassThrough(); stream.end(artifactContent); mockedGcsStorage.mockImplementationOnce(() => ({ bucket: () => ({ getFiles: () => Promise.resolve([[{ name: 'hello/world.txt', createReadStream: () => stream }]]), }), })); const configs = loadConfigs(argv, {}); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=gcs&bucket=ml-pipeline&key=hello%2Fworld.txt&peek=5') .expect(200, artifactContent.slice(0, 5), done); }); it('responds with a volume artifact if source=volume', done => { const artifactContent = 'hello world'; const tempPath = path.join(mkTempDir(), 'content'); fs.writeFileSync(tempPath, artifactContent); jest.spyOn(serverInfo, 'getHostPod').mockImplementation(() => Promise.resolve([ { spec: { containers: [ { volumeMounts: [ { name: 'artifact', mountPath: path.dirname(tempPath), subPath: 'subartifact', }, ], name: 'ml-pipeline-ui', }, ], volumes: [ { name: 'artifact', persistentVolumeClaim: { claimName: 'artifact_pvc', }, }, ], }, } as any, undefined, ]), ); const configs = loadConfigs(argv, {}); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/get?source=volume&bucket=artifact&key=subartifact/content') .expect(200, artifactContent, done); }); it('responds with a partial volume artifact if peek=5 is set', done => { const artifactContent = 'hello world'; const tempPath = path.join(mkTempDir(), 'content'); fs.writeFileSync(tempPath, artifactContent); jest.spyOn(serverInfo, 'getHostPod').mockImplementation(() => Promise.resolve([ { spec: { containers: [ { volumeMounts: [ { name: 'artifact', mountPath: path.dirname(tempPath), }, ], name: 'ml-pipeline-ui', }, ], volumes: [ { name: 'artifact', persistentVolumeClaim: { claimName: 'artifact_pvc', }, }, ], }, } as any, undefined, ]), ); const configs = loadConfigs(argv, {}); app = new UIServer(configs); const request = requests(app.start()); request .get(`/artifacts/get?source=volume&bucket=artifact&key=content&peek=5`) .expect(200, artifactContent.slice(0, 5), done); }); it('responds error with a not exist volume', done => { jest.spyOn(serverInfo, 'getHostPod').mockImplementation(() => Promise.resolve([ { metadata: { name: 'ml-pipeline-ui', }, spec: { containers: [ { volumeMounts: [ { name: 'artifact', mountPath: '/foo/bar/path', }, ], name: 'ml-pipeline-ui', }, ], volumes: [ { name: 'artifact', persistentVolumeClaim: { claimName: 'artifact_pvc', }, }, ], }, } as any, undefined, ]), ); const configs = loadConfigs(argv, {}); app = new UIServer(configs); const request = requests(app.start()); request .get(`/artifacts/get?source=volume&bucket=notexist&key=content`) .expect(404, 'Failed to open volume.', done); }); it('responds error with a not exist volume mount path if source=volume', done => { jest.spyOn(serverInfo, 'getHostPod').mockImplementation(() => Promise.resolve([ { metadata: { name: 'ml-pipeline-ui', }, spec: { containers: [ { volumeMounts: [ { name: 'artifact', mountPath: '/foo/bar/path', subPath: 'subartifact', }, ], name: 'ml-pipeline-ui', }, ], volumes: [ { name: 'artifact', persistentVolumeClaim: { claimName: 'artifact_pvc', }, }, ], }, } as any, undefined, ]), ); const configs = loadConfigs(argv, {}); app = new UIServer(configs); const request = requests(app.start()); request .get(`/artifacts/get?source=volume&bucket=artifact&key=notexist/config`) .expect(404, 'Failed to open volume.', done); }); it('responds error with a not exist volume mount artifact if source=volume', done => { jest.spyOn(serverInfo, 'getHostPod').mockImplementation(() => Promise.resolve([ { spec: { containers: [ { volumeMounts: [ { name: 'artifact', mountPath: '/foo/bar', subPath: 'subartifact', }, ], name: 'ml-pipeline-ui', }, ], volumes: [ { name: 'artifact', persistentVolumeClaim: { claimName: 'artifact_pvc', }, }, ], }, } as any, undefined, ]), ); const configs = loadConfigs(argv, {}); app = new UIServer(configs); const request = requests(app.start()); request .get(`/artifacts/get?source=volume&bucket=artifact&key=subartifact/notxist.csv`) .expect(500, 'Failed to open volume.', done); }); }); describe('/:source/:bucket/:key', () => { it('downloads a minio artifact', done => { const configs = loadConfigs(argv, {}); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/minio/ml-pipeline/hello/world.txt') // url .expect(200, artifactContent, done); }); it('downloads a tar gzipped artifact as is', done => { // base64 encoding for tar gzipped 'hello-world' const tarGzBase64 = 'H4sIAFa7DV4AA+3PSwrCMBRG4Y5dxV1BuSGPridgwcItkTZSl++johNBJ0WE803OIHfwZ87j0fq2nmuzGVVNIcitXYqPpntXLojzSb33MToVdTG5rhHdbtLLaa55uk5ZBrMhj23ty9u7T+/rT+TZP3HozYosZbL97tdbAAAAAAAAAAAAAAAAAADfuwAyiYcHACgAAA=='; const tarGzBuffer = Buffer.from(tarGzBase64, 'base64'); artifactContent = tarGzBuffer; const configs = loadConfigs(argv, {}); app = new UIServer(configs); const request = requests(app.start()); request .get('/artifacts/minio/ml-pipeline/hello/world.txt') // url .expect(200, tarGzBuffer.toString(), done); }); }); });