Merge pull request #24593 from backstage/techdocs-move-away-from-container-runners

Add Docker container runner to techdocs-node
This commit is contained in:
Fredrik Adelöw
2024-06-17 17:07:31 +02:00
committed by GitHub
17 changed files with 394 additions and 60 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-node': patch
---
`TechdocsGenerator` won't require a `containerRunner` option anymore for generating TechDocs in docker.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/create-app': patch
'@backstage/plugin-techdocs-backend': patch
'@techdocs/cli': patch
---
Removed `dockerode` dependency.
@@ -14,14 +14,12 @@
* limitations under the License.
*/
import { DockerContainerRunner } from '@backstage/backend-common';
import {
createRouter,
Generators,
Preparers,
Publisher,
} from '@backstage/plugin-techdocs-backend';
import Docker from 'dockerode';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
@@ -34,14 +32,9 @@ export default async function createPlugin(
reader: env.reader,
});
// Docker client (conditionally) used by the generators, based on techdocs.generators config.
const dockerClient = new Docker();
const containerRunner = new DockerContainerRunner({ dockerClient });
// Generators are used for generating documentation sites.
const generators = await Generators.fromConfig(env.config, {
logger: env.logger,
containerRunner,
});
// Publisher is used for
@@ -40,14 +40,12 @@
"@backstage/plugin-techdocs-backend": "^{{version '@backstage/plugin-techdocs-backend'}}",
"app": "link:../app",
"better-sqlite3": "^9.0.0",
"dockerode": "^3.3.1",
"node-gyp": "^10.0.0",
"pg": "^8.11.3",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^{{version '@backstage/cli'}}",
"@types/dockerode": "^3.3.0",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/luxon": "^2.0.4"
-2
View File
@@ -61,9 +61,7 @@
"@backstage/cli-common": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-techdocs-node": "workspace:^",
"@types/dockerode": "^3.3.0",
"commander": "^12.0.0",
"dockerode": "^4.0.0",
"fs-extra": "^11.0.0",
"global-agent": "^3.0.0",
"http-proxy": "^1.18.1",
@@ -17,16 +17,11 @@
import { resolve } from 'path';
import { OptionValues } from 'commander';
import fs from 'fs-extra';
import Docker from 'dockerode';
import {
TechdocsGenerator,
ParsedLocationAnnotation,
getMkdocsYml,
} from '@backstage/plugin-techdocs-node';
import {
ContainerRunner,
DockerContainerRunner,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import {
convertTechDocsRefToLocationAnnotation,
@@ -75,14 +70,6 @@ export default async function generate(opts: OptionValues) {
},
});
// Docker client (conditionally) used by the generators, based on techdocs.generators config.
let containerRunner: ContainerRunner | undefined;
if (opts.docker) {
const dockerClient = new Docker();
containerRunner = new DockerContainerRunner({ dockerClient });
}
let parsedLocationAnnotation = {} as ParsedLocationAnnotation;
if (opts.techdocsRef) {
try {
@@ -97,7 +84,6 @@ export default async function generate(opts: OptionValues) {
// Generate docs using @backstage/plugin-techdocs-node
const techdocsGenerator = await TechdocsGenerator.fromConfig(config, {
logger,
containerRunner,
});
logger.info('Generating documentation...');
-2
View File
@@ -69,7 +69,6 @@
"@backstage/plugin-search-backend-module-techdocs": "workspace:^",
"@backstage/plugin-techdocs-node": "workspace:^",
"@types/express": "^4.17.6",
"dockerode": "^4.0.0",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "^11.2.0",
@@ -83,7 +82,6 @@
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/dockerode": "^3.3.0",
"msw": "^1.0.0",
"supertest": "^6.1.3"
},
-7
View File
@@ -16,7 +16,6 @@
import {
cacheToPluginCacheManager,
DockerContainerRunner,
loggerToWinstonLogger,
} from '@backstage/backend-common';
import {
@@ -36,7 +35,6 @@ import {
techdocsGeneratorExtensionPoint,
techdocsPreparerExtensionPoint,
} from '@backstage/plugin-techdocs-node';
import Docker from 'dockerode';
import { createRouter } from '@backstage/plugin-techdocs-backend';
import * as winston from 'winston';
@@ -118,14 +116,9 @@ export const techdocsPlugin = createBackendPlugin({
preparers.register(protocol, preparer);
}
// Docker client (conditionally) used by the generators, based on techdocs.generators config.
const dockerClient = new Docker();
const containerRunner = new DockerContainerRunner({ dockerClient });
// Generators are used for generating documentation sites.
const generators = await Generators.fromConfig(config, {
logger: winstonLogger,
containerRunner,
customGenerator: customTechdocsGenerator,
});
+2 -2
View File
@@ -48,8 +48,8 @@ export type GeneratorBuilder = {
// @public
export type GeneratorOptions = {
containerRunner?: ContainerRunner;
logger: Logger;
containerRunner?: ContainerRunner;
};
// @public
@@ -72,7 +72,7 @@ export class Generators implements GeneratorBuilder {
config: Config,
options: {
logger: Logger;
containerRunner: ContainerRunner;
containerRunner?: ContainerRunner;
customGenerator?: TechdocsGenerator;
},
): Promise<GeneratorBuilder>;
+1
View File
@@ -64,6 +64,7 @@
"@smithy/node-http-handler": "^2.1.7",
"@trendyol-js/openstack-swift-sdk": "^0.0.7",
"@types/express": "^4.17.6",
"dockerode": "^4.0.0",
"express": "^4.17.1",
"fs-extra": "^11.2.0",
"git-url-parse": "^14.0.0",
@@ -0,0 +1,218 @@
/*
* 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 fs from 'fs-extra';
import Stream, { PassThrough } from 'stream';
import { DockerContainerRunner, UserOptions } from './DockerContainerRunner';
import { createMockDirectory } from '@backstage/backend-test-utils';
const mockPull = jest.fn();
const mockRun = jest.fn();
const mockPing = jest.fn();
jest.mock(
'dockerode',
() =>
function MockDocker() {
return {
pull: mockPull,
run: mockRun,
ping: mockPing,
};
},
);
describe('DockerContainerRunner', () => {
let containerTaskApi: DockerContainerRunner;
const inputDir = createMockDirectory();
const outputDir = createMockDirectory();
beforeEach(() => {
inputDir.clear();
outputDir.clear();
mockPull.mockImplementation(
(
_repoTag: string,
_options: {},
callback: (error?: any, result?: any) => void,
) => {
const mockStream = new PassThrough();
callback(undefined, mockStream);
mockStream.end();
},
);
mockRun.mockResolvedValue([{ Error: null, StatusCode: 0 }]);
mockPing.mockResolvedValue(Buffer.from('OK', 'utf-8'));
containerTaskApi = new DockerContainerRunner();
});
afterEach(() => {
jest.clearAllMocks();
});
const imageName = 'dockerorg/image';
const args = ['bash', '-c', 'echo test'];
const mountDirs = {
[inputDir.path]: '/input',
[outputDir.path]: '/output',
};
const workingDir = inputDir.path;
const envVars = { HOME: '/tmp', LOG_LEVEL: 'debug' };
const envVarsArray = ['HOME=/tmp', 'LOG_LEVEL=debug'];
it('should pull the docker container', async () => {
await containerTaskApi.runContainer({
imageName,
args,
});
expect(mockPull).toHaveBeenCalledWith(imageName, {}, expect.any(Function));
expect(mockRun).toHaveBeenCalled();
});
it('should not pull the docker container when pullImage is false', async () => {
await containerTaskApi.runContainer({
imageName,
args,
pullImage: false,
});
expect(mockPull).not.toHaveBeenCalled();
expect(mockRun).toHaveBeenCalled();
});
it('should call the dockerClient run command with the correct arguments passed through', async () => {
await containerTaskApi.runContainer({
imageName,
args,
mountDirs,
envVars,
workingDir,
});
expect(mockRun).toHaveBeenCalledWith(
imageName,
args,
expect.any(Stream),
expect.objectContaining({
Env: envVarsArray,
WorkingDir: workingDir,
HostConfig: {
AutoRemove: true,
Binds: expect.arrayContaining([
`${await fs.realpath(inputDir.path)}:/input`,
`${await fs.realpath(outputDir.path)}:/output`,
]),
},
Volumes: {
'/input': {},
'/output': {},
},
}),
);
});
it('should ping docker to test availability', async () => {
await containerTaskApi.runContainer({
imageName,
args,
});
expect(mockPing).toHaveBeenCalled();
});
it('should pass through the user and group id from the host machine and set the home dir', async () => {
await containerTaskApi.runContainer({
imageName,
args,
});
const userOptions: UserOptions = {};
if (process.getuid && process.getgid) {
userOptions.User = `${process.getuid()}:${process.getgid()}`;
}
expect(mockRun).toHaveBeenCalledWith(
imageName,
args,
expect.any(Stream),
expect.objectContaining({
...userOptions,
}),
);
});
it('throws a correct error if the command fails in docker', async () => {
mockRun.mockResolvedValueOnce([
{
Error: new Error('Something went wrong with docker'),
StatusCode: 0,
},
]);
await expect(
containerTaskApi.runContainer({
imageName,
args,
}),
).rejects.toThrow(/Something went wrong with docker/);
});
describe('where docker is unavailable', () => {
const dockerError = 'a docker error';
beforeEach(() => {
mockPing.mockImplementationOnce(() => {
throw new Error(dockerError);
});
});
it('should throw with a descriptive error message including the docker error message', async () => {
await expect(
containerTaskApi.runContainer({
imageName,
args,
}),
).rejects.toThrow(new RegExp(`.+: ${dockerError}`));
});
});
it('should pass through the log stream to the docker client', async () => {
const logStream = new PassThrough();
await containerTaskApi.runContainer({
imageName,
args,
logStream,
});
expect(mockRun).toHaveBeenCalledWith(
imageName,
args,
logStream,
expect.objectContaining({
HostConfig: {
AutoRemove: true,
Binds: [],
},
Volumes: {},
}),
);
});
});
@@ -0,0 +1,145 @@
/*
* 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 Docker from 'dockerode';
import fs from 'fs-extra';
import { ForwardedError } from '@backstage/errors';
import { PassThrough } from 'stream';
import { pipeline as pipelineStream } from 'stream';
import { promisify } from 'util';
import { Writable } from 'stream';
const pipeline = promisify(pipelineStream);
export type UserOptions = {
User?: string;
};
/**
* @internal
*/
export class DockerContainerRunner {
private readonly dockerClient: Docker;
constructor() {
this.dockerClient = new Docker();
}
async runContainer(options: {
imageName: string;
command?: string | string[];
args: string[];
logStream?: Writable;
mountDirs?: Record<string, string>;
workingDir?: string;
envVars?: Record<string, string>;
pullImage?: boolean;
defaultUser?: boolean;
}) {
const {
imageName,
command,
args,
logStream = new PassThrough(),
mountDirs = {},
workingDir,
envVars = {},
pullImage = true,
defaultUser = false,
} = options;
// Show a better error message when Docker is unavailable.
try {
await this.dockerClient.ping();
} catch (e) {
throw new ForwardedError(
'This operation requires Docker. Docker does not appear to be available. Docker.ping() failed with',
e,
);
}
if (pullImage) {
await new Promise<void>((resolve, reject) => {
this.dockerClient.pull(imageName, {}, (err, stream) => {
if (err) {
reject(err);
return;
}
pipeline(stream, logStream, { end: false })
.then(resolve)
.catch(reject);
});
});
}
const userOptions: UserOptions = {};
if (!defaultUser && process.getuid && process.getgid) {
// Files that are created inside the Docker container will be owned by
// root on the host system on non Mac systems, because of reasons. Mainly the fact that
// volume sharing is done using NFS on Mac and actual mounts in Linux world.
// So we set the user in the container as the same user and group id as the host.
// On Windows we don't have process.getuid nor process.getgid
userOptions.User = `${process.getuid()}:${process.getgid()}`;
}
// Initialize volumes to mount based on mountDirs map
const Volumes: { [T: string]: object } = {};
for (const containerDir of Object.values(mountDirs)) {
Volumes[containerDir] = {};
}
// Create bind volumes
const Binds: string[] = [];
for (const [hostDir, containerDir] of Object.entries(mountDirs)) {
// Need to use realpath here as Docker mounting does not like
// symlinks for binding volumes
const realHostDir = await fs.realpath(hostDir);
Binds.push(`${realHostDir}:${containerDir}`);
}
// Create docker environment variables array
const Env = [];
for (const [key, value] of Object.entries(envVars)) {
Env.push(`${key}=${value}`);
}
const [{ Error: error, StatusCode: statusCode }] =
await this.dockerClient.run(imageName, args, logStream, {
Volumes,
HostConfig: {
AutoRemove: true,
Binds,
},
...(workingDir ? { WorkingDir: workingDir } : {}),
Entrypoint: command,
Env,
...userOptions,
} as Docker.ContainerCreateOptions);
if (error) {
throw new Error(
`Docker failed to run with the following error message: ${error}`,
);
}
if (statusCode !== 0) {
throw new Error(
`Docker container returned a non-zero exit code (${statusCode})`,
);
}
}
}
@@ -14,10 +14,7 @@
* limitations under the License.
*/
import {
ContainerRunner,
loggerToWinstonLogger,
} from '@backstage/backend-common';
import { loggerToWinstonLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { Generators } from './generators';
import { TechdocsGenerator } from './techdocs';
@@ -34,10 +31,6 @@ const mockEntity = {
};
describe('generators', () => {
const containerRunner: jest.Mocked<ContainerRunner> = {
runContainer: jest.fn(),
};
it('should return error if no generator is registered', async () => {
const generators = new Generators();
@@ -50,7 +43,6 @@ describe('generators', () => {
const generators = new Generators();
const techdocs = TechdocsGenerator.fromConfig(new ConfigReader({}), {
logger,
containerRunner,
});
generators.register('techdocs', techdocs);
@@ -42,7 +42,7 @@ export class Generators implements GeneratorBuilder {
config: Config,
options: {
logger: Logger;
containerRunner: ContainerRunner;
containerRunner?: ContainerRunner;
customGenerator?: TechdocsGenerator;
},
): Promise<GeneratorBuilder> {
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { ContainerRunner } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import path from 'path';
import { Logger } from 'winston';
@@ -43,6 +42,8 @@ import {
GeneratorRunOptions,
} from './types';
import { ForwardedError } from '@backstage/errors';
import { DockerContainerRunner } from './DockerContainerRunner';
import { ContainerRunner } from '@backstage/backend-common';
/**
* Generates documentation files
@@ -155,13 +156,10 @@ export class TechdocsGenerator implements GeneratorBase {
`Successfully generated docs from ${inputDir} into ${outputDir} using local mkdocs`,
);
break;
case 'docker':
if (this.containerRunner === undefined) {
throw new Error(
"Invalid state: containerRunner cannot be undefined when runIn is 'docker'",
);
}
await this.containerRunner.runContainer({
case 'docker': {
const containerRunner =
this.containerRunner || new DockerContainerRunner();
await containerRunner.runContainer({
imageName:
this.options.dockerImage ?? TechdocsGenerator.defaultDockerImage,
args: ['build', '-d', '/output'],
@@ -178,6 +176,7 @@ export class TechdocsGenerator implements GeneratorBase {
`Successfully generated docs from ${inputDir} into ${outputDir} using techdocs-container`,
);
break;
}
default:
throw new Error(
`Invalid config value "${this.options.runIn}" provided in 'techdocs.generators.techdocs'.`,
@@ -14,11 +14,11 @@
* limitations under the License.
*/
import { ContainerRunner } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Writable } from 'stream';
import { Logger } from 'winston';
import { ParsedLocationAnnotation } from '../../helpers';
import { ContainerRunner } from '@backstage/backend-common';
// Determines where the generator will be run
export type GeneratorRunInType = 'docker' | 'local';
@@ -28,8 +28,12 @@ export type GeneratorRunInType = 'docker' | 'local';
* @public
*/
export type GeneratorOptions = {
containerRunner?: ContainerRunner;
logger: Logger;
/**
* @deprecated containerRunner is now instantiated in
* the generator and this option will be removed in the future
*/
containerRunner?: ContainerRunner;
};
/**
+1 -4
View File
@@ -7733,9 +7733,7 @@ __metadata:
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-search-backend-module-techdocs": "workspace:^"
"@backstage/plugin-techdocs-node": "workspace:^"
"@types/dockerode": ^3.3.0
"@types/express": ^4.17.6
dockerode: ^4.0.0
express: ^4.17.1
express-promise-router: ^4.1.0
fs-extra: ^11.2.0
@@ -7807,6 +7805,7 @@ __metadata:
"@types/recursive-readdir": ^2.2.0
"@types/supertest": ^2.0.8
aws-sdk-client-mock: ^4.0.0
dockerode: ^4.0.0
express: ^4.17.1
fs-extra: ^11.2.0
git-url-parse: ^14.0.0
@@ -16613,14 +16612,12 @@ __metadata:
"@backstage/config": "workspace:^"
"@backstage/plugin-techdocs-node": "workspace:^"
"@types/commander": ^2.12.2
"@types/dockerode": ^3.3.0
"@types/fs-extra": ^11.0.0
"@types/http-proxy": ^1.17.4
"@types/node": ^18.17.8
"@types/serve-handler": ^6.1.0
"@types/webpack-env": ^1.15.3
commander: ^12.0.0
dockerode: ^4.0.0
find-process: ^1.4.5
fs-extra: ^11.0.0
global-agent: ^3.0.0