Support multicontainer local mode in resin push

Change-type: minor
Signed-off-by: Cameron Diver <cameron@resin.io>
This commit is contained in:
Cameron Diver 2018-10-16 11:25:37 +01:00 committed by Tim Perry
parent c5d4e30e24
commit 947f91d570
11 changed files with 714 additions and 43 deletions

View File

@ -159,7 +159,7 @@ environment variable (in the same standard URL format).
- Push - Push
- [push &#60;application&#62;](#push-application-) - [push &#60;applicationOrDevice&#62;](#push-applicationordevice-)
- Settings - Settings
@ -1249,19 +1249,29 @@ Docker host TLS key file
# Push # Push
## push &#60;application&#62; ## push &#60;applicationOrDevice&#62;
This command can be used to start a build on the remote This command can be used to start a build on the remote
resin.io cloud builders. The given source directory will be sent to the resin.io cloud builders, or a local mode resin device.
When building on the resin cloud the given source directory will be sent to the
resin.io builder, and the build will proceed. This can be used as a drop-in resin.io builder, and the build will proceed. This can be used as a drop-in
replacement for git push to deploy. replacement for git push to deploy.
When building on a local mode device, the given source directory will be built on
device, and the resulting contianers will be run on the device. Logs will be
streamed back from the device as part of the same invocation.
Examples: Examples:
$ resin push myApp $ resin push myApp
$ resin push myApp --source <source directory> $ resin push myApp --source <source directory>
$ resin push myApp -s <source directory> $ resin push myApp -s <source directory>
$ resin push 10.0.0.1
$ resin push 10.0.0.1 --source <source directory>
$ resin push 10.0.0.1 -s <source directory>
### Options ### Options
#### --source, -s &#60;source&#62; #### --source, -s &#60;source&#62;

View File

@ -15,8 +15,33 @@ limitations under the License.
*/ */
import { CommandDefinition } from 'capitano'; import { CommandDefinition } from 'capitano';
import { ResinSDK } from 'resin-sdk';
import { stripIndent } from 'common-tags'; import { stripIndent } from 'common-tags';
import { ResinSDK } from 'resin-sdk';
import { BuildError } from '../utils/device/errors';
// An regex to detect an IP address, from https://www.regular-expressions.info/ip.html
const IP_REGEX = new RegExp(
/\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/,
);
enum BuildTarget {
Cloud,
Device,
}
function getBuildTarget(appOrDevice: string): BuildTarget | null {
// First try the application regex from the api
if (/^[a-zA-Z0-9_-]+$/.test(appOrDevice)) {
return BuildTarget.Cloud;
}
if (IP_REGEX.test(appOrDevice)) {
return BuildTarget.Device;
}
return null;
}
async function getAppOwner(sdk: ResinSDK, appName: string) { async function getAppOwner(sdk: ResinSDK, appName: string) {
const { const {
@ -75,7 +100,7 @@ async function getAppOwner(sdk: ResinSDK, appName: string) {
export const push: CommandDefinition< export const push: CommandDefinition<
{ {
application: string; applicationOrDevice: string;
}, },
{ {
source: string; source: string;
@ -83,19 +108,30 @@ export const push: CommandDefinition<
nocache: boolean; nocache: boolean;
} }
> = { > = {
signature: 'push <application>', signature: 'push <applicationOrDevice>',
description: 'Start a remote build on the resin.io cloud build servers', description:
'Start a remote build on the resin.io cloud build servers or a local mode device',
help: stripIndent` help: stripIndent`
This command can be used to start a build on the remote This command can be used to start a build on the remote
resin.io cloud builders. The given source directory will be sent to the resin.io cloud builders, or a local mode resin device.
When building on the resin cloud the given source directory will be sent to the
resin.io builder, and the build will proceed. This can be used as a drop-in resin.io builder, and the build will proceed. This can be used as a drop-in
replacement for git push to deploy. replacement for git push to deploy.
When building on a local mode device, the given source directory will be built on
device, and the resulting containers will be run on the device. Logs will be
streamed back from the device as part of the same invocation.
Examples: Examples:
$ resin push myApp $ resin push myApp
$ resin push myApp --source <source directory> $ resin push myApp --source <source directory>
$ resin push myApp -s <source directory> $ resin push myApp -s <source directory>
$ resin push 10.0.0.1
$ resin push 10.0.0.1 --source <source directory>
$ resin push 10.0.0.1 -s <source directory>
`, `,
permission: 'user', permission: 'user',
options: [ options: [
@ -123,11 +159,12 @@ export const push: CommandDefinition<
const sdk = (await import('resin-sdk')).fromSharedOptions(); const sdk = (await import('resin-sdk')).fromSharedOptions();
const Bluebird = await import('bluebird'); const Bluebird = await import('bluebird');
const remote = await import('../utils/remote-build'); const remote = await import('../utils/remote-build');
const deviceDeploy = await import('../utils/device/deploy');
const { exitWithExpectedError } = await import('../utils/patterns'); const { exitWithExpectedError } = await import('../utils/patterns');
const app: string | null = params.application; const appOrDevice: string | null = params.applicationOrDevice;
if (app == null) { if (appOrDevice == null) {
exitWithExpectedError('You must specify an application'); exitWithExpectedError('You must specify an application or a device');
} }
const source = options.source || '.'; const source = options.source || '.';
@ -135,6 +172,10 @@ export const push: CommandDefinition<
console.log(`[debug] Using ${source} as build source`); console.log(`[debug] Using ${source} as build source`);
} }
const buildTarget = getBuildTarget(appOrDevice);
switch (buildTarget) {
case BuildTarget.Cloud:
const app = appOrDevice;
Bluebird.join( Bluebird.join(
sdk.auth.getToken(), sdk.auth.getToken(),
sdk.settings.get('resinUrl'), sdk.settings.get('resinUrl'),
@ -156,8 +197,33 @@ export const push: CommandDefinition<
return remote.startRemoteBuild(args); return remote.startRemoteBuild(args);
}, },
).nodeify(done);
break;
case BuildTarget.Device:
const device = appOrDevice;
// TODO: Support passing a different port
Bluebird.resolve(
deviceDeploy.deployToDevice({
source,
deviceHost: device,
}),
) )
.catch(remote.RemoteBuildFailedError, exitWithExpectedError) .catch(BuildError, e => {
exitWithExpectedError(e.toString());
})
.nodeify(done); .nodeify(done);
break;
default:
exitWithExpectedError(
stripIndent`
Build target not recognised. Please provide either an application name or device address.
The only supported device addresses currently are IP addresses.
If you believe your build target should have been detected, and this is an error, please
create an issue.`,
);
break;
}
}, },
}; };

170
lib/utils/device/api.ts Normal file
View File

@ -0,0 +1,170 @@
import * as Bluebird from 'bluebird';
import * as request from 'request';
import * as Stream from 'stream';
import Logger = require('../logger');
import * as ApiErrors from './errors';
export interface DeviceResponse {
[key: string]: any;
status: 'success' | 'failed';
message?: string;
}
export interface DeviceInfo {
deviceType: string;
arch: string;
}
const deviceEndpoints = {
setTargetState: 'v2/local/target-state',
getTargetState: 'v2/local/target-state',
getDeviceInformation: 'v2/local/device-info',
logs: 'v2/local/logs',
ping: 'ping',
};
export class DeviceAPI {
private deviceAddress: string;
public constructor(
private logger: Logger,
addr: string,
port: number = 48484,
) {
this.deviceAddress = `http://${addr}:${port}/`;
}
// Either return nothing, or throw an error with the info
public async setTargetState(state: any): Promise<void> {
const url = this.getUrlForAction('setTargetState');
return DeviceAPI.promisifiedRequest(
request.post,
{
url,
json: true,
body: state,
},
this.logger,
);
}
public async getTargetState(): Promise<any> {
const url = this.getUrlForAction('getTargetState');
return DeviceAPI.promisifiedRequest(
request.get,
{
url,
json: true,
},
this.logger,
).then(body => {
return body.state;
});
}
public async getDeviceInformation(): Promise<DeviceInfo> {
const url = this.getUrlForAction('getDeviceInformation');
return DeviceAPI.promisifiedRequest(
request.get,
{
url,
json: true,
},
this.logger,
).then(body => {
return body.info;
});
}
public async ping(): Promise<void> {
const url = this.getUrlForAction('ping');
return DeviceAPI.promisifiedRequest(
request.get,
{
url,
},
this.logger,
);
}
public getLogStream(): Bluebird<Stream.Readable> {
const url = this.getUrlForAction('logs');
// Don't use the promisified version here as we want to stream the output
return new Bluebird((resolve, reject) => {
const req = request.get(url);
req.on('error', reject).on('response', res => {
if (res.statusCode !== 200) {
reject(
new ApiErrors.DeviceAPIError(
'Non-200 response from log streaming endpoint',
),
);
}
resolve(res);
});
});
}
private getUrlForAction(action: keyof typeof deviceEndpoints): string {
return `${this.deviceAddress}${deviceEndpoints[action]}`;
}
// A helper method for promisifying general (non-streaming) requests. Streaming
// requests should use a seperate setup
private static async promisifiedRequest<T>(
requestMethod: (
opts: T,
cb: (err?: any, res?: any, body?: any) => void,
) => void,
opts: T,
logger?: Logger,
): Promise<any> {
const Bluebird = await import('bluebird');
const _ = await import('lodash');
type ObjectWithUrl = { url?: string };
if (logger != null) {
let url: string | null = null;
if (_.isObject(opts) && (opts as ObjectWithUrl).url != null) {
// the `as string` shouldn't be necessary, but the type system
// is getting a little confused
url = (opts as ObjectWithUrl).url as string;
} else if (_.isString(opts)) {
url = opts;
}
if (url != null) {
logger.logDebug(`Sending request to ${url}`);
}
}
return Bluebird.fromCallback(
cb => {
return requestMethod(opts, cb);
},
{ multiArgs: true },
).then(([response, body]) => {
switch (response.statusCode) {
case 200:
return body;
case 400:
throw new ApiErrors.BadRequestDeviceAPIError(body.message);
case 503:
throw new ApiErrors.ServiceUnavailableAPIError(body.message);
default:
throw new ApiErrors.DeviceAPIError(body.message);
}
});
}
}
export default DeviceAPI;

284
lib/utils/device/deploy.ts Normal file
View File

@ -0,0 +1,284 @@
import * as Bluebird from 'bluebird';
import * as Docker from 'dockerode';
import * as _ from 'lodash';
import { Composition } from 'resin-compose-parse';
import { BuildTask, LocalImage } from 'resin-multibuild';
import { Readable } from 'stream';
import Logger = require('../logger');
import { displayBuildLog } from './logs';
import { DeviceInfo } from './api';
import * as LocalPushErrors from './errors';
// Define the logger here so the debug output
// can be used everywhere
const logger = new Logger();
export interface DeviceDeployOptions {
source: string;
deviceHost: string;
devicePort?: number;
}
async function checkSource(source: string): Promise<boolean> {
const { fs } = await import('mz');
return (await fs.exists(source)) && (await fs.stat(source)).isDirectory();
}
export async function deployToDevice(opts: DeviceDeployOptions): Promise<void> {
const { loadProject, tarDirectory } = await import('../compose');
const { exitWithExpectedError } = await import('../patterns');
const { DeviceAPI } = await import('./api');
const { displayDeviceLogs } = await import('./logs');
if (!(await checkSource(opts.source))) {
exitWithExpectedError(`Could not access source directory: ${opts.source}`);
}
const api = new DeviceAPI(logger, opts.deviceHost);
// TODO: Before merge, replace this with the supervisor version endpoint, to
// ensure we're working with a supervisor version that supports the stuff we need
await api.ping();
logger.logInfo(`Starting build on device ${opts.deviceHost}`);
const project = await loadProject(logger, opts.source, 'local');
// Attempt to attach to the device's docker daemon
const docker = connectToDocker(
opts.deviceHost,
opts.devicePort != null ? opts.devicePort : 2375,
);
const tarStream = await tarDirectory(opts.source);
// Try to detect the device information
const deviceInfo = await api.getDeviceInformation();
await performBuilds(
project.composition,
tarStream,
docker,
deviceInfo,
logger,
);
logger.logDebug('Setting device state...');
// Now set the target state on the device
const currentTargetState = await api.getTargetState();
const targetState = generateTargetState(
currentTargetState,
project.composition,
);
logger.logDebug(`Sending target state: ${JSON.stringify(targetState)}`);
await api.setTargetState(targetState);
// Print an empty newline to seperate the build output
// from the device output
console.log();
logger.logInfo('Streaming device logs...');
// Now all we need to do is stream back the logs
const logStream = await api.getLogStream();
await displayDeviceLogs(logStream, logger);
}
function connectToDocker(host: string, port: number): Docker {
return new Docker({
host,
port,
Promise: Bluebird as any,
});
}
export async function performBuilds(
composition: Composition,
tarStream: Readable,
docker: Docker,
deviceInfo: DeviceInfo,
logger: Logger,
): Promise<void> {
const multibuild = await import('resin-multibuild');
const buildTasks = await multibuild.splitBuildStream(composition, tarStream);
logger.logDebug('Found build tasks:');
_.each(buildTasks, task => {
let infoStr: string;
if (task.external) {
infoStr = `image pull [${task.imageName}]`;
} else {
infoStr = `build [${task.context}]`;
}
logger.logDebug(` ${task.serviceName}: ${infoStr}`);
});
logger.logDebug(
`Resolving services with [${deviceInfo.deviceType}|${deviceInfo.arch}]`,
);
await multibuild.performResolution(
buildTasks,
deviceInfo.arch,
deviceInfo.deviceType,
);
logger.logDebug('Found project types:');
_.each(buildTasks, task => {
if (!task.external) {
logger.logDebug(` ${task.serviceName}: ${task.projectType}`);
} else {
logger.logDebug(` ${task.serviceName}: External image`);
}
});
logger.logDebug('Probing remote daemon for cache images');
await assignDockerBuildOpts(docker, buildTasks);
logger.logDebug('Starting builds...');
await assignOutputHandlers(buildTasks, logger);
const localImages = await multibuild.performBuilds(buildTasks, docker);
// Check for failures
await inspectBuildResults(localImages);
// Now tag any external images with the correct name that they should be,
// as this won't be done by resin-multibuild
await Bluebird.map(localImages, async localImage => {
if (localImage.external) {
// We can be sure that localImage.name is set here, because of the failure code above
const image = docker.getImage(localImage.name!);
await image.tag({
repo: generateImageName(localImage.serviceName),
force: true,
});
await image.remove({ force: true });
}
});
}
function assignOutputHandlers(buildTasks: BuildTask[], logger: Logger) {
_.each(buildTasks, task => {
if (task.external) {
task.progressHook = progressObj => {
displayBuildLog(
{ serviceName: task.serviceName, message: progressObj.progress },
logger,
);
};
} else {
task.streamHook = stream => {
stream.on('data', (buf: Buffer) => {
const str = buf.toString().trimRight();
if (str !== '') {
displayBuildLog(
{ serviceName: task.serviceName, message: str },
logger,
);
}
});
};
}
});
}
async function getDeviceDockerImages(docker: Docker): Promise<string[]> {
const images = await docker.listImages();
return _.map(images, 'Id');
}
// Mutates buildTasks
async function assignDockerBuildOpts(
docker: Docker,
buildTasks: BuildTask[],
): Promise<void> {
// Get all of the images on the remote docker daemon, so
// that we can use all of them for cache
const images = await getDeviceDockerImages(docker);
logger.logDebug(`Using ${images.length} on-device images for cache...`);
_.each(buildTasks, (task: BuildTask) => {
task.dockerOpts = {
cachefrom: images,
labels: {
'io.resin.local.image': '1',
'io.resin.local.service': task.serviceName,
},
t: generateImageName(task.serviceName),
};
});
}
function generateImageName(serviceName: string): string {
return `local_image_${serviceName}:latest`;
}
function generateTargetState(
currentTargetState: any,
composition: Composition,
): any {
const services: { [serviceId: string]: any } = {};
let idx = 1;
_.each(composition.services, (opts, name) => {
// Get rid of any build specific stuff
opts = _.cloneDeep(opts);
delete opts.build;
delete opts.image;
const defaults = {
environment: {},
labels: {},
};
services[idx] = _.merge(defaults, opts, {
imageId: idx,
serviceName: name,
serviceId: idx,
image: generateImageName(name),
running: true,
});
idx += 1;
});
const targetState = _.cloneDeep(currentTargetState);
delete targetState.local.apps;
targetState.local.apps = {
1: {
name: 'localapp',
commit: 'localcommit',
releaseId: '1',
services,
volumes: composition.volumes || {},
networks: composition.networks || {},
},
};
return targetState;
}
async function inspectBuildResults(images: LocalImage[]): Promise<void> {
const { exitWithExpectedError } = await import('../patterns');
const failures: LocalPushErrors.BuildFailure[] = [];
_.each(images, image => {
if (!image.successful) {
failures.push({
error: image.error!,
serviceName: image.serviceName,
});
}
});
if (failures.length > 0) {
exitWithExpectedError(new LocalPushErrors.BuildError(failures));
}
}

View File

@ -0,0 +1,30 @@
import * as _ from 'lodash';
import { TypedError } from 'typed-error';
export interface BuildFailure {
error: Error;
serviceName: string;
}
export class BuildError extends TypedError {
private failures: BuildFailure[];
public constructor(failures: BuildFailure[]) {
super('Build error');
this.failures = failures;
}
public toString(): string {
let str = 'Some services failed to build:\n';
_.each(this.failures, failure => {
str += `\t${failure.serviceName}: ${failure.error.message}\n`;
});
return str;
}
}
export class DeviceAPIError extends TypedError {}
export class BadRequestDeviceAPIError extends DeviceAPIError {}
export class ServiceUnavailableAPIError extends DeviceAPIError {}

83
lib/utils/device/logs.ts Normal file
View File

@ -0,0 +1,83 @@
import * as Bluebird from 'bluebird';
import chalk from 'chalk';
import ColorHash = require('color-hash');
import * as _ from 'lodash';
import { Readable } from 'stream';
import Logger = require('../logger');
interface Log {
message: string;
timestamp?: number;
serviceName?: string;
// There's also a serviceId and imageId, but they're
// meaningless in local mode
}
interface BuildLog {
serviceName: string;
message: string;
}
/**
* Display logs from a device logging stream. This function will return
* when the log stream ends.
*
* @param logs A stream which produces newline seperated log objects
*/
export function displayDeviceLogs(
logs: Readable,
logger: Logger,
): Bluebird<void> {
return new Bluebird((resolve, reject) => {
logs.on('data', log => {
displayLogLine(log, logger);
});
logs.on('error', reject);
logs.on('end', resolve);
});
}
export function displayBuildLog(log: BuildLog, logger: Logger): void {
const toPrint = `${getServiceColourFn(log.serviceName)(
`[${log.serviceName}]`,
)} ${log.message}`;
logger.logBuild(toPrint);
}
// mutates serviceColours
function displayLogLine(log: string | Buffer, logger: Logger): void {
try {
const obj: Log = JSON.parse(log.toString());
let toPrint: string;
if (obj.timestamp != null) {
toPrint = `[${new Date(obj.timestamp).toLocaleString()}]`;
} else {
toPrint = `[${new Date().toLocaleString()}]`;
}
if (obj.serviceName != null) {
const colourFn = getServiceColourFn(obj.serviceName);
toPrint += ` ${colourFn(`[${obj.serviceName}]`)}`;
}
toPrint += ` ${obj.message}`;
logger.logLogs(toPrint);
} catch (e) {
logger.logDebug(`Dropping device log due to failed parsing: ${e}`);
}
}
const getServiceColourFn = _.memoize(_getServiceColourFn);
const colorHash = new ColorHash();
function _getServiceColourFn(serviceName: string): (msg: string) => string {
const [r, g, b] = colorHash.rgb(serviceName);
return chalk.rgb(r, g, b);
}

View File

@ -1,6 +1,6 @@
import { EOL as eol } from 'os';
import _ = require('lodash');
import chalk from 'chalk'; import chalk from 'chalk';
import _ = require('lodash');
import { EOL as eol } from 'os';
import { StreamLogger } from 'resin-stream-logger'; import { StreamLogger } from 'resin-stream-logger';
class Logger { class Logger {

View File

@ -229,7 +229,9 @@ export function inferOrSelectDevice(preferredUuid: string) {
throw new Error("You don't have any devices online"); throw new Error("You don't have any devices online");
} }
const defaultUuid = _.map(onlineDevices, 'uuid').includes(preferredUuid) const defaultUuid = _(onlineDevices)
.map('uuid')
.includes(preferredUuid)
? preferredUuid ? preferredUuid
: onlineDevices[0].uuid; : onlineDevices[0].uuid;

View File

@ -43,7 +43,7 @@
"test:fast": "npm run build:fast && gulp test", "test:fast": "npm run build:fast && gulp test",
"ci": "npm run test && catch-uncommitted", "ci": "npm run test && catch-uncommitted",
"watch": "gulp watch", "watch": "gulp watch",
"prettify": "prettier --write \"{lib,tests,automation,typings}/**/*.ts\"", "prettify": "prettier --write \"{lib,tests,automation,typings}/**/*.ts\" --config ./node_modules/resin-lint/config/.prettierrc",
"lint": "resin-lint lib/ tests/ && resin-lint --typescript automation/ lib/ typings/ tests/", "lint": "resin-lint lib/ tests/ && resin-lint --typescript automation/ lib/ typings/ tests/",
"prepublish": "require-npm4-to-publish", "prepublish": "require-npm4-to-publish",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build"
@ -80,10 +80,10 @@
"gulp-shell": "^0.5.2", "gulp-shell": "^0.5.2",
"mochainon": "^2.0.0", "mochainon": "^2.0.0",
"pkg": "^4.3.0-beta.1", "pkg": "^4.3.0-beta.1",
"prettier": "1.13.5", "prettier": "^1.14.2",
"publish-release": "^1.3.3", "publish-release": "^1.3.3",
"require-npm4-to-publish": "^1.0.0", "require-npm4-to-publish": "^1.0.0",
"resin-lint": "^1.5.0", "resin-lint": "^2.0.0",
"rewire": "^3.0.2", "rewire": "^3.0.2",
"ts-node": "^4.0.1", "ts-node": "^4.0.1",
"typescript": "2.8.1" "typescript": "2.8.1"
@ -104,6 +104,7 @@
"chalk": "^2.3.0", "chalk": "^2.3.0",
"cli-truncate": "^1.1.0", "cli-truncate": "^1.1.0",
"coffeescript": "^1.12.6", "coffeescript": "^1.12.6",
"color-hash": "^1.0.3",
"columnify": "^1.5.2", "columnify": "^1.5.2",
"common-tags": "^1.7.2", "common-tags": "^1.7.2",
"denymount": "^2.2.0", "denymount": "^2.2.0",
@ -139,17 +140,17 @@
"raven": "^2.5.0", "raven": "^2.5.0",
"reconfix": "^0.1.0", "reconfix": "^0.1.0",
"request": "^2.81.0", "request": "^2.81.0",
"resin-bundle-resolve": "^0.5.3", "resin-bundle-resolve": "^0.6.0",
"resin-cli-form": "^2.0.0", "resin-cli-form": "^2.0.0",
"resin-cli-visuals": "^1.4.0", "resin-cli-visuals": "^1.4.0",
"resin-compose-parse": "^1.10.2", "resin-compose-parse": "^2.0.0",
"resin-config-json": "^1.0.0", "resin-config-json": "^1.0.0",
"resin-device-config": "^4.0.0", "resin-device-config": "^4.0.0",
"resin-device-init": "^4.0.0", "resin-device-init": "^4.0.0",
"resin-doodles": "0.0.1", "resin-doodles": "0.0.1",
"resin-image-fs": "^5.0.2", "resin-image-fs": "^5.0.2",
"resin-image-manager": "^5.0.0", "resin-image-manager": "^5.0.0",
"resin-multibuild": "^0.5.1", "resin-multibuild": "^0.9.0",
"resin-preload": "^7.0.0", "resin-preload": "^7.0.0",
"resin-release": "^1.2.0", "resin-release": "^1.2.0",
"resin-sdk": "10.0.0-beta2", "resin-sdk": "10.0.0-beta2",

12
typings/color-hash.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
declare module 'color-hash' {
interface Hasher {
hex(text: string): string;
}
class ColorHash {
hex(text: string): string;
rgb(text: string): [number, number, number];
}
export = ColorHash;
}

13
typings/dockerfile-template.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
declare module 'dockerfile-template' {
/**
* Variables which define what will be replaced, and what they will be replaced with.
*/
export interface TemplateVariables {
[key: string]: string;
}
export function process(
content: string,
variables: TemplateVariables,
): string;
}