|
| 1 | +// Copyright 2019 Google LLC |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | +import * as os from 'os'; |
| 15 | +import * as fs from 'fs'; |
| 16 | +import * as path from 'path'; |
| 17 | +import { PassThrough } from 'stream'; |
| 18 | + |
| 19 | +import fetch from 'node-fetch'; |
| 20 | +import * as requests from 'supertest'; |
| 21 | +import { Client as MinioClient } from 'minio'; |
| 22 | +import { Storage as GCSStorage } from '@google-cloud/storage'; |
| 23 | + |
| 24 | +import { UIServer } from './app'; |
| 25 | +import { loadConfigs } from './configs'; |
| 26 | +import * as minioHelper from './minio-helper'; |
| 27 | +import { getTensorboardInstance } from './k8s-helper'; |
| 28 | + |
| 29 | +jest.mock('minio'); |
| 30 | +jest.mock('node-fetch'); |
| 31 | +jest.mock('@google-cloud/storage'); |
| 32 | +jest.mock('./minio-helper'); |
| 33 | +jest.mock('./k8s-helper'); |
| 34 | + |
| 35 | +describe('UIServer', () => { |
| 36 | + let app: UIServer; |
| 37 | + const indexHtmlPath = path.resolve(os.tmpdir(), 'index.html'); |
| 38 | + const argv = ['node', 'dist/server.js', os.tmpdir(), '3000']; |
| 39 | + const buildDate = new Date().toISOString(); |
| 40 | + const commitHash = 'abcdefg'; |
| 41 | + const indexHtmlContent = ` |
| 42 | +<html> |
| 43 | +<head> |
| 44 | + <script> |
| 45 | + window.KFP_FLAGS.DEPLOYMENT=null |
| 46 | + </script> |
| 47 | + <script id="kubeflow-client-placeholder"></script> |
| 48 | +</head> |
| 49 | +</html>`; |
| 50 | + const expectedIndexHtml = ` |
| 51 | +<html> |
| 52 | +<head> |
| 53 | + <script> |
| 54 | + window.KFP_FLAGS.DEPLOYMENT="KUBEFLOW" |
| 55 | + </script> |
| 56 | + <script id="kubeflow-client-placeholder" src="/dashboard_lib.bundle.js"></script> |
| 57 | +</head> |
| 58 | +</html>`; |
| 59 | + |
| 60 | + beforeAll(() => { |
| 61 | + fs.writeFileSync(path.resolve(__dirname, 'BUILD_DATE'), buildDate); |
| 62 | + fs.writeFileSync(path.resolve(__dirname, 'COMMIT_HASH'), commitHash); |
| 63 | + fs.writeFileSync(indexHtmlPath, indexHtmlContent); |
| 64 | + }); |
| 65 | + |
| 66 | + afterAll(() => { |
| 67 | + fs.unlinkSync(path.resolve(__dirname, 'BUILD_DATE')); |
| 68 | + fs.unlinkSync(path.resolve(__dirname, 'COMMIT_HASH')); |
| 69 | + fs.unlinkSync(indexHtmlPath); |
| 70 | + }); |
| 71 | + |
| 72 | + beforeEach(() => { |
| 73 | + (MinioClient as any).mockClear(); |
| 74 | + (fetch as any).mockClear(); |
| 75 | + (GCSStorage as any).mockClear(); |
| 76 | + (getTensorboardInstance as any).mockClear(); |
| 77 | + }); |
| 78 | + |
| 79 | + afterEach(() => { |
| 80 | + app && app.close(); |
| 81 | + }); |
| 82 | + |
| 83 | + it('api server is not ready', done => { |
| 84 | + (fetch as any).mockImplementationOnce((_url: string, _opt: any) => ({ |
| 85 | + json: () => Promise.reject('Unknown error'), |
| 86 | + })); |
| 87 | + |
| 88 | + const configs = loadConfigs(argv, {}); |
| 89 | + app = new UIServer(configs); |
| 90 | + requests(app.start()) |
| 91 | + .get('/apis/v1beta1/healthz') |
| 92 | + .expect( |
| 93 | + 200, |
| 94 | + { |
| 95 | + buildDate, |
| 96 | + frontendCommitHash: commitHash, |
| 97 | + apiServerReady: false, |
| 98 | + }, |
| 99 | + done, |
| 100 | + ); |
| 101 | + }); |
| 102 | + |
| 103 | + it('api server is ready', done => { |
| 104 | + (fetch as any).mockImplementationOnce((_url: string, _opt: any) => ({ |
| 105 | + json: () => |
| 106 | + Promise.resolve({ |
| 107 | + commit_sha: 'commit_sha', |
| 108 | + }), |
| 109 | + })); |
| 110 | + |
| 111 | + const configs = loadConfigs(argv, {}); |
| 112 | + app = new UIServer(configs); |
| 113 | + requests(app.start()) |
| 114 | + .get('/apis/v1beta1/healthz') |
| 115 | + .expect( |
| 116 | + 200, |
| 117 | + { |
| 118 | + buildDate, |
| 119 | + frontendCommitHash: commitHash, |
| 120 | + apiServerReady: true, |
| 121 | + apiServerCommitHash: 'commit_sha', |
| 122 | + }, |
| 123 | + done, |
| 124 | + ); |
| 125 | + }); |
| 126 | + |
| 127 | + it('is not a kubeflow deployment', done => { |
| 128 | + const configs = loadConfigs(argv, {}); |
| 129 | + app = new UIServer(configs); |
| 130 | + |
| 131 | + const request = requests(app.start()); |
| 132 | + request |
| 133 | + .get('/') |
| 134 | + .expect('Content-Type', 'text/html; charset=utf-8') |
| 135 | + .expect(200, indexHtmlContent, done); |
| 136 | + }); |
| 137 | + |
| 138 | + it('is a kubeflow deployment', done => { |
| 139 | + const configs = loadConfigs(argv, { DEPLOYMENT: 'kubeflow' }); |
| 140 | + app = new UIServer(configs); |
| 141 | + |
| 142 | + const request = requests(app.start()); |
| 143 | + request |
| 144 | + .get('/') |
| 145 | + .expect('Content-Type', 'text/html; charset=utf-8') |
| 146 | + .expect(200, expectedIndexHtml, done); |
| 147 | + }); |
| 148 | + |
| 149 | + it('is a minio artifact', done => { |
| 150 | + const artifactContent = 'hello world'; |
| 151 | + const mockedMinioClient: jest.Mock = MinioClient as any; |
| 152 | + const mockedGetTarObjectAsString: jest.Mock = minioHelper.getTarObjectAsString as any; |
| 153 | + mockedGetTarObjectAsString.mockImplementationOnce(opt => |
| 154 | + opt.bucket === 'ml-pipeline' && opt.key === 'hello/world.txt' |
| 155 | + ? Promise.resolve(artifactContent) |
| 156 | + : Promise.reject('Unable to retrieve minio artifact.'), |
| 157 | + ); |
| 158 | + const configs = loadConfigs(argv, { |
| 159 | + MINIO_ACCESS_KEY: 'minio', |
| 160 | + MINIO_SECRET_KEY: 'minio123', |
| 161 | + MINIO_PORT: '9000', |
| 162 | + MINIO_HOST: 'minio-service', |
| 163 | + MINIO_NAMESPACE: 'kubeflow', |
| 164 | + MINIO_SSL: 'false', |
| 165 | + }); |
| 166 | + app = new UIServer(configs); |
| 167 | + |
| 168 | + const request = requests(app.start()); |
| 169 | + request |
| 170 | + .get('/artifacts/get?source=minio&bucket=ml-pipeline&encodedKey=hello%2Fworld.txt') |
| 171 | + .expect(200, artifactContent, err => { |
| 172 | + expect(mockedMinioClient).toBeCalledWith({ |
| 173 | + accessKey: 'minio', |
| 174 | + secretKey: 'minio123', |
| 175 | + endPoint: 'minio-service.kubeflow', |
| 176 | + port: 9000, |
| 177 | + useSSL: false, |
| 178 | + }); |
| 179 | + done(err); |
| 180 | + }); |
| 181 | + }); |
| 182 | + |
| 183 | + it('is a s3 artifact', done => { |
| 184 | + const artifactContent = 'hello world'; |
| 185 | + const mockedMinioClient: jest.Mock = minioHelper.createMinioClient as any; |
| 186 | + const mockedGetObjectStream: jest.Mock = minioHelper.getObjectStream as any; |
| 187 | + const stream = new PassThrough(); |
| 188 | + stream.write(artifactContent); |
| 189 | + stream.end(); |
| 190 | + |
| 191 | + mockedGetObjectStream.mockImplementationOnce(opt => |
| 192 | + opt.bucket === 'ml-pipeline' && opt.key === 'hello/world.txt' |
| 193 | + ? Promise.resolve(stream) |
| 194 | + : Promise.reject('Unable to retrieve s3 artifact.'), |
| 195 | + ); |
| 196 | + const configs = loadConfigs(argv, { |
| 197 | + AWS_ACCESS_KEY_ID: 'aws123', |
| 198 | + AWS_SECRET_ACCESS_KEY: 'awsSecret123', |
| 199 | + }); |
| 200 | + app = new UIServer(configs); |
| 201 | + |
| 202 | + const request = requests(app.start()); |
| 203 | + request |
| 204 | + .get('/artifacts/get?source=s3&bucket=ml-pipeline&encodedKey=hello%2Fworld.txt') |
| 205 | + .expect(200, artifactContent, err => { |
| 206 | + expect(mockedMinioClient).toBeCalledWith({ |
| 207 | + accessKey: 'aws123', |
| 208 | + secretKey: 'awsSecret123', |
| 209 | + endPoint: 's3.amazonaws.com', |
| 210 | + }); |
| 211 | + done(err); |
| 212 | + }); |
| 213 | + }); |
| 214 | + |
| 215 | + it('is a http artifact', done => { |
| 216 | + const artifactContent = 'hello world'; |
| 217 | + const mockedFetch: jest.Mock = fetch as any; |
| 218 | + mockedFetch.mockImplementationOnce((url: string, opts: any) => |
| 219 | + url === 'http://foo.bar/ml-pipeline/hello/world.txt' |
| 220 | + ? Promise.resolve({ buffer: () => Promise.resolve(artifactContent) }) |
| 221 | + : Promise.reject('Unable to retrieve http artifact.'), |
| 222 | + ); |
| 223 | + const configs = loadConfigs(argv, { |
| 224 | + HTTP_BASE_URL: 'foo.bar/', |
| 225 | + }); |
| 226 | + app = new UIServer(configs); |
| 227 | + |
| 228 | + const request = requests(app.start()); |
| 229 | + request |
| 230 | + .get('/artifacts/get?source=http&bucket=ml-pipeline&encodedKey=hello%2Fworld.txt') |
| 231 | + .expect(200, artifactContent, err => { |
| 232 | + expect(mockedFetch).toBeCalledWith('http://foo.bar/ml-pipeline/hello/world.txt', { |
| 233 | + headers: {}, |
| 234 | + }); |
| 235 | + done(err); |
| 236 | + }); |
| 237 | + }); |
| 238 | + |
| 239 | + it('is a https artifact', done => { |
| 240 | + const artifactContent = 'hello world'; |
| 241 | + const mockedFetch: jest.Mock = fetch as any; |
| 242 | + mockedFetch.mockImplementationOnce((url: string, opts: any) => |
| 243 | + url === 'https://foo.bar/ml-pipeline/hello/world.txt' && |
| 244 | + opts.headers.Authorization === 'someToken' |
| 245 | + ? Promise.resolve({ buffer: () => Promise.resolve(artifactContent) }) |
| 246 | + : Promise.reject('Unable to retrieve http artifact.'), |
| 247 | + ); |
| 248 | + const configs = loadConfigs(argv, { |
| 249 | + HTTP_BASE_URL: 'foo.bar/', |
| 250 | + HTTP_AUTHORIZATION_KEY: 'Authorization', |
| 251 | + HTTP_AUTHORIZATION_DEFAULT_VALUE: 'someToken', |
| 252 | + }); |
| 253 | + app = new UIServer(configs); |
| 254 | + |
| 255 | + const request = requests(app.start()); |
| 256 | + request |
| 257 | + .get('/artifacts/get?source=https&bucket=ml-pipeline&encodedKey=hello%2Fworld.txt') |
| 258 | + .expect(200, artifactContent, err => { |
| 259 | + expect(mockedFetch).toBeCalledWith('https://foo.bar/ml-pipeline/hello/world.txt', { |
| 260 | + headers: { |
| 261 | + Authorization: 'someToken', |
| 262 | + }, |
| 263 | + }); |
| 264 | + done(err); |
| 265 | + }); |
| 266 | + }); |
| 267 | + |
| 268 | + it('is a https artifact and inherits the headers', done => { |
| 269 | + const artifactContent = 'hello world'; |
| 270 | + const mockedFetch: jest.Mock = fetch as any; |
| 271 | + mockedFetch.mockImplementationOnce((url: string, opts: any) => |
| 272 | + url === 'https://foo.bar/ml-pipeline/hello/world.txt' |
| 273 | + ? Promise.resolve({ buffer: () => Promise.resolve(artifactContent) }) |
| 274 | + : Promise.reject('Unable to retrieve http artifact.'), |
| 275 | + ); |
| 276 | + const configs = loadConfigs(argv, { |
| 277 | + HTTP_BASE_URL: 'foo.bar/', |
| 278 | + HTTP_AUTHORIZATION_KEY: 'Authorization', |
| 279 | + }); |
| 280 | + app = new UIServer(configs); |
| 281 | + |
| 282 | + const request = requests(app.start()); |
| 283 | + request |
| 284 | + .get('/artifacts/get?source=https&bucket=ml-pipeline&encodedKey=hello%2Fworld.txt') |
| 285 | + .set('Authorization', 'inheritedToken') |
| 286 | + .expect(200, artifactContent, err => { |
| 287 | + expect(mockedFetch).toBeCalledWith('https://foo.bar/ml-pipeline/hello/world.txt', { |
| 288 | + headers: { |
| 289 | + Authorization: 'inheritedToken', |
| 290 | + }, |
| 291 | + }); |
| 292 | + done(err); |
| 293 | + }); |
| 294 | + }); |
| 295 | + |
| 296 | + it('is a gcs artifact', done => { |
| 297 | + const artifactContent = 'hello world'; |
| 298 | + const mockedGcsStorage: jest.Mock = GCSStorage as any; |
| 299 | + const stream = new PassThrough(); |
| 300 | + stream.write(artifactContent); |
| 301 | + stream.end(); |
| 302 | + mockedGcsStorage.mockImplementationOnce(() => ({ |
| 303 | + bucket: () => ({ |
| 304 | + getFiles: () => |
| 305 | + Promise.resolve([[{ name: 'hello/world.txt', createReadStream: () => stream }]]), |
| 306 | + }), |
| 307 | + })); |
| 308 | + const configs = loadConfigs(argv, {}); |
| 309 | + app = new UIServer(configs); |
| 310 | + |
| 311 | + const request = requests(app.start()); |
| 312 | + request |
| 313 | + .get('/artifacts/get?source=gcs&bucket=ml-pipeline&encodedKey=hello%2Fworld.txt') |
| 314 | + .expect(200, artifactContent + '\n', done); |
| 315 | + }); |
| 316 | + |
| 317 | + // TODO: refractor k8s helper module so that k8s APIs can be mocked |
| 318 | + // it('get a tensorboard url', done => { |
| 319 | + // const tensorboardUrl = 'http://tensorboard.view/abc'; |
| 320 | + // const mockedGetTensorboardInstance: jest.Mock = getTensorboardInstance as any; |
| 321 | + // mockedGetTensorboardInstance.mockImplementationOnce(logdir => |
| 322 | + // logdir === 'hello/world' |
| 323 | + // ? Promise.resolve(tensorboardUrl) |
| 324 | + // : Promise.reject('Invalid logdir.'), |
| 325 | + // ); |
| 326 | + // const configs = loadConfigs(argv, {}); |
| 327 | + // app = new UIServer(configs); |
| 328 | + |
| 329 | + // const request = requests(app.start()); |
| 330 | + // request.get('/apps/tensorboard?logdir=hello%2Fworld') |
| 331 | + // .expect(200, tensorboardUrl, done); |
| 332 | + // }); |
| 333 | +}); |
0 commit comments