// Copyright 2018 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 * as express from 'express'; import { Response } from 'express-serve-static-core'; import * as fs from 'fs'; import * as _path from 'path'; import { ApiExperiment, ApiListExperimentsResponse } from '../src/apis/experiment'; import { ApiFilter, PredicateOp } from '../src/apis/filter'; import { ApiJob, ApiListJobsResponse } from '../src/apis/job'; import { ApiListPipelinesResponse, ApiListPipelineVersionsResponse, ApiPipeline, ApiPipelineVersion, } from '../src/apis/pipeline'; import { ApiListRunsResponse, ApiResourceType, ApiRun, ApiRunStorageState } from '../src/apis/run'; import { ExperimentSortKeys, PipelineSortKeys, RunSortKeys } from '../src/lib/Apis'; import RunUtils from '../src/lib/RunUtils'; import { data as fixedData, namedPipelines, PIPELINE_VERSIONS_LIST_FULL, PIPELINE_VERSIONS_LIST_MAP, v2PipelineSpecMap, } from './fixed-data'; import helloWorldRuntime from './data/v1/runtime/integration-test-runtime'; import proxyMiddleware from './proxy-middleware'; const rocMetadataJsonPath = './eval-output/metadata.json'; const rocMetadataJsonPath2 = './eval-output/metadata2.json'; const rocDataPath = './eval-output/roc.csv'; const rocDataPath2 = './eval-output/roc2.csv'; const tableDataPath = './eval-output/table.csv'; const confusionMatrixMetadataJsonPath = './model-output/metadata.json'; const confusionMatrixPath = './model-output/confusion_matrix.csv'; const helloWorldHtmlPath = './model-output/hello-world.html'; const helloWorldBigHtmlPath = './model-output/hello-world-big.html'; const v1beta1Prefix = '/apis/v1beta1'; let tensorboardPod = ''; // This is a copy of the BaseResource defined within src/pages/ResourceSelector interface BaseResource { id?: string; created_at?: Date; description?: string; name?: string; error?: string; } // tslint:disable-next-line:no-default-export export default (app: express.Application) => { app.use((req, _, next) => { // tslint:disable-next-line:no-console console.info(req.method + ' ' + req.originalUrl); next(); }); proxyMiddleware(app as any, v1beta1Prefix); app.set('json spaces', 2); app.use(express.json()); app.get(v1beta1Prefix + '/healthz', (_, res) => { res.header('Content-Type', 'application/json'); res.send({ apiServerCommitHash: 'd3c4add0a95e930c70a330466d0923827784eb9a', apiServerReady: true, buildDate: 'Wed Jan 9 19:40:24 UTC 2019', frontendCommitHash: '8efb2fcff9f666ba5b101647e909dc9c6889cecb', }); }); app.get('/hub/', (_, res) => { res.sendStatus(200); }); function getSortKeyAndOrder( defaultSortKey: string, queryParam?: string, ): { desc: boolean; key: string } { let key = defaultSortKey; let desc = false; if (queryParam) { const keyParts = queryParam.split(' '); key = keyParts[0]; // Check that the key is properly formatted. if ( keyParts.length > 2 || (keyParts.length === 2 && keyParts[1] !== 'asc' && keyParts[1] !== 'desc') ) { throw new Error(`Invalid sort string: ${queryParam}`); } desc = keyParts.length === 2 && keyParts[1] === 'desc'; } return { desc, key }; } app.get(v1beta1Prefix + '/jobs', (req, res) => { res.header('Content-Type', 'application/json'); // Note: the way that we use the next_page_token here may not reflect the way the backend works. const response: ApiListJobsResponse = { jobs: [], next_page_token: '', }; let jobs: ApiJob[] = fixedData.jobs; if (req.query.filter) { jobs = filterResources(fixedData.jobs, req.query.filter); } const { desc, key } = getSortKeyAndOrder(ExperimentSortKeys.CREATED_AT, req.query.sort_by); jobs.sort((a, b) => { let result = 1; if (a[key]! < b[key]!) { result = -1; } if (a[key]! === b[key]!) { result = 0; } return result * (desc ? -1 : 1); }); const start = req.query.page_token ? +req.query.page_token : 0; const end = start + (+req.query.page_size || 20); response.jobs = jobs.slice(start, end); if (end < jobs.length) { response.next_page_token = end + ''; } res.json(response); }); app.get(v1beta1Prefix + '/experiments', (req, res) => { res.header('Content-Type', 'application/json'); // Note: the way that we use the next_page_token here may not reflect the way the backend works. const response: ApiListExperimentsResponse = { experiments: [], next_page_token: '', }; let experiments: ApiExperiment[] = fixedData.experiments; if (req.query.filter) { experiments = filterResources(fixedData.experiments, req.query.filter); } const { desc, key } = getSortKeyAndOrder(ExperimentSortKeys.NAME, req.query.sortBy); experiments.sort((a, b) => { let result = 1; if (a[key]! < b[key]!) { result = -1; } if (a[key]! === b[key]!) { result = 0; } return result * (desc ? -1 : 1); }); const start = req.query.pageToken ? +req.query.pageToken : 0; const end = start + (+req.query.pageSize || 20); response.experiments = experiments.slice(start, end); if (end < experiments.length) { response.next_page_token = end + ''; } res.json(response); }); app.post(v1beta1Prefix + '/experiments', (req, res) => { const experiment: ApiExperiment = req.body; if (fixedData.experiments.find(e => e.name!.toLowerCase() === experiment.name!.toLowerCase())) { res.status(404).send('An experiment with the same name already exists'); return; } experiment.id = 'new-experiment-' + (fixedData.experiments.length + 1); fixedData.experiments.push(experiment); setTimeout(() => { res.send(fixedData.experiments[fixedData.experiments.length - 1]); }, 1000); }); app.get(v1beta1Prefix + '/experiments/:eid', (req, res) => { res.header('Content-Type', 'application/json'); const experiment = fixedData.experiments.find(exp => exp.id === req.params.eid); if (!experiment) { res.status(404).send(`No experiment was found with ID: ${req.params.eid}`); return; } res.json(experiment); }); app.post(v1beta1Prefix + '/jobs', (req, res) => { const job: ApiJob = req.body; job.id = 'new-job-' + (fixedData.jobs.length + 1); job.created_at = new Date(); job.updated_at = new Date(); job.enabled = !!job.trigger; fixedData.jobs.push(job); const date = new Date().toISOString(); fixedData.runs.push({ pipeline_runtime: { workflow_manifest: JSON.stringify(helloWorldRuntime), }, run: { created_at: new Date(), id: 'job-at-' + date, name: 'job-' + job.name + date, scheduled_at: new Date(), status: 'Running', }, }); setTimeout(() => { res.send(fixedData.jobs[fixedData.jobs.length - 1]); }, 1000); }); app.all(v1beta1Prefix + '/jobs/:jid', (req, res) => { res.header('Content-Type', 'application/json'); switch (req.method) { case 'DELETE': const i = fixedData.jobs.findIndex(j => j.id === req.params.jid); if (fixedData.jobs[i].name!.startsWith('Cannot be deleted')) { res.status(502).send(`Deletion failed for job: '${fixedData.jobs[i].name}'`); } else { // Delete the job from fixedData. fixedData.jobs.splice(i, 1); res.json({}); } break; case 'GET': const job = fixedData.jobs.find(j => j.id === req.params.jid); if (job) { res.json(job); } else { res.status(404).send(`No job was found with ID: ${req.params.jid}`); } break; default: res.status(405).send('Unsupported request type: ' + req.method); } }); app.get(v1beta1Prefix + '/runs', (req, res) => { res.header('Content-Type', 'application/json'); // Note: the way that we use the next_page_token here may not reflect the way the backend works. const response: ApiListRunsResponse = { next_page_token: '', runs: [], }; let runs: ApiRun[] = fixedData.runs.map(r => r.run!); if (req.query.filter) { runs = filterResources(runs, req.query.filter); } if (req.query['resource_reference_key.type'] === ApiResourceType.EXPERIMENT) { runs = runs.filter(r => RunUtils.getAllExperimentReferences(r).some( ref => (ref.key && ref.key.id && ref.key.id === req.query['resource_reference_key.id']) || false, ), ); } const { desc, key } = getSortKeyAndOrder(RunSortKeys.CREATED_AT, req.query.sort_by); runs.sort((a, b) => { let result = 1; if (a[key]! < b[key]!) { result = -1; } if (a[key]! === b[key]!) { result = 0; } return result * (desc ? -1 : 1); }); const start = req.query.page_token ? +req.query.page_token : 0; const end = start + (+req.query.page_size || 20); response.runs = runs.slice(start, end); if (end < runs.length) { response.next_page_token = end + ''; } res.json(response); }); app.get(v1beta1Prefix + '/runs/:rid', (req, res) => { const rid = req.params.rid; const run = fixedData.runs.find(r => r.run!.id === rid); if (!run) { res.status(404).send('Cannot find a run with id: ' + rid); return; } res.json(run); }); app.post(v1beta1Prefix + '/runs', (req, res) => { const date = new Date(); const run: ApiRun = req.body; run.id = 'new-run-' + (fixedData.runs.length + 1); run.created_at = date; run.scheduled_at = date; run.status = 'Running'; fixedData.runs.push({ pipeline_runtime: { workflow_manifest: JSON.stringify(helloWorldRuntime), }, run, }); setTimeout(() => { res.send(fixedData.jobs[fixedData.jobs.length - 1]); }, 1000); }); app.post(v1beta1Prefix + '/runs/:rid::method', (req, res) => { if (req.params.method !== 'archive' && req.params.method !== 'unarchive') { res.status(500).send('Bad method'); } const runDetail = fixedData.runs.find(r => r.run!.id === req.params.rid); if (runDetail) { runDetail.run!.storage_state = req.params.method === 'archive' ? ApiRunStorageState.ARCHIVED : ApiRunStorageState.AVAILABLE; res.json({}); } else { res.status(500).send('Cannot find a run with id ' + req.params.rid); } }); app.post(v1beta1Prefix + '/jobs/:jid/enable', (req, res) => { setTimeout(() => { const job = fixedData.jobs.find(j => j.id === req.params.jid); if (job) { job.enabled = true; res.json({}); } else { res.status(500).send('Cannot find a job with id ' + req.params.jid); } }, 1000); }); app.post(v1beta1Prefix + '/jobs/:jid/disable', (req, res) => { setTimeout(() => { const job = fixedData.jobs.find(j => j.id === req.params.jid); if (job) { job.enabled = false; res.json({}); } else { res.status(500).send('Cannot find a job with id ' + req.params.jid); } }, 1000); }); function filterResources(resources: BaseResource[], filterString?: string): BaseResource[] { if (!filterString) { return resources; } const filter: ApiFilter = JSON.parse(decodeURIComponent(filterString)); ((filter && filter.predicates) || []).forEach(p => { resources = resources.filter(r => { switch (p.op) { case PredicateOp.EQUALS: if (p.key === 'name') { return ( r.name && r.name.toLocaleLowerCase() === (p.string_value || '').toLocaleLowerCase() ); } else if (p.key === 'storage_state') { return ( (r as ApiRun).storage_state && (r as ApiRun).storage_state!.toString() === p.string_value ); } else { throw new Error(`Key: ${p.key} is not yet supported by the mock API server`); } case PredicateOp.NOTEQUALS: if (p.key === 'name') { return ( r.name && r.name.toLocaleLowerCase() !== (p.string_value || '').toLocaleLowerCase() ); } else if (p.key === 'storage_state') { return ((r as ApiRun).storage_state || {}).toString() !== p.string_value; } else { throw new Error(`Key: ${p.key} is not yet supported by the mock API server`); } case PredicateOp.ISSUBSTRING: if (p.key !== 'name') { throw new Error(`Key: ${p.key} is not yet supported by the mock API server`); } return ( r.name && r.name.toLocaleLowerCase().includes((p.string_value || '').toLocaleLowerCase()) ); case PredicateOp.NOTEQUALS: // Fall through case PredicateOp.GREATERTHAN: // Fall through case PredicateOp.GREATERTHANEQUALS: // Fall through case PredicateOp.LESSTHAN: // Fall through case PredicateOp.LESSTHANEQUALS: // Fall through throw new Error(`Op: ${p.op} is not yet supported by the mock API server`); default: throw new Error(`Unknown Predicate op: ${p.op}`); } }); }); return resources; } app.get(v1beta1Prefix + '/pipelines', (req, res) => { res.header('Content-Type', 'application/json'); const response: ApiListPipelinesResponse = { next_page_token: '', pipelines: [], }; let pipelines: ApiPipeline[] = fixedData.pipelines; if (req.query.filter) { pipelines = filterResources(fixedData.pipelines, req.query.filter); } const { desc, key } = getSortKeyAndOrder(PipelineSortKeys.CREATED_AT, req.query.sort_by); pipelines.sort((a, b) => { let result = 1; if (a[key]! < b[key]!) { result = -1; } if (a[key]! === b[key]!) { result = 0; } return result * (desc ? -1 : 1); }); const start = req.query.page_token ? +req.query.page_token : 0; const end = start + (+req.query.page_size || 20); response.pipelines = pipelines.slice(start, end); if (end < pipelines.length) { response.next_page_token = end + ''; } res.json(response); }); app.delete(v1beta1Prefix + '/pipelines/:pid', (req, res) => { res.header('Content-Type', 'application/json'); const i = fixedData.pipelines.findIndex(p => p.id === req.params.pid); if (i === -1) { res.status(404).send(`No pipelines was found with ID: ${req.params.pid}`); return; } if (fixedData.pipelines[i].name!.startsWith('Cannot be deleted')) { res.status(502).send(`Deletion failed for pipeline: '${fixedData.pipelines[i].name}'`); return; } // Delete the pipelines from fixedData. fixedData.pipelines.splice(i, 1); res.json({}); }); app.get(v1beta1Prefix + '/pipelines/:pid', (req, res) => { res.header('Content-Type', 'application/json'); const pipeline = fixedData.pipelines.find(p => p.id === req.params.pid); if (!pipeline) { res.status(404).send(`No pipeline was found with ID: ${req.params.pid}`); return; } res.json(pipeline); }); app.get(v1beta1Prefix + '/pipelines/:pid/templates', (req, res) => { res.header('Content-Type', 'text/x-yaml'); const pipeline = fixedData.pipelines.find(p => p.id === req.params.pid); if (!pipeline) { res.status(404).send(`No pipeline was found with ID: ${req.params.pid}`); return; } let filePath = ''; if (req.params.pid === namedPipelines.noParams.id) { filePath = './mock-backend/data/v1/template/mock-conditional-template.yaml'; } else if (req.params.pid === namedPipelines.unstructuredText.id) { filePath = './mock-backend/data/v1/template/mock-recursive-template.yaml'; } else { filePath = './mock-backend/data/v1/template/mock-template.yaml'; } if (v2PipelineSpecMap.has(req.params.pid)) { const specPath = v2PipelineSpecMap.get(req.params.pid); if (specPath) { filePath = specPath; } console.log(filePath); } res.send(JSON.stringify({ template: fs.readFileSync(filePath, 'utf-8') })); }); app.get(v1beta1Prefix + '/pipeline_versions/:pid/templates', (req, res) => { res.header('Content-Type', 'text/x-yaml'); // Find v2 pipeline template const templatePath = v2PipelineSpecMap.get(req.params.pid); if (templatePath != null) { console.log(templatePath); res.send(JSON.stringify({ template: fs.readFileSync(templatePath, 'utf-8') })); return; } // Default and v1 version list. Return mock template consistently. const version = fixedData.versions.find(p => p.id === req.params.pid); if (!version) { res.status(404).send(`No pipeline was found with ID: ${req.params.pid}`); return; } const filePath = './mock-backend/mock-recursive-template.yaml'; res.send(JSON.stringify({ template: fs.readFileSync(filePath, 'utf-8') })); }); app.get(v1beta1Prefix + '/pipeline_versions/:pid', (req, res) => { res.header('Content-Type', 'application/json'); const pipeline = PIPELINE_VERSIONS_LIST_FULL.find(p => p.id === req.params.pid); if (!pipeline) { res.status(404).send(`No pipeline was found with ID: ${req.params.pid}`); return; } res.json(pipeline); }); app.get(v1beta1Prefix + '/pipeline_versions', (req, res) => { // Sample query format: // query: { // 'resource_key.type': 'PIPELINE', // 'resource_key.id': '8fbe3bd6-a01f-11e8-98d0-529269fb1459', // page_size: '50', // sort_by: 'created_at desc' // }, if ( req.query['resource_key.id'] && req.query['resource_key.type'] === 'PIPELINE' && req.query.page_size > 0 ) { const response: ApiListPipelineVersionsResponse = { next_page_token: '', versions: [], }; let versions: ApiPipelineVersion[] = PIPELINE_VERSIONS_LIST_MAP.get(req.query['resource_key.id']) || []; if (versions.length === 0) { const pipeline = fixedData.pipelines.find(p => p.id === req.query['resource_key.id']); if (pipeline == null || !pipeline.default_version) { return; } // Default version list is pipeline with single default version. const pipeline_versions_list_response: ApiListPipelineVersionsResponse = { total_size: 1, versions: [pipeline.default_version], }; res.json(pipeline_versions_list_response); return; } const start = req.query.page_token ? +req.query.page_token : 0; const end = start + (+req.query.page_size || 20); response.versions = versions.slice(start, end); if (end < versions.length) { response.next_page_token = end + ''; } res.json(response); return; } return; }); app.get(v1beta1Prefix + '/pipeline_versions/:pid', (req, res) => { // TODO: Temporary returning default version only. It requires // keeping a record of all pipeline id in order to search non-default version. res.header('Content-Type', 'application/json'); const pipeline = fixedData.pipelines.find(p => p.id === req.params.pid); if (!pipeline) { res .status(404) .send( `No pipeline found with ID: ${req.params.pid}, non-default version can't be found yet.`, ); return; } if (pipeline.default_version) { res.json(pipeline.default_version); } }); function mockCreatePipeline(res: Response, name: string, body?: any): void { res.header('Content-Type', 'application/json'); // Don't allow uploading multiple pipelines with the same name if (fixedData.pipelines.find(p => p.name === name)) { res .status(502) .send(`A Pipeline named: "${name}" already exists. Please choose a different name.`); } else { const pipeline = body || {}; pipeline.id = 'new-pipeline-' + (fixedData.pipelines.length + 1); pipeline.name = name; pipeline.created_at = new Date(); pipeline.description = 'TODO: the mock middleware does not actually use the uploaded pipeline'; pipeline.parameters = [ { name: 'output', }, { name: 'param-1', }, { name: 'param-2', }, ]; fixedData.pipelines.push(pipeline); setTimeout(() => { res.send(fixedData.pipelines[fixedData.pipelines.length - 1]); }, 1000); } } app.post(v1beta1Prefix + '/pipelines', (req, res) => { mockCreatePipeline(res, req.body.name); }); app.post(v1beta1Prefix + '/pipelines/upload', (req, res) => { mockCreatePipeline(res, decodeURIComponent(req.query.name), req.body); }); app.get('/artifacts/get', (req, res) => { const key = decodeURIComponent(req.query.key); res.header('Content-Type', 'application/json'); if (key.endsWith('roc.csv')) { res.sendFile(_path.resolve(__dirname, rocDataPath)); } else if (key.endsWith('roc2.csv')) { res.sendFile(_path.resolve(__dirname, rocDataPath2)); } else if (key.endsWith('confusion_matrix.csv')) { res.sendFile(_path.resolve(__dirname, confusionMatrixPath)); } else if (key.endsWith('table.csv')) { res.sendFile(_path.resolve(__dirname, tableDataPath)); } else if (key.endsWith('hello-world.html')) { res.sendFile(_path.resolve(__dirname, helloWorldHtmlPath)); } else if (key.endsWith('hello-world-big.html')) { res.sendFile(_path.resolve(__dirname, helloWorldBigHtmlPath)); } else if (key === 'analysis') { res.sendFile(_path.resolve(__dirname, confusionMatrixMetadataJsonPath)); } else if (key === 'analysis2') { res.sendFile(_path.resolve(__dirname, confusionMatrixMetadataJsonPath)); } else if (key === 'model') { res.sendFile(_path.resolve(__dirname, rocMetadataJsonPath)); } else if (key === 'model2') { res.sendFile(_path.resolve(__dirname, rocMetadataJsonPath2)); } else { // TODO: what does production return here? res.send('dummy file for key: ' + key); } }); app.get('/apps/tensorboard', (req, res) => { res.send(tensorboardPod); }); app.post('/apps/tensorboard', (req, res) => { tensorboardPod = 'http://tensorboardserver:port'; setTimeout(() => { res.send('ok'); }, 1000); }); app.get('/k8s/pod/logs', (req, res) => { const podName = decodeURIComponent(req.query.podname); if (podName === 'json-12abc') { res.status(404).send('pod not found'); return; } if (podName === 'coinflip-recursive-q7dqb-3721646052') { res.status(500).send('Failed to retrieve log'); return; } const shortLog = fs.readFileSync('./mock-backend/shortlog.txt', 'utf-8'); const longLog = fs.readFileSync('./mock-backend/longlog.txt', 'utf-8'); const log = podName === 'coinflip-recursive-q7dqb-3466727817' ? longLog : shortLog; setTimeout(() => { res.send(log); }, 300); }); app.get('/visualizations/allowed', (req, res) => { res.send(true); }); // Uncomment this instead to test 404 endpoints. // app.get('/system/cluster-name', (_, res) => { // res.status(404).send('404 Not Found'); // }); // app.get('/system/project-id', (_, res) => { // res.status(404).send('404 Not Found'); // }); app.get('/system/cluster-name', (_, res) => { res.send('mock-cluster-name'); }); app.get('/system/project-id', (_, res) => { res.send('mock-project-id'); }); app.all(v1beta1Prefix + '*', (req, res) => { res.status(404).send('Bad request endpoint.'); }); };