Skip to content

Commit

Permalink
feat: move jupyter and jupyterlab to single host
Browse files Browse the repository at this point in the history
  • Loading branch information
andrew-longmore-tessella committed May 13, 2021
1 parent 871211a commit 3a94761
Show file tree
Hide file tree
Showing 24 changed files with 378 additions and 36 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# 47. Remove need for wildcard certificates

Date: 2021-05-13

## Status

Accepted

## Context

DataLabs makes extensive use of reverse proxying, to give users access to resources (such as Minio or JupyterLabs). These resources need individually from an external URL to an internal service URL. There are four design options for reverse proxying (<http://sawers.com/blog/reverse-proxying-with-nginx/>):

1. Subdomain - this allows the external path and internal path to be the same, probably with a default root base path (/). Different services are identified by the external URL's hostname. This has some disadvantages - multiple hostnames require a wildcard certificate, or multiple certificates if a wildcard certificate can not be acquired; and it makes the development environment more difficult, because you can not just use localhost.
2. Port - this also allows the external path and internal path to be the same, probably with a default root base path (/). Different services are identified by the external URL's port. This has the disadvantage that some organisational firewalls restrict http traffic to unusual hosts.
3. Symmetric Path - this allows the external path and internal path to be the same, but with that path configured. Different services are identified by the path. This is the best option, but the internal service must allow the path to be configurable.
4. Asymmetric Path - here the external and internal paths are different. Different services are identifiable by the external path. This requires a search-and-replace of the path on the rendered HTML and JavaScript, so unless these are simple, then this is too fragile.

Historically DataLabs has used Subdomain proxying.

## Decision

Where possible, Symmetric Path or Asymmetric Path proxying should be used. If this is not possible, a ConfigMap option should determine whether the remaining proxying strategy should be Subdomain or Port proxying.

## Consequences

* Developer effort is required to develop the different proxying strategies.
* Portability will be improved and local development will be simplified.
4 changes: 2 additions & 2 deletions code/workspaces/common/src/config/catalogue.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import data from './catalogue_asset_repo_config.json';

export const catalogueAvailable = () => data.catalogue && data.catalogue.available;
export const catalogueServer = () => catalogueAvailable() && data.catalogue.storage.server;
export const catalogueFileLocation = () => catalogueAvailable() && data.catalogue.storage.rootDirectory;
export const catalogueServer = () => (catalogueAvailable() ? data.catalogue.storage.server : null);
export const catalogueFileLocation = () => (catalogueAvailable() ? data.catalogue.storage.rootDirectory : null);
11 changes: 11 additions & 0 deletions code/workspaces/common/src/stackTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ const PROJECT = 'project';
const RSHINY = 'rshiny';
const RSTUDIO = 'rstudio';
const ZEPPELIN = 'zeppelin';
const singleHostNameTypes = [JUPYTER, JUPYTERLAB];

// returns true if this stack type can be handled by a single host name
const isSingleHostName = type => singleHostNameTypes.includes(type);

// base path for resources
const basePath = (type, projectKey, name) => (isSingleHostName(type)
? `/resource/${projectKey}/${name}`
: '/');

export {
DASK,
Expand All @@ -19,4 +28,6 @@ export {
RSHINY,
RSTUDIO,
ZEPPELIN,
isSingleHostName,
basePath,
};
15 changes: 15 additions & 0 deletions code/workspaces/common/src/stackTypes.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { JUPYTER, basePath } from './stackTypes';

describe('stackTypes', () => {
describe('basePath', () => {
const projectKey = 'project-key';
const name = 'name';
it('gives custom base path if type is for single hostname', () => {
expect(basePath(JUPYTER, projectKey, name)).toEqual('/resource/project-key/name');
});

it('gives root base path if type is for multiple hostname', () => {
expect(basePath('assumed-to-be-multiple', projectKey, name)).toEqual('/');
});
});
});
2 changes: 1 addition & 1 deletion code/workspaces/infrastructure-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@
"lodash": "4.17.19",
"mongoose": "5.7.5",
"mustache": "3.0.1",
"uuid": "3.3.2",
"service-chassis": "^0.1.0",
"uuid": "3.3.2",
"winston": "3.2.1"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ spec:
name: {{ name }}
command: ["start.sh",
"jupyter", "{{ startCmd }}",
"--NotebookApp.token='$(JUPYTER_TOKEN)'",
"--NotebookApp.allow_origin='{{ domain }}'",
"--NotebookApp.notebook_dir='/data/notebooks/{{ name }}'"]
"--NotebookApp.token=$(JUPYTER_TOKEN)",
"--NotebookApp.allow_origin={{ domain }}",
"--NotebookApp.notebook_dir=/data/notebooks/{{ name }}",
"--NotebookApp.base_url={{{ basePath }}}"]
ports:
- containerPort: 8888
protocol: TCP
Expand Down Expand Up @@ -77,7 +78,7 @@ spec:
memory: 8Gi
livenessProbe:
httpGet:
path: /
path: {{{ basePath }}}
port: 8888
initialDelaySeconds: 5
periodSeconds: 10
Expand Down
6 changes: 6 additions & 0 deletions code/workspaces/infrastructure-api/src/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ const config = convict({
default: 'info',
env: 'LOG_LEVEL',
},
datalabName: {
doc: 'The name of the datalab',
format: 'String',
default: 'testlab',
env: 'DATALAB_NAME',
},
datalabDomain: {
doc: 'The domain the datalabs instance is running on',
format: 'String',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,9 +274,10 @@ spec:
name: deployment-name
command: [\\"start.sh\\",
\\"jupyter\\", \\"lab\\",
\\"--NotebookApp.token='$(JUPYTER_TOKEN)'\\",
\\"--NotebookApp.allow_origin='project-key-notebook-name.datalabs.localhost'\\",
\\"--NotebookApp.notebook_dir='/data/notebooks/deployment-name'\\"]
\\"--NotebookApp.token=$(JUPYTER_TOKEN)\\",
\\"--NotebookApp.allow_origin=project-key-notebook-name.datalabs.localhost\\",
\\"--NotebookApp.notebook_dir=/data/notebooks/deployment-name\\",
\\"--NotebookApp.base_url=/resource/project-key/notebook-name\\"]
ports:
- containerPort: 8888
protocol: TCP
Expand Down Expand Up @@ -305,7 +306,7 @@ spec:
memory: 8Gi
livenessProbe:
httpGet:
path: /
path: /resource/project-key/notebook-name
port: 8888
initialDelaySeconds: 5
periodSeconds: 10
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,28 @@ spec:
servicePort: 80
"
`;

exports[`Ingress generator should use datalab hostname and custom path for single hostname type 1`] = `
"---
apiVersion: networking.k8s.io/v1beta1
kind: Ingress
metadata:
name: name-ingress
annotations:
nginx.ingress.kubernetes.io/auth-url: http://localhost:9000/auth
nginx.ingress.kubernetes.io/auth-signin: https://testlab.test-datalabs.nerc.ac.uk
nginx.ingress.kubernetes.io/proxy-body-size: 50g
spec:
tls:
- hosts:
- testlab.datalabs.localhost
rules:
- host: testlab.datalabs.localhost
http:
paths:
- path: /resource/project/name
backend:
serviceName: name-service
servicePort: 80
"
`;
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { imageConfig, image, defaultImage } from 'common/src/config/images';
import { stackTypes } from 'common';
import { DeploymentTemplates, ServiceTemplates, generateManifest, ConfigMapTemplates, NetworkPolicyTemplates, AutoScalerTemplates } from './manifestGenerator';
import nameGenerator from '../common/nameGenerators';
import config from '../config/config';
import logger from '../config/logger';

const { basePath } = stackTypes;

const containerInfo = imageConfig();

function getImage(type, version) {
Expand All @@ -15,13 +18,14 @@ function getImage(type, version) {
}
}

function createJupyterDeployment({ projectKey, deploymentName, notebookName, type, volumeMount, version }) {
function createJupyterDeployment({ projectKey, deploymentName, name, type, volumeMount, version }) {
const startCmd = type === 'jupyterlab' ? 'lab' : 'notebook';
const img = getImage(type, version);
const context = {
name: deploymentName,
grantSudo: 'yes',
domain: `${projectKey}-${notebookName}.${config.get('datalabDomain')}`,
domain: `${projectKey}-${name}.${config.get('datalabDomain')}`,
basePath: basePath(type, projectKey, name),
jupyter: {
image: img.image,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('deploymentGenerator', () => {
const params = {
projectKey: 'project-key',
deploymentName: 'deployment-name',
notebookName: 'notebook-name',
name: 'notebook-name',
type: 'jupyterlab',
volumeMount: 'volume-mount',
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { isSingleHostName, basePath } from 'common/src/stackTypes';
import { join } from 'path';
import config from '../config/config';
import { IngressTemplates, generateManifest } from './manifestGenerator';

function createIngress({ name, projectKey, ingressName, serviceName, port,
connectPort, rewriteTarget, proxyTimeout, visible, proxyRequestBuffering }) {
const host = createSniInfo(name, projectKey);
const paths = createPathInfo(serviceName, port, connectPort);
connectPort, rewriteTarget, proxyTimeout, visible, proxyRequestBuffering, type }) {
const host = createSniInfo(name, projectKey, type);
const paths = createPathInfo(serviceName, port, connectPort, type, projectKey, name);
const privateEndpoint = visible !== 'public';
const authServiceUrlRoot = config.get('authorisationServiceForIngress') || config.get('authorisationService');
const context = {
Expand All @@ -22,21 +24,26 @@ function createIngress({ name, projectKey, ingressName, serviceName, port,
return generateManifest(context, IngressTemplates.DEFAULT_INGRESS);
}

function createSniInfo(name, projectKey) {
return `${projectKey}-${name}.${config.get('datalabDomain')}`;
function createSniInfo(name, projectKey, type) {
const domain = config.get('datalabDomain');
const lab = config.get('datalabName');
return isSingleHostName(type)
? `${lab}.${domain}`
: `${projectKey}-${name}.${domain}`;
}

function createPathInfo(serviceName, port, connectPort) {
function createPathInfo(serviceName, port, connectPort, type, projectKey, name) {
const paths = [];
const base = basePath(type, projectKey, name);
paths.push({
path: '/',
path: base,
serviceName,
servicePort: port,
});

if (connectPort) {
paths.push({
path: '/connect',
path: join(base, 'connect'),
serviceName,
servicePort: connectPort,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { JUPYTER } from 'common/src/stackTypes';
import ingressGenerator from './ingressGenerator';
import config from '../config/config';

Expand Down Expand Up @@ -104,4 +105,18 @@ describe('Ingress generator', () => {

return expect(template).resolves.toMatchSnapshot();
});

it('should use datalab hostname and custom path for single hostname type', () => {
const options = {
name: 'name',
projectKey: 'project',
ingressName: 'name-ingress',
serviceName: 'name-service',
port: 80,
type: JUPYTER,
};
const template = ingressGenerator.createIngress(options);

return expect(template).resolves.toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Array [
"projectKey": "project",
"status": "requested",
"type": "jupyter",
"url": "https://project-expectedName.datalabs.localhost",
"url": "https://testlab.datalabs.localhost/resource/project/expectedName",
},
],
]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import chalk from 'chalk';
import { stackTypes } from 'common';
import logger from '../config/logger';
import deploymentApi from '../kubernetes/deploymentApi';
import serviceApi from '../kubernetes/serviceApi';
Expand All @@ -10,6 +11,8 @@ import autoScalerApi from '../kubernetes/autoScalerApi';
import deploymentGenerator from '../kubernetes/deploymentGenerator';
import nameGenerator from '../common/nameGenerators';

const { basePath } = stackTypes;

export const createDeployment = (params, generator) => () => {
const { name, projectKey, type } = params;
const deploymentName = nameGenerator.deploymentName(name, type);
Expand Down Expand Up @@ -89,8 +92,9 @@ export const createIngressRule = (params, generator) => (service) => {
const ingressName = nameGenerator.deploymentName(name, type);
const serviceName = service.metadata.name;
const { port } = service.spec.ports[0];
const base = basePath(type, projectKey, name);

return generator({ ...params, ingressName, serviceName, port })
return generator({ ...params, ingressName, serviceName, port, basePath: base })
.then((manifest) => {
logger.info(`Creating ingress rule ${chalk.blue(ingressName)} with manifest:`);
logger.debug(manifest.toString());
Expand Down
22 changes: 17 additions & 5 deletions code/workspaces/infrastructure-api/src/stacks/stackManager.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import path from 'path';
import { join } from 'path';
import { imageCategory } from 'common/src/config/images';
import { catalogueFileLocation, catalogueServer } from 'common/src/config/catalogue';
import { stackTypes } from 'common';
import logger from '../config/logger';
import config from '../config/config';
import Stacks from './Stacks';
Expand All @@ -10,6 +11,8 @@ import nameGenerator from '../common/nameGenerators';
import deploymentApi from '../kubernetes/deploymentApi';
import centralAssetRepoRepository from '../dataaccess/centralAssetRepoRepository';

const { isSingleHostName, basePath } = stackTypes;

async function createStack(user, params) {
const { projectKey, name, type } = params;
const stack = Stacks.getStack(type);
Expand All @@ -30,13 +33,22 @@ async function createStack(user, params) {
...params,
category: imageCategory(type),
status: status.REQUESTED,
url: `https://${projectKey}-${name}.${config.get('datalabDomain')}`,
url: url(projectKey, name, type),
internalEndpoint: `http://${params.type}-${name}.${projectKey}`,
},
);
return creationResponse;
}

function url(projectKey, name, type) {
const lab = config.get('datalabName');
const domain = config.get('datalabDomain');
const singleHostNameAndPath = join(`${lab}.${domain}`, basePath(type, projectKey, name));
return isSingleHostName(type)
? `https://${singleHostNameAndPath}`
: `https://${projectKey}-${name}.${domain}`;
}

function restartStack(params) {
const { projectKey, name, type } = params;

Expand Down Expand Up @@ -120,13 +132,13 @@ async function createAssetVolumeAndVolumeMountArrays(assetIds) {
name: volumeName,
nfs: {
server: storageServer,
path: path.join(storageLocation, fileLocation),
path: join(storageLocation || '', fileLocation),
readOnly: true,
},
});
assetVolumeMounts.push({
name: volumeName,
mountPath: path.join('/assets', fileLocation),
mountPath: join('/assets', fileLocation),
readOnly: true,
});
});
Expand All @@ -144,4 +156,4 @@ function getContainerNonAssetVolumeMounts(container) {
return volumeMounts.filter(volumeMount => !nameGenerator.isAssetVolume(volumeMount.name));
}

export default { createStack, restartStack, deleteStack, mountAssetsOnStack };
export default { createStack, restartStack, deleteStack, mountAssetsOnStack, url };
Loading

0 comments on commit 3a94761

Please sign in to comment.