refactor: reunification of techdocs-cli and backstage repos
Signed-off-by: Andrew Thauer <athauer@wealthsimple.com>
This commit is contained in:
committed by
Camila Belo
parent
01a0a39521
commit
71e7b0ee92
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 { resolve } from 'path';
|
||||
import { Command } from 'commander';
|
||||
import fs from 'fs-extra';
|
||||
import Docker from 'dockerode';
|
||||
import {
|
||||
TechdocsGenerator,
|
||||
ParsedLocationAnnotation,
|
||||
} from '@backstage/techdocs-common';
|
||||
import { DockerContainerRunner } from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import {
|
||||
convertTechDocsRefToLocationAnnotation,
|
||||
createLogger,
|
||||
} from '../../lib/utility';
|
||||
import { stdout } from 'process';
|
||||
|
||||
export default async function generate(cmd: Command) {
|
||||
// Use techdocs-common package to generate docs. Keep consistency between Backstage and CI generating docs.
|
||||
// Docs can be prepared using actions/checkout or git clone, or similar paradigms on CI. The TechDocs CI workflow
|
||||
// will run on the CI pipeline containing the documentation files.
|
||||
|
||||
const logger = createLogger({ verbose: cmd.verbose });
|
||||
|
||||
const sourceDir = resolve(cmd.sourceDir);
|
||||
const outputDir = resolve(cmd.outputDir);
|
||||
const dockerImage = cmd.dockerImage;
|
||||
const pullImage = cmd.pull;
|
||||
|
||||
logger.info(`Using source dir ${sourceDir}`);
|
||||
logger.info(`Will output generated files in ${outputDir}`);
|
||||
|
||||
logger.verbose('Creating output directory if it does not exist.');
|
||||
|
||||
await fs.ensureDir(outputDir);
|
||||
|
||||
const config = new ConfigReader({
|
||||
techdocs: {
|
||||
generator: {
|
||||
runIn: cmd.docker ? 'docker' : 'local',
|
||||
dockerImage,
|
||||
pullImage,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Docker client (conditionally) used by the generators, based on techdocs.generators config.
|
||||
const dockerClient = new Docker();
|
||||
const containerRunner = new DockerContainerRunner({ dockerClient });
|
||||
|
||||
let parsedLocationAnnotation = {} as ParsedLocationAnnotation;
|
||||
if (cmd.techdocsRef) {
|
||||
try {
|
||||
parsedLocationAnnotation = convertTechDocsRefToLocationAnnotation(
|
||||
cmd.techdocsRef,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate docs using @backstage/techdocs-common
|
||||
const techdocsGenerator = await TechdocsGenerator.fromConfig(config, {
|
||||
logger,
|
||||
containerRunner,
|
||||
});
|
||||
|
||||
logger.info('Generating documentation...');
|
||||
|
||||
await techdocsGenerator.run({
|
||||
inputDir: sourceDir,
|
||||
outputDir,
|
||||
...(cmd.techdocsRef
|
||||
? {
|
||||
parsedLocationAnnotation,
|
||||
}
|
||||
: {}),
|
||||
logger,
|
||||
etag: cmd.etag,
|
||||
...(process.env.LOG_LEVEL === 'debug' ? { logStream: stdout } : {}),
|
||||
});
|
||||
|
||||
logger.info('Done!');
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 { CommanderStatic } from 'commander';
|
||||
import { TechdocsGenerator } from '@backstage/techdocs-common';
|
||||
|
||||
const defaultDockerImage = TechdocsGenerator.defaultDockerImage;
|
||||
|
||||
export function registerCommands(program: CommanderStatic) {
|
||||
program
|
||||
.command('generate')
|
||||
.description('Generate TechDocs documentation site using MkDocs.')
|
||||
.option(
|
||||
'--source-dir <PATH>',
|
||||
'Source directory containing mkdocs.yml and docs/ directory.',
|
||||
'.',
|
||||
)
|
||||
.option(
|
||||
'--output-dir <PATH>',
|
||||
'Output directory containing generated TechDocs site.',
|
||||
'./site/',
|
||||
)
|
||||
.option(
|
||||
'--docker-image <DOCKER_IMAGE>',
|
||||
'The mkdocs docker container to use',
|
||||
defaultDockerImage,
|
||||
)
|
||||
.option('--no-pull', 'Do not pull the latest docker image', false)
|
||||
.option(
|
||||
'--no-docker',
|
||||
'Do not use Docker, use MkDocs executable and plugins in current user environment.',
|
||||
)
|
||||
.option(
|
||||
'--techdocs-ref <HOST_TYPE:URL>',
|
||||
'The repository hosting documentation source files e.g. github:https://ghe.mycompany.net.com/org/repo.' +
|
||||
'\nThis value is same as the backstage.io/techdocs-ref annotation of the corresponding Backstage entity.' +
|
||||
'\nIt is completely fine to skip this as it is only being used to set repo_url in mkdocs.yml if not found.\n',
|
||||
)
|
||||
.option(
|
||||
'--etag <ETAG>',
|
||||
'A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json.',
|
||||
)
|
||||
.option('-v --verbose', 'Enable verbose output.', false)
|
||||
.alias('build')
|
||||
.action(lazy(() => import('./generate/generate').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('migrate')
|
||||
.description(
|
||||
'Migrate objects with case-sensitive entity triplets to lower-case versions.',
|
||||
)
|
||||
.requiredOption(
|
||||
'--publisher-type <TYPE>',
|
||||
'(Required always) awsS3 | googleGcs | azureBlobStorage | openStackSwift - same as techdocs.publisher.type in Backstage app-config.yaml',
|
||||
)
|
||||
.requiredOption(
|
||||
'--storage-name <BUCKET/CONTAINER NAME>',
|
||||
'(Required always) In case of AWS/GCS, use the bucket name. In case of Azure, use container name. Same as techdocs.publisher.[TYPE].bucketName',
|
||||
)
|
||||
.option(
|
||||
'--azureAccountName <AZURE ACCOUNT NAME>',
|
||||
'(Required for Azure) specify when --publisher-type azureBlobStorage',
|
||||
)
|
||||
.option(
|
||||
'--azureAccountKey <AZURE ACCOUNT KEY>',
|
||||
'Azure Storage Account key to use for authentication. If not specified, you must set AZURE_TENANT_ID, AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment variables.',
|
||||
)
|
||||
.option(
|
||||
'--awsRoleArn <AWS ROLE ARN>',
|
||||
'Optional AWS ARN of role to be assumed.',
|
||||
)
|
||||
.option(
|
||||
'--awsEndpoint <AWS ENDPOINT>',
|
||||
'Optional AWS endpoint to send requests to.',
|
||||
)
|
||||
.option(
|
||||
'--awsS3ForcePathStyle',
|
||||
'Optional AWS S3 option to force path style.',
|
||||
)
|
||||
.option(
|
||||
'--osCredentialId <OPENSTACK SWIFT APPLICATION CREDENTIAL ID>',
|
||||
'(Required for OpenStack) specify when --publisher-type openStackSwift',
|
||||
)
|
||||
.option(
|
||||
'--osSecret <OPENSTACK SWIFT APPLICATION CREDENTIAL SECRET>',
|
||||
'(Required for OpenStack) specify when --publisher-type openStackSwift',
|
||||
)
|
||||
.option(
|
||||
'--osAuthUrl <OPENSTACK SWIFT AUTHURL>',
|
||||
'(Required for OpenStack) specify when --publisher-type openStackSwift',
|
||||
)
|
||||
.option(
|
||||
'--osSwiftUrl <OPENSTACK SWIFT SWIFTURL>',
|
||||
'(Required for OpenStack) specify when --publisher-type openStackSwift',
|
||||
)
|
||||
.option(
|
||||
'--removeOriginal',
|
||||
'Optional Files are copied by default. If flag is set, files are renamed/moved instead.',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
'--concurrency <MAX CONCURRENT REQS>',
|
||||
'Optional Controls the number of API requests allowed to be performed simultaneously.',
|
||||
'25',
|
||||
)
|
||||
.option('-v --verbose', 'Enable verbose output.', false)
|
||||
.action(lazy(() => import('./migrate/migrate').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('publish')
|
||||
.description(
|
||||
'Publish generated TechDocs site to an external storage AWS S3, Google GCS, etc.',
|
||||
)
|
||||
.requiredOption(
|
||||
'--publisher-type <TYPE>',
|
||||
'(Required always) awsS3 | googleGcs | azureBlobStorage | openStackSwift - same as techdocs.publisher.type in Backstage app-config.yaml',
|
||||
)
|
||||
.requiredOption(
|
||||
'--storage-name <BUCKET/CONTAINER NAME>',
|
||||
'(Required always) In case of AWS/GCS, use the bucket name. In case of Azure, use container name. Same as techdocs.publisher.[TYPE].bucketName',
|
||||
)
|
||||
.requiredOption(
|
||||
'--entity <NAMESPACE/KIND/NAME>',
|
||||
'(Required always) Entity uid separated by / in namespace/kind/name order (case-sensitive). Example: default/Component/myEntity ',
|
||||
)
|
||||
.option(
|
||||
'--legacyUseCaseSensitiveTripletPaths',
|
||||
'Publishes objects with cased entity triplet prefix when set (e.g. namespace/Kind/name). Only use if your TechDocs backend is configured the same way.',
|
||||
false,
|
||||
)
|
||||
.option(
|
||||
'--azureAccountName <AZURE ACCOUNT NAME>',
|
||||
'(Required for Azure) specify when --publisher-type azureBlobStorage',
|
||||
)
|
||||
.option(
|
||||
'--azureAccountKey <AZURE ACCOUNT KEY>',
|
||||
'Azure Storage Account key to use for authentication. If not specified, you must set AZURE_TENANT_ID, AZURE_CLIENT_ID & AZURE_CLIENT_SECRET as environment variables.',
|
||||
)
|
||||
.option(
|
||||
'--awsRoleArn <AWS ROLE ARN>',
|
||||
'Optional AWS ARN of role to be assumed.',
|
||||
)
|
||||
.option(
|
||||
'--awsEndpoint <AWS ENDPOINT>',
|
||||
'Optional AWS endpoint to send requests to.',
|
||||
)
|
||||
.option(
|
||||
'--awsS3ForcePathStyle',
|
||||
'Optional AWS S3 option to force path style.',
|
||||
)
|
||||
.option(
|
||||
'--osCredentialId <OPENSTACK SWIFT APPLICATION CREDENTIAL ID>',
|
||||
'(Required for OpenStack) specify when --publisher-type openStackSwift',
|
||||
)
|
||||
.option(
|
||||
'--osSecret <OPENSTACK SWIFT APPLICATION CREDENTIAL SECRET>',
|
||||
'(Required for OpenStack) specify when --publisher-type openStackSwift',
|
||||
)
|
||||
.option(
|
||||
'--osAuthUrl <OPENSTACK SWIFT AUTHURL>',
|
||||
'(Required for OpenStack) specify when --publisher-type openStackSwift',
|
||||
)
|
||||
.option(
|
||||
'--osSwiftUrl <OPENSTACK SWIFT SWIFTURL>',
|
||||
'(Required for OpenStack) specify when --publisher-type openStackSwift',
|
||||
)
|
||||
.option(
|
||||
'--directory <PATH>',
|
||||
'Path of the directory containing generated files to publish',
|
||||
'./site/',
|
||||
)
|
||||
.action(lazy(() => import('./publish/publish').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('serve:mkdocs')
|
||||
.description('Serve a documentation project locally using MkDocs serve.')
|
||||
.option(
|
||||
'-i, --docker-image <DOCKER_IMAGE>',
|
||||
'The mkdocs docker container to use',
|
||||
defaultDockerImage,
|
||||
)
|
||||
.option(
|
||||
'--no-docker',
|
||||
'Do not use Docker, run `mkdocs serve` in current user environment.',
|
||||
)
|
||||
.option('-p, --port <PORT>', 'Port to serve documentation locally', '8000')
|
||||
.option('-v --verbose', 'Enable verbose output.', false)
|
||||
.action(lazy(() => import('./serve/mkdocs').then(m => m.default)));
|
||||
|
||||
program
|
||||
.command('serve')
|
||||
.description(
|
||||
'Serve a documentation project locally in a Backstage app-like environment',
|
||||
)
|
||||
.option(
|
||||
'-i, --docker-image <DOCKER_IMAGE>',
|
||||
'The mkdocs docker container to use',
|
||||
defaultDockerImage,
|
||||
)
|
||||
.option(
|
||||
'--no-docker',
|
||||
'Do not use Docker, use MkDocs executable in current user environment.',
|
||||
)
|
||||
.option('--mkdocs-port <PORT>', 'Port for MkDocs server to use', '8000')
|
||||
.option('-v --verbose', 'Enable verbose output.', false)
|
||||
.action(lazy(() => import('./serve/serve').then(m => m.default)));
|
||||
}
|
||||
|
||||
// Wraps an action function so that it always exits and handles errors
|
||||
// Humbly taken from backstage-cli's registerCommands
|
||||
function lazy(
|
||||
getActionFunc: () => Promise<(...args: any[]) => Promise<void>>,
|
||||
): (...args: any[]) => Promise<never> {
|
||||
return async (...args: any[]) => {
|
||||
try {
|
||||
const actionFunc = await getActionFunc();
|
||||
await actionFunc(...args);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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 { SingleHostDiscovery } from '@backstage/backend-common';
|
||||
import { Publisher } from '@backstage/techdocs-common';
|
||||
import { Command } from 'commander';
|
||||
import { createLogger } from '../../lib/utility';
|
||||
import { PublisherConfig } from '../../lib/PublisherConfig';
|
||||
|
||||
export default async function migrate(cmd: Command) {
|
||||
const logger = createLogger({ verbose: cmd.verbose });
|
||||
|
||||
const config = PublisherConfig.getValidConfig(cmd);
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
const publisher = await Publisher.fromConfig(config, { logger, discovery });
|
||||
|
||||
if (!publisher.migrateDocsCase) {
|
||||
throw new Error(`Migration not implemented for ${cmd.publisherType}`);
|
||||
}
|
||||
|
||||
// Check that the publisher's underlying storage is ready and available.
|
||||
const { isAvailable } = await publisher.getReadiness();
|
||||
if (!isAvailable) {
|
||||
// Error messages printed in getReadiness() call. This ensures exit code 1.
|
||||
throw new Error('');
|
||||
}
|
||||
|
||||
// Validate and parse migration arguments.
|
||||
const removeOriginal = cmd.removeOriginal;
|
||||
const numericConcurrency = parseInt(cmd.concurrency, 10);
|
||||
|
||||
if (!Number.isInteger(numericConcurrency) || numericConcurrency <= 0) {
|
||||
throw new Error(
|
||||
`Concurrency must be a number greater than 1. ${cmd.concurrency} provided.`,
|
||||
);
|
||||
}
|
||||
|
||||
await publisher.migrateDocsCase({
|
||||
concurrency: numericConcurrency,
|
||||
removeOriginal,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 { resolve } from 'path';
|
||||
import { Command } from 'commander';
|
||||
import { createLogger } from '../../lib/utility';
|
||||
import { SingleHostDiscovery } from '@backstage/backend-common';
|
||||
import { Publisher } from '@backstage/techdocs-common';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { PublisherConfig } from '../../lib/PublisherConfig';
|
||||
|
||||
export default async function publish(cmd: Command): Promise<any> {
|
||||
const logger = createLogger({ verbose: cmd.verbose });
|
||||
|
||||
const config = PublisherConfig.getValidConfig(cmd);
|
||||
const discovery = SingleHostDiscovery.fromConfig(config);
|
||||
const publisher = await Publisher.fromConfig(config, { logger, discovery });
|
||||
|
||||
// Check that the publisher's underlying storage is ready and available.
|
||||
const { isAvailable } = await publisher.getReadiness();
|
||||
if (!isAvailable) {
|
||||
// Error messages printed in getReadiness() call. This ensures exit code 1.
|
||||
return Promise.reject(new Error(''));
|
||||
}
|
||||
|
||||
const [namespace, kind, name] = cmd.entity.split('/');
|
||||
const entity = {
|
||||
kind,
|
||||
metadata: {
|
||||
namespace,
|
||||
name,
|
||||
},
|
||||
} as Entity;
|
||||
|
||||
const directory = resolve(cmd.directory);
|
||||
await publisher.publish({ entity, directory });
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 { Command } from 'commander';
|
||||
import openBrowser from 'react-dev-utils/openBrowser';
|
||||
import { createLogger } from '../../lib/utility';
|
||||
import { runMkdocsServer } from '../../lib/mkdocsServer';
|
||||
import { LogFunc, waitForSignal } from '../../lib/run';
|
||||
|
||||
export default async function serveMkdocs(cmd: Command) {
|
||||
const logger = createLogger({ verbose: cmd.verbose });
|
||||
|
||||
const dockerAddr = `http://0.0.0.0:${cmd.port}`;
|
||||
const localAddr = `http://127.0.0.1:${cmd.port}`;
|
||||
const expectedDevAddr = cmd.docker ? dockerAddr : localAddr;
|
||||
// We want to open browser only once based on a log.
|
||||
let boolOpenBrowserTriggered = false;
|
||||
|
||||
const logFunc: LogFunc = data => {
|
||||
// Sometimes the lines contain an unnecessary extra new line in between
|
||||
const logLines = data.toString().split('\n');
|
||||
const logPrefix = cmd.docker ? '[docker/mkdocs]' : '[mkdocs]';
|
||||
logLines.forEach(line => {
|
||||
if (line === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Logs from container is verbose.
|
||||
logger.verbose(`${logPrefix} ${line}`);
|
||||
|
||||
// When the server has started, open a new browser tab for the user.
|
||||
if (
|
||||
!boolOpenBrowserTriggered &&
|
||||
line.includes(`Serving on ${expectedDevAddr}`)
|
||||
) {
|
||||
// Always open the local address, since 0.0.0.0 belongs to docker
|
||||
logger.info(`\nStarting mkdocs server on ${localAddr}\n`);
|
||||
openBrowser(localAddr);
|
||||
boolOpenBrowserTriggered = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
// mkdocs writes all of its logs to stderr by default, and not stdout.
|
||||
// https://github.com/mkdocs/mkdocs/issues/879#issuecomment-203536006
|
||||
// Had me questioning this whole implementation for half an hour.
|
||||
|
||||
// Commander stores --no-docker in cmd.docker variable
|
||||
const childProcess = await runMkdocsServer({
|
||||
port: cmd.port,
|
||||
dockerImage: cmd.dockerImage,
|
||||
useDocker: cmd.docker,
|
||||
stdoutLogFunc: logFunc,
|
||||
stderrLogFunc: logFunc,
|
||||
});
|
||||
|
||||
// Keep waiting for user to cancel the process
|
||||
await waitForSignal([childProcess]);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 { Command } from 'commander';
|
||||
import path from 'path';
|
||||
import openBrowser from 'react-dev-utils/openBrowser';
|
||||
import HTTPServer from '../../lib/httpServer';
|
||||
import { runMkdocsServer } from '../../lib/mkdocsServer';
|
||||
import { LogFunc, waitForSignal } from '../../lib/run';
|
||||
import { createLogger } from '../../lib/utility';
|
||||
|
||||
export default async function serve(cmd: Command) {
|
||||
const logger = createLogger({ verbose: cmd.verbose });
|
||||
|
||||
// Determine if we want to run in local dev mode or not
|
||||
// This will run the backstage http server on a different port and only used
|
||||
// for proxying mkdocs to the backstage app running locally (e.g. with webpack-dev-server)
|
||||
const isDevMode = Object.keys(process.env).includes('TECHDOCS_CLI_DEV_MODE')
|
||||
? true
|
||||
: false;
|
||||
|
||||
// TODO: Backstage app port should also be configurable as a CLI option. However, since we bundle
|
||||
// a backstage app, we define app.baseUrl in the app-config.yaml.
|
||||
// Hence, it is complicated to make this configurable.
|
||||
const backstagePort = 3000;
|
||||
const backstageBackendPort = 7000;
|
||||
|
||||
const mkdocsDockerAddr = `http://0.0.0.0:${cmd.mkdocsPort}`;
|
||||
const mkdocsLocalAddr = `http://127.0.0.1:${cmd.mkdocsPort}`;
|
||||
const mkdocsExpectedDevAddr = cmd.docker ? mkdocsDockerAddr : mkdocsLocalAddr;
|
||||
|
||||
let mkdocsServerHasStarted = false;
|
||||
const mkdocsLogFunc: LogFunc = data => {
|
||||
// Sometimes the lines contain an unnecessary extra new line
|
||||
const logLines = data.toString().split('\n');
|
||||
const logPrefix = cmd.docker ? '[docker/mkdocs]' : '[mkdocs]';
|
||||
logLines.forEach(line => {
|
||||
if (line === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.verbose(`${logPrefix} ${line}`);
|
||||
|
||||
// When the server has started, open a new browser tab for the user.
|
||||
if (
|
||||
!mkdocsServerHasStarted &&
|
||||
line.includes(`Serving on ${mkdocsExpectedDevAddr}`)
|
||||
) {
|
||||
mkdocsServerHasStarted = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
// mkdocs writes all of its logs to stderr by default, and not stdout.
|
||||
// https://github.com/mkdocs/mkdocs/issues/879#issuecomment-203536006
|
||||
// Had me questioning this whole implementation for half an hour.
|
||||
logger.info('Starting mkdocs server.');
|
||||
const mkdocsChildProcess = await runMkdocsServer({
|
||||
port: cmd.mkdocsPort,
|
||||
dockerImage: cmd.dockerImage,
|
||||
useDocker: cmd.docker,
|
||||
stdoutLogFunc: mkdocsLogFunc,
|
||||
stderrLogFunc: mkdocsLogFunc,
|
||||
});
|
||||
|
||||
// Wait until mkdocs server has started so that Backstage starts with docs loaded
|
||||
// Takes 1-5 seconds
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
if (mkdocsServerHasStarted) {
|
||||
break;
|
||||
}
|
||||
logger.info('Waiting for mkdocs server to start...');
|
||||
}
|
||||
|
||||
if (!mkdocsServerHasStarted) {
|
||||
logger.error(
|
||||
'mkdocs server did not start. Exiting. Try re-running command with -v option for more details.',
|
||||
);
|
||||
}
|
||||
|
||||
// Run the embedded-techdocs Backstage app
|
||||
const techdocsPreviewBundlePath = path.join(
|
||||
path.dirname(require.resolve('@techdocs/cli/package.json')),
|
||||
'dist',
|
||||
'techdocs-preview-bundle',
|
||||
);
|
||||
|
||||
const httpServer = new HTTPServer(
|
||||
techdocsPreviewBundlePath,
|
||||
isDevMode ? backstageBackendPort : backstagePort,
|
||||
cmd.mkdocsPort,
|
||||
cmd.verbose,
|
||||
);
|
||||
|
||||
httpServer
|
||||
.serve()
|
||||
.catch(err => {
|
||||
logger.error(err);
|
||||
mkdocsChildProcess.kill();
|
||||
process.exit(1);
|
||||
})
|
||||
.then(() => {
|
||||
// The last three things default/component/local/ don't matter. They can be anything.
|
||||
openBrowser(
|
||||
`http://localhost:${backstagePort}/docs/default/component/local/`,
|
||||
);
|
||||
logger.info(
|
||||
`Serving docs in Backstage at http://localhost:${backstagePort}/docs/default/component/local/\nOpening browser.`,
|
||||
);
|
||||
});
|
||||
|
||||
await waitForSignal([mkdocsChildProcess]);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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 { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
const PROJECT_ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const FIXTURE_DIR = path.resolve(PROJECT_ROOT_DIR, 'src/fixture');
|
||||
|
||||
describe('end-to-end', () => {
|
||||
it('shows help text', async () => {
|
||||
jest.setTimeout(10000);
|
||||
const proc = await executeTechDocsCliCommand(['--help']);
|
||||
|
||||
expect(proc.combinedStdOutErr).toContain('Usage: techdocs-cli [options]');
|
||||
expect(proc.exit).toEqual(0);
|
||||
});
|
||||
|
||||
it('can generate', async () => {
|
||||
jest.setTimeout(10000);
|
||||
const proc = await executeTechDocsCliCommand(['generate', '--no-docker'], {
|
||||
cwd: FIXTURE_DIR,
|
||||
killAfter: 8000,
|
||||
});
|
||||
|
||||
expect(proc.combinedStdOutErr).toContain('Successfully generated docs');
|
||||
expect(proc.exit).toEqual(0);
|
||||
});
|
||||
|
||||
it('can serve in mkdocs', async () => {
|
||||
jest.setTimeout(10000);
|
||||
const proc = await executeTechDocsCliCommand(
|
||||
['serve:mkdocs', '--no-docker'],
|
||||
{
|
||||
cwd: FIXTURE_DIR,
|
||||
killAfter: 8000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(proc.combinedStdOutErr).toContain('Starting mkdocs server');
|
||||
expect(proc.exit).toEqual(0);
|
||||
});
|
||||
|
||||
it('can serve in backstage', async () => {
|
||||
jest.setTimeout(10000);
|
||||
const proc = await executeTechDocsCliCommand(
|
||||
['serve', '--no-docker', '--mkdocs-port=8888'],
|
||||
{
|
||||
cwd: FIXTURE_DIR,
|
||||
killAfter: 8000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(proc.combinedStdOutErr).toContain('Starting mkdocs server');
|
||||
expect(proc.combinedStdOutErr).toContain('Serving docs in Backstage at');
|
||||
expect(proc.exit).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
type CommandResponse = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
combinedStdOutErr: string;
|
||||
exit: number;
|
||||
};
|
||||
|
||||
type ExecuteCommandOptions = {
|
||||
killAfter?: number;
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
function executeTechDocsCliCommand(
|
||||
args: string[],
|
||||
opts: ExecuteCommandOptions = {},
|
||||
): Promise<CommandResponse> {
|
||||
return new Promise(resolve => {
|
||||
const pathToCli = path.resolve(PROJECT_ROOT_DIR, 'bin/techdocs-cli');
|
||||
const commandResponse = {
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
combinedStdOutErr: '',
|
||||
exit: 0,
|
||||
};
|
||||
|
||||
const listen = spawn(pathToCli, args, {
|
||||
cwd: opts.cwd,
|
||||
});
|
||||
|
||||
const stdOutChunks: any[] = [];
|
||||
const stdErrChunks: any[] = [];
|
||||
const combinedChunks: any[] = [];
|
||||
|
||||
listen.stdout.on('data', data => {
|
||||
stdOutChunks.push(data);
|
||||
combinedChunks.push(data);
|
||||
});
|
||||
|
||||
listen.stderr.on('data', data => {
|
||||
stdErrChunks.push(data);
|
||||
combinedChunks.push(data);
|
||||
});
|
||||
|
||||
listen.on('exit', code => {
|
||||
commandResponse.exit = code as number;
|
||||
commandResponse.stdout = Buffer.concat(stdOutChunks).toString('utf8');
|
||||
commandResponse.stderr = Buffer.concat(stdErrChunks).toString('utf8');
|
||||
commandResponse.combinedStdOutErr =
|
||||
Buffer.concat(combinedChunks).toString('utf8');
|
||||
resolve(commandResponse);
|
||||
});
|
||||
|
||||
if (opts.killAfter) {
|
||||
setTimeout(() => {
|
||||
listen.kill('SIGTERM');
|
||||
}, opts.killAfter);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# Test Fixture
|
||||
@@ -0,0 +1,8 @@
|
||||
site_name: docs-test-fixture
|
||||
site_description: Documentation site test fixture
|
||||
|
||||
nav:
|
||||
- HOME: README.md
|
||||
|
||||
plugins:
|
||||
- techdocs-core
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 program from 'commander';
|
||||
import { registerCommands } from './commands';
|
||||
import { version } from '../package.json';
|
||||
|
||||
const main = (argv: string[]) => {
|
||||
program.name('techdocs-cli').version(version);
|
||||
|
||||
registerCommands(program);
|
||||
|
||||
program.parse(argv);
|
||||
};
|
||||
|
||||
main(process.argv);
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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 { Command } from 'commander';
|
||||
import { PublisherConfig } from './PublisherConfig';
|
||||
|
||||
describe('getValidPublisherConfig', () => {
|
||||
it('should not allow unknown publisher types', () => {
|
||||
const invalidConfig = {
|
||||
publisherType: 'unknown publisher',
|
||||
} as unknown as Command;
|
||||
|
||||
expect(() => PublisherConfig.getValidConfig(invalidConfig)).toThrowError(
|
||||
`Unknown publisher type ${invalidConfig.publisherType}`,
|
||||
);
|
||||
});
|
||||
|
||||
describe('for azureBlobStorage', () => {
|
||||
it('should require --azureAccountName', () => {
|
||||
const config = {
|
||||
publisherType: 'azureBlobStorage',
|
||||
} as unknown as Command;
|
||||
|
||||
expect(() => PublisherConfig.getValidConfig(config)).toThrowError(
|
||||
'azureBlobStorage requires --azureAccountName to be specified',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return valid ConfigReader', () => {
|
||||
const config = {
|
||||
publisherType: 'azureBlobStorage',
|
||||
azureAccountName: 'someAccountName',
|
||||
storageName: 'someContainer',
|
||||
} as unknown as Command;
|
||||
|
||||
const actualConfig = PublisherConfig.getValidConfig(config);
|
||||
expect(actualConfig.getString('techdocs.publisher.type')).toBe(
|
||||
'azureBlobStorage',
|
||||
);
|
||||
expect(
|
||||
actualConfig.getString(
|
||||
'techdocs.publisher.azureBlobStorage.containerName',
|
||||
),
|
||||
).toBe('someContainer');
|
||||
expect(
|
||||
actualConfig.getString(
|
||||
'techdocs.publisher.azureBlobStorage.credentials.accountName',
|
||||
),
|
||||
).toBe('someAccountName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('for awsS3', () => {
|
||||
it('should return valid ConfigReader', () => {
|
||||
const config = {
|
||||
publisherType: 'awsS3',
|
||||
storageName: 'someStorageName',
|
||||
} as unknown as Command;
|
||||
|
||||
const actualConfig = PublisherConfig.getValidConfig(config);
|
||||
expect(actualConfig.getString('techdocs.publisher.type')).toBe('awsS3');
|
||||
expect(
|
||||
actualConfig.getString('techdocs.publisher.awsS3.bucketName'),
|
||||
).toBe('someStorageName');
|
||||
});
|
||||
});
|
||||
|
||||
describe('for openStackSwift', () => {
|
||||
it('should throw error on missing parameters', () => {
|
||||
const config = {
|
||||
publisherType: 'openStackSwift',
|
||||
osCredentialId: 'someCredentialId',
|
||||
osSecret: 'someSecret',
|
||||
} as unknown as Command;
|
||||
|
||||
expect(() => PublisherConfig.getValidConfig(config)).toThrowError(
|
||||
`openStackSwift requires the following params to be specified: ${[
|
||||
'osAuthUrl',
|
||||
'osSwiftUrl',
|
||||
].join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return valid ConfigReader', () => {
|
||||
const config = {
|
||||
publisherType: 'openStackSwift',
|
||||
storageName: 'someStorageName',
|
||||
osCredentialId: 'someCredentialId',
|
||||
osSecret: 'someSecret',
|
||||
osAuthUrl: 'someAuthUrl',
|
||||
osSwiftUrl: 'someSwiftUrl',
|
||||
} as unknown as Command;
|
||||
|
||||
const actualConfig = PublisherConfig.getValidConfig(config);
|
||||
expect(actualConfig.getString('techdocs.publisher.type')).toBe(
|
||||
'openStackSwift',
|
||||
);
|
||||
expect(
|
||||
actualConfig.getConfig('techdocs.publisher.openStackSwift').get(),
|
||||
).toMatchObject({
|
||||
containerName: 'someStorageName',
|
||||
credentials: {
|
||||
id: 'someCredentialId',
|
||||
secret: 'someSecret',
|
||||
},
|
||||
authUrl: 'someAuthUrl',
|
||||
swiftUrl: 'someSwiftUrl',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('for googleGcs', () => {
|
||||
it('should return valid ConfigReader', () => {
|
||||
const config = {
|
||||
publisherType: 'googleGcs',
|
||||
storageName: 'someStorageName',
|
||||
} as unknown as Command;
|
||||
|
||||
const actualConfig = PublisherConfig.getValidConfig(config);
|
||||
expect(actualConfig.getString('techdocs.publisher.type')).toBe(
|
||||
'googleGcs',
|
||||
);
|
||||
expect(
|
||||
actualConfig.getString('techdocs.publisher.googleGcs.bucketName'),
|
||||
).toBe('someStorageName');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage 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 { ConfigReader } from '@backstage/config';
|
||||
import { Command } from 'commander';
|
||||
|
||||
type Publisher = keyof typeof PublisherConfig['configFactories'];
|
||||
type PublisherConfiguration = {
|
||||
[p in Publisher]?: any;
|
||||
} & {
|
||||
type: Publisher;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper when working with publisher-related configurations.
|
||||
*/
|
||||
export class PublisherConfig {
|
||||
/**
|
||||
* Maps publisher-specific config keys to config getters.
|
||||
*/
|
||||
private static configFactories = {
|
||||
awsS3: PublisherConfig.getValidAwsS3Config,
|
||||
azureBlobStorage: PublisherConfig.getValidAzureConfig,
|
||||
googleGcs: PublisherConfig.getValidGoogleGcsConfig,
|
||||
openStackSwift: PublisherConfig.getValidOpenStackSwiftConfig,
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns Backstage config suitable for use when instantiating a Publisher. If
|
||||
* there are any missing or invalid options provided, an error is thrown.
|
||||
*
|
||||
* Note: This assumes that proper credentials are set in Environment
|
||||
* variables for the respective GCS/AWS clients to work.
|
||||
*/
|
||||
static getValidConfig(cmd: Command): ConfigReader {
|
||||
const publisherType = cmd.publisherType;
|
||||
|
||||
if (!PublisherConfig.isKnownPublisher(publisherType)) {
|
||||
throw new Error(`Unknown publisher type ${cmd.publisherType}`);
|
||||
}
|
||||
|
||||
return new ConfigReader({
|
||||
// This backend config is not used at all. Just something needed a create a mock discovery instance.
|
||||
backend: {
|
||||
baseUrl: 'http://localhost:7000',
|
||||
listen: {
|
||||
port: 7000,
|
||||
},
|
||||
},
|
||||
techdocs: {
|
||||
publisher: PublisherConfig.configFactories[publisherType](cmd),
|
||||
legacyUseCaseSensitiveTripletPaths:
|
||||
cmd.legacyUseCaseSensitiveTripletPaths,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Typeguard to ensure the publisher has a known config getter.
|
||||
*/
|
||||
private static isKnownPublisher(
|
||||
type: string,
|
||||
): type is keyof typeof PublisherConfig['configFactories'] {
|
||||
return PublisherConfig.configFactories.hasOwnProperty(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve valid AWS S3 configuration based on the command.
|
||||
*/
|
||||
private static getValidAwsS3Config(cmd: Command): PublisherConfiguration {
|
||||
return {
|
||||
type: 'awsS3',
|
||||
awsS3: {
|
||||
bucketName: cmd.storageName,
|
||||
...(cmd.awsRoleArn && { credentials: { roleArn: cmd.awsRoleArn } }),
|
||||
...(cmd.awsEndpoint && { endpoint: cmd.awsEndpoint }),
|
||||
...(cmd.awsS3ForcePathStyle && { s3ForcePathStyle: true }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve valid Azure Blob Storage configuration based on the command.
|
||||
*/
|
||||
private static getValidAzureConfig(cmd: Command): PublisherConfiguration {
|
||||
if (!cmd.azureAccountName) {
|
||||
throw new Error(
|
||||
`azureBlobStorage requires --azureAccountName to be specified`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'azureBlobStorage',
|
||||
azureBlobStorage: {
|
||||
containerName: cmd.storageName,
|
||||
credentials: {
|
||||
accountName: cmd.azureAccountName,
|
||||
accountKey: cmd.azureAccountKey,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve valid GCS configuration based on the command.
|
||||
*/
|
||||
private static getValidGoogleGcsConfig(cmd: Command): PublisherConfiguration {
|
||||
return {
|
||||
type: 'googleGcs',
|
||||
googleGcs: {
|
||||
bucketName: cmd.storageName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves valid OpenStack Swift configuration based on the command.
|
||||
*/
|
||||
private static getValidOpenStackSwiftConfig(
|
||||
cmd: Command,
|
||||
): PublisherConfiguration {
|
||||
const missingParams = [
|
||||
'osCredentialId',
|
||||
'osSecret',
|
||||
'osAuthUrl',
|
||||
'osSwiftUrl',
|
||||
].filter((param: string) => !cmd[param]);
|
||||
|
||||
if (missingParams.length) {
|
||||
throw new Error(
|
||||
`openStackSwift requires the following params to be specified: ${missingParams.join(
|
||||
', ',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'openStackSwift',
|
||||
openStackSwift: {
|
||||
containerName: cmd.storageName,
|
||||
credentials: {
|
||||
id: cmd.osCredentialId,
|
||||
secret: cmd.osSecret,
|
||||
},
|
||||
authUrl: cmd.osAuthUrl,
|
||||
swiftUrl: cmd.osSwiftUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 serveHandler from 'serve-handler';
|
||||
import http from 'http';
|
||||
import httpProxy from 'http-proxy';
|
||||
import { createLogger } from './utility';
|
||||
|
||||
export default class HTTPServer {
|
||||
private readonly proxyEndpoint: string;
|
||||
private readonly backstageBundleDir: string;
|
||||
private readonly backstagePort: number;
|
||||
private readonly mkdocsPort: number;
|
||||
private readonly verbose: boolean;
|
||||
|
||||
constructor(
|
||||
backstageBundleDir: string,
|
||||
backstagePort: number,
|
||||
mkdocsPort: number,
|
||||
verbose: boolean,
|
||||
) {
|
||||
this.proxyEndpoint = '/api/';
|
||||
this.backstageBundleDir = backstageBundleDir;
|
||||
this.backstagePort = backstagePort;
|
||||
this.mkdocsPort = mkdocsPort;
|
||||
this.verbose = verbose;
|
||||
}
|
||||
|
||||
// Create a Proxy for mkdocs server
|
||||
private createProxy() {
|
||||
const proxy = httpProxy.createProxyServer({
|
||||
target: `http://localhost:${this.mkdocsPort}`,
|
||||
});
|
||||
|
||||
return (request: http.IncomingMessage): [httpProxy, string] => {
|
||||
// If the request goes to /api/ we want to remove /api/ from the prefix of the request URL.
|
||||
// e.g. ['/', 'api', pathChunks]
|
||||
const [, , ...pathChunks] = request.url?.split('/') ?? [];
|
||||
const forwardPath = pathChunks.join('/');
|
||||
|
||||
return [proxy, forwardPath];
|
||||
};
|
||||
}
|
||||
|
||||
public async serve(): Promise<http.Server> {
|
||||
return new Promise<http.Server>((resolve, reject) => {
|
||||
const proxyHandler = this.createProxy();
|
||||
const server = http.createServer(
|
||||
(request: http.IncomingMessage, response: http.ServerResponse) => {
|
||||
if (request.url?.startsWith(this.proxyEndpoint)) {
|
||||
const [proxy, forwardPath] = proxyHandler(request);
|
||||
|
||||
proxy.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
response.setHeader('Access-Control-Allow-Origin', '*');
|
||||
response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
|
||||
request.url = forwardPath;
|
||||
return proxy.web(request, response);
|
||||
}
|
||||
|
||||
return serveHandler(request, response, {
|
||||
public: this.backstageBundleDir,
|
||||
trailingSlash: true,
|
||||
rewrites: [{ source: '**', destination: 'index.html' }],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const logger = createLogger({ verbose: false });
|
||||
server.listen(this.backstagePort, () => {
|
||||
if (this.verbose) {
|
||||
logger.info(
|
||||
`[techdocs-preview-bundle] Running local version of Backstage at http://localhost:${this.backstagePort}`,
|
||||
);
|
||||
}
|
||||
resolve(server);
|
||||
});
|
||||
|
||||
server.on('error', (error: Error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 { runMkdocsServer } from './mkdocsServer';
|
||||
import { run } from './run';
|
||||
|
||||
jest.mock('./run', () => {
|
||||
return {
|
||||
run: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('runMkdocsServer', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('docker', () => {
|
||||
it('should run docker directly by default', async () => {
|
||||
await runMkdocsServer({});
|
||||
|
||||
const quotedCwd = `"${process.cwd()}":/content`;
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining([
|
||||
'run',
|
||||
quotedCwd,
|
||||
'8000:8000',
|
||||
'serve',
|
||||
'--dev-addr',
|
||||
'0.0.0.0:8000',
|
||||
'spotify/techdocs',
|
||||
]),
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept port option', async () => {
|
||||
await runMkdocsServer({ port: '5678' });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining(['5678:5678', '0.0.0.0:5678']),
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept custom docker image', async () => {
|
||||
await runMkdocsServer({ dockerImage: 'my-org/techdocs' });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'docker',
|
||||
expect.arrayContaining(['my-org/techdocs']),
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mkdocs', () => {
|
||||
it('should run mkdocs if specified', async () => {
|
||||
await runMkdocsServer({ useDocker: false });
|
||||
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'mkdocs',
|
||||
expect.arrayContaining(['serve', '--dev-addr', '127.0.0.1:8000']),
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept port option', async () => {
|
||||
await runMkdocsServer({ useDocker: false, port: '5678' });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
'mkdocs',
|
||||
expect.arrayContaining(['127.0.0.1:5678']),
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 { ChildProcess } from 'child_process';
|
||||
import { run, LogFunc } from './run';
|
||||
|
||||
export const runMkdocsServer = async (options: {
|
||||
port?: string;
|
||||
useDocker?: boolean;
|
||||
dockerImage?: string;
|
||||
stdoutLogFunc?: LogFunc;
|
||||
stderrLogFunc?: LogFunc;
|
||||
}): Promise<ChildProcess> => {
|
||||
const port = options.port ?? '8000';
|
||||
const useDocker = options.useDocker ?? true;
|
||||
const dockerImage = options.dockerImage ?? 'spotify/techdocs';
|
||||
|
||||
if (useDocker) {
|
||||
return await run(
|
||||
'docker',
|
||||
[
|
||||
'run',
|
||||
'--rm',
|
||||
'-w',
|
||||
'/content',
|
||||
'-v',
|
||||
`"${process.cwd()}":/content`,
|
||||
'-p',
|
||||
`${port}:${port}`,
|
||||
dockerImage,
|
||||
'serve',
|
||||
'--dev-addr',
|
||||
`0.0.0.0:${port}`,
|
||||
],
|
||||
{
|
||||
stdoutLogFunc: options.stdoutLogFunc,
|
||||
stderrLogFunc: options.stderrLogFunc,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return await run('mkdocs', ['serve', '--dev-addr', `127.0.0.1:${port}`], {
|
||||
stdoutLogFunc: options.stdoutLogFunc,
|
||||
stderrLogFunc: options.stderrLogFunc,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 { spawn, SpawnOptions, ChildProcess } from 'child_process';
|
||||
|
||||
export type LogFunc = (data: Buffer | string) => void;
|
||||
type SpawnOptionsPartialEnv = Omit<SpawnOptions, 'env'> & {
|
||||
env?: Partial<NodeJS.ProcessEnv>;
|
||||
// Pipe stdout to this log function
|
||||
stdoutLogFunc?: LogFunc;
|
||||
// Pipe stderr to this log function
|
||||
stderrLogFunc?: LogFunc;
|
||||
};
|
||||
|
||||
// TODO: Accept log functions to pipe logs with.
|
||||
// Runs a child command, returning the child process instance.
|
||||
// Use it along with waitForSignal to run a long running process e.g. mkdocs serve
|
||||
export const run = async (
|
||||
name: string,
|
||||
args: string[] = [],
|
||||
options: SpawnOptionsPartialEnv = {},
|
||||
): Promise<ChildProcess> => {
|
||||
const { stdoutLogFunc, stderrLogFunc } = options;
|
||||
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
FORCE_COLOR: 'true',
|
||||
...(options.env ?? {}),
|
||||
};
|
||||
|
||||
// Refer: https://nodejs.org/api/child_process.html#child_process_subprocess_stdio
|
||||
const stdio = [
|
||||
'inherit',
|
||||
stdoutLogFunc ? 'pipe' : 'inherit',
|
||||
stderrLogFunc ? 'pipe' : 'inherit',
|
||||
] as ('inherit' | 'pipe')[];
|
||||
|
||||
const childProcess = spawn(name, args, {
|
||||
stdio: stdio,
|
||||
shell: true,
|
||||
...options,
|
||||
env,
|
||||
});
|
||||
|
||||
if (stdoutLogFunc && childProcess.stdout) {
|
||||
childProcess.stdout.on('data', stdoutLogFunc);
|
||||
}
|
||||
if (stderrLogFunc && childProcess.stderr) {
|
||||
childProcess.stderr.on('data', stderrLogFunc);
|
||||
}
|
||||
|
||||
return childProcess;
|
||||
};
|
||||
|
||||
// Block indefinitely and wait for a signal to kill the child process(es)
|
||||
// Throw error if any child process errors
|
||||
// Resolves only when all processes exit with status code 0
|
||||
export async function waitForSignal(
|
||||
childProcesses: Array<ChildProcess>,
|
||||
): Promise<void> {
|
||||
const promises: Array<Promise<void>> = [];
|
||||
|
||||
childProcesses.forEach(childProcess => {
|
||||
if (typeof childProcess.exitCode === 'number') {
|
||||
if (childProcess.exitCode) {
|
||||
throw new Error(`Non zero exit code from child process`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
||||
process.on(signal, () => {
|
||||
childProcess.kill(signal);
|
||||
// exit instead of resolve. The process is shutting down and resolving a promise here logs an error
|
||||
process.exit();
|
||||
});
|
||||
}
|
||||
|
||||
promises.push(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
childProcess.once('error', error => reject(error));
|
||||
childProcess.once('exit', code => {
|
||||
if (code) {
|
||||
reject(new Error(`Non zero exit code from child process`));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2020 The Backstage 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 {
|
||||
RemoteProtocol,
|
||||
ParsedLocationAnnotation,
|
||||
} from '@backstage/techdocs-common';
|
||||
import * as winston from 'winston';
|
||||
|
||||
export const convertTechDocsRefToLocationAnnotation = (
|
||||
techdocsRef: string,
|
||||
): ParsedLocationAnnotation => {
|
||||
// Split on the first colon for the protocol and the rest after the first split
|
||||
// is the location.
|
||||
const [type, target] = techdocsRef.split(/:(.+)/) as [
|
||||
RemoteProtocol?,
|
||||
string?,
|
||||
];
|
||||
|
||||
if (!type || !target) {
|
||||
throw new Error(
|
||||
`Can not parse --techdocs-ref ${techdocsRef}. Should be of type HOST:URL.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { type, target };
|
||||
};
|
||||
|
||||
export const createLogger = ({
|
||||
verbose = false,
|
||||
}: {
|
||||
verbose: boolean;
|
||||
}): winston.Logger => {
|
||||
const logger = winston.createLogger({
|
||||
level: verbose ? 'verbose' : 'info',
|
||||
transports: [
|
||||
new winston.transports.Console({ format: winston.format.simple() }),
|
||||
],
|
||||
});
|
||||
|
||||
return logger;
|
||||
};
|
||||
Reference in New Issue
Block a user