refactor: reunification of techdocs-cli and backstage repos

Signed-off-by: Andrew Thauer <athauer@wealthsimple.com>
This commit is contained in:
Andrew Thauer
2021-10-28 10:08:08 -04:00
committed by Camila Belo
parent 01a0a39521
commit 71e7b0ee92
62 changed files with 3089 additions and 3 deletions
+11
View File
@@ -0,0 +1,11 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
overrides: [
{
files: ['**/*.ts?(x)'],
rules: {
'no-restricted-imports': 0,
},
},
],
};
+70
View File
@@ -0,0 +1,70 @@
# @techdocs/cli
## 0.8.2
### Patch Changes
- 8fc7384: Allow to execute techdocs-cli serve using docker techdocs-container on Windows
## 0.8.1
### Patch Changes
- 0187424: Separate build and publish release steps
## 0.8.0
### Minor Changes
- c6f437a: OpenStack Swift configuration changed due to OSS SDK Client change in @backstage/techdocs-common, it was a breaking change.
PR Reference: https://github.com/backstage/backstage/pull/6839
### Patch Changes
- 05f0409: Merge Jobs for Release Pull Requests and Package Publishes
## 0.7.0
### Minor Changes
- 9d1f8d8: The `techdocs-cli publish` command will now publish TechDocs content to remote
storage using the lowercase'd entity triplet as the storage path. This is in
line with the beta release of the TechDocs plugin (`v0.11.0`).
If you have been running `techdocs-cli` prior to this version, you will need to
follow this [migration guide](https://backstage.io/docs/features/techdocs/how-to-guides#how-to-migrate-from-techdocs-alpha-to-beta).
## 0.6.2
### Patch Changes
- f1bcf1a: Changelog (from v0.6.1 to v0.6.2)
#### :bug: Bug Fix
- `techdocs-cli`
- [#105](https://github.com/backstage/techdocs-cli/pull/105) Add azureAccountKey parameter back to the publish command ([@emmaindal](https://github.com/emmaindal))
#### :house: Internal
- `embedded-techdocs-app`
- [#122](https://github.com/backstage/techdocs-cli/pull/122) chore(deps-dev): bump @types/node from 12.20.20 to 16.7.1 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot))
- [#120](https://github.com/backstage/techdocs-cli/pull/120) chore(deps-dev): bump @types/react-dom from 16.9.14 to 17.0.9 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot))
- [#119](https://github.com/backstage/techdocs-cli/pull/119) chore(deps-dev): bump @testing-library/user-event from 12.8.3 to 13.2.1 in /packages/embedded-techdocs-app ([@dependabot[bot]](https://github.com/apps/dependabot))
- [#118](https://github.com/backstage/techdocs-cli/pull/118) chore(deps-dev): bump @testing-library/react from 10.4.9 to 12.0.0 ([@dependabot[bot]](https://github.com/apps/dependabot))
- Other
- [#117](https://github.com/backstage/techdocs-cli/pull/117) chore(deps): bump @backstage/plugin-catalog from 0.6.11 to 0.6.12 ([@dependabot[bot]](https://github.com/apps/dependabot))
- [#124](https://github.com/backstage/techdocs-cli/pull/124) Update release process docs ([@emmaindal](https://github.com/emmaindal))
- [#116](https://github.com/backstage/techdocs-cli/pull/116) ignore dependabot branches for project board workflow ([@emmaindal](https://github.com/emmaindal))
- [#106](https://github.com/backstage/techdocs-cli/pull/106) Configure dependabot for all packages ([@emmaindal](https://github.com/emmaindal))
- [#102](https://github.com/backstage/techdocs-cli/pull/102) readme: add information about running techdocs-common locally ([@vcapretz](https://github.com/vcapretz))
- [#103](https://github.com/backstage/techdocs-cli/pull/103) Introduce changesets and improve the publish workflow ([@minkimcello](https://github.com/minkimcello))
- [#101](https://github.com/backstage/techdocs-cli/pull/101) update yarn lockfile to get rid of old version of node-forge ([@emmaindal](https://github.com/emmaindal))
#### Committers: 3
Thank you for contributing ❤️
- `Emma Indal` ([@emmaindal](https://github.com/emmaindal))
- `Min Kim` ([@minkimcello](https://github.com/minkimcello))
- `Vitor Capretz` ([@vcapretz](https://github.com/vcapretz))
+68
View File
@@ -0,0 +1,68 @@
# techdocs-cli
[![NPM Version badge](https://img.shields.io/npm/v/@techdocs/cli)](https://www.npmjs.com/package/@techdocs/cli)
## Usage
See [techdocs-cli usage docs](https://backstage.io/docs/features/techdocs/cli).
## Development
NOTE: When we build `techdocs-cli` it copies the output `embedded-techdocs-app`
bundle into the `packages/techdocs-cli/dist` which is then published with the
`@techdocs/cli` npm package.
### Running
```sh
# From the root of this repository run
# NOTE: This will build the embedded-techdocs-app and copy the output into the cli dist directory
yarn build --scope @techdocs/cli
# Now execute the binary
packages/techdocs-cli/bin/techdocs-cli
# ... or as a shell alias in ~/.zshrc or ~/.zprofile or ~/.bashrc or similar
export PATH=/path/to/backstage/packages/techdocs-cli/bin:$PATH
```
If you want to test live test changes to the `packages/embedded-techdocs-app`
you can serve the app and run the CLI using the following commands:
```sh
# Open a shell to the embedded-techdocs-app directory
cd packages/embedded-techdocs-app
# Run the embedded-techdocs-app using dev mode
yarn start
# In another shell use the techdocs-cli from the root of this repo
yarn techdocs-cli:dev [...options]
```
### Testing
Running unit tests requires mkdocs to be installed locally:
```sh
pip install mkdocs
pip install mkdocs-techdocs-core
```
Then run `yarn test`.
### Use an example docs project
We have created an [example documentation project](https://github.com/backstage/techdocs-container/tree/main/mock-docs) and it's shipped with [techdocs-container](https://github.com/backstage/techdocs-container) repository, for the purpose of local development. But you are free to create your own local test site. All it takes is a `docs/index.md` and `mkdocs.yml` in a directory.
```sh
git clone https://github.com/backstage/techdocs-container.git
cd techdocs-container/mock-docs
# To get a view of your docs in Backstage, use:
techdocs-cli serve
# To view the raw mkdocs site (without Backstage), use:
techdocs-cli serve:mkdocs
```
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
/*
* 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.
*/
const path = require('path');
// Figure out whether we're running inside the backstage repo or as an installed dependency
/* eslint-disable-next-line no-restricted-syntax */
const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src'));
if (!isLocal) {
require('..');
} else {
require('ts-node').register({
transpileOnly: true,
project: path.resolve(__dirname, '../../../tsconfig.json'),
compilerOptions: {
module: 'CommonJS',
},
});
require('../src');
}
+70
View File
@@ -0,0 +1,70 @@
{
"name": "@techdocs/cli",
"description": "Utility CLI for managing TechDocs sites in Backstage.",
"version": "0.8.4",
"private": false,
"publishConfig": {
"access": "public"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "packages/techdocs-cli"
},
"keywords": [
"backstage",
"techdocs"
],
"license": "Apache-2.0",
"main": "dist/index.cjs.js",
"types": "",
"scripts": {
"start": "nodemon --",
"build": "./scripts/build.sh",
"clean": "backstage-cli clean",
"lint": "backstage-cli lint",
"test": "backstage-cli test --testPathIgnorePatterns src/e2e.test.ts",
"test:e2e": "backstage-cli test --testPathPattern src/e2e.test.ts --runInBand"
},
"bin": {
"techdocs-cli": "bin/techdocs-cli"
},
"devDependencies": {
"@backstage/cli": "^0.8.0",
"@types/commander": "^2.12.2",
"@types/fs-extra": "^9.0.6",
"@types/http-proxy": "^1.17.4",
"@types/jest": "^26.0.7",
"@types/node": "^14.14.32",
"@types/react-dev-utils": "^9.0.4",
"@types/serve-handler": "^6.1.0",
"@types/webpack-env": "^1.15.3",
"embedded-techdocs-app": "0.0.0",
"nodemon": "^2.0.2",
"ts-node": "^10.0.0"
},
"files": [
"bin",
"dist"
],
"nodemonConfig": {
"watch": "./src",
"exec": "bin/techdocs-cli",
"ext": "ts"
},
"dependencies": {
"@backstage/backend-common": "^0.9.7",
"@backstage/catalog-model": "^0.9.5",
"@backstage/config": "^0.1.10",
"@backstage/techdocs-common": "^0.10.4",
"@types/dockerode": "^3.2.1",
"commander": "^6.1.0",
"dockerode": "^3.2.1",
"fs-extra": "^9.0.1",
"http-proxy": "^1.18.1",
"react-dev-utils": "^11.0.4",
"serve-handler": "^6.1.3",
"winston": "^3.2.1"
}
}
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# 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.
set -e
# Build the TechDocs CLI
npx backstage-cli -- build --outputs cjs
# Make sure to do `yarn run build` in packages/embedded-techdocs before building here.
EMBEDDED_TECHDOCS_APP_PATH=../embedded-techdocs-app
TECHDOCS_PREVIEW_SOURCE=$EMBEDDED_TECHDOCS_APP_PATH/dist
TECHDOCS_PREVIEW_DEST=dist/techdocs-preview-bundle
# Build the embedded-techdocs-app
pushd $EMBEDDED_TECHDOCS_APP_PATH >/dev/null
yarn build
popd >/dev/null
cp -r $TECHDOCS_PREVIEW_SOURCE $TECHDOCS_PREVIEW_DEST
# Write to console
echo "[techdocs-cli]: Built the dist/ folder"
echo "[techdocs-cli]: Imported @backstage/plugin-techdocs dist/ folder into techdocs-preview-bundle/"
@@ -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!');
}
+238
View File
@@ -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]);
}
+131
View File
@@ -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
+29
View File
@@ -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,
},
};
}
}
+100
View File
@@ -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,
});
};
+106
View File
@@ -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);
}
+54
View File
@@ -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;
};