pipelines/frontend/mock-backend/mock-api-middleware.ts

752 lines
24 KiB
TypeScript

// 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.');
});
};