techdocs-backend: instantiate containerRunner internally

Signed-off-by: Vincenzo Scamporlino <vincenzos@spotify.com>
This commit is contained in:
Vincenzo Scamporlino
2024-05-02 14:22:20 +02:00
parent 27c3700433
commit 130d0842a1
10 changed files with 174 additions and 37 deletions
+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,137 @@
/*
* 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 { ContainerRunner, RunContainerOptions } from './types';
const pipeline = promisify(pipelineStream);
export type UserOptions = {
User?: string;
};
/**
* A {@link ContainerRunner} for Docker containers.
*
* @public
*/
export class DockerContainerRunner implements ContainerRunner {
private readonly dockerClient: Docker;
constructor() {
this.dockerClient = new Docker();
}
async runContainer(options: RunContainerOptions) {
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,7 +14,6 @@
* limitations under the License.
*/
import { ContainerRunner } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
@@ -42,7 +41,6 @@ export class Generators implements GeneratorBuilder {
config: Config,
options: {
logger: Logger;
containerRunner: ContainerRunner;
customGenerator?: TechdocsGenerator;
},
): Promise<GeneratorBuilder> {
@@ -17,6 +17,8 @@ export { TechdocsGenerator } from './techdocs';
export { Generators } from './generators';
export { getMkdocsYml } from './helpers';
export type {
RunContainerOptions,
ContainerRunner,
GeneratorBase,
GeneratorOptions,
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';
@@ -36,6 +35,7 @@ import {
patchMkdocsYmlWithPlugins,
} from './mkdocsPatchers';
import {
ContainerRunner,
GeneratorBase,
GeneratorConfig,
GeneratorOptions,
@@ -43,6 +43,7 @@ import {
GeneratorRunOptions,
} from './types';
import { ForwardedError } from '@backstage/errors';
import { DockerContainerRunner } from './DockerContainerRunner';
/**
* Generates documentation files
@@ -55,10 +56,9 @@ export class TechdocsGenerator implements GeneratorBase {
*/
public static readonly defaultDockerImage = 'spotify/techdocs:v1.2.3';
private readonly logger: Logger;
private readonly containerRunner?: ContainerRunner;
private readonly options: GeneratorConfig;
private readonly scmIntegrations: ScmIntegrationRegistry;
private containerRunner?: ContainerRunner;
/**
* Returns a instance of TechDocs generator
* @param config - A Backstage configuration
@@ -157,9 +157,7 @@ export class TechdocsGenerator implements GeneratorBase {
break;
case 'docker':
if (this.containerRunner === undefined) {
throw new Error(
"Invalid state: containerRunner cannot be undefined when runIn is 'docker'",
);
this.containerRunner = new DockerContainerRunner();
}
await this.containerRunner.runContainer({
imageName:
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { ContainerRunner } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Writable } from 'stream';
import { Logger } from 'winston';
@@ -99,3 +98,32 @@ export type DefaultMkdocsContent = {
docs_dir: string;
plugins: String[];
};
/**
* Options passed to the {@link ContainerRunner.runContainer} method.
*
* @public
*/
export type RunContainerOptions = {
imageName: string;
command?: string | string[];
args: string[];
logStream?: Writable;
mountDirs?: Record<string, string>;
workingDir?: string;
envVars?: Record<string, string>;
pullImage?: boolean;
defaultUser?: boolean;
};
/**
* Handles the running of containers, on behalf of others.
*
* @public
*/
export interface ContainerRunner {
/**
* Runs a container image to completion.
*/
runContainer(opts: RunContainerOptions): Promise<void>;
}