Move packages/techdocs-common -> plugins/techdocs-node

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2022-03-03 13:20:36 +01:00
parent d5dc0fc206
commit 84a8788794
62 changed files with 0 additions and 0 deletions
+326
View File
@@ -0,0 +1,326 @@
/*
* 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 {
ReadTreeResponse,
SearchResponse,
UrlReader,
} from '@backstage/backend-common';
import { Entity, getEntitySourceLocation } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import os from 'os';
import path from 'path';
import { Readable } from 'stream';
import {
getDocFilesFromRepository,
getLocationForEntity,
parseReferenceAnnotation,
transformDirLocation,
} from './helpers';
jest.mock('@backstage/catalog-model', () => ({
...jest.requireActual('@backstage/catalog-model'),
getEntitySourceLocation: jest.fn(),
}));
const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const entityBase: Entity = {
metadata: {
namespace: 'default',
name: 'mytestcomponent',
description: 'A component for testing',
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
type: 'documentation',
lifecycle: 'experimental',
owner: 'testuser',
},
};
const metadataBase = {
namespace: 'default',
name: 'mytestcomponent',
description: 'A component for testing',
};
const goodAnnotation = {
annotations: {
'backstage.io/techdocs-ref':
'url:https://github.com/backstage/backstage/blob/master/subfolder/',
},
};
const mockEntityWithAnnotation: Entity = {
...entityBase,
...{
metadata: {
...metadataBase,
...goodAnnotation,
},
},
};
const badAnnotation = {
annotations: {
'backstage.io/techdocs-ref': 'bad-annotation',
},
};
const mockEntityWithBadAnnotation: Entity = {
...entityBase,
...{
metadata: {
...metadataBase,
...badAnnotation,
},
},
};
const scmIntegrations = ScmIntegrations.fromConfig(new ConfigReader({}));
afterEach(() => jest.resetAllMocks());
describe('parseReferenceAnnotation', () => {
it('should parse annotation', () => {
const parsedLocationAnnotation = parseReferenceAnnotation(
'backstage.io/techdocs-ref',
mockEntityWithAnnotation,
);
expect(parsedLocationAnnotation.type).toBe('url');
expect(parsedLocationAnnotation.target).toBe(
'https://github.com/backstage/backstage/blob/master/subfolder/',
);
});
it('should throw error without annotation', () => {
expect(() => {
parseReferenceAnnotation('backstage.io/techdocs-ref', entityBase);
}).toThrow(/No location annotation/);
});
it('should throw error with bad annotation', () => {
expect(() => {
parseReferenceAnnotation(
'backstage.io/techdocs-ref',
mockEntityWithBadAnnotation,
);
}).toThrow(/Unable to parse/);
});
});
describe('transformDirLocation', () => {
it.each`
techdocsRef | target
${'dir:.'} | ${'https://my-url/folder/'}
${'dir:./sub-folder'} | ${'https://my-url/folder/sub-folder'}
`(
'should transform "$techdocsRef" for url type locations',
({ techdocsRef, target }) => {
(getEntitySourceLocation as jest.Mock).mockReturnValue({
type: 'url',
target: 'https://my-url/folder/',
});
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'backstage.io/techdocs-ref': techdocsRef,
},
},
};
const result = transformDirLocation(
entity,
parseReferenceAnnotation('backstage.io/techdocs-ref', entity),
scmIntegrations,
);
expect(result).toEqual({ type: 'url', target });
},
);
it.each`
techdocsRef | target
${'dir:.'} | ${path.join(rootDir, 'working-copy')}
${'dir:./sub-folder'} | ${path.join(rootDir, 'working-copy', 'sub-folder')}
`(
'should transform "$techdocsRef" for file type locations',
({ techdocsRef, target }) => {
(getEntitySourceLocation as jest.Mock).mockReturnValue({
type: 'file',
target: path.join(rootDir, 'working-copy', 'catalog-info.yaml'),
});
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'backstage.io/techdocs-ref': techdocsRef,
},
},
};
const result = transformDirLocation(
entity,
parseReferenceAnnotation('backstage.io/techdocs-ref', entity),
scmIntegrations,
);
expect(result).toEqual({ type: 'dir', target });
},
);
it('should reject unsafe file location', () => {
(getEntitySourceLocation as jest.Mock).mockReturnValue({
type: 'file',
target: '/tmp/catalog-info.yaml',
});
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'backstage.io/techdocs-ref': 'dir:..',
},
},
};
expect(() =>
transformDirLocation(
entity,
parseReferenceAnnotation('backstage.io/techdocs-ref', entity),
scmIntegrations,
),
).toThrow(
/Relative path is not allowed to refer to a directory outside its parent/,
);
});
it('should reject other location types', () => {
(getEntitySourceLocation as jest.Mock).mockReturnValue({
type: 'other',
target: '/',
});
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'backstage.io/techdocs-ref': 'dir:.',
},
},
};
expect(() =>
transformDirLocation(
entity,
parseReferenceAnnotation('backstage.io/techdocs-ref', entity),
scmIntegrations,
),
).toThrow(/Unable to resolve location type other/);
});
});
describe('getLocationForEntity', () => {
it('should handle dir locations', () => {
(getEntitySourceLocation as jest.Mock).mockReturnValue({
type: 'url',
target: 'https://my-url/folder/',
});
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'test',
annotations: {
'backstage.io/techdocs-ref': 'dir:.',
},
},
};
const parsedLocationAnnotation = getLocationForEntity(
entity,
scmIntegrations,
);
expect(parsedLocationAnnotation.type).toBe('url');
expect(parsedLocationAnnotation.target).toBe('https://my-url/folder/');
});
it('should get location for entity', () => {
const parsedLocationAnnotation = getLocationForEntity(
mockEntityWithAnnotation,
scmIntegrations,
);
expect(parsedLocationAnnotation.type).toBe('url');
expect(parsedLocationAnnotation.target).toBe(
'https://github.com/backstage/backstage/blob/master/subfolder/',
);
});
});
describe('getDocFilesFromRepository', () => {
it('should read a remote directory using UrlReader.readTree', async () => {
class MockUrlReader implements UrlReader {
async read() {
return Buffer.from('mock');
}
async readTree(): Promise<ReadTreeResponse> {
return {
dir: async () => {
return '/tmp/testfolder';
},
files: async () => {
return [];
},
archive: async () => {
return Readable.from('');
},
etag: 'etag123abc',
};
}
async search(): Promise<SearchResponse> {
return {
etag: '',
files: [],
};
}
}
const output = await getDocFilesFromRepository(
new MockUrlReader(),
mockEntityWithAnnotation,
);
expect(output.preparedDir).toBe('/tmp/testfolder');
expect(output.etag).toBe('etag123abc');
});
});
+167
View File
@@ -0,0 +1,167 @@
/*
* 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 { resolveSafeChildPath, UrlReader } from '@backstage/backend-common';
import {
Entity,
getEntitySourceLocation,
parseLocationRef,
} from '@backstage/catalog-model';
import { InputError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import path from 'path';
import { Logger } from 'winston';
import { PreparerResponse, RemoteProtocol } from './stages/prepare/types';
/**
* Parsed location annotation
* @public
*/
export type ParsedLocationAnnotation = {
type: RemoteProtocol;
target: string;
};
/**
* Returns a parset locations annotation
* @public
* @param annotationName - The name of the annotation in the entity metadata
* @param entity - A TechDocs entity instance
*/
export const parseReferenceAnnotation = (
annotationName: string,
entity: Entity,
): ParsedLocationAnnotation => {
const annotation = entity.metadata.annotations?.[annotationName];
if (!annotation) {
throw new InputError(
`No location annotation provided in entity: ${entity.metadata.name}`,
);
}
const { type, target } = parseLocationRef(annotation);
return {
type: type as RemoteProtocol,
target,
};
};
/**
* TechDocs references of type `dir` are relative the source location of the entity.
* This function transforms relative references to absolute ones, based on the
* location the entity was ingested from. If the entity was registered by a `url`
* location, it returns a `url` location with a resolved target that points to the
* targeted subfolder. If the entity was registered by a `file` location, it returns
* an absolute `dir` location.
* @public
* @param entity - the entity with annotations
* @param dirAnnotation - the parsed techdocs-ref annotation of type 'dir'
* @param scmIntegrations - access to the scmIntegration to do url transformations
* @throws if the entity doesn't specify a `dir` location or is ingested from an unsupported location.
* @returns the transformed location with an absolute target.
*/
export const transformDirLocation = (
entity: Entity,
dirAnnotation: ParsedLocationAnnotation,
scmIntegrations: ScmIntegrationRegistry,
): { type: 'dir' | 'url'; target: string } => {
const location = getEntitySourceLocation(entity);
switch (location.type) {
case 'url': {
const target = scmIntegrations.resolveUrl({
url: dirAnnotation.target,
base: location.target,
});
return {
type: 'url',
target,
};
}
case 'file': {
// only permit targets in the same folder as the target of the `file` location!
const target = resolveSafeChildPath(
path.dirname(location.target),
dirAnnotation.target,
);
return {
type: 'dir',
target,
};
}
default:
throw new InputError(`Unable to resolve location type ${location.type}`);
}
};
/**
* Returns a entity reference based on the TechDocs annotation type
* @public
* @param entity - A TechDocs instance
* @param scmIntegration - An implementation for SCM integration API
*/
export const getLocationForEntity = (
entity: Entity,
scmIntegration: ScmIntegrationRegistry,
): ParsedLocationAnnotation => {
const annotation = parseReferenceAnnotation(
'backstage.io/techdocs-ref',
entity,
);
switch (annotation.type) {
case 'url':
return annotation;
case 'dir':
return transformDirLocation(entity, annotation, scmIntegration);
default:
throw new Error(`Invalid reference annotation ${annotation.type}`);
}
};
/**
* Returns a preparer response {@link PreparerResponse}
* @public
* @param reader - Read a tree of files from a repository
* @param entity - A TechDocs entity instance
* @param opts - Options for configuring the reader, e.g. logger, etag, etc.
*/
export const getDocFilesFromRepository = async (
reader: UrlReader,
entity: Entity,
opts?: { etag?: string; logger?: Logger },
): Promise<PreparerResponse> => {
const { target } = parseReferenceAnnotation(
'backstage.io/techdocs-ref',
entity,
);
opts?.logger?.debug(`Reading files from ${target}`);
// readTree will throw NotModifiedError if etag has not changed.
const readTreeResponse = await reader.readTree(target, { etag: opts?.etag });
const preparedDir = await readTreeResponse.dir();
opts?.logger?.debug(`Tree downloaded and stored at ${preparedDir}`);
return {
preparedDir,
etag: readTreeResponse.etag,
};
};
+25
View File
@@ -0,0 +1,25 @@
/*
* 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.
*/
/**
* Common functionalities for TechDocs, to be shared between techdocs-backend plugin and techdocs-cli
*
* @packageDocumentation
*/
export * from './stages';
export * from './helpers';
export * from './techdocsTypes';
+20
View File
@@ -0,0 +1,20 @@
/*
* 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 { StorageFilesMock } from './testUtils/StorageFilesMock';
(global as any).rootDir = StorageFilesMock.rootDir;
(global as any).storageFilesMock = new StorageFilesMock();
@@ -0,0 +1,2 @@
site_name: Test site name
site_description: Test site description
@@ -0,0 +1,3 @@
site_name: Test site name
site_description: Test site description
docs_dir: /etc
@@ -0,0 +1,3 @@
site_name: Test site name
site_description: Test site description
docs_dir: ../../etc/
@@ -0,0 +1,3 @@
site_name: Test site name
site_description: Test site description
docs_dir: docs/
@@ -0,0 +1,6 @@
site_name: Test site name
site_description: Test site description
docs_dir: docs/
plugins:
- not-techdocs-core
- also-not-techdocs-core
@@ -0,0 +1,3 @@
site_name: Test site name
site_description: Test site description
# This is a comment that is removed after editing
@@ -0,0 +1,4 @@
site_name: Test site name
site_description: Test site description
edit_uri: https://github.com/backstage/backstage/edit/main/docs
@@ -0,0 +1,7 @@
site_name: Test site name
site_description: Test site description
markdown_extensions:
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
@@ -0,0 +1,4 @@
site_name: Test site name
site_description: Test site description
repo_url: https://github.com/backstage/backstage
@@ -0,0 +1,5 @@
site_name: Test site name
site_description: Test site description
# This is a comment that is removed after editing
plugins:
- techdocs-core
@@ -0,0 +1,3 @@
site_name: Test site name
site_description: Test site description
docs_dir: docs/
@@ -0,0 +1,56 @@
/*
* 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 { ContainerRunner, getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { Generators } from './generators';
import { TechdocsGenerator } from './techdocs';
const logger = getVoidLogger();
const mockEntity = {
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'testName',
},
};
describe('generators', () => {
const containerRunner: jest.Mocked<ContainerRunner> = {
runContainer: jest.fn(),
};
it('should return error if no generator is registered', async () => {
const generators = new Generators();
expect(() => generators.get(mockEntity)).toThrowError(
'No generator registered for entity: "techdocs"',
);
});
it('should return correct registered generator', async () => {
const generators = new Generators();
const techdocs = TechdocsGenerator.fromConfig(new ConfigReader({}), {
logger,
containerRunner,
});
generators.register('techdocs', techdocs);
expect(generators.get(mockEntity)).toBe(techdocs);
});
});
@@ -0,0 +1,76 @@
/*
* 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 { ContainerRunner } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
import { getGeneratorKey } from './helpers';
import { TechdocsGenerator } from './techdocs';
import {
GeneratorBase,
GeneratorBuilder,
SupportedGeneratorKey,
} from './types';
/**
* Collection of docs generators
* @public
*/
export class Generators implements GeneratorBuilder {
private generatorMap = new Map<SupportedGeneratorKey, GeneratorBase>();
/**
* Returns a generators instance containing a generator for TechDocs
* @param config - A Backstage configuration
* @param options - Options to configure the TechDocs generator
*/
static async fromConfig(
config: Config,
options: { logger: Logger; containerRunner: ContainerRunner },
): Promise<GeneratorBuilder> {
const generators = new Generators();
const techdocsGenerator = TechdocsGenerator.fromConfig(config, options);
generators.register('techdocs', techdocsGenerator);
return generators;
}
/**
* Register a generator in the generators collection
* @param generatorKey - Unique identifier for the generator
* @param generator - The generator instance to register
*/
register(generatorKey: SupportedGeneratorKey, generator: GeneratorBase) {
this.generatorMap.set(generatorKey, generator);
}
/**
* Returns the generator for a given TechDocs entity
* @param entity - A TechDocs entity instance
*/
get(entity: Entity): GeneratorBase {
const generatorKey = getGeneratorKey(entity);
const generator = this.generatorMap.get(generatorKey);
if (!generator) {
throw new Error(`No generator registered for entity: "${generatorKey}"`);
}
return generator;
}
}
@@ -0,0 +1,591 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import fs from 'fs-extra';
import mockFs from 'mock-fs';
import os from 'os';
import path, { resolve as resolvePath } from 'path';
import { ParsedLocationAnnotation } from '../../helpers';
import {
createOrUpdateMetadata,
getGeneratorKey,
getMkdocsYml,
getRepoUrlFromLocationAnnotation,
patchIndexPreBuild,
storeEtagMetadata,
validateMkdocsYaml,
} from './helpers';
import {
patchMkdocsYmlPreBuild,
pathMkdocsYmlWithTechdocsPlugin,
} from './mkDocsPatchers';
import yaml from 'js-yaml';
const mockEntity = {
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'testName',
},
};
const mkdocsYml = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs.yml'),
);
const mkdocsYmlWithExtensions = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_extensions.yml'),
);
const mkdocsYmlWithRepoUrl = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_repo_url.yml'),
);
const mkdocsYmlWithEditUri = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_edit_uri.yml'),
);
const mkdocsYmlWithValidDocDir = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_valid_doc_dir.yml'),
);
const mkdocsYmlWithInvalidDocDir = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_invalid_doc_dir.yml'),
);
const mkdocsYmlWithInvalidDocDir2 = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_invalid_doc_dir2.yml'),
);
const mkdocsYmlWithComments = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_comments.yml'),
);
const mkdocsYmlWithTechdocsPlugins = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_techdocs_plugin.yml'),
);
const mkdocsYmlWithoutPlugins = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_without_plugins.yml'),
);
const mkdocsYmlWithAdditionalPlugins = fs.readFileSync(
resolvePath(__filename, '../__fixtures__/mkdocs_with_additional_plugins.yml'),
);
const mockLogger = getVoidLogger();
const warn = jest.spyOn(mockLogger, 'warn');
const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const scmIntegrations = ScmIntegrations.fromConfig(new ConfigReader({}));
describe('helpers', () => {
describe('getGeneratorKey', () => {
it('should return techdocs as the only generator key', () => {
const key = getGeneratorKey(mockEntity);
expect(key).toBe('techdocs');
});
});
describe('getRepoUrlFromLocationAnnotation', () => {
it.each`
url | repo_url | edit_uri
${'https://github.com/backstage/backstage'} | ${'https://github.com/backstage/backstage'} | ${undefined}
${'https://github.com/backstage/backstage/tree/main/examples/techdocs/'} | ${undefined} | ${'https://github.com/backstage/backstage/edit/main/examples/techdocs/docs'}
${'https://github.com/backstage/backstage/tree/main/'} | ${undefined} | ${'https://github.com/backstage/backstage/edit/main/docs'}
${'https://gitlab.com/backstage/backstage'} | ${'https://gitlab.com/backstage/backstage'} | ${undefined}
${'https://gitlab.com/backstage/backstage/-/blob/main/examples/techdocs/'} | ${undefined} | ${'https://gitlab.com/backstage/backstage/-/edit/main/examples/techdocs/docs'}
${'https://gitlab.com/backstage/backstage/-/blob/main/'} | ${undefined} | ${'https://gitlab.com/backstage/backstage/-/edit/main/docs'}
`('should convert $url', ({ url: target, repo_url, edit_uri }) => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target,
};
expect(
getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
),
).toEqual({ repo_url, edit_uri });
});
it.each`
url | edit_uri
${'https://github.com/backstage/backstage/tree/main/examples/techdocs/'} | ${'https://github.com/backstage/backstage/edit/main/examples/techdocs/custom/folder'}
${'https://github.com/backstage/backstage/tree/main/'} | ${'https://github.com/backstage/backstage/edit/main/custom/folder'}
${'https://gitlab.com/backstage/backstage/-/blob/main/examples/techdocs/'} | ${'https://gitlab.com/backstage/backstage/-/edit/main/examples/techdocs/custom/folder'}
${'https://gitlab.com/backstage/backstage/-/blob/main/'} | ${'https://gitlab.com/backstage/backstage/-/edit/main/custom/folder'}
`(
'should convert $url with custom docsFolder',
({ url: target, edit_uri }) => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target,
};
expect(
getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
'./custom/folder',
),
).toEqual({ edit_uri });
},
);
it.each`
url
${'https://bitbucket.org/backstage/backstage/src/master/examples/techdocs/'}
${'https://bitbucket.org/backstage/backstage/src/master/'}
${'https://dev.azure.com/organization/project/_git/repository?path=%2Fexamples%2Ftechdocs'}
${'https://dev.azure.com/organization/project/_git/repository?path=%2F'}
`('should ignore $url', ({ url: target }) => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target,
};
expect(
getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
),
).toEqual({});
});
it('should ignore unsupported location type', () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'dir',
target: '/home/user/workspace/docs-repository',
};
expect(
getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
),
).toEqual({});
});
});
describe('patchMkdocsYmlPreBuild', () => {
beforeEach(() => {
mockFs({
'/mkdocs.yml': mkdocsYml,
'/mkdocs_with_repo_url.yml': mkdocsYmlWithRepoUrl,
'/mkdocs_with_edit_uri.yml': mkdocsYmlWithEditUri,
'/mkdocs_with_extensions.yml': mkdocsYmlWithExtensions,
'/mkdocs_with_comments.yml': mkdocsYmlWithComments,
});
});
afterEach(() => {
mockFs.restore();
});
it('should add edit_uri to mkdocs.yml', async () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target: 'https://github.com/backstage/backstage',
};
await patchMkdocsYmlPreBuild(
'/mkdocs.yml',
mockLogger,
parsedLocationAnnotation,
scmIntegrations,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs.yml');
expect(updatedMkdocsYml.toString()).toContain(
'repo_url: https://github.com/backstage/backstage',
);
});
it('should add repo_url to mkdocs.yml that contains custom yaml tags', async () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target: 'https://github.com/backstage/backstage',
};
await patchMkdocsYmlPreBuild(
'/mkdocs_with_extensions.yml',
mockLogger,
parsedLocationAnnotation,
scmIntegrations,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs_with_extensions.yml');
expect(updatedMkdocsYml.toString()).toContain(
'repo_url: https://github.com/backstage/backstage',
);
expect(updatedMkdocsYml.toString()).toContain(
"emoji_index: !!python/name:materialx.emoji.twemoji ''",
);
});
it('should not override existing repo_url in mkdocs.yml', async () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target: 'https://github.com/neworg/newrepo',
};
await patchMkdocsYmlPreBuild(
'/mkdocs_with_repo_url.yml',
mockLogger,
parsedLocationAnnotation,
scmIntegrations,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs_with_repo_url.yml');
expect(updatedMkdocsYml.toString()).toContain(
'repo_url: https://github.com/backstage/backstage',
);
expect(updatedMkdocsYml.toString()).not.toContain(
'repo_url: https://github.com/neworg/newrepo',
);
});
it('should not override existing edit_uri in mkdocs.yml', async () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'url',
target: 'https://github.com/neworg/newrepo',
};
await patchMkdocsYmlPreBuild(
'/mkdocs_with_edit_uri.yml',
mockLogger,
parsedLocationAnnotation,
scmIntegrations,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs_with_edit_uri.yml');
expect(updatedMkdocsYml.toString()).toContain(
'edit_uri: https://github.com/backstage/backstage/edit/main/docs',
);
expect(updatedMkdocsYml.toString()).not.toContain(
'https://github.com/neworg/newrepo',
);
});
it('should not update mkdocs.yml if nothing should be changed', async () => {
const parsedLocationAnnotation: ParsedLocationAnnotation = {
type: 'dir',
target: '/unsupported/path',
};
await patchMkdocsYmlPreBuild(
'/mkdocs_with_comments.yml',
mockLogger,
parsedLocationAnnotation,
scmIntegrations,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs_with_comments.yml');
expect(updatedMkdocsYml.toString()).toContain(
'# This is a comment that is removed after editing',
);
expect(updatedMkdocsYml.toString()).not.toContain('edit_uri');
expect(updatedMkdocsYml.toString()).not.toContain('repo_url');
});
});
describe('pathMkdocsYmlWithTechdocsPlugin', () => {
beforeEach(() => {
mockFs({
'/mkdocs_with_techdocs_plugin.yml': mkdocsYmlWithTechdocsPlugins,
'/mkdocs_without_plugins.yml': mkdocsYmlWithoutPlugins,
'/mkdocs_with_additional_plugins.yml': mkdocsYmlWithAdditionalPlugins,
});
});
it('should not add additional plugins if techdocs exists already in mkdocs file', async () => {
await pathMkdocsYmlWithTechdocsPlugin(
'/mkdocs_with_techdocs_plugin.yml',
mockLogger,
);
const updatedMkdocsYml = await fs.readFile(
'/mkdocs_with_techdocs_plugin.yml',
);
const parsedYml = yaml.load(updatedMkdocsYml.toString()) as {
plugins: string[];
};
expect(parsedYml.plugins).toHaveLength(1);
expect(parsedYml.plugins).toContain('techdocs-core');
});
it("should add the needed plugin if it doesn't exist in mkdocs file", async () => {
await pathMkdocsYmlWithTechdocsPlugin(
'/mkdocs_without_plugins.yml',
mockLogger,
);
const updatedMkdocsYml = await fs.readFile('/mkdocs_without_plugins.yml');
const parsedYml = yaml.load(updatedMkdocsYml.toString()) as {
plugins: string[];
};
expect(parsedYml.plugins).toHaveLength(1);
expect(parsedYml.plugins).toContain('techdocs-core');
});
it('should not override existing plugins', async () => {
await pathMkdocsYmlWithTechdocsPlugin(
'/mkdocs_with_additional_plugins.yml',
mockLogger,
);
const updatedMkdocsYml = await fs.readFile(
'/mkdocs_with_additional_plugins.yml',
);
const parsedYml = yaml.load(updatedMkdocsYml.toString()) as {
plugins: string[];
};
expect(parsedYml.plugins).toHaveLength(3);
expect(parsedYml.plugins).toContain('techdocs-core');
expect(parsedYml.plugins).toContain('not-techdocs-core');
expect(parsedYml.plugins).toContain('also-not-techdocs-core');
});
});
describe('patchIndexPreBuild', () => {
afterEach(() => {
warn.mockClear();
});
it('should have no effect if docs/index.md exists', async () => {
mockFs({
'/docs/index.md': 'index.md content',
'/docs/README.md': 'docs/README.md content',
});
await patchIndexPreBuild({ inputDir: '/', logger: mockLogger });
expect(fs.readFileSync('/docs/index.md', 'utf-8')).toEqual(
'index.md content',
);
expect(warn).not.toHaveBeenCalledWith();
mockFs.restore();
});
it("should use docs/README.md if docs/index.md doesn't exists", async () => {
mockFs({
'/docs/README.md': 'docs/README.md content',
'/README.md': 'main README.md content',
});
await patchIndexPreBuild({ inputDir: '/', logger: mockLogger });
expect(fs.readFileSync('/docs/index.md', 'utf-8')).toEqual(
'docs/README.md content',
);
expect(warn.mock.calls).toEqual([
[`${path.normalize('docs/index.md')} not found.`],
]);
mockFs.restore();
});
it('should use README.md if neither docs/index.md or docs/README.md exist', async () => {
mockFs({
'/README.md': 'main README.md content',
});
await patchIndexPreBuild({ inputDir: '/', logger: mockLogger });
expect(fs.readFileSync('/docs/index.md', 'utf-8')).toEqual(
'main README.md content',
);
expect(warn.mock.calls).toEqual([
[`${path.normalize('docs/index.md')} not found.`],
[`${path.normalize('docs/README.md')} not found.`],
[`${path.normalize('docs/readme.md')} not found.`],
]);
mockFs.restore();
});
it('should not use any file as index.md if no one matches the requirements', async () => {
mockFs({});
await patchIndexPreBuild({ inputDir: '/', logger: mockLogger });
expect(() => fs.readFileSync('/docs/index.md', 'utf-8')).toThrow();
const paths = [
path.normalize('docs/index.md'),
path.normalize('docs/README.md'),
path.normalize('docs/readme.md'),
'README.md',
'readme.md',
];
expect(warn.mock.calls).toEqual([
...paths.map(p => [`${p} not found.`]),
[
`Could not find any techdocs' index file. Please make sure at least one of ${paths
.map(p => path.sep + p)
.join(' ')} exists.`,
],
]);
mockFs.restore();
});
});
describe('addBuildTimestampMetadata', () => {
const mockFiles = {
'invalid_techdocs_metadata.json': 'dsds',
'techdocs_metadata.json': '{"site_name": "Tech Docs"}',
};
beforeEach(() => {
mockFs.restore();
mockFs({
[rootDir]: mockFiles,
});
});
afterEach(() => {
mockFs.restore();
});
it('should create the file if it does not exist', async () => {
const filePath = path.join(rootDir, 'wrong_techdocs_metadata.json');
await createOrUpdateMetadata(filePath, mockLogger);
// Check if the file exists
await expect(
fs.access(filePath, fs.constants.F_OK),
).resolves.not.toThrowError();
});
it('should throw error when the JSON is invalid', async () => {
const filePath = path.join(rootDir, 'invalid_techdocs_metadata.json');
await expect(
createOrUpdateMetadata(filePath, mockLogger),
).rejects.toThrowError('Unexpected token d in JSON at position 0');
});
it('should add build timestamp to the metadata json', async () => {
const filePath = path.join(rootDir, 'techdocs_metadata.json');
await createOrUpdateMetadata(filePath, mockLogger);
const json = await fs.readJson(filePath);
expect(json.build_timestamp).toBeLessThanOrEqual(Date.now());
});
it('should add list of files to the metadata json', async () => {
const filePath = path.join(rootDir, 'techdocs_metadata.json');
await createOrUpdateMetadata(filePath, mockLogger);
const json = await fs.readJson(filePath);
expect(json.files[0]).toEqual(Object.keys(mockFiles)[0]);
expect(json.files[1]).toEqual(Object.keys(mockFiles)[1]);
});
});
describe('storeEtagMetadata', () => {
beforeEach(() => {
mockFs.restore();
mockFs({
[rootDir]: {
'invalid_techdocs_metadata.json': 'dsds',
'techdocs_metadata.json': '{"site_name": "Tech Docs"}',
},
});
});
afterEach(() => {
mockFs.restore();
});
it('should throw error when the JSON is invalid', async () => {
const filePath = path.join(rootDir, 'invalid_techdocs_metadata.json');
await expect(
storeEtagMetadata(filePath, 'etag123abc'),
).rejects.toThrowError('Unexpected token d in JSON at position 0');
});
it('should add etag to the metadata json', async () => {
const filePath = path.join(rootDir, 'techdocs_metadata.json');
await storeEtagMetadata(filePath, 'etag123abc');
const json = await fs.readJson(filePath);
expect(json.etag).toBe('etag123abc');
});
});
describe('getMkdocsYml', () => {
afterEach(() => {
mockFs.restore();
});
const inputDir = resolvePath(__filename, '../__fixtures__/');
it('returns expected contents when .yml file is present', async () => {
const key = path.join(inputDir, 'mkdocs.yml');
mockFs({ [key]: mkdocsYml });
const { path: mkdocsPath, content } = await getMkdocsYml(inputDir);
expect(mkdocsPath).toBe(key);
expect(content).toBe(mkdocsYml.toString());
});
it('returns expected contents when .yaml file is present', async () => {
const key = path.join(inputDir, 'mkdocs.yaml');
mockFs({ [key]: mkdocsYml });
const { path: mkdocsPath, content } = await getMkdocsYml(inputDir);
expect(mkdocsPath).toBe(key);
expect(content).toBe(mkdocsYml.toString());
});
it('throws when neither .yml nor .yaml file is present', async () => {
const invalidInputDir = resolvePath(__filename);
await expect(getMkdocsYml(invalidInputDir)).rejects.toThrowError(
/Could not read MkDocs YAML config file mkdocs.yml or mkdocs.yaml for validation/,
);
});
});
describe('validateMkdocsYaml', () => {
const inputDir = resolvePath(__filename, '../__fixtures__/');
it('should return true on when no docs_dir present', async () => {
await expect(
validateMkdocsYaml(inputDir, mkdocsYml.toString()),
).resolves.toBeUndefined();
});
it('should return true on when a valid docs_dir is present', async () => {
await expect(
validateMkdocsYaml(inputDir, mkdocsYmlWithValidDocDir.toString()),
).resolves.toBe('docs/');
});
it('should return false on absolute doc_dir path', async () => {
await expect(
validateMkdocsYaml(inputDir, mkdocsYmlWithInvalidDocDir.toString()),
).rejects.toThrow();
});
it('should return false on doc_dir path that traverses directory structure backwards', async () => {
await expect(
validateMkdocsYaml(inputDir, mkdocsYmlWithInvalidDocDir2.toString()),
).rejects.toThrow();
});
it('should validate files with custom yaml tags', async () => {
await expect(
validateMkdocsYaml(inputDir, mkdocsYmlWithExtensions.toString()),
).resolves.toBeUndefined();
});
});
});
@@ -0,0 +1,318 @@
/*
* 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 { isChildPath } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { assertError, ForwardedError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { SpawnOptionsWithoutStdio, spawn } from 'child_process';
import fs from 'fs-extra';
import gitUrlParse from 'git-url-parse';
import yaml, { DEFAULT_SCHEMA, Type } from 'js-yaml';
import path, { resolve as resolvePath } from 'path';
import { PassThrough, Writable } from 'stream';
import { Logger } from 'winston';
import { ParsedLocationAnnotation } from '../../helpers';
import { SupportedGeneratorKey } from './types';
import { getFileTreeRecursively } from '../publish/helpers';
// TODO: Implement proper support for more generators.
export function getGeneratorKey(entity: Entity): SupportedGeneratorKey {
if (!entity) {
throw new Error('No entity provided');
}
return 'techdocs';
}
export type RunCommandOptions = {
/** command to run */
command: string;
/** arguments to pass the command */
args: string[];
/** options to pass to spawn */
options: SpawnOptionsWithoutStdio;
/** stream to capture stdout and stderr output */
logStream?: Writable;
};
/**
* Run a command in a sub-process, normally a shell command.
*/
export const runCommand = async ({
command,
args,
options,
logStream = new PassThrough(),
}: RunCommandOptions) => {
await new Promise<void>((resolve, reject) => {
const process = spawn(command, args, options);
process.stdout.on('data', stream => {
logStream.write(stream);
});
process.stderr.on('data', stream => {
logStream.write(stream);
});
process.on('error', error => {
return reject(error);
});
process.on('close', code => {
if (code !== 0) {
return reject(`Command ${command} failed, exit code: ${code}`);
}
return resolve();
});
});
};
/**
* Return the source url for MkDocs based on the backstage.io/techdocs-ref annotation.
* Depending on the type of target, it can either return a repo_url, an edit_uri, both, or none.
*
* @param parsedLocationAnnotation - Object with location url and type
* @param scmIntegrations - the scmIntegration to do url transformations
* @param docsFolder - the configured docs folder in the mkdocs.yml (defaults to 'docs')
* @returns the settings for the mkdocs.yml
*/
export const getRepoUrlFromLocationAnnotation = (
parsedLocationAnnotation: ParsedLocationAnnotation,
scmIntegrations: ScmIntegrationRegistry,
docsFolder: string = 'docs',
): { repo_url?: string; edit_uri?: string } => {
const { type: locationType, target } = parsedLocationAnnotation;
if (locationType === 'url') {
const integration = scmIntegrations.byUrl(target);
// We only support it for github and gitlab for now as the edit_uri
// is not properly supported for others yet.
if (integration && ['github', 'gitlab'].includes(integration.type)) {
// handle the case where a user manually writes url:https://github.com/backstage/backstage i.e. without /blob/...
const { filepathtype } = gitUrlParse(target);
if (filepathtype === '') {
return { repo_url: target };
}
const sourceFolder = integration.resolveUrl({
url: `./${docsFolder}`,
base: target,
});
return { edit_uri: integration.resolveEditUrl(sourceFolder) };
}
}
return {};
};
class UnknownTag {
constructor(public readonly data: any, public readonly type?: string) {}
}
export const MKDOCS_SCHEMA = DEFAULT_SCHEMA.extend([
new Type('', {
kind: 'scalar',
multi: true,
representName: o => (o as UnknownTag).type,
represent: o => (o as UnknownTag).data ?? '',
instanceOf: UnknownTag,
construct: (data: string, type?: string) => new UnknownTag(data, type),
}),
]);
/**
* Finds and loads the contents of either an mkdocs.yml or mkdocs.yaml file,
* depending on which is present (MkDocs supports both as of v1.2.2).
*
* @param inputDir - base dir to be searched for either an mkdocs.yml or
* mkdocs.yaml file.
*/
export const getMkdocsYml = async (
inputDir: string,
): Promise<{ path: string; content: string }> => {
let mkdocsYmlPath: string;
let mkdocsYmlFileString: string;
try {
mkdocsYmlPath = path.join(inputDir, 'mkdocs.yaml');
mkdocsYmlFileString = await fs.readFile(mkdocsYmlPath, 'utf8');
} catch {
try {
mkdocsYmlPath = path.join(inputDir, 'mkdocs.yml');
mkdocsYmlFileString = await fs.readFile(mkdocsYmlPath, 'utf8');
} catch (error) {
throw new ForwardedError(
'Could not read MkDocs YAML config file mkdocs.yml or mkdocs.yaml for validation',
error,
);
}
}
return {
path: mkdocsYmlPath,
content: mkdocsYmlFileString,
};
};
/**
* Validating mkdocs config file for incorrect/insecure values
* Throws on invalid configs
*
* @param inputDir - base dir to be used as a docs_dir path validity check
* @param mkdocsYmlFileString - The string contents of the loaded
* mkdocs.yml or equivalent of a docs site
* @returns the parsed docs_dir or undefined
*/
export const validateMkdocsYaml = async (
inputDir: string,
mkdocsYmlFileString: string,
): Promise<string | undefined> => {
const mkdocsYml = yaml.load(mkdocsYmlFileString, {
schema: MKDOCS_SCHEMA,
});
if (mkdocsYml === null || typeof mkdocsYml !== 'object') {
return undefined;
}
const parsedMkdocsYml: Record<string, any> = mkdocsYml;
if (
parsedMkdocsYml.docs_dir &&
!isChildPath(inputDir, resolvePath(inputDir, parsedMkdocsYml.docs_dir))
) {
throw new Error(
`docs_dir configuration value in mkdocs can't be an absolute directory or start with ../ for security reasons.
Use relative paths instead which are resolved relative to your mkdocs.yml file location.`,
);
}
return parsedMkdocsYml.docs_dir;
};
/**
* Update docs/index.md file before TechDocs generator uses it to generate docs site,
* falling back to docs/README.md or README.md in case a default docs/index.md
* is not provided.
*/
export const patchIndexPreBuild = async ({
inputDir,
logger,
docsDir = 'docs',
}: {
inputDir: string;
logger: Logger;
docsDir?: string;
}) => {
const docsPath = path.join(inputDir, docsDir);
const indexMdPath = path.join(docsPath, 'index.md');
if (await fs.pathExists(indexMdPath)) {
return;
}
logger.warn(`${path.join(docsDir, 'index.md')} not found.`);
const fallbacks = [
path.join(docsPath, 'README.md'),
path.join(docsPath, 'readme.md'),
path.join(inputDir, 'README.md'),
path.join(inputDir, 'readme.md'),
];
await fs.ensureDir(docsPath);
for (const filePath of fallbacks) {
try {
await fs.copyFile(filePath, indexMdPath);
return;
} catch (error) {
logger.warn(`${path.relative(inputDir, filePath)} not found.`);
}
}
logger.warn(
`Could not find any techdocs' index file. Please make sure at least one of ${[
indexMdPath,
...fallbacks,
].join(' ')} exists.`,
);
};
/**
* Create or update the techdocs_metadata.json. Values initialized/updated are:
* - The build_timestamp (now)
* - The list of files generated
*
* @param techdocsMetadataPath - File path to techdocs_metadata.json
*/
export const createOrUpdateMetadata = async (
techdocsMetadataPath: string,
logger: Logger,
): Promise<void> => {
const techdocsMetadataDir = techdocsMetadataPath
.split(path.sep)
.slice(0, -1)
.join(path.sep);
// check if file exists, create if it does not.
try {
await fs.access(techdocsMetadataPath, fs.constants.F_OK);
} catch (err) {
// Bootstrap file with empty JSON
await fs.writeJson(techdocsMetadataPath, JSON.parse('{}'));
}
// check if valid Json
let json;
try {
json = await fs.readJson(techdocsMetadataPath);
} catch (err) {
assertError(err);
const message = `Invalid JSON at ${techdocsMetadataPath} with error ${err.message}`;
logger.error(message);
throw new Error(message);
}
json.build_timestamp = Date.now();
// Get and write generated files to the metadata JSON. Each file string is in
// a form appropriate for invalidating the associated object from cache.
try {
json.files = (await getFileTreeRecursively(techdocsMetadataDir)).map(file =>
file.replace(`${techdocsMetadataDir}${path.sep}`, ''),
);
} catch (err) {
assertError(err);
json.files = [];
logger.warn(`Unable to add files list to metadata: ${err.message}`);
}
await fs.writeJson(techdocsMetadataPath, json);
return;
};
/**
* Update the techdocs_metadata.json to add etag of the prepared tree (e.g. commit SHA or actual Etag of the resource).
* This is helpful to check if a TechDocs site in storage has gone outdated, without maintaining an in-memory build info
* per Backstage instance.
*
* @param techdocsMetadataPath - File path to techdocs_metadata.json
* @param etag - The ETag to use
*/
export const storeEtagMetadata = async (
techdocsMetadataPath: string,
etag: string,
): Promise<void> => {
const json = await fs.readJson(techdocsMetadataPath);
json.etag = etag;
await fs.writeJson(techdocsMetadataPath, json);
};
@@ -0,0 +1,24 @@
/*
* 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.
*/
export { TechdocsGenerator } from './techdocs';
export { Generators } from './generators';
export type {
GeneratorBase,
GeneratorOptions,
GeneratorBuilder,
GeneratorRunOptions,
SupportedGeneratorKey,
} from './types';
@@ -0,0 +1,166 @@
/*
* Copyright 2022 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 { Logger } from 'winston';
import fs from 'fs-extra';
import yaml from 'js-yaml';
import { ParsedLocationAnnotation } from '../../helpers';
import { getRepoUrlFromLocationAnnotation, MKDOCS_SCHEMA } from './helpers';
import { assertError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
type MkDocsObject = {
plugins?: string[];
docs_dir: string;
repo_url?: string;
edit_uri?: string;
};
const patchMkdocsFile = async (
mkdocsYmlPath: string,
logger: Logger,
updateAction: (mkdocsYml: MkDocsObject) => boolean,
) => {
// We only want to override the mkdocs.yml if it has actually changed. This is relevant if
// used with a 'dir' location on the file system as this would permanently update the file.
let didEdit = false;
let mkdocsYmlFileString;
try {
mkdocsYmlFileString = await fs.readFile(mkdocsYmlPath, 'utf8');
} catch (error) {
assertError(error);
logger.warn(
`Could not read MkDocs YAML config file ${mkdocsYmlPath} before running the generator: ${error.message}`,
);
return;
}
let mkdocsYml: any;
try {
mkdocsYml = yaml.load(mkdocsYmlFileString, { schema: MKDOCS_SCHEMA });
// mkdocsYml should be an object type after successful parsing.
// But based on its type definition, it can also be a string or undefined, which we don't want.
if (typeof mkdocsYml === 'string' || typeof mkdocsYml === 'undefined') {
throw new Error('Bad YAML format.');
}
} catch (error) {
assertError(error);
logger.warn(
`Error in parsing YAML at ${mkdocsYmlPath} before running the generator. ${error.message}`,
);
return;
}
didEdit = updateAction(mkdocsYml);
try {
if (didEdit) {
await fs.writeFile(
mkdocsYmlPath,
yaml.dump(mkdocsYml, { schema: MKDOCS_SCHEMA }),
'utf8',
);
}
} catch (error) {
assertError(error);
logger.warn(
`Could not write to ${mkdocsYmlPath} after updating it before running the generator. ${error.message}`,
);
return;
}
};
/**
* Update the mkdocs.yml file before TechDocs generator uses it to generate docs site.
*
* List of tasks:
* - Add repo_url or edit_uri if it does not exists
* If mkdocs.yml has a repo_url, the generated docs site gets an Edit button on the pages by default.
* If repo_url is missing in mkdocs.yml, we will use techdocs annotation of the entity to possibly get
* the repository URL.
*
* This function will not throw an error since this is not critical to the whole TechDocs pipeline.
* Instead it will log warnings if there are any errors in reading, parsing or writing YAML.
*
* @param mkdocsYmlPath - Absolute path to mkdocs.yml or equivalent of a docs site
* @param logger - A logger instance
* @param parsedLocationAnnotation - Object with location url and type
* @param scmIntegrations - the scmIntegration to do url transformations
*/
export const patchMkdocsYmlPreBuild = async (
mkdocsYmlPath: string,
logger: Logger,
parsedLocationAnnotation: ParsedLocationAnnotation,
scmIntegrations: ScmIntegrationRegistry,
) => {
await patchMkdocsFile(mkdocsYmlPath, logger, mkdocsYml => {
if (!('repo_url' in mkdocsYml) && !('edit_uri' in mkdocsYml)) {
// Add edit_uri and/or repo_url to mkdocs.yml if it is missing.
// This will enable the Page edit button generated by MkDocs.
// If the either has been set, keep the original value
const result = getRepoUrlFromLocationAnnotation(
parsedLocationAnnotation,
scmIntegrations,
mkdocsYml.docs_dir,
);
if (result.repo_url || result.edit_uri) {
mkdocsYml.repo_url = result.repo_url;
mkdocsYml.edit_uri = result.edit_uri;
logger.info(
`Set ${JSON.stringify(
result,
)}. You can disable this feature by manually setting 'repo_url' or 'edit_uri' according to the MkDocs documentation at https://www.mkdocs.org/user-guide/configuration/#repo_url`,
);
return true;
}
}
return false;
});
};
/**
* Update the mkdocs.yml file before TechDocs generator uses it to generate docs site.
*
* List of tasks:
* - Add techdocs-core plugin to mkdocs file if it doesn't exist
*
* This function will not throw an error since this is not critical to the whole TechDocs pipeline.
* Instead it will log warnings if there are any errors in reading, parsing or writing YAML.
*
* @param mkdocsYmlPath - Absolute path to mkdocs.yml or equivalent of a docs site
* @param logger - A logger instance
*/
export const pathMkdocsYmlWithTechdocsPlugin = async (
mkdocsYmlPath: string,
logger: Logger,
) => {
await patchMkdocsFile(mkdocsYmlPath, logger, mkdocsYml => {
// Modify mkdocs.yaml to contain the needed techdocs-core plugin if it is not there
if (!('plugins' in mkdocsYml)) {
mkdocsYml.plugins = ['techdocs-core'];
return true;
}
if (mkdocsYml.plugins && !mkdocsYml.plugins.includes('techdocs-core')) {
mkdocsYml.plugins.push('techdocs-core');
return true;
}
return false;
});
};
@@ -0,0 +1,140 @@
/*
* 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 { ConfigReader } from '@backstage/config';
import { readGeneratorConfig } from './techdocs';
const mockLogger = {
warn: jest.fn(),
};
describe('readGeneratorConfig', () => {
beforeEach(() => {
jest.resetAllMocks();
});
const logger = mockLogger as any;
it('defaults to runIn docker', () => {
const config = new ConfigReader({
techdocs: {
generator: {},
},
});
expect(readGeneratorConfig(config, logger)).toEqual({
runIn: 'docker',
dockerImage: undefined,
pullImage: undefined,
});
});
it('should read local config', () => {
const config = new ConfigReader({
techdocs: {
generator: {
runIn: 'local',
},
},
});
expect(readGeneratorConfig(config, logger)).toEqual({
runIn: 'local',
});
});
it('should read docker config', () => {
const config = new ConfigReader({
techdocs: {
generator: {
runIn: 'docker',
},
},
});
expect(readGeneratorConfig(config, logger)).toEqual({
runIn: 'docker',
});
});
it('should read custom docker image', () => {
const config = new ConfigReader({
techdocs: {
generator: {
runIn: 'docker',
dockerImage: 'my-org/techdocs',
},
},
});
expect(readGeneratorConfig(config, logger)).toEqual({
runIn: 'docker',
dockerImage: 'my-org/techdocs',
});
});
it('should read config disabling docker pull', () => {
const config = new ConfigReader({
techdocs: {
generator: {
runIn: 'docker',
dockerImage: 'my-org/techdocs',
pullImage: false,
},
},
});
expect(readGeneratorConfig(config, logger)).toEqual({
runIn: 'docker',
dockerImage: 'my-org/techdocs',
pullImage: false,
});
});
describe('with legacy techdocs.generators.techdocs config', () => {
it('should read legacy docker option', () => {
const config = new ConfigReader({
techdocs: {
generators: {
techdocs: 'docker',
},
},
});
expect(readGeneratorConfig(config, logger)).toEqual({
runIn: 'docker',
});
});
it('legacy option should log warning', () => {
const config = new ConfigReader({
techdocs: {
generators: {
techdocs: 'local',
},
},
});
expect(readGeneratorConfig(config, logger)).toEqual({
runIn: 'local',
});
expect(logger.warn).toHaveBeenCalledWith(
`The 'techdocs.generators.techdocs' configuration key is deprecated and will be removed in the future. Please use 'techdocs.generator' instead. ` +
`See here https://backstage.io/docs/features/techdocs/configuration`,
);
});
});
});
@@ -0,0 +1,222 @@
/*
* 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 { ContainerRunner } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import path from 'path';
import { Logger } from 'winston';
import {
ScmIntegrationRegistry,
ScmIntegrations,
} from '@backstage/integration';
import {
createOrUpdateMetadata,
getMkdocsYml,
patchIndexPreBuild,
runCommand,
storeEtagMetadata,
validateMkdocsYaml,
} from './helpers';
import {
patchMkdocsYmlPreBuild,
pathMkdocsYmlWithTechdocsPlugin,
} from './mkDocsPatchers';
import {
GeneratorBase,
GeneratorConfig,
GeneratorOptions,
GeneratorRunInType,
GeneratorRunOptions,
} from './types';
import { ForwardedError } from '@backstage/errors';
/**
* Generates documentation files
* @public
*/
export class TechdocsGenerator implements GeneratorBase {
/**
* The default docker image (and version) used to generate content. Public
* and static so that techdocs-common consumers can use the same version.
*/
public static readonly defaultDockerImage = 'spotify/techdocs:v0.3.7';
private readonly logger: Logger;
private readonly containerRunner: ContainerRunner;
private readonly options: GeneratorConfig;
private readonly scmIntegrations: ScmIntegrationRegistry;
/**
* Returns a instance of TechDocs generator
* @param config - A Backstage configuration
* @param options - Options to configure the generator
*/
static fromConfig(config: Config, options: GeneratorOptions) {
const { containerRunner, logger } = options;
const scmIntegrations = ScmIntegrations.fromConfig(config);
return new TechdocsGenerator({
logger,
containerRunner,
config,
scmIntegrations,
});
}
constructor(options: {
logger: Logger;
containerRunner: ContainerRunner;
config: Config;
scmIntegrations: ScmIntegrationRegistry;
}) {
this.logger = options.logger;
this.options = readGeneratorConfig(options.config, options.logger);
this.containerRunner = options.containerRunner;
this.scmIntegrations = options.scmIntegrations;
}
/** {@inheritDoc GeneratorBase.run} */
public async run(options: GeneratorRunOptions): Promise<void> {
const {
inputDir,
outputDir,
parsedLocationAnnotation,
etag,
logger: childLogger,
logStream,
} = options;
// Do some updates to mkdocs.yml before generating docs e.g. adding repo_url
const { path: mkdocsYmlPath, content } = await getMkdocsYml(inputDir);
// validate the docs_dir first
const docsDir = await validateMkdocsYaml(inputDir, content);
if (parsedLocationAnnotation) {
await patchMkdocsYmlPreBuild(
mkdocsYmlPath,
childLogger,
parsedLocationAnnotation,
this.scmIntegrations,
);
await patchIndexPreBuild({ inputDir, logger: childLogger, docsDir });
}
if (!this.options.omitTechdocsCoreMkdocsPlugin) {
await pathMkdocsYmlWithTechdocsPlugin(mkdocsYmlPath, childLogger);
}
// Directories to bind on container
const mountDirs = {
[inputDir]: '/input',
[outputDir]: '/output',
};
try {
switch (this.options.runIn) {
case 'local':
await runCommand({
command: 'mkdocs',
args: ['build', '-d', outputDir, '-v'],
options: {
cwd: inputDir,
},
logStream,
});
childLogger.info(
`Successfully generated docs from ${inputDir} into ${outputDir} using local mkdocs`,
);
break;
case 'docker':
await this.containerRunner.runContainer({
imageName:
this.options.dockerImage ?? TechdocsGenerator.defaultDockerImage,
args: ['build', '-d', '/output'],
logStream,
mountDirs,
workingDir: '/input',
// Set the home directory inside the container as something that applications can
// write to, otherwise they will just fail trying to write to /
envVars: { HOME: '/tmp' },
pullImage: this.options.pullImage,
});
childLogger.info(
`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'.`,
);
}
} catch (error) {
this.logger.debug(
`Failed to generate docs from ${inputDir} into ${outputDir}`,
);
throw new ForwardedError(
`Failed to generate docs from ${inputDir} into ${outputDir}`,
error,
);
}
/**
* Post Generate steps
*/
// Add build timestamp and files to techdocs_metadata.json
// Creates techdocs_metadata.json if file does not exist.
await createOrUpdateMetadata(
path.join(outputDir, 'techdocs_metadata.json'),
childLogger,
);
// Add etag of the prepared tree to techdocs_metadata.json
// Assumes that the file already exists.
if (etag) {
await storeEtagMetadata(
path.join(outputDir, 'techdocs_metadata.json'),
etag,
);
}
}
}
export function readGeneratorConfig(
config: Config,
logger: Logger,
): GeneratorConfig {
const legacyGeneratorType = config.getOptionalString(
'techdocs.generators.techdocs',
) as GeneratorRunInType;
if (legacyGeneratorType) {
logger.warn(
`The 'techdocs.generators.techdocs' configuration key is deprecated and will be removed in the future. Please use 'techdocs.generator' instead. ` +
`See here https://backstage.io/docs/features/techdocs/configuration`,
);
}
return {
runIn:
legacyGeneratorType ??
config.getOptionalString('techdocs.generator.runIn') ??
'docker',
dockerImage: config.getOptionalString('techdocs.generator.dockerImage'),
pullImage: config.getOptionalBoolean('techdocs.generator.pullImage'),
omitTechdocsCoreMkdocsPlugin: config.getOptionalBoolean(
'techdocs.generator.mkdocs.omitTechdocsCorePlugin',
),
};
}
@@ -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 { ContainerRunner } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Writable } from 'stream';
import { Logger } from 'winston';
import { ParsedLocationAnnotation } from '../../helpers';
// Determines where the generator will be run
export type GeneratorRunInType = 'docker' | 'local';
/**
* Options for building generators
* @public
*/
export type GeneratorOptions = {
containerRunner: ContainerRunner;
logger: Logger;
};
/**
* The techdocs generator configurations options.
*/
export type GeneratorConfig = {
runIn: GeneratorRunInType;
dockerImage?: string;
pullImage?: boolean;
omitTechdocsCoreMkdocsPlugin?: boolean;
};
/**
* The values that the generator will receive.
*
* @public
* @param inputDir - The directory of the uncompiled documentation, with the values from the frontend
* @param outputDir - Directory to store generated docs in. Usually - a newly created temporary directory.
* @param parsedLocationAnnotation - backstage.io/techdocs-ref annotation of an entity
* @param etag - A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json.
* @param logger - A logger that forwards the messages to the caller to be displayed outside of the backend.
* @param logStream - A log stream that can send raw log messages to the caller to be displayed outside of the backend.
*/
export type GeneratorRunOptions = {
inputDir: string;
outputDir: string;
parsedLocationAnnotation?: ParsedLocationAnnotation;
etag?: string;
logger: Logger;
logStream?: Writable;
};
/**
* Generates documentation files
* @public
*/
export type GeneratorBase = {
/**
* Runs the generator with the values
* @public
*/
run(opts: GeneratorRunOptions): Promise<void>;
};
/**
* List of supported generator options
* @public
*/
export type SupportedGeneratorKey = 'techdocs' | string;
/**
* The generator builder holds the generator ready for run time
* @public
*/
export type GeneratorBuilder = {
register(protocol: SupportedGeneratorKey, generator: GeneratorBase): void;
get(entity: Entity): GeneratorBase;
};
+19
View File
@@ -0,0 +1,19 @@
/*
* 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.
*/
export * from './generate';
export * from './prepare';
export * from './publish';
@@ -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 { getVoidLogger, UrlReader } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { DirectoryPreparer } from './dir';
function normalizePath(path: string) {
return path
.replace(/^[a-z]:/i, '')
.split('\\')
.join('/');
}
jest.mock('../../helpers', () => ({
...jest.requireActual<{}>('../../helpers'),
}));
const logger = getVoidLogger();
const createMockEntity = (annotations: {}) => {
return {
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'testName',
annotations: {
...annotations,
},
},
};
};
const mockConfig = new ConfigReader({});
const mockUrlReader: jest.Mocked<UrlReader> = {
read: jest.fn(),
readTree: jest.fn(),
search: jest.fn(),
};
describe('directory preparer', () => {
it('should merge managed-by-location and techdocs-ref when techdocs-ref is relative', async () => {
const directoryPreparer = new DirectoryPreparer(
mockConfig,
logger,
mockUrlReader,
);
const mockEntity = createMockEntity({
'backstage.io/managed-by-location':
'file:/directory/documented-component.yaml',
'backstage.io/techdocs-ref': 'dir:./our-documentation',
});
const { preparedDir } = await directoryPreparer.prepare(mockEntity);
expect(normalizePath(preparedDir)).toEqual('/directory/our-documentation');
});
it('should reject when techdocs-ref is absolute', async () => {
const directoryPreparer = new DirectoryPreparer(
mockConfig,
logger,
mockUrlReader,
);
const mockEntity = createMockEntity({
'backstage.io/managed-by-location':
'file:/directory/documented-component.yaml',
'backstage.io/techdocs-ref': 'dir:/our-documentation/techdocs',
});
await expect(directoryPreparer.prepare(mockEntity)).rejects.toThrow(
/Relative path is not allowed to refer to a directory outside its parent/,
);
});
it('should reject when managed-by-location has an unknown type', async () => {
const directoryPreparer = new DirectoryPreparer(
mockConfig,
logger,
mockUrlReader,
);
const mockEntity = createMockEntity({
'backstage.io/managed-by-location':
'does-not-exist:https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
'backstage.io/techdocs-ref': 'dir:./docs',
});
await expect(directoryPreparer.prepare(mockEntity)).rejects.toThrow(
/Unable to resolve location type does-not-exist/,
);
});
});
@@ -0,0 +1,105 @@
/*
* 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 { UrlReader } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/errors';
import {
ScmIntegrationRegistry,
ScmIntegrations,
} from '@backstage/integration';
import { Logger } from 'winston';
import { parseReferenceAnnotation, transformDirLocation } from '../../helpers';
import {
PreparerBase,
PreparerConfig,
PreparerOptions,
PreparerResponse,
} from './types';
/**
* Preparer used to retrieve documentation files from a local directory
* @public
*/
export class DirectoryPreparer implements PreparerBase {
private readonly scmIntegrations: ScmIntegrationRegistry;
private readonly reader: UrlReader;
/** @deprecated use static fromConfig method instead */
constructor(config: Config, _logger: Logger | null, reader: UrlReader) {
this.reader = reader;
this.scmIntegrations = ScmIntegrations.fromConfig(config);
}
/**
* Returns a directory preparer instance
* @param config - A backstage config
* @param options - A directory preparer options containing a logger and reader
*/
static fromConfig(
config: Config,
{ logger, reader }: PreparerConfig,
): DirectoryPreparer {
return new DirectoryPreparer(config, logger, reader);
}
/** {@inheritDoc PreparerBase.prepare} */
async prepare(
entity: Entity,
options?: PreparerOptions,
): Promise<PreparerResponse> {
const annotation = parseReferenceAnnotation(
'backstage.io/techdocs-ref',
entity,
);
const { type, target } = transformDirLocation(
entity,
annotation,
this.scmIntegrations,
);
switch (type) {
case 'url': {
options?.logger?.debug(`Reading files from ${target}`);
// the target is an absolute url since it has already been transformed
const response = await this.reader.readTree(target, {
etag: options?.etag,
});
const preparedDir = await response.dir();
options?.logger?.debug(`Tree downloaded and stored at ${preparedDir}`);
return {
preparedDir,
etag: response.etag,
};
}
case 'dir': {
return {
// the transformation already validated that the target is in a safe location
preparedDir: target,
// Instead of supporting caching on local sources, use techdocs-cli for local development and debugging.
etag: '',
};
}
default:
throw new InputError(`Unable to resolve location type ${type}`);
}
}
}
@@ -0,0 +1,27 @@
/*
* 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.
*/
export { DirectoryPreparer } from './dir';
export { UrlPreparer } from './url';
export { Preparers } from './preparers';
export type {
PreparerBase,
PreparerBuilder,
PreparerConfig,
PreparerOptions,
PreparerResponse,
RemoteProtocol,
ETag,
} from './types';
@@ -0,0 +1,91 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { parseReferenceAnnotation } from '../../helpers';
import { DirectoryPreparer } from './dir';
import { UrlPreparer } from './url';
import {
PreparerBase,
PreparerBuilder,
PreparerConfig,
RemoteProtocol,
} from './types';
/**
* Collection of docs preparers (dir and url)
* @public
*/
export class Preparers implements PreparerBuilder {
private preparerMap = new Map<RemoteProtocol, PreparerBase>();
/**
* Returns a generators instance containing a generator for TechDocs
* @public
* @param backstageConfig - A Backstage configuration
* @param preparerConfig - Options to configure preparers
*/
static async fromConfig(
backstageConfig: Config,
{ logger, reader }: PreparerConfig,
): Promise<PreparerBuilder> {
const preparers = new Preparers();
const urlPreparer = new UrlPreparer(reader, logger);
preparers.register('url', urlPreparer);
/**
* Dir preparer is a syntactic sugar for users to define techdocs-ref annotation.
* When using dir preparer, the docs will be fetched using URL Reader.
*/
const directoryPreparer = new DirectoryPreparer(
backstageConfig,
logger,
reader,
);
preparers.register('dir', directoryPreparer);
return preparers;
}
/**
* Register a preparer in the preparers collection
* @param protocol - url or dir to associate with preparer
* @param preparer - The preparer instance to set
*/
register(protocol: RemoteProtocol, preparer: PreparerBase) {
this.preparerMap.set(protocol, preparer);
}
/**
* Returns the preparer for a given TechDocs entity
* @param entity - A TechDocs entity instance
* @returns
*/
get(entity: Entity): PreparerBase {
const { type } = parseReferenceAnnotation(
'backstage.io/techdocs-ref',
entity,
);
const preparer = this.preparerMap.get(type);
if (!preparer) {
throw new Error(`No preparer registered for type: "${type}"`);
}
return preparer;
}
}
@@ -0,0 +1,96 @@
/*
* 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 type { Entity } from '@backstage/catalog-model';
import { UrlReader } from '@backstage/backend-common';
import { Logger } from 'winston';
/**
* A unique identifier of the tree blob, usually the commit SHA or etag from the target.
* @public
*/
export type ETag = string;
/**
* Options for building preparers
* @public
*/
export type PreparerConfig = {
logger: Logger;
reader: UrlReader;
};
/**
* Options for configuring the content preparation process.
* @public
*/
export type PreparerOptions = {
/**
* An instance of the logger
*/
logger?: Logger;
/**
* see {@link ETag}
*/
etag?: ETag;
};
/**
* Result of the preparation step.
* @public
*/
export type PreparerResponse = {
/**
* The path to directory where the tree is downloaded.
*/
preparedDir: string;
/**
* see {@link ETag}
*/
etag: ETag;
};
/**
* Definition of a TechDocs preparer
* @public
*/
export type PreparerBase = {
/**
* Given an Entity definition from the Software Catalog, go and prepare a directory
* with contents from the location in temporary storage and return the path.
*
* @param entity - The entity from the Software Catalog
* @param options - If etag is provided, it will be used to check if the target has
* updated since the last build.
* @throws `NotModifiedError` when the prepared directory has not been changed since the last build.
*/
prepare(entity: Entity, options?: PreparerOptions): Promise<PreparerResponse>;
};
/**
* Definition for a TechDocs preparer builder
* @public
*/
export type PreparerBuilder = {
register(protocol: RemoteProtocol, preparer: PreparerBase): void;
get(entity: Entity): PreparerBase;
};
/**
* Location where documentation files are stored
* @public
*/
export type RemoteProtocol = 'url' | 'dir';
@@ -0,0 +1,75 @@
/*
* 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 { assertError } from '@backstage/errors';
import { UrlReader } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { Logger } from 'winston';
import { getDocFilesFromRepository } from '../../helpers';
import {
PreparerBase,
PreparerConfig,
PreparerOptions,
PreparerResponse,
} from './types';
/**
* Preparer used to retrieve documentation files from a remote repository
* @public
*/
export class UrlPreparer implements PreparerBase {
private readonly logger: Logger;
private readonly reader: UrlReader;
/** @deprecated use static fromConfig method instead */
constructor(reader: UrlReader, logger: Logger) {
this.logger = logger;
this.reader = reader;
}
/**
* Returns a directory preparer instance
* @param config - A URL preparer config containing the a logger and reader
*/
static fromConfig({ reader, logger }: PreparerConfig): UrlPreparer {
return new UrlPreparer(reader, logger);
}
/** {@inheritDoc PreparerBase.prepare} */
async prepare(
entity: Entity,
options?: PreparerOptions,
): Promise<PreparerResponse> {
try {
return await getDocFilesFromRepository(this.reader, entity, {
etag: options?.etag,
logger: this.logger,
});
} catch (error) {
assertError(error);
// NotModifiedError means that etag based cache is still valid.
if (error.name === 'NotModifiedError') {
this.logger.debug(`Cache is valid for etag ${options?.etag}`);
} else {
this.logger.debug(
`Unable to fetch files for building docs ${error.message}`,
);
}
throw error;
}
}
}
@@ -0,0 +1,502 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import express from 'express';
import request from 'supertest';
import mockFs from 'mock-fs';
import path from 'path';
import fs from 'fs-extra';
import { AwsS3Publish } from './awsS3';
// NOTE: /packages/techdocs-common/__mocks__ is being used to mock aws-sdk client library
const rootDir = (global as any).rootDir; // Set by setupTests.ts
const getEntityRootDir = (entity: Entity) => {
const {
kind,
metadata: { namespace, name },
} = entity;
return path.join(rootDir, namespace || DEFAULT_NAMESPACE, kind, name);
};
const logger = getVoidLogger();
const loggerInfoSpy = jest.spyOn(logger, 'info');
const loggerErrorSpy = jest.spyOn(logger, 'error');
const createPublisherFromConfig = ({
bucketName = 'bucketName',
bucketRootPath = '/',
legacyUseCaseSensitiveTripletPaths = false,
sse,
}: {
bucketName?: string;
bucketRootPath?: string;
legacyUseCaseSensitiveTripletPaths?: boolean;
sse?: string;
} = {}) => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'awsS3',
awsS3: {
credentials: {
accessKeyId: 'accessKeyId',
secretAccessKey: 'secretAccessKey',
},
bucketName,
bucketRootPath,
sse,
},
},
legacyUseCaseSensitiveTripletPaths,
},
});
return AwsS3Publish.fromConfig(mockConfig, logger);
};
describe('AwsS3Publish', () => {
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'backstage',
namespace: 'default',
annotations: {},
},
};
const entityName = {
kind: 'Component',
name: 'backstage',
namespace: 'default',
};
const techdocsMetadata = {
site_name: 'backstage',
site_description: 'site_content',
etag: 'etag',
build_timestamp: 612741599,
};
const directory = getEntityRootDir(entity);
const files = {
'index.html': '',
'404.html': '',
'techdocs_metadata.json': JSON.stringify(techdocsMetadata),
assets: {
'main.css': '',
},
html: {
'unsafe.html': '<html></html>',
},
img: {
'with spaces.png': 'found it',
'unsafe.svg': '<svg></svg>',
},
'some folder': {
'also with spaces.js': 'found it too',
},
};
beforeEach(() => {
mockFs({
[directory]: files,
});
});
afterEach(() => {
mockFs.restore();
});
describe('getReadiness', () => {
it('should validate correct config', async () => {
const publisher = createPublisherFromConfig();
expect(await publisher.getReadiness()).toEqual({
isAvailable: true,
});
});
it('should reject incorrect config', async () => {
const publisher = createPublisherFromConfig({
bucketName: 'errorBucket',
});
expect(await publisher.getReadiness()).toEqual({
isAvailable: false,
});
});
});
describe('publish', () => {
it('should publish a directory', async () => {
const publisher = createPublisherFromConfig();
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'default/component/backstage/404.html',
`default/component/backstage/index.html`,
`default/component/backstage/assets/main.css`,
]),
});
});
it('should publish a directory as well when legacy casing is used', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'default/Component/backstage/404.html',
`default/Component/backstage/index.html`,
`default/Component/backstage/assets/main.css`,
]),
});
});
it('should publish a directory when root path is specified', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
});
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'backstage-data/techdocs/default/component/backstage/404.html',
`backstage-data/techdocs/default/component/backstage/index.html`,
`backstage-data/techdocs/default/component/backstage/assets/main.css`,
]),
});
});
it('should publish a directory when root path is specified and legacy casing is used', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
legacyUseCaseSensitiveTripletPaths: true,
});
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'backstage-data/techdocs/default/Component/backstage/404.html',
`backstage-data/techdocs/default/Component/backstage/index.html`,
`backstage-data/techdocs/default/Component/backstage/assets/main.css`,
]),
});
});
it('should publish a directory when sse is specified', async () => {
const publisher = createPublisherFromConfig({
sse: 'aws:kms',
});
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'default/component/backstage/404.html',
'default/component/backstage/index.html',
'default/component/backstage/assets/main.css',
]),
});
});
it('should fail to publish a directory', async () => {
const wrongPathToGeneratedDirectory = path.join(
rootDir,
'wrong',
'path',
'to',
'generatedDirectory',
);
const publisher = createPublisherFromConfig();
const fails = publisher.publish({
entity,
directory: wrongPathToGeneratedDirectory,
});
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(
'Unable to upload file(s) to AWS S3. Error: Failed to read template directory: ENOENT, no such file or directory',
),
});
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(wrongPathToGeneratedDirectory),
});
});
it('should delete stale files after upload', async () => {
const bucketName = 'delete_stale_files_success';
const publisher = createPublisherFromConfig({ bucketName: bucketName });
await publisher.publish({ entity, directory });
expect(loggerInfoSpy).toHaveBeenLastCalledWith(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: 1`,
);
});
it('should log error when the stale files deletion fails', async () => {
const bucketName = 'delete_stale_files_error';
const publisher = createPublisherFromConfig({ bucketName: bucketName });
await publisher.publish({ entity, directory });
expect(loggerErrorSpy).toHaveBeenLastCalledWith(
'Unable to delete file(s) from AWS S3. Error: Message',
);
});
});
describe('hasDocsBeenGenerated', () => {
it('should return true if docs has been generated', async () => {
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
});
it('should return true if docs has been generated even if the legacy case is enabled', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
});
it('should return true if docs has been generated if root path is specified', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
});
await publisher.publish({ entity, directory });
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
});
it('should return true if docs has been generated if root path is specified and legacy casing is used', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
});
it('should return false if docs has not been generated', async () => {
const publisher = createPublisherFromConfig();
expect(
await publisher.hasDocsBeenGenerated({
kind: 'entity',
metadata: {
namespace: 'invalid',
name: 'triplet',
},
} as Entity),
).toBe(false);
});
});
describe('fetchTechDocsMetadata', () => {
it('should return tech docs metadata', async () => {
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
});
it('should return tech docs metadata even if the legacy case is enabled', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
});
it('should return tech docs metadata even if root path is specified', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
});
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
});
it('should return tech docs metadata if root path is specified and legacy casing is used', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
});
it('should return tech docs metadata when json encoded with single quotes', async () => {
const techdocsMetadataPath = path.join(
directory,
'techdocs_metadata.json',
);
const techdocsMetadataContent = files['techdocs_metadata.json'];
fs.writeFileSync(
techdocsMetadataPath,
techdocsMetadataContent.replace(/"/g, "'"),
);
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
fs.writeFileSync(techdocsMetadataPath, techdocsMetadataContent);
});
it('should return an error if the techdocs_metadata.json file is not present', async () => {
const publisher = createPublisherFromConfig();
const invalidEntityName = {
namespace: 'invalid',
kind: 'triplet',
name: 'path',
};
const fails = publisher.fetchTechDocsMetadata(invalidEntityName);
await expect(fails).rejects.toMatchObject({
message: expect.stringMatching(
/TechDocs metadata fetch failed; caused by Error: The file .* does not exist/i,
),
});
});
});
describe('docsRouter', () => {
const entityTripletPath = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
let app: express.Express;
beforeEach(async () => {
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
app = express().use(publisher.docsRouter());
});
it('should pass expected object path to bucket', async () => {
// Ensures leading slash is trimmed and encoded path is decoded.
const pngResponse = await request(app).get(
`/${entityTripletPath}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass expected object path to bucket even if the legacy case is enabled', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
app = express().use(publisher.docsRouter());
// Ensures leading slash is trimmed and encoded path is decoded.
const pngResponse = await request(app).get(
`/${entityTripletPath}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass expected object path to bucket if root path is specified', async () => {
const rootPath = 'backstage-data/techdocs';
const publisher = createPublisherFromConfig({
bucketRootPath: rootPath,
});
await publisher.publish({ entity, directory });
app = express().use(publisher.docsRouter());
const pngResponse = await request(app).get(
`/${rootPath}/${entityTripletPath}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${rootPath}/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass expected object path to bucket if root path is specified and legacy case is enabled', async () => {
const rootPath = 'backstage-data/techdocs';
const publisher = createPublisherFromConfig({
bucketRootPath: rootPath,
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
app = express().use(publisher.docsRouter());
const pngResponse = await request(app).get(
`/${rootPath}/${entityTripletPath}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${rootPath}/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass text/plain content-type for html', async () => {
const htmlResponse = await request(app).get(
`/${entityTripletPath}/html/unsafe.html`,
);
expect(htmlResponse.text).toEqual('<html></html>');
expect(htmlResponse.header).toMatchObject({
'content-type': 'text/plain; charset=utf-8',
});
const svgResponse = await request(app).get(
`/${entityTripletPath}/img/unsafe.svg`,
);
expect(svgResponse.text).toEqual('<svg></svg>');
expect(svgResponse.header).toMatchObject({
'content-type': 'text/plain; charset=utf-8',
});
});
it('should return 404 if file is not found', async () => {
const response = await request(app).get(
`/${entityTripletPath}/not-found.html`,
);
expect(response.status).toBe(404);
expect(Buffer.from(response.text).toString('utf8')).toEqual(
'File Not Found',
);
});
});
});
@@ -0,0 +1,514 @@
/*
* 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 { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError, ForwardedError } from '@backstage/errors';
import aws, { Credentials } from 'aws-sdk';
import { ListObjectsV2Output } from 'aws-sdk/clients/s3';
import { CredentialsOptions } from 'aws-sdk/lib/credentials';
import express from 'express';
import fs from 'fs-extra';
import JSON5 from 'json5';
import createLimiter from 'p-limit';
import path from 'path';
import { Readable } from 'stream';
import { Logger } from 'winston';
import {
bulkStorageOperation,
getCloudPathForLocalPath,
getFileTreeRecursively,
getHeadersForFileExtension,
getStaleFiles,
lowerCaseEntityTriplet,
lowerCaseEntityTripletInStoragePath,
normalizeExternalStorageRootPath,
} from './helpers';
import {
PublisherBase,
PublishRequest,
PublishResponse,
ReadinessResponse,
TechDocsMetadata,
} from './types';
const streamToBuffer = (stream: Readable): Promise<Buffer> => {
return new Promise((resolve, reject) => {
try {
const chunks: any[] = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
} catch (e) {
throw new ForwardedError('Unable to parse the response data', e);
}
});
};
export class AwsS3Publish implements PublisherBase {
private readonly storageClient: aws.S3;
private readonly bucketName: string;
private readonly legacyPathCasing: boolean;
private readonly logger: Logger;
private readonly bucketRootPath: string;
private readonly sse?: 'aws:kms' | 'AES256';
constructor(options: {
storageClient: aws.S3;
bucketName: string;
legacyPathCasing: boolean;
logger: Logger;
bucketRootPath: string;
sse?: 'aws:kms' | 'AES256';
}) {
this.storageClient = options.storageClient;
this.bucketName = options.bucketName;
this.legacyPathCasing = options.legacyPathCasing;
this.logger = options.logger;
this.bucketRootPath = options.bucketRootPath;
this.sse = options.sse;
}
static fromConfig(config: Config, logger: Logger): PublisherBase {
let bucketName = '';
try {
bucketName = config.getString('techdocs.publisher.awsS3.bucketName');
} catch (error) {
throw new Error(
"Since techdocs.publisher.type is set to 'awsS3' in your app config, " +
'techdocs.publisher.awsS3.bucketName is required.',
);
}
const bucketRootPath = normalizeExternalStorageRootPath(
config.getOptionalString('techdocs.publisher.awsS3.bucketRootPath') || '',
);
const sse = config.getOptionalString('techdocs.publisher.awsS3.sse') as
| 'aws:kms'
| 'AES256'
| undefined;
// Credentials is an optional config. If missing, the default ways of authenticating AWS SDK V2 will be used.
// 1. AWS environment variables
// https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html
// 2. AWS shared credentials file at ~/.aws/credentials
// https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html
// 3. IAM Roles for EC2
// https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-iam.html
const credentialsConfig = config.getOptionalConfig(
'techdocs.publisher.awsS3.credentials',
);
const credentials = AwsS3Publish.buildCredentials(credentialsConfig);
// AWS Region is an optional config. If missing, default AWS env variable AWS_REGION
// or AWS shared credentials file at ~/.aws/credentials will be used.
const region = config.getOptionalString('techdocs.publisher.awsS3.region');
// AWS endpoint is an optional config. If missing, the default endpoint is built from
// the configured region.
const endpoint = config.getOptionalString(
'techdocs.publisher.awsS3.endpoint',
);
// AWS forcePathStyle is an optional config. If missing, it defaults to false. Needs to be enabled for cases
// where endpoint url points to locally hosted S3 compatible storage like Localstack
const s3ForcePathStyle = config.getOptionalBoolean(
'techdocs.publisher.awsS3.s3ForcePathStyle',
);
const storageClient = new aws.S3({
credentials,
...(region && { region }),
...(endpoint && { endpoint }),
...(s3ForcePathStyle && { s3ForcePathStyle }),
});
const legacyPathCasing =
config.getOptionalBoolean(
'techdocs.legacyUseCaseSensitiveTripletPaths',
) || false;
return new AwsS3Publish({
storageClient,
bucketName,
bucketRootPath,
legacyPathCasing,
logger,
sse,
});
}
private static buildCredentials(
config?: Config,
): Credentials | CredentialsOptions | undefined {
if (!config) {
return undefined;
}
const accessKeyId = config.getOptionalString('accessKeyId');
const secretAccessKey = config.getOptionalString('secretAccessKey');
let explicitCredentials: Credentials | undefined;
if (accessKeyId && secretAccessKey) {
explicitCredentials = new Credentials({
accessKeyId,
secretAccessKey,
});
}
const roleArn = config.getOptionalString('roleArn');
if (roleArn) {
return new aws.ChainableTemporaryCredentials({
masterCredentials: explicitCredentials,
params: {
RoleSessionName: 'backstage-aws-techdocs-s3-publisher',
RoleArn: roleArn,
},
});
}
return explicitCredentials;
}
/**
* Check if the defined bucket exists. Being able to connect means the configuration is good
* and the storage client will work.
*/
async getReadiness(): Promise<ReadinessResponse> {
try {
await this.storageClient
.headBucket({ Bucket: this.bucketName })
.promise();
this.logger.info(
`Successfully connected to the AWS S3 bucket ${this.bucketName}.`,
);
return { isAvailable: true };
} catch (error) {
this.logger.error(
`Could not retrieve metadata about the AWS S3 bucket ${this.bucketName}. ` +
'Make sure the bucket exists. Also make sure that authentication is setup either by ' +
'explicitly defining credentials and region in techdocs.publisher.awsS3 in app config or ' +
'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage',
);
this.logger.error(`from AWS client library`, error);
return {
isAvailable: false,
};
}
}
/**
* Upload all the files from the generated `directory` to the S3 bucket.
* Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html
*/
async publish({
entity,
directory,
}: PublishRequest): Promise<PublishResponse> {
const objects: string[] = [];
const useLegacyPathCasing = this.legacyPathCasing;
const bucketRootPath = this.bucketRootPath;
const sse = this.sse;
// First, try to retrieve a list of all individual files currently existing
let existingFiles: string[] = [];
try {
const remoteFolder = getCloudPathForLocalPath(
entity,
undefined,
useLegacyPathCasing,
bucketRootPath,
);
existingFiles = await this.getAllObjectsFromBucket({
prefix: remoteFolder,
});
} catch (e) {
assertError(e);
this.logger.error(
`Unable to list files for Entity ${entity.metadata.name}: ${e.message}`,
);
}
// Then, merge new files into the same folder
let absoluteFilesToUpload;
try {
// Remove the absolute path prefix of the source directory
// Path of all files to upload, relative to the root of the source directory
// e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png']
absoluteFilesToUpload = await getFileTreeRecursively(directory);
await bulkStorageOperation(
async absoluteFilePath => {
const relativeFilePath = path.relative(directory, absoluteFilePath);
const fileStream = fs.createReadStream(absoluteFilePath);
const params = {
Bucket: this.bucketName,
Key: getCloudPathForLocalPath(
entity,
relativeFilePath,
useLegacyPathCasing,
bucketRootPath,
),
Body: fileStream,
...(sse && { ServerSideEncryption: sse }),
} as aws.S3.PutObjectRequest;
objects.push(params.Key);
return this.storageClient.upload(params).promise();
},
absoluteFilesToUpload,
{ concurrencyLimit: 10 },
);
this.logger.info(
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${absoluteFilesToUpload.length}`,
);
} catch (e) {
const errorMessage = `Unable to upload file(s) to AWS S3. ${e}`;
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
// Last, try to remove the files that were *only* present previously
try {
const relativeFilesToUpload = absoluteFilesToUpload.map(
absoluteFilePath =>
getCloudPathForLocalPath(
entity,
path.relative(directory, absoluteFilePath),
useLegacyPathCasing,
bucketRootPath,
),
);
const staleFiles = getStaleFiles(relativeFilesToUpload, existingFiles);
await bulkStorageOperation(
async relativeFilePath => {
return await this.storageClient
.deleteObject({
Bucket: this.bucketName,
Key: relativeFilePath,
})
.promise();
},
staleFiles,
{ concurrencyLimit: 10 },
);
this.logger.info(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: ${staleFiles.length}`,
);
} catch (error) {
const errorMessage = `Unable to delete file(s) from AWS S3. ${error}`;
this.logger.error(errorMessage);
}
return { objects };
}
async fetchTechDocsMetadata(
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata> {
try {
return await new Promise<TechDocsMetadata>(async (resolve, reject) => {
const entityTriplet = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
const entityDir = this.legacyPathCasing
? entityTriplet
: lowerCaseEntityTriplet(entityTriplet);
const entityRootDir = path.posix.join(this.bucketRootPath, entityDir);
const stream = this.storageClient
.getObject({
Bucket: this.bucketName,
Key: `${entityRootDir}/techdocs_metadata.json`,
})
.createReadStream();
try {
const techdocsMetadataJson = await streamToBuffer(stream);
if (!techdocsMetadataJson) {
throw new Error(
`Unable to parse the techdocs metadata file ${entityRootDir}/techdocs_metadata.json.`,
);
}
const techdocsMetadata = JSON5.parse(
techdocsMetadataJson.toString('utf-8'),
);
resolve(techdocsMetadata);
} catch (err) {
assertError(err);
this.logger.error(err.message);
reject(new Error(err.message));
}
});
} catch (e) {
throw new ForwardedError('TechDocs metadata fetch failed', e);
}
}
/**
* Express route middleware to serve static files on a route in techdocs-backend.
*/
docsRouter(): express.Handler {
return async (req, res) => {
// Decode and trim the leading forward slash
const decodedUri = decodeURI(req.path.replace(/^\//, ''));
// Root path is removed from the Uri so that legacy casing can be applied
// to the entity triplet without manipulating the root path
const decodedUriNoRoot = path.relative(this.bucketRootPath, decodedUri);
// filePath example - /default/component/documented-component/index.html
const filePathNoRoot = this.legacyPathCasing
? decodedUriNoRoot
: lowerCaseEntityTripletInStoragePath(decodedUriNoRoot);
// Re-prepend the root path to the relative file path
const filePath = path.posix.join(this.bucketRootPath, filePathNoRoot);
// Files with different extensions (CSS, HTML) need to be served with different headers
const fileExtension = path.extname(filePath);
const responseHeaders = getHeadersForFileExtension(fileExtension);
const stream = this.storageClient
.getObject({ Bucket: this.bucketName, Key: filePath })
.createReadStream();
try {
// Inject response headers
for (const [headerKey, headerValue] of Object.entries(
responseHeaders,
)) {
res.setHeader(headerKey, headerValue);
}
res.send(await streamToBuffer(stream));
} catch (err) {
assertError(err);
this.logger.warn(
`TechDocs S3 router failed to serve static files from bucket ${this.bucketName} at key ${filePath}: ${err.message}`,
);
res.status(404).send('File Not Found');
}
};
}
/**
* A helper function which checks if index.html of an Entity's docs site is available. This
* can be used to verify if there are any pre-generated docs available to serve.
*/
async hasDocsBeenGenerated(entity: Entity): Promise<boolean> {
try {
const entityTriplet = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
const entityDir = this.legacyPathCasing
? entityTriplet
: lowerCaseEntityTriplet(entityTriplet);
const entityRootDir = path.posix.join(this.bucketRootPath, entityDir);
await this.storageClient
.headObject({
Bucket: this.bucketName,
Key: `${entityRootDir}/index.html`,
})
.promise();
return Promise.resolve(true);
} catch (e) {
return Promise.resolve(false);
}
}
async migrateDocsCase({
removeOriginal = false,
concurrency = 25,
}): Promise<void> {
// Iterate through every file in the root of the publisher.
const allObjects = await this.getAllObjectsFromBucket();
const limiter = createLimiter(concurrency);
await Promise.all(
allObjects.map(f =>
limiter(async file => {
let newPath;
try {
newPath = lowerCaseEntityTripletInStoragePath(file);
} catch (e) {
assertError(e);
this.logger.warn(e.message);
return;
}
// If all parts are already lowercase, ignore.
if (file === newPath) {
return;
}
try {
this.logger.verbose(`Migrating ${file}`);
await this.storageClient
.copyObject({
Bucket: this.bucketName,
CopySource: [this.bucketName, file].join('/'),
Key: newPath,
})
.promise();
if (removeOriginal) {
await this.storageClient
.deleteObject({
Bucket: this.bucketName,
Key: file,
})
.promise();
}
} catch (e) {
assertError(e);
this.logger.warn(`Unable to migrate ${file}: ${e.message}`);
}
}, f),
),
);
}
/**
* Returns a list of all object keys from the configured bucket.
*/
protected async getAllObjectsFromBucket(
{ prefix } = { prefix: '' },
): Promise<string[]> {
const objects: string[] = [];
let nextContinuation: string | undefined;
let allObjects: ListObjectsV2Output;
// Iterate through every file in the root of the publisher.
do {
allObjects = await this.storageClient
.listObjectsV2({
Bucket: this.bucketName,
ContinuationToken: nextContinuation,
...(prefix ? { Prefix: prefix } : {}),
})
.promise();
objects.push(
...(allObjects.Contents || []).map(f => f.Key || '').filter(f => !!f),
);
nextContinuation = allObjects.NextContinuationToken;
} while (nextContinuation);
return objects;
}
}
@@ -0,0 +1,403 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import express from 'express';
import request from 'supertest';
import mockFs from 'mock-fs';
import path from 'path';
import fs from 'fs-extra';
import { AzureBlobStoragePublish } from './azureBlobStorage';
// NOTE: /packages/techdocs-common/__mocks__ is being used to mock Azure client library
const rootDir = (global as any).rootDir; // Set by setupTests.ts
const getEntityRootDir = (entity: Entity) => {
const {
kind,
metadata: { namespace, name },
} = entity;
return path.join(rootDir, namespace || DEFAULT_NAMESPACE, kind, name);
};
const logger = getVoidLogger();
jest.spyOn(logger, 'error').mockReturnValue(logger);
const createPublisherFromConfig = ({
accountName = 'accountName',
containerName = 'containerName',
legacyUseCaseSensitiveTripletPaths = false,
}: {
accountName?: string;
containerName?: string;
legacyUseCaseSensitiveTripletPaths?: boolean;
} = {}) => {
const config = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'azureBlobStorage',
azureBlobStorage: {
credentials: {
accountName,
accountKey: 'accountKey',
},
containerName,
},
},
legacyUseCaseSensitiveTripletPaths,
},
});
return AzureBlobStoragePublish.fromConfig(config, logger);
};
describe('AzureBlobStoragePublish', () => {
const entity = {
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'backstage',
namespace: 'default',
annotations: {},
},
};
const entityName = {
kind: 'Component',
name: 'backstage',
namespace: 'default',
};
const techdocsMetadata = {
site_name: 'backstage',
site_description: 'site_content',
etag: 'etag',
build_timestamp: 612741599,
};
const directory = getEntityRootDir(entity);
beforeEach(() => {
(logger.error as jest.Mock).mockClear();
});
const files = {
'index.html': '',
'404.html': '',
'techdocs_metadata.json': JSON.stringify(techdocsMetadata),
assets: {
'main.css': '',
},
html: {
'unsafe.html': '<html></html>',
},
img: {
'with spaces.png': 'found it',
'unsafe.svg': '<svg></svg>',
},
'some folder': {
'also with spaces.js': 'found it too',
},
};
beforeEach(async () => {
mockFs({
[directory]: files,
});
});
afterEach(() => {
mockFs.restore();
});
describe('getReadiness', () => {
it('should validate correct config', async () => {
const publisher = createPublisherFromConfig();
expect(await publisher.getReadiness()).toEqual({
isAvailable: true,
});
});
it('should reject incorrect config', async () => {
const errorPublisher = createPublisherFromConfig({
containerName: 'bad_container',
});
expect(await errorPublisher.getReadiness()).toEqual({
isAvailable: false,
});
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
`Could not retrieve metadata about the Azure Blob Storage container bad_container.`,
),
);
});
});
describe('publish', () => {
it('should publish a directory', async () => {
const publisher = createPublisherFromConfig();
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'default/component/backstage/404.html',
`default/component/backstage/index.html`,
`default/component/backstage/assets/main.css`,
]),
});
});
it('should publish a directory as well when legacy casing is used', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'default/Component/backstage/404.html',
`default/Component/backstage/index.html`,
`default/Component/backstage/assets/main.css`,
]),
});
});
it('should fail to publish a directory', async () => {
const wrongPathToGeneratedDirectory = path.join(
rootDir,
'wrong',
'path',
'to',
'generatedDirectory',
);
const publisher = createPublisherFromConfig({
containerName: 'bad_container',
});
const fails = publisher.publish({
entity,
directory: wrongPathToGeneratedDirectory,
});
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(
'Unable to upload file(s) to Azure. Error: Failed to read template directory: ENOENT, no such file or directory',
),
});
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(wrongPathToGeneratedDirectory),
});
});
it('reports an error when bad account credentials', async () => {
const publisher = createPublisherFromConfig({
accountName: 'bad_account_credentials',
});
let error;
try {
await publisher.publish({ entity, directory });
} catch (e: any) {
error = e;
}
expect(error.message).toContain(`Unable to upload file(s) to Azure`);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(
`Unable to upload file(s) to Azure. Error: Upload failed for ${path.join(
directory,
'404.html',
)} with status code 500`,
),
);
});
});
describe('hasDocsBeenGenerated', () => {
it('should check expected file', async () => {
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
});
it('should check expected file when legacy case flag is passed', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
});
it('should return false if docs has not been generated', async () => {
const publisher = createPublisherFromConfig();
expect(
await publisher.hasDocsBeenGenerated({
kind: 'triplet',
metadata: {
namespace: 'invalid',
name: 'path',
},
} as Entity),
).toBe(false);
});
});
describe('fetchTechDocsMetadata', () => {
it('should return tech docs metadata', async () => {
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
});
it('should return tech docs metadata even if the legacy case is enabled', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
});
it('should return tech docs metadata when json encoded with single quotes', async () => {
const techdocsMetadataPath = path.join(
directory,
'techdocs_metadata.json',
);
const techdocsMetadataContent = files['techdocs_metadata.json'];
fs.writeFileSync(
techdocsMetadataPath,
techdocsMetadataContent.replace(/"/g, "'"),
);
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
fs.writeFileSync(techdocsMetadataPath, techdocsMetadataContent);
});
it('should return an error if the techdocs_metadata.json file is not present', async () => {
const publisher = createPublisherFromConfig();
const invalidEntityName = {
namespace: 'invalid',
kind: 'triplet',
name: 'path',
};
const techDocsMetadaFilePath = path.posix.join(
...Object.values(invalidEntityName),
'techdocs_metadata.json',
);
const fails = publisher.fetchTechDocsMetadata(invalidEntityName);
await expect(fails).rejects.toMatchObject({
message: `TechDocs metadata fetch failed; caused by Error: The file ${techDocsMetadaFilePath} does not exist!`,
});
});
});
describe('docsRouter', () => {
const entityTripletPath = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
let app: express.Express;
beforeEach(async () => {
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
app = express().use(publisher.docsRouter());
});
afterEach(() => {
mockFs.restore();
});
it('should pass expected object path to bucket', async () => {
// Ensures leading slash is trimmed and encoded path is decoded.
const pngResponse = await request(app).get(
`/${entityTripletPath}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass expected object path to bucket even if the legacy case is enabled', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
app = express().use(publisher.docsRouter());
// Ensures leading slash is trimmed and encoded path is decoded.
const pngResponse = await request(app).get(
`/${entityTripletPath}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass text/plain content-type for html', async () => {
const htmlResponse = await request(app).get(
`/${entityTripletPath}/html/unsafe.html`,
);
expect(htmlResponse.text).toEqual('<html></html>');
expect(htmlResponse.header).toMatchObject({
'content-type': 'text/plain; charset=utf-8',
});
const svgResponse = await request(app).get(
`/${entityTripletPath}/img/unsafe.svg`,
);
expect(svgResponse.text).toEqual('<svg></svg>');
expect(svgResponse.header).toMatchObject({
'content-type': 'text/plain; charset=utf-8',
});
});
it('should return 404 if file is not found', async () => {
const response = await request(app).get(
`/${entityTripletPath}/not-found.html`,
);
expect(response.status).toBe(404);
expect(Buffer.from(response.text).toString('utf8')).toEqual(
'File Not Found',
);
});
});
});
@@ -0,0 +1,465 @@
/*
* 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 { DefaultAzureCredential } from '@azure/identity';
import {
BlobServiceClient,
ContainerClient,
StorageSharedKeyCredential,
} from '@azure/storage-blob';
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError, ForwardedError } from '@backstage/errors';
import express from 'express';
import JSON5 from 'json5';
import limiterFactory from 'p-limit';
import { default as path, default as platformPath } from 'path';
import { Logger } from 'winston';
import {
bulkStorageOperation,
getCloudPathForLocalPath,
getFileTreeRecursively,
getHeadersForFileExtension,
lowerCaseEntityTriplet,
getStaleFiles,
lowerCaseEntityTripletInStoragePath,
} from './helpers';
import {
PublisherBase,
PublishRequest,
PublishResponse,
ReadinessResponse,
TechDocsMetadata,
} from './types';
// The number of batches that may be ongoing at the same time.
const BATCH_CONCURRENCY = 3;
export class AzureBlobStoragePublish implements PublisherBase {
private readonly storageClient: BlobServiceClient;
private readonly containerName: string;
private readonly legacyPathCasing: boolean;
private readonly logger: Logger;
constructor(options: {
storageClient: BlobServiceClient;
containerName: string;
legacyPathCasing: boolean;
logger: Logger;
}) {
this.storageClient = options.storageClient;
this.containerName = options.containerName;
this.legacyPathCasing = options.legacyPathCasing;
this.logger = options.logger;
}
static fromConfig(config: Config, logger: Logger): PublisherBase {
let containerName = '';
try {
containerName = config.getString(
'techdocs.publisher.azureBlobStorage.containerName',
);
} catch (error) {
throw new Error(
"Since techdocs.publisher.type is set to 'azureBlobStorage' in your app config, " +
'techdocs.publisher.azureBlobStorage.containerName is required.',
);
}
let accountName = '';
try {
accountName = config.getString(
'techdocs.publisher.azureBlobStorage.credentials.accountName',
);
} catch (error) {
throw new Error(
"Since techdocs.publisher.type is set to 'azureBlobStorage' in your app config, " +
'techdocs.publisher.azureBlobStorage.credentials.accountName is required.',
);
}
// Credentials is an optional config. If missing, default Azure Blob Storage environment variables will be used.
// https://docs.microsoft.com/en-us/azure/storage/common/storage-auth-aad-app
const accountKey = config.getOptionalString(
'techdocs.publisher.azureBlobStorage.credentials.accountKey',
);
let credential;
if (accountKey) {
credential = new StorageSharedKeyCredential(accountName, accountKey);
} else {
credential = new DefaultAzureCredential();
}
const storageClient = new BlobServiceClient(
`https://${accountName}.blob.core.windows.net`,
credential,
);
const legacyPathCasing =
config.getOptionalBoolean(
'techdocs.legacyUseCaseSensitiveTripletPaths',
) || false;
return new AzureBlobStoragePublish({
storageClient: storageClient,
containerName: containerName,
legacyPathCasing: legacyPathCasing,
logger: logger,
});
}
async getReadiness(): Promise<ReadinessResponse> {
try {
const response = await this.storageClient
.getContainerClient(this.containerName)
.getProperties();
if (response._response.status === 200) {
return {
isAvailable: true,
};
}
if (response._response.status >= 400) {
this.logger.error(
`Failed to retrieve metadata from ${response._response.request.url} with status code ${response._response.status}.`,
);
}
} catch (e) {
assertError(e);
this.logger.error(`from Azure Blob Storage client library: ${e.message}`);
}
this.logger.error(
`Could not retrieve metadata about the Azure Blob Storage container ${this.containerName}. ` +
'Make sure that the Azure project and container exist and the access key is setup correctly ' +
'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' +
'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage',
);
return { isAvailable: false };
}
/**
* Upload all the files from the generated `directory` to the Azure Blob Storage container.
* Directory structure used in the container is - entityNamespace/entityKind/entityName/index.html
*/
async publish({
entity,
directory,
}: PublishRequest): Promise<PublishResponse> {
const objects: string[] = [];
const useLegacyPathCasing = this.legacyPathCasing;
// First, try to retrieve a list of all individual files currently existing
const remoteFolder = getCloudPathForLocalPath(
entity,
undefined,
useLegacyPathCasing,
);
let existingFiles: string[] = [];
try {
existingFiles = await this.getAllBlobsFromContainer({
prefix: remoteFolder,
maxPageSize: BATCH_CONCURRENCY,
});
} catch (e) {
assertError(e);
this.logger.error(
`Unable to list files for Entity ${entity.metadata.name}: ${e.message}`,
);
}
// Then, merge new files into the same folder
let absoluteFilesToUpload;
let container: ContainerClient;
try {
// Remove the absolute path prefix of the source directory
// Path of all files to upload, relative to the root of the source directory
// e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png']
absoluteFilesToUpload = await getFileTreeRecursively(directory);
container = this.storageClient.getContainerClient(this.containerName);
const failedOperations: Error[] = [];
await bulkStorageOperation(
async absoluteFilePath => {
const relativeFilePath = path.normalize(
path.relative(directory, absoluteFilePath),
);
const remotePath = getCloudPathForLocalPath(
entity,
relativeFilePath,
useLegacyPathCasing,
);
objects.push(remotePath);
const response = await container
.getBlockBlobClient(remotePath)
.uploadFile(absoluteFilePath);
if (response._response.status >= 400) {
failedOperations.push(
new Error(
`Upload failed for ${absoluteFilePath} with status code ${response._response.status}`,
),
);
}
return response;
},
absoluteFilesToUpload,
{ concurrencyLimit: BATCH_CONCURRENCY },
);
if (failedOperations.length > 0) {
throw new Error(
failedOperations
.map(r => r.message)
.filter(Boolean)
.join(' '),
);
}
this.logger.info(
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${absoluteFilesToUpload.length}`,
);
} catch (e) {
const errorMessage = `Unable to upload file(s) to Azure. ${e}`;
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
// Last, try to remove the files that were *only* present previously
try {
const relativeFilesToUpload = absoluteFilesToUpload.map(
absoluteFilePath =>
getCloudPathForLocalPath(
entity,
path.relative(directory, absoluteFilePath),
useLegacyPathCasing,
),
);
const staleFiles = getStaleFiles(relativeFilesToUpload, existingFiles);
await bulkStorageOperation(
async relativeFilePath => {
return await container.deleteBlob(relativeFilePath);
},
staleFiles,
{ concurrencyLimit: BATCH_CONCURRENCY },
);
this.logger.info(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: ${staleFiles.length}`,
);
} catch (error) {
const errorMessage = `Unable to delete file(s) from Azure. ${error}`;
this.logger.error(errorMessage);
}
return { objects };
}
private download(containerName: string, blobPath: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const fileStreamChunks: Array<any> = [];
this.storageClient
.getContainerClient(containerName)
.getBlockBlobClient(blobPath)
.download()
.then(res => {
const body = res.readableStreamBody;
if (!body) {
reject(new Error(`Unable to parse the response data`));
return;
}
body
.on('error', reject)
.on('data', chunk => {
fileStreamChunks.push(chunk);
})
.on('end', () => {
resolve(Buffer.concat(fileStreamChunks));
});
})
.catch(reject);
});
}
async fetchTechDocsMetadata(
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata> {
const entityTriplet = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
const entityRootDir = this.legacyPathCasing
? entityTriplet
: lowerCaseEntityTriplet(entityTriplet);
try {
const techdocsMetadataJson = await this.download(
this.containerName,
`${entityRootDir}/techdocs_metadata.json`,
);
if (!techdocsMetadataJson) {
throw new Error(
`Unable to parse the techdocs metadata file ${entityRootDir}/techdocs_metadata.json.`,
);
}
const techdocsMetadata = JSON5.parse(
techdocsMetadataJson.toString('utf-8'),
);
return techdocsMetadata;
} catch (e) {
throw new ForwardedError('TechDocs metadata fetch failed', e);
}
}
/**
* Express route middleware to serve static files on a route in techdocs-backend.
*/
docsRouter(): express.Handler {
return (req, res) => {
// Decode and trim the leading forward slash
const decodedUri = decodeURI(req.path.replace(/^\//, ''));
// filePath example - /default/Component/documented-component/index.html
const filePath = this.legacyPathCasing
? decodedUri
: lowerCaseEntityTripletInStoragePath(decodedUri);
// Files with different extensions (CSS, HTML) need to be served with different headers
const fileExtension = platformPath.extname(filePath);
const responseHeaders = getHeadersForFileExtension(fileExtension);
this.download(this.containerName, filePath)
.then(fileContent => {
// Inject response headers
for (const [headerKey, headerValue] of Object.entries(
responseHeaders,
)) {
res.setHeader(headerKey, headerValue);
}
res.send(fileContent);
})
.catch(e => {
this.logger.warn(
`TechDocs Azure router failed to serve content from container ${this.containerName} at path ${filePath}: ${e.message}`,
);
res.status(404).send('File Not Found');
});
};
}
/**
* A helper function which checks if index.html of an Entity's docs site is available. This
* can be used to verify if there are any pre-generated docs available to serve.
*/
hasDocsBeenGenerated(entity: Entity): Promise<boolean> {
const entityTriplet = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
const entityRootDir = this.legacyPathCasing
? entityTriplet
: lowerCaseEntityTriplet(entityTriplet);
return this.storageClient
.getContainerClient(this.containerName)
.getBlockBlobClient(`${entityRootDir}/index.html`)
.exists();
}
protected async renameBlob(
originalName: string,
newName: string,
removeOriginal = false,
): Promise<void> {
const container = this.storageClient.getContainerClient(this.containerName);
const blob = container.getBlobClient(newName);
const { url } = container.getBlobClient(originalName);
const response = await blob.beginCopyFromURL(url);
await response.pollUntilDone();
if (removeOriginal) {
await container.deleteBlob(originalName);
}
}
protected async renameBlobToLowerCase(
originalPath: string,
removeOriginal: boolean,
) {
let newPath;
try {
newPath = lowerCaseEntityTripletInStoragePath(originalPath);
} catch (e) {
assertError(e);
this.logger.warn(e.message);
return;
}
if (originalPath === newPath) return;
try {
this.logger.verbose(`Migrating ${originalPath}`);
await this.renameBlob(originalPath, newPath, removeOriginal);
} catch (e) {
assertError(e);
this.logger.warn(`Unable to migrate ${originalPath}: ${e.message}`);
}
}
async migrateDocsCase({
removeOriginal = false,
concurrency = 25,
}): Promise<void> {
const promises = [];
const limiter = limiterFactory(concurrency);
const container = this.storageClient.getContainerClient(this.containerName);
for await (const blob of container.listBlobsFlat()) {
promises.push(
limiter(
this.renameBlobToLowerCase.bind(this),
blob.name,
removeOriginal,
),
);
}
await Promise.all(promises);
}
protected async getAllBlobsFromContainer({
prefix,
maxPageSize,
}: {
prefix: string;
maxPageSize: number;
}): Promise<string[]> {
const blobs: string[] = [];
const container = this.storageClient.getContainerClient(this.containerName);
let iterator = container.listBlobsFlat({ prefix }).byPage({ maxPageSize });
let response = (await iterator.next()).value;
do {
for (const blob of response?.segment?.blobItems ?? []) {
blobs.push(blob.name);
}
iterator = container
.listBlobsFlat({ prefix })
.byPage({ continuationToken: response.continuationToken, maxPageSize });
response = (await iterator.next()).value;
} while (response && response.continuationToken);
return blobs;
}
}
@@ -0,0 +1,484 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import express from 'express';
import request from 'supertest';
import mockFs from 'mock-fs';
import path from 'path';
import fs from 'fs-extra';
import { GoogleGCSPublish } from './googleStorage';
// NOTE: /packages/techdocs-common/__mocks__ is being used to mock Google Cloud Storage client library
const rootDir = (global as any).rootDir; // Set by setupTests.ts
const getEntityRootDir = (entity: Entity) => {
const {
kind,
metadata: { namespace, name },
} = entity;
return path.join(rootDir, namespace || DEFAULT_NAMESPACE, kind, name);
};
const logger = getVoidLogger();
jest.spyOn(logger, 'info').mockReturnValue(logger);
jest.spyOn(logger, 'error').mockReturnValue(logger);
const createPublisherFromConfig = ({
bucketName = 'bucketName',
bucketRootPath = '/',
legacyUseCaseSensitiveTripletPaths = false,
}: {
bucketName?: string;
bucketRootPath?: string;
legacyUseCaseSensitiveTripletPaths?: boolean;
} = {}) => {
const config = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'googleGcs',
googleGcs: {
credentials: '{}',
bucketName,
bucketRootPath,
},
},
legacyUseCaseSensitiveTripletPaths,
},
});
return GoogleGCSPublish.fromConfig(config, logger);
};
describe('GoogleGCSPublish', () => {
const entity = {
apiVersion: 'version',
kind: 'Component',
metadata: {
name: 'backstage',
namespace: 'default',
annotations: {},
},
};
const entityName = {
kind: 'Component',
name: 'backstage',
namespace: 'default',
};
const techdocsMetadata = {
site_name: 'backstage',
site_description: 'site_content',
etag: 'etag',
build_timestamp: 612741599,
};
const directory = getEntityRootDir(entity);
const files = {
'index.html': '',
'404.html': '',
assets: {
'main.css': '',
},
'techdocs_metadata.json': JSON.stringify(techdocsMetadata),
html: {
'unsafe.html': '<html></html>',
},
img: {
'with spaces.png': 'found it',
'unsafe.svg': '<svg></svg>',
},
'some folder': {
'also with spaces.js': 'found it too',
},
};
beforeAll(() => {
mockFs({
[directory]: files,
});
});
afterAll(() => {
mockFs.restore();
});
describe('getReadiness', () => {
it('should validate correct config', async () => {
const publisher = createPublisherFromConfig();
expect(await publisher.getReadiness()).toEqual({
isAvailable: true,
});
});
it('should reject incorrect config', async () => {
const publisher = createPublisherFromConfig({
bucketName: 'bad_bucket_name',
});
expect(await publisher.getReadiness()).toEqual({
isAvailable: false,
});
});
});
describe('publish', () => {
it('should publish a directory', async () => {
const publisher = createPublisherFromConfig();
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'default/component/backstage/404.html',
`default/component/backstage/index.html`,
`default/component/backstage/assets/main.css`,
]),
});
});
it('should publish a directory as well when legacy casing is used', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'default/Component/backstage/404.html',
`default/Component/backstage/index.html`,
`default/Component/backstage/assets/main.css`,
]),
});
});
it('should publish a directory when root path is specified', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
});
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'backstage-data/techdocs/default/component/backstage/404.html',
`backstage-data/techdocs/default/component/backstage/index.html`,
`backstage-data/techdocs/default/component/backstage/assets/main.css`,
]),
});
});
it('should publish a directory when root path is specified and legacy casing is used', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
legacyUseCaseSensitiveTripletPaths: true,
});
expect(await publisher.publish({ entity, directory })).toMatchObject({
objects: expect.arrayContaining([
'backstage-data/techdocs/default/Component/backstage/404.html',
`backstage-data/techdocs/default/Component/backstage/index.html`,
`backstage-data/techdocs/default/Component/backstage/assets/main.css`,
]),
});
});
it('should fail to publish a directory', async () => {
const wrongPathToGeneratedDirectory = path.join(
rootDir,
'wrong',
'path',
'to',
'generatedDirectory',
);
const publisher = createPublisherFromConfig();
const fails = publisher.publish({
entity,
directory: wrongPathToGeneratedDirectory,
});
// Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error
// Issue reported https://github.com/tschaub/mock-fs/issues/118
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(
`Unable to upload file(s) to Google Cloud Storage. Error: Failed to read template directory: ENOENT, no such file or directory`,
),
});
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(wrongPathToGeneratedDirectory),
});
});
it('should delete stale files after upload', async () => {
const bucketName = 'delete_stale_files_success';
const publisher = createPublisherFromConfig({ bucketName });
await publisher.publish({ entity, directory });
expect(logger.info).toHaveBeenLastCalledWith(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: 1`,
);
});
it('should log error when the stale files deletion fails', async () => {
const bucketName = 'delete_stale_files_error';
const publisher = createPublisherFromConfig({ bucketName });
await publisher.publish({ entity, directory });
expect(logger.error).toHaveBeenLastCalledWith(
'Unable to delete file(s) from Google Cloud Storage. Error: Message',
);
});
});
describe('hasDocsBeenGenerated', () => {
it('should return true if docs has been generated', async () => {
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
});
it('should return true if docs has been generated even if the legacy case is enabled', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
});
it('should return true if docs has been generated if root path is specified', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
});
await publisher.publish({ entity, directory });
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
});
it('should return true if docs has been generated if root path is specified and legacy casing is used', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
});
it('should return false if docs has not been generated', async () => {
const publisher = createPublisherFromConfig();
expect(
await publisher.hasDocsBeenGenerated({
kind: 'entity',
metadata: {
namespace: 'invalid',
name: 'triplet',
},
} as Entity),
).toBe(false);
});
});
describe('fetchTechDocsMetadata', () => {
it('should return tech docs metadata', async () => {
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
});
it('should return tech docs metadata even if the legacy case is enabled', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
});
it('should return tech docs metadata even if root path is specified', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
});
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
});
it('should return tech docs metadata if root path is specified and legacy casing is used', async () => {
const publisher = createPublisherFromConfig({
bucketRootPath: 'backstage-data/techdocs',
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
});
it('should return tech docs metadata when json encoded with single quotes', async () => {
const techdocsMetadataPath = path.join(
directory,
'techdocs_metadata.json',
);
const techdocsMetadataContent = files['techdocs_metadata.json'];
fs.writeFileSync(
techdocsMetadataPath,
techdocsMetadataContent.replace(/"/g, "'"),
);
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
expect(await publisher.fetchTechDocsMetadata(entityName)).toStrictEqual(
techdocsMetadata,
);
fs.writeFileSync(techdocsMetadataPath, techdocsMetadataContent);
});
it('should return an error if the techdocs_metadata.json file is not present', async () => {
const publisher = createPublisherFromConfig();
const invalidEntityName = {
namespace: 'invalid',
kind: 'triplet',
name: 'path',
};
const fails = publisher.fetchTechDocsMetadata(invalidEntityName);
await expect(fails).rejects.toMatchObject({
message: expect.stringMatching(/The file .* does not exist/i),
});
});
});
describe('docsRouter', () => {
const entityTripletPath = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
// const entityTripletPath =
let app: Express.Application;
beforeEach(async () => {
const publisher = createPublisherFromConfig();
await publisher.publish({ entity, directory });
app = express().use(publisher.docsRouter());
});
it('should pass expected object path to bucket', async () => {
// Ensures leading slash is trimmed and encoded path is decoded.
const pngResponse = await request(app).get(
`/${entityTripletPath}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass expected object path to bucket even if the legacy case is enabled', async () => {
const publisher = createPublisherFromConfig({
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
app = express().use(publisher.docsRouter());
// Ensures leading slash is trimmed and encoded path is decoded.
const pngResponse = await request(app).get(
`/${entityTripletPath}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass expected object path to bucket if root path is specified', async () => {
const rootPath = 'backstage-data/techdocs';
const publisher = createPublisherFromConfig({
bucketRootPath: rootPath,
});
await publisher.publish({ entity, directory });
app = express().use(publisher.docsRouter());
const pngResponse = await request(app).get(
`/${rootPath}/${entityTripletPath}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${rootPath}/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass expected object path to bucket if root path is specified and legacy case is enabled', async () => {
const rootPath = 'backstage-data/techdocs';
const publisher = createPublisherFromConfig({
bucketRootPath: rootPath,
legacyUseCaseSensitiveTripletPaths: true,
});
await publisher.publish({ entity, directory });
app = express().use(publisher.docsRouter());
const pngResponse = await request(app).get(
`/${rootPath}/${entityTripletPath}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${rootPath}/${entityTripletPath}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass text/plain content-type for html', async () => {
const htmlResponse = await request(app).get(
`/${entityTripletPath}/html/unsafe.html`,
);
expect(htmlResponse.text).toEqual('<html></html>');
expect(htmlResponse.header).toMatchObject({
'content-type': 'text/plain; charset=utf-8',
});
const svgResponse = await request(app).get(
`/${entityTripletPath}/img/unsafe.svg`,
);
expect(svgResponse.text).toEqual('<svg></svg>');
expect(svgResponse.header).toMatchObject({
'content-type': 'text/plain; charset=utf-8',
});
});
it('should return 404 if file is not found', async () => {
const response = await request(app).get(
`/${entityTripletPath}/not-found.html`,
);
expect(response.status).toBe(404);
expect(Buffer.from(response.text).toString('utf8')).toEqual(
'File Not Found',
);
});
});
});
@@ -0,0 +1,387 @@
/*
* 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 { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError } from '@backstage/errors';
import { File, FileExistsResponse, Storage } from '@google-cloud/storage';
import express from 'express';
import JSON5 from 'json5';
import path from 'path';
import { Readable } from 'stream';
import { Logger } from 'winston';
import {
getFileTreeRecursively,
getHeadersForFileExtension,
lowerCaseEntityTriplet,
lowerCaseEntityTripletInStoragePath,
bulkStorageOperation,
getCloudPathForLocalPath,
getStaleFiles,
normalizeExternalStorageRootPath,
} from './helpers';
import { MigrateWriteStream } from './migrations';
import {
PublisherBase,
PublishRequest,
PublishResponse,
ReadinessResponse,
TechDocsMetadata,
} from './types';
export class GoogleGCSPublish implements PublisherBase {
private readonly storageClient: Storage;
private readonly bucketName: string;
private readonly legacyPathCasing: boolean;
private readonly logger: Logger;
private readonly bucketRootPath: string;
constructor(options: {
storageClient: Storage;
bucketName: string;
legacyPathCasing: boolean;
logger: Logger;
bucketRootPath: string;
}) {
this.storageClient = options.storageClient;
this.bucketName = options.bucketName;
this.legacyPathCasing = options.legacyPathCasing;
this.logger = options.logger;
this.bucketRootPath = options.bucketRootPath;
}
static fromConfig(config: Config, logger: Logger): PublisherBase {
let bucketName = '';
try {
bucketName = config.getString('techdocs.publisher.googleGcs.bucketName');
} catch (error) {
throw new Error(
"Since techdocs.publisher.type is set to 'googleGcs' in your app config, " +
'techdocs.publisher.googleGcs.bucketName is required.',
);
}
const bucketRootPath = normalizeExternalStorageRootPath(
config.getOptionalString('techdocs.publisher.googleGcs.bucketRootPath') ||
'',
);
// Credentials is an optional config. If missing, default GCS environment variables will be used.
// Read more here https://cloud.google.com/docs/authentication/production
const credentials = config.getOptionalString(
'techdocs.publisher.googleGcs.credentials',
);
let credentialsJson: any = {};
if (credentials) {
try {
credentialsJson = JSON.parse(credentials);
} catch (err) {
throw new Error(
'Error in parsing techdocs.publisher.googleGcs.credentials config to JSON.',
);
}
}
const storageClient = new Storage({
...(credentials && {
projectId: credentialsJson.project_id,
credentials: credentialsJson,
}),
});
const legacyPathCasing =
config.getOptionalBoolean(
'techdocs.legacyUseCaseSensitiveTripletPaths',
) || false;
return new GoogleGCSPublish({
storageClient,
bucketName,
legacyPathCasing,
logger,
bucketRootPath,
});
}
/**
* Check if the defined bucket exists. Being able to connect means the configuration is good
* and the storage client will work.
*/
async getReadiness(): Promise<ReadinessResponse> {
try {
await this.storageClient.bucket(this.bucketName).getMetadata();
this.logger.info(
`Successfully connected to the GCS bucket ${this.bucketName}.`,
);
return {
isAvailable: true,
};
} catch (err) {
assertError(err);
this.logger.error(
`Could not retrieve metadata about the GCS bucket ${this.bucketName}. ` +
'Make sure the bucket exists. Also make sure that authentication is setup either by explicitly defining ' +
'techdocs.publisher.googleGcs.credentials in app config or by using environment variables. ' +
'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage',
);
this.logger.error(`from GCS client library: ${err.message}`);
return { isAvailable: false };
}
}
/**
* Upload all the files from the generated `directory` to the GCS bucket.
* Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html
*/
async publish({
entity,
directory,
}: PublishRequest): Promise<PublishResponse> {
const objects: string[] = [];
const useLegacyPathCasing = this.legacyPathCasing;
const bucket = this.storageClient.bucket(this.bucketName);
const bucketRootPath = this.bucketRootPath;
// First, try to retrieve a list of all individual files currently existing
let existingFiles: string[] = [];
try {
const remoteFolder = getCloudPathForLocalPath(
entity,
undefined,
useLegacyPathCasing,
bucketRootPath,
);
existingFiles = await this.getFilesForFolder(remoteFolder);
} catch (e) {
assertError(e);
this.logger.error(
`Unable to list files for Entity ${entity.metadata.name}: ${e.message}`,
);
}
// Then, merge new files into the same folder
let absoluteFilesToUpload;
try {
// Remove the absolute path prefix of the source directory
// Path of all files to upload, relative to the root of the source directory
// e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png']
absoluteFilesToUpload = await getFileTreeRecursively(directory);
await bulkStorageOperation(
async absoluteFilePath => {
const relativeFilePath = path.relative(directory, absoluteFilePath);
const destination = getCloudPathForLocalPath(
entity,
relativeFilePath,
useLegacyPathCasing,
bucketRootPath,
);
objects.push(destination);
return await bucket.upload(absoluteFilePath, { destination });
},
absoluteFilesToUpload,
{ concurrencyLimit: 10 },
);
this.logger.info(
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${absoluteFilesToUpload.length}`,
);
} catch (e) {
const errorMessage = `Unable to upload file(s) to Google Cloud Storage. ${e}`;
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
// Last, try to remove the files that were *only* present previously
try {
const relativeFilesToUpload = absoluteFilesToUpload.map(
absoluteFilePath =>
getCloudPathForLocalPath(
entity,
path.relative(directory, absoluteFilePath),
useLegacyPathCasing,
bucketRootPath,
),
);
const staleFiles = getStaleFiles(relativeFilesToUpload, existingFiles);
await bulkStorageOperation(
async relativeFilePath => {
return await bucket.file(relativeFilePath).delete();
},
staleFiles,
{ concurrencyLimit: 10 },
);
this.logger.info(
`Successfully deleted stale files for Entity ${entity.metadata.name}. Total number of files: ${staleFiles.length}`,
);
} catch (error) {
const errorMessage = `Unable to delete file(s) from Google Cloud Storage. ${error}`;
this.logger.error(errorMessage);
}
return { objects };
}
fetchTechDocsMetadata(
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata> {
return new Promise((resolve, reject) => {
const entityTriplet = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
const entityDir = this.legacyPathCasing
? entityTriplet
: lowerCaseEntityTriplet(entityTriplet);
const entityRootDir = path.posix.join(this.bucketRootPath, entityDir);
const fileStreamChunks: Array<any> = [];
this.storageClient
.bucket(this.bucketName)
.file(`${entityRootDir}/techdocs_metadata.json`)
.createReadStream()
.on('error', err => {
this.logger.error(err.message);
reject(err);
})
.on('data', chunk => {
fileStreamChunks.push(chunk);
})
.on('end', () => {
const techdocsMetadataJson =
Buffer.concat(fileStreamChunks).toString('utf-8');
resolve(JSON5.parse(techdocsMetadataJson));
});
});
}
/**
* Express route middleware to serve static files on a route in techdocs-backend.
*/
docsRouter(): express.Handler {
return (req, res) => {
// Decode and trim the leading forward slash
const decodedUri = decodeURI(req.path.replace(/^\//, ''));
// Root path is removed from the Uri so that legacy casing can be applied
// to the entity triplet without manipulating the root path
const decodedUriNoRoot = path.relative(this.bucketRootPath, decodedUri);
const filePathNoRoot = this.legacyPathCasing
? decodedUriNoRoot
: lowerCaseEntityTripletInStoragePath(decodedUriNoRoot);
// Re-prepend the root path to the relative file path
const filePath = path.posix.join(this.bucketRootPath, filePathNoRoot);
// Files with different extensions (CSS, HTML) need to be served with different headers
const fileExtension = path.extname(filePath);
const responseHeaders = getHeadersForFileExtension(fileExtension);
// Pipe file chunks directly from storage to client.
this.storageClient
.bucket(this.bucketName)
.file(filePath)
.createReadStream()
.on('pipe', () => {
res.writeHead(200, responseHeaders);
})
.on('error', err => {
this.logger.warn(
`TechDocs Google GCS router failed to serve content from bucket ${this.bucketName} at path ${filePath}: ${err.message}`,
);
// Send a 404 with a meaningful message if possible.
if (!res.headersSent) {
res.status(404).send('File Not Found');
} else {
res.destroy();
}
})
.pipe(res);
};
}
/**
* A helper function which checks if index.html of an Entity's docs site is available. This
* can be used to verify if there are any pre-generated docs available to serve.
*/
async hasDocsBeenGenerated(entity: Entity): Promise<boolean> {
return new Promise(resolve => {
const entityTriplet = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
const entityDir = this.legacyPathCasing
? entityTriplet
: lowerCaseEntityTriplet(entityTriplet);
const entityRootDir = path.posix.join(this.bucketRootPath, entityDir);
this.storageClient
.bucket(this.bucketName)
.file(`${entityRootDir}/index.html`)
.exists()
.then((response: FileExistsResponse) => {
resolve(response[0]);
})
.catch(() => {
resolve(false);
});
});
}
migrateDocsCase({ removeOriginal = false, concurrency = 25 }): Promise<void> {
return new Promise((resolve, reject) => {
// Iterate through every file in the root of the publisher.
const allFileMetadata: Readable = this.storageClient
.bucket(this.bucketName)
.getFilesStream();
const migrateFiles = new MigrateWriteStream(
this.logger,
removeOriginal,
concurrency,
);
migrateFiles.on('finish', resolve).on('error', reject);
allFileMetadata.pipe(migrateFiles).on('error', error => {
migrateFiles.destroy();
reject(error);
});
});
}
private getFilesForFolder(folder: string): Promise<string[]> {
const fileMetadataStream: Readable = this.storageClient
.bucket(this.bucketName)
.getFilesStream({ prefix: folder });
return new Promise((resolve, reject) => {
const files: string[] = [];
fileMetadataStream.on('error', error => {
// push file to file array
reject(error);
});
fileMetadataStream.on('data', (file: File) => {
// push file to file array
files.push(file.name);
});
fileMetadataStream.on('end', () => {
// resolve promise
resolve(files);
});
});
}
}
@@ -0,0 +1,307 @@
/*
* 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 mockFs from 'mock-fs';
import * as os from 'os';
import * as path from 'path';
import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import {
getStaleFiles,
getFileTreeRecursively,
getCloudPathForLocalPath,
getHeadersForFileExtension,
bulkStorageOperation,
lowerCaseEntityTriplet,
lowerCaseEntityTripletInStoragePath,
normalizeExternalStorageRootPath,
} from './helpers';
describe('getHeadersForFileExtension', () => {
const correctMapOfExtensions = [
['.html', 'text/plain; charset=utf-8'],
['.htm', 'text/plain; charset=utf-8'],
['.HTML', 'text/plain; charset=utf-8'],
['.dhtml', 'text/plain; charset=utf-8'],
['.xhtml', 'text/plain; charset=utf-8'],
['.xml', 'text/plain; charset=utf-8'],
['.css', 'text/css; charset=utf-8'],
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.svg', 'text/plain; charset=utf-8'],
['.SVG', 'text/plain; charset=utf-8'],
['.json', 'application/json; charset=utf-8'],
['.this-in-not-an-extension', 'text/plain; charset=utf-8'],
];
test.each(correctMapOfExtensions)(
'check content-type for %s extension',
(extension, expectedContentType) => {
const headers = getHeadersForFileExtension(extension);
expect(headers).toHaveProperty('Content-Type');
expect(headers['Content-Type'].toLowerCase()).toBe(expectedContentType);
},
);
});
describe('getFileTreeRecursively', () => {
const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
beforeEach(() => {
mockFs({
[root]: {
file1: '',
subDirA: {
file2: '',
emptyDir1: mockFs.directory(),
},
emptyDir2: mockFs.directory(),
},
});
});
afterEach(() => {
mockFs.restore();
});
it('returns complete file tree of a path', async () => {
const fileList = await getFileTreeRecursively(root);
expect(fileList.length).toBe(2);
expect(fileList).toContain(path.resolve(root, 'file1'));
expect(fileList).toContain(path.resolve(root, 'subDirA/file2'));
});
});
describe('lowerCaseEntityTriplet', () => {
it('returns lower-cased entity triplet path', () => {
const originalPath = 'default/Component/backstage/index.html';
const actualPath = lowerCaseEntityTriplet(originalPath);
expect(actualPath).toBe('default/component/backstage/index.html');
});
});
describe('lowerCaseEntityTripletInStoragePath', () => {
it('does not lowercase beyond the triplet', () => {
const originalPath = 'default/Component/backstage/assets/IMAGE.png';
const actualPath = lowerCaseEntityTripletInStoragePath(originalPath);
expect(actualPath).toBe('default/component/backstage/assets/IMAGE.png');
});
it('throws error when there is no triplet', () => {
const originalPath = '/default/component/IMAGE.png';
const error = `Encountered file unmanaged by TechDocs ${originalPath}. Skipping.`;
expect(() =>
lowerCaseEntityTripletInStoragePath(originalPath),
).toThrowError(error);
});
});
describe('normalizeExternalStorageRootPath', () => {
it('returns an empty string when empty string provided', () => {
const originalPath = '';
const normalPath = normalizeExternalStorageRootPath(originalPath);
expect(normalPath).toBe('');
});
it('returns an empty string when only separator is provided', () => {
const originalPath = '/';
const normalPath = normalizeExternalStorageRootPath(originalPath);
expect(normalPath).toBe('');
});
it('returns normalized path from path with leading and trailing sep', () => {
const originalPath = '/backstage-data/techdocs/';
const normalPath = normalizeExternalStorageRootPath(originalPath);
expect(normalPath).toBe('backstage-data/techdocs');
});
it('returns normalized path from path without leading and trailing sep', () => {
const originalPath = 'backstage-data/techdocs';
const normalPath = normalizeExternalStorageRootPath(originalPath);
expect(normalPath).toBe('backstage-data/techdocs');
});
it('returns normalized path from path with trailing sep', () => {
const originalPath = 'backstage-data/techdocs/';
const normalPath = normalizeExternalStorageRootPath(originalPath);
expect(normalPath).toBe('backstage-data/techdocs');
});
});
describe('getStaleFiles', () => {
const defaultFiles = [
'default/Component/backstage/index.html',
'default/Component/backstage/techdocs_metadata.json',
'default/Component/backstage/assests/javascripts/bundle.7f4f3c92.min.js',
'default/Component/backstage/assets/stylesheets/main.fe0cca5b.min.css',
];
it('should return empty array if there is no stale file', () => {
const oldFiles = [...defaultFiles];
const newFiles = [...defaultFiles];
const staleFiles = getStaleFiles(newFiles, oldFiles);
expect(staleFiles).toHaveLength(0);
});
it('should return all stale files when they exists', () => {
const oldFiles = [...defaultFiles, 'stale_file.png'];
const newFiles = [...defaultFiles];
const staleFiles = getStaleFiles(newFiles, oldFiles);
expect(staleFiles).toHaveLength(1);
expect(staleFiles).toEqual(expect.arrayContaining(['stale_file.png']));
});
});
describe('getCloudPathForLocalPath', () => {
const entity: Entity = {
apiVersion: 'version',
metadata: { namespace: 'custom', name: 'backstage' },
kind: 'Component',
};
it('should compose a remote bucket path including entity information', () => {
const remoteBucket = getCloudPathForLocalPath(entity);
expect(remoteBucket).toBe('custom/component/backstage/');
});
it('should compose a remote filename including entity information', () => {
const localPath = 'index.html';
const remoteBucket = getCloudPathForLocalPath(entity, localPath);
expect(remoteBucket).toBe(`custom/component/backstage/${localPath}`);
});
it('should use the default namespace when it is undefined', () => {
const localPath = 'index.html';
const {
kind,
metadata: { name },
} = entity;
const remoteBucket = getCloudPathForLocalPath(
{ kind, metadata: { name } } as Entity,
localPath,
);
expect(remoteBucket).toBe(
`${DEFAULT_NAMESPACE}/component/backstage/${localPath}`,
);
});
it('should preserve case when legacy flag is passed', () => {
const remoteBucket = getCloudPathForLocalPath(entity, undefined, true);
expect(remoteBucket).toBe('custom/Component/backstage/');
});
it('should throw error when entity is invalid', () => {
expect(() => getCloudPathForLocalPath({} as Entity)).toThrow();
});
it('should prepend root directory to destination', () => {
const localPath = 'index/html';
const rootPath = 'backstage-data/techdocs/';
const remoteBucket = getCloudPathForLocalPath(
entity,
localPath,
false,
rootPath,
);
expect(remoteBucket).toBe(
`backstage-data/techdocs/custom/component/backstage/${localPath}`,
);
});
it('should add trailing seperator to root directory', () => {
const localPath = 'index/html';
const rootPath = 'backstage-data/techdocs';
const remoteBucket = getCloudPathForLocalPath(
entity,
localPath,
false,
rootPath,
);
expect(remoteBucket).toBe(
`backstage-data/techdocs/custom/component/backstage/${localPath}`,
);
});
it('should remove leading seperator from root directory', () => {
const localPath = 'index/html';
const rootPath = '/backstage-data/techdocs/';
const remoteBucket = getCloudPathForLocalPath(
entity,
localPath,
false,
rootPath,
);
expect(remoteBucket).toBe(
`backstage-data/techdocs/custom/component/backstage/${localPath}`,
);
});
it('should ignore seperator if root directory is explicitly defined', () => {
const localPath = 'index/html';
const rootPath = '/';
const remoteBucket = getCloudPathForLocalPath(
entity,
localPath,
false,
rootPath,
);
expect(remoteBucket).toBe(`custom/component/backstage/${localPath}`);
});
});
describe('bulkStorageOperation', () => {
const length = 26;
const args = Array.from({ length });
const createConcurrentRequestCounter = (
callback: (count: number) => void,
) => {
let count = 0;
return () =>
new Promise(resolve => {
callback(++count);
setTimeout(() => {
count--;
resolve(null);
}, 100);
});
};
it('should take care of rate limit by default', async () => {
const operation = createConcurrentRequestCounter((count: number) => {
expect(count <= 25).toBeTruthy();
});
await bulkStorageOperation(operation, args);
});
it('should accept the number of concurrency limit', async () => {
const concurrencyLimit = 10;
const operation = createConcurrentRequestCounter((count: number) => {
expect(count <= concurrencyLimit).toBeTruthy();
});
await bulkStorageOperation(operation, args, { concurrencyLimit });
});
it('should wait for all promises be resolved', async () => {
const callback = jest.fn();
const operation = createConcurrentRequestCounter(callback);
await bulkStorageOperation(operation, args);
expect(callback).toHaveBeenCalledTimes(length);
});
it('should call operation with the correct argument', async () => {
const files = ['file1.txt', 'file2.txt'];
const fn = jest.fn();
await bulkStorageOperation(fn, files);
expect(fn).toHaveBeenCalledTimes(2);
expect(fn).toHaveBeenNthCalledWith(1, files[0]);
expect(fn).toHaveBeenNthCalledWith(2, files[1]);
});
});
@@ -0,0 +1,219 @@
/*
* 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 { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import mime from 'mime-types';
import path from 'path';
import createLimiter from 'p-limit';
import recursiveReadDir from 'recursive-readdir';
/**
* Helper to get the expected content-type for a given file extension. Also
* takes XSS mitigation into account.
*/
const getContentTypeForExtension = (ext: string): string => {
const defaultContentType = 'text/plain; charset=utf-8';
// Prevent sanitization bypass by preventing browsers from directly rendering
// the contents of untrusted files.
if (ext.match(/htm|xml|svg/i)) {
return defaultContentType;
}
return mime.contentType(ext) || defaultContentType;
};
export type responseHeadersType = {
'Content-Type': string;
};
/**
* Some files need special headers to be used correctly by the frontend. This function
* generates headers in the response to those file requests.
* @param fileExtension - .html, .css, .js, .png etc.
*/
export const getHeadersForFileExtension = (
fileExtension: string,
): responseHeadersType => {
return {
'Content-Type': getContentTypeForExtension(fileExtension),
};
};
/**
* Recursively traverse all the sub-directories of a path and return
* a list of absolute paths of all the files. e.g. tree command in Unix
*
* @example
*
* /User/username/my_dir
* dirA
* | subDirA
* | | file1
* EmptyDir
* dirB
* | file2
* file3
*
* getFileListRecursively('/Users/username/myDir')
* // returns
* [
* '/User/username/my_dir/dirA/subDirA/file1',
* '/User/username/my_dir/dirB/file2',
* '/User/username/my_dir/file3'
* ]
* @param rootDirPath - Absolute path to the root directory.
*/
export const getFileTreeRecursively = async (
rootDirPath: string,
): Promise<string[]> => {
// Iterate on all the files in the directory and its sub-directories
const fileList = await recursiveReadDir(rootDirPath).catch(error => {
throw new Error(`Failed to read template directory: ${error.message}`);
});
return fileList;
};
/**
* Takes a posix path and returns a lower-cased version of entity's triplet
* with the remaining path in posix.
*
* Path must not include a starting slash.
*
* @example
* lowerCaseEntityTriplet('default/Component/backstage')
* // return default/component/backstage
*/
export const lowerCaseEntityTriplet = (posixPath: string): string => {
const [namespace, kind, name, ...rest] = posixPath.split(path.posix.sep);
const lowerNamespace = namespace.toLowerCase();
const lowerKind = kind.toLowerCase();
const lowerName = name.toLowerCase();
return [lowerNamespace, lowerKind, lowerName, ...rest].join(path.posix.sep);
};
/**
* Takes either a win32 or posix path and returns a lower-cased version of entity's triplet
* with the remaining path in posix.
*
* Starting slashes will be trimmed.
*
* Throws an error if the path does not appear to be an entity triplet.
*
* @example
* lowerCaseEntityTripletInStoragePath('/default/Component/backstage/file.txt')
* // return default/component/backstage/file.txt
*/
export const lowerCaseEntityTripletInStoragePath = (
originalPath: string,
): string => {
let posixPath = originalPath;
if (originalPath.includes(path.win32.sep)) {
posixPath = originalPath.split(path.win32.sep).join(path.posix.sep);
}
// remove leading slash
const parts = posixPath.split(path.posix.sep);
if (parts[0] === '') {
parts.shift();
}
// check if all parts of the entity exist (name, namespace, kind) plus filename
if (parts.length <= 3) {
throw new Error(
`Encountered file unmanaged by TechDocs ${originalPath}. Skipping.`,
);
}
return lowerCaseEntityTriplet(parts.join(path.posix.sep));
};
/**
* Take a posix path and return a path without leading and trailing
* separators
*
* @example
* normalizeExternalStorageRootPath('/backstage-data/techdocs/')
* // return backstage-data/techdocs
*/
export const normalizeExternalStorageRootPath = (posixPath: string): string => {
// remove leading slash
let normalizedPath = posixPath;
if (posixPath.startsWith(path.posix.sep)) {
normalizedPath = posixPath.slice(1);
}
// remove trailing slash
if (normalizedPath.endsWith(path.posix.sep)) {
normalizedPath = normalizedPath.slice(0, normalizedPath.length - 1);
}
return normalizedPath;
};
// Only returns the files that existed previously and are not present anymore.
export const getStaleFiles = (
newFiles: string[],
oldFiles: string[],
): string[] => {
const staleFiles = new Set(oldFiles);
newFiles.forEach(newFile => {
staleFiles.delete(newFile);
});
return Array.from(staleFiles);
};
// Compose actual filename on remote bucket including entity information
export const getCloudPathForLocalPath = (
entity: Entity,
localPath = '',
useLegacyPathCasing = false,
externalStorageRootPath = '',
): string => {
// Convert destination file path to a POSIX path for uploading.
// GCS expects / as path separator and relativeFilePath will contain \\ on Windows.
// https://cloud.google.com/storage/docs/gsutil/addlhelp/HowSubdirectoriesWork
const relativeFilePathPosix = localPath.split(path.sep).join(path.posix.sep);
// The / delimiter is intentional since it represents the cloud storage and not the local file system.
const entityRootDir = `${entity.metadata?.namespace ?? DEFAULT_NAMESPACE}/${
entity.kind
}/${entity.metadata.name}`;
const relativeFilePathTriplet = `${entityRootDir}/${relativeFilePathPosix}`;
const destination = useLegacyPathCasing
? relativeFilePathTriplet
: lowerCaseEntityTriplet(relativeFilePathTriplet);
// Again, the / delimiter is intentional, as it represents remote storage.
const destinationWithRoot = [
// The extra filter prevents unintended double slashes and prefixes.
...externalStorageRootPath.split(path.posix.sep).filter(s => s !== ''),
destination,
].join('/');
return destinationWithRoot; // Remote storage file relative path
};
// Perform rate limited generic operations by passing a function and a list of arguments
export const bulkStorageOperation = async <T>(
operation: (arg: T) => Promise<unknown>,
args: T[],
{ concurrencyLimit } = { concurrencyLimit: 25 },
) => {
const limiter = createLimiter(concurrencyLimit);
await Promise.all(args.map(arg => limiter(operation, arg)));
};
@@ -0,0 +1,26 @@
/*
* 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.
*/
export { Publisher } from './publish';
export type {
PublisherBase,
PublisherType,
PublisherFactory,
PublishRequest,
PublishResponse,
MigrateRequest,
ReadinessResponse,
TechDocsMetadata,
} from './types';
@@ -0,0 +1,194 @@
/*
* 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 {
getVoidLogger,
PluginEndpointDiscovery,
resolvePackagePath,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import express from 'express';
import request from 'supertest';
import mockFs from 'mock-fs';
import * as os from 'os';
import { LocalPublish } from './local';
const createMockEntity = (annotations = {}, lowerCase = false) => {
return {
apiVersion: 'version',
kind: lowerCase ? 'testkind' : 'TestKind',
metadata: {
name: 'test-component-name',
annotations: {
...annotations,
},
},
};
};
const testDiscovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest.fn().mockResolvedValue('http://localhost:7007/api/techdocs'),
getExternalBaseUrl: jest.fn(),
};
const logger = getVoidLogger();
const tmpDir =
os.platform() === 'win32' ? 'C:\\tmp\\generatedDir' : '/tmp/generatedDir';
const resolvedDir = resolvePackagePath(
'@backstage/plugin-techdocs-backend',
'static/docs',
);
describe('local publisher', () => {
it('should publish generated documentation dir', async () => {
mockFs({
[tmpDir]: {
'index.html': '',
},
});
const mockConfig = new ConfigReader({});
const publisher = LocalPublish.fromConfig(
mockConfig,
logger,
testDiscovery,
);
const mockEntity = createMockEntity();
const lowerMockEntity = createMockEntity(undefined, true);
await publisher.publish({ entity: mockEntity, directory: tmpDir });
expect(await publisher.hasDocsBeenGenerated(mockEntity)).toBe(true);
// Lower/upper should be treated the same.
expect(await publisher.hasDocsBeenGenerated(lowerMockEntity)).toBe(true);
mockFs.restore();
});
it('should respect legacy casing', async () => {
mockFs({
[tmpDir]: {
'index.html': '',
},
});
const mockConfig = new ConfigReader({
techdocs: {
legacyUseCaseSensitiveTripletPaths: true,
},
});
const publisher = LocalPublish.fromConfig(
mockConfig,
logger,
testDiscovery,
);
const mockEntity = createMockEntity();
const lowerMockEntity = createMockEntity(undefined, true);
await publisher.publish({ entity: mockEntity, directory: tmpDir });
expect(await publisher.hasDocsBeenGenerated(mockEntity)).toBe(true);
// Lower/upper should be treated differently.
expect(await publisher.hasDocsBeenGenerated(lowerMockEntity)).toBe(false);
mockFs.restore();
});
describe('docsRouter', () => {
const mockConfig = new ConfigReader({});
const publisher = LocalPublish.fromConfig(
mockConfig,
logger,
testDiscovery,
);
let app: express.Express;
beforeEach(() => {
app = express().use(publisher.docsRouter());
mockFs.restore();
mockFs({
[resolvedDir]: {
'unsafe.html': '<html></html>',
'unsafe.svg': '<svg></svg>',
default: {
testkind: {
testname: {
'index.html': 'found it',
},
},
},
},
});
});
afterEach(() => {
mockFs.restore();
});
it('should pass text/plain content-type for unsafe types', async () => {
const htmlResponse = await request(app).get(`/unsafe.html`);
expect(htmlResponse.text).toEqual('<html></html>');
expect(htmlResponse.header).toMatchObject({
'content-type': 'text/plain; charset=utf-8',
});
const svgResponse = await request(app).get(`/unsafe.svg`);
expect(svgResponse.text).toEqual('<svg></svg>');
expect(svgResponse.header).toMatchObject({
'content-type': 'text/plain; charset=utf-8',
});
});
it('should redirect case-sensitive triplet path to lower-case', async () => {
const response = await request(app)
.get('/default/TestKind/TestName/index.html')
.expect('Location', '/default/testkind/testname/index.html');
expect(response.status).toBe(301);
});
it('should resolve lower-case triplet path content eventually', async () => {
const response = await request(app)
.get('/default/TestKind/TestName/index.html')
.redirects(1);
expect(response.text).toEqual('found it');
});
it('should not redirect when legacy case setting is used', async () => {
const legacyConfig = new ConfigReader({
techdocs: {
legacyUseCaseSensitiveTripletPaths: true,
},
});
const legacyPublisher = LocalPublish.fromConfig(
legacyConfig,
logger,
testDiscovery,
);
app = express().use(legacyPublisher.docsRouter());
const response = await request(app).get(
'/default/TestKind/TestName/index.html',
);
expect(response.status).toBe(404);
});
});
});
@@ -0,0 +1,294 @@
/*
* 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 {
PluginEndpointDiscovery,
resolvePackagePath,
} from '@backstage/backend-common';
import { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import express from 'express';
import fs from 'fs-extra';
import os from 'os';
import createLimiter from 'p-limit';
import path from 'path';
import { Logger } from 'winston';
import {
PublisherBase,
PublishRequest,
PublishResponse,
ReadinessResponse,
TechDocsMetadata,
} from './types';
import {
getFileTreeRecursively,
getHeadersForFileExtension,
lowerCaseEntityTripletInStoragePath,
} from './helpers';
import { assertError } from '@backstage/errors';
// TODO: Use a more persistent storage than node_modules or /tmp directory.
// Make it configurable with techdocs.publisher.local.publishDirectory
let staticDocsDir = '';
try {
staticDocsDir = resolvePackagePath(
'@backstage/plugin-techdocs-backend',
'static/docs',
);
} catch (err) {
// This will most probably never be used.
// The try/catch is introduced so that techdocs-cli can import @backstage/techdocs-common
// on CI/CD without installing techdocs backend plugin.
staticDocsDir = os.tmpdir();
}
/**
* Local publisher which uses the local filesystem to store the generated static files. It uses a directory
* called "static" at the root of techdocs-backend plugin.
*/
export class LocalPublish implements PublisherBase {
private readonly legacyPathCasing: boolean;
private readonly logger: Logger;
private readonly discovery: PluginEndpointDiscovery;
// TODO: Move the logic of setting staticDocsDir based on config over to
// fromConfig, and set the value as a class parameter.
constructor(options: {
logger: Logger;
discovery: PluginEndpointDiscovery;
legacyPathCasing: boolean;
}) {
this.logger = options.logger;
this.discovery = options.discovery;
this.legacyPathCasing = options.legacyPathCasing;
}
static fromConfig(
config: Config,
logger: Logger,
discovery: PluginEndpointDiscovery,
): PublisherBase {
const legacyPathCasing =
config.getOptionalBoolean(
'techdocs.legacyUseCaseSensitiveTripletPaths',
) || false;
return new LocalPublish({
logger,
discovery,
legacyPathCasing,
});
}
async getReadiness(): Promise<ReadinessResponse> {
return {
isAvailable: true,
};
}
async publish({
entity,
directory,
}: PublishRequest): Promise<PublishResponse> {
const entityNamespace = entity.metadata.namespace ?? 'default';
const publishDir = this.staticEntityPathJoin(
entityNamespace,
entity.kind,
entity.metadata.name,
);
if (!fs.existsSync(publishDir)) {
this.logger.info(`Could not find ${publishDir}, creating the directory.`);
fs.mkdirSync(publishDir, { recursive: true });
}
try {
await fs.copy(directory, publishDir);
this.logger.info(`Published site stored at ${publishDir}`);
} catch (error) {
this.logger.debug(
`Failed to copy docs from ${directory} to ${publishDir}`,
);
throw error;
}
// Generate publish response.
const techdocsApiUrl = await this.discovery.getBaseUrl('techdocs');
const publishedFilePaths = (await getFileTreeRecursively(publishDir)).map(
abs => {
return abs.split(`${staticDocsDir}/`)[1];
},
);
return {
remoteUrl: `${techdocsApiUrl}/static/docs/${encodeURIComponent(
entity.metadata.name,
)}`,
objects: publishedFilePaths,
};
}
async fetchTechDocsMetadata(
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata> {
const metadataPath = this.staticEntityPathJoin(
entityName.namespace,
entityName.kind,
entityName.name,
'techdocs_metadata.json',
);
try {
return await fs.readJson(metadataPath);
} catch (err) {
assertError(err);
this.logger.error(
`Unable to read techdocs_metadata.json at ${metadataPath}. Error: ${err}`,
);
throw new Error(err.message);
}
}
docsRouter(): express.Handler {
const router = express.Router();
// Redirect middleware ensuring that requests to case-sensitive entity
// triplet paths are always sent to lower-case versions.
router.use((req, res, next) => {
// If legacy path casing is on, let the request immediately continue.
if (this.legacyPathCasing) {
return next();
}
// Generate a lower-case entity triplet path.
const [_, namespace, kind, name, ...rest] = req.path.split('/');
// Ignore non-triplet objects.
if (!namespace || !kind || !name) {
return next();
}
const newPath = [
_,
namespace.toLowerCase(),
kind.toLowerCase(),
name.toLowerCase(),
...rest,
].join('/');
// If there was no change, then let express.static() handle the request.
if (newPath === req.path) {
return next();
}
// Otherwise, redirect to the new path.
return res.redirect(req.baseUrl + newPath, 301);
});
router.use(
express.static(staticDocsDir, {
// Handle content-type header the same as all other publishers.
setHeaders: (res, filePath) => {
const fileExtension = path.extname(filePath);
const headers = getHeadersForFileExtension(fileExtension);
for (const [header, value] of Object.entries(headers)) {
res.setHeader(header, value);
}
},
}),
);
return router;
}
async hasDocsBeenGenerated(entity: Entity): Promise<boolean> {
const namespace = entity.metadata.namespace ?? 'default';
const indexHtmlPath = this.staticEntityPathJoin(
namespace,
entity.kind,
entity.metadata.name,
'index.html',
);
// Check if the file exists
try {
await fs.access(indexHtmlPath, fs.constants.F_OK);
return true;
} catch (err) {
return false;
}
}
/**
* This code will never run in practice. It is merely here to illustrate how
* to implement this method for other storage providers.
*/
async migrateDocsCase({
removeOriginal = false,
concurrency = 25,
}): Promise<void> {
// Iterate through every file in the root of the publisher.
const files = await getFileTreeRecursively(staticDocsDir);
const limit = createLimiter(concurrency);
await Promise.all(
files.map(f =>
limit(async file => {
const relativeFile = file.replace(`${staticDocsDir}${path.sep}`, '');
const newFile = lowerCaseEntityTripletInStoragePath(relativeFile);
// If all parts are already lowercase, ignore.
if (relativeFile === newFile) {
return;
}
// Otherwise, copy or move the file.
await new Promise<void>(resolve => {
const migrate = removeOriginal ? fs.move : fs.copyFile;
this.logger.verbose(`Migrating ${relativeFile}`);
migrate(file, newFile, err => {
if (err) {
this.logger.warn(
`Unable to migrate ${relativeFile}: ${err.message}`,
);
}
resolve();
});
});
}, f),
),
);
}
/**
* Utility wrapper around path.join(), used to control legacy case logic.
*/
protected staticEntityPathJoin(...allParts: string[]): string {
if (this.legacyPathCasing) {
const [namespace, kind, name, ...parts] = allParts;
return path.join(staticDocsDir, namespace, kind, name, ...parts);
}
const [namespace, kind, name, ...parts] = allParts;
return path.join(
staticDocsDir,
namespace.toLowerCase(),
kind.toLowerCase(),
name.toLowerCase(),
...parts,
);
}
}
@@ -0,0 +1,81 @@
/*
* 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 { assertError } from '@backstage/errors';
import { File } from '@google-cloud/storage';
import { Writable } from 'stream';
import { Logger } from 'winston';
import { lowerCaseEntityTripletInStoragePath } from '../helpers';
/**
* Writable stream to handle object copy/move operations. This implementation
* ensures we don't read in files from GCS faster than GCS can copy/move them.
*/
export class MigrateWriteStream extends Writable {
protected logger: Logger;
protected removeOriginal: boolean;
protected maxConcurrency: number;
protected inFlight = 0;
constructor(logger: Logger, removeOriginal: boolean, concurrency: number) {
super({ objectMode: true });
this.logger = logger;
this.removeOriginal = removeOriginal;
this.maxConcurrency = concurrency;
}
_write(file: File, _encoding: BufferEncoding, next: Function) {
let shouldCallNext = true;
let newFile;
try {
newFile = lowerCaseEntityTripletInStoragePath(file.name);
} catch (e) {
assertError(e);
this.logger.warn(e.message);
next();
return;
}
// If all parts are already lowercase, ignore.
if (newFile === file.name) {
next();
return;
}
// Allow up to n-many files to be migrated at a time.
this.inFlight++;
if (this.inFlight < this.maxConcurrency) {
next();
shouldCallNext = false;
}
// Otherwise, copy or move the file.
const migrate = this.removeOriginal
? file.move.bind(file)
: file.copy.bind(file);
this.logger.verbose(`Migrating ${file.name}`);
migrate(newFile)
.catch(e =>
this.logger.warn(`Unable to migrate ${file.name}: ${e.message}`),
)
.finally(() => {
this.inFlight--;
if (shouldCallNext) {
next();
}
});
}
}
@@ -0,0 +1,17 @@
/*
* 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.
*/
export { MigrateWriteStream } from './GoogleMigration';
@@ -0,0 +1,392 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import {
Entity,
CompoundEntityRef,
DEFAULT_NAMESPACE,
} from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import express from 'express';
import request from 'supertest';
import mockFs from 'mock-fs';
import os from 'os';
import path from 'path';
import { OpenStackSwiftPublish } from './openStackSwift';
import { PublisherBase, TechDocsMetadata } from './types';
// NOTE: /packages/techdocs-common/__mocks__ is being used to mock @trendyol-js/openstack-swift-sdk client library
const createMockEntity = (annotations = {}): Entity => {
return {
apiVersion: 'version',
kind: 'TestKind',
metadata: {
name: 'test-component-name',
namespace: 'test-namespace',
annotations: {
...annotations,
},
},
};
};
const createMockEntityName = (): CompoundEntityRef => ({
kind: 'TestKind',
name: 'test-component-name',
namespace: 'test-namespace',
});
const rootDir = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const getEntityRootDir = (entity: Entity) => {
const {
kind,
metadata: { namespace, name },
} = entity;
return path.join(rootDir, namespace || DEFAULT_NAMESPACE, kind, name);
};
const getPosixEntityRootDir = (entity: Entity) => {
const {
kind,
metadata: { namespace, name },
} = entity;
return path.posix.join(
'/rootDir',
namespace || DEFAULT_NAMESPACE,
kind,
name,
);
};
const logger = getVoidLogger();
let publisher: PublisherBase;
beforeEach(() => {
mockFs.restore();
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'openStackSwift',
openStackSwift: {
credentials: {
id: 'mockid',
secret: 'verystrongsecret',
},
authUrl: 'mockauthurl',
swiftUrl: 'mockSwiftUrl',
containerName: 'mock',
},
},
},
});
publisher = OpenStackSwiftPublish.fromConfig(mockConfig, logger);
});
describe('OpenStackSwiftPublish', () => {
describe('getReadiness', () => {
it('should validate correct config', async () => {
expect(await publisher.getReadiness()).toEqual({
isAvailable: true,
});
});
it('should reject incorrect config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'openStackSwift',
openStackSwift: {
credentials: {
id: 'mockId',
secret: 'mockSecret',
},
authUrl: 'mockauthurl',
swiftUrl: 'mockSwiftUrl',
containerName: 'errorBucket',
},
},
},
});
const errorPublisher = OpenStackSwiftPublish.fromConfig(
mockConfig,
logger,
);
expect(await errorPublisher.getReadiness()).toEqual({
isAvailable: false,
});
});
});
describe('publish', () => {
beforeEach(() => {
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
mockFs({
[entityRootDir]: {
'index.html': '',
'404.html': '',
assets: {
'main.css': '',
},
},
});
});
afterEach(() => {
mockFs.restore();
});
it('should publish a directory', async () => {
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
expect(
await publisher.publish({
entity,
directory: entityRootDir,
}),
).toMatchObject({
objects: expect.arrayContaining([
'test-namespace/TestKind/test-component-name/404.html',
`test-namespace/TestKind/test-component-name/index.html`,
`test-namespace/TestKind/test-component-name/assets/main.css`,
]),
});
});
it('should fail to publish a directory', async () => {
const wrongPathToGeneratedDirectory = path.join(
rootDir,
'wrong',
'path',
'to',
'generatedDirectory',
);
const entity = createMockEntity();
await expect(
publisher.publish({
entity,
directory: wrongPathToGeneratedDirectory,
}),
).rejects.toThrowError();
const fails = publisher.publish({
entity,
directory: wrongPathToGeneratedDirectory,
});
// Can not do exact error message match due to mockFs adding unexpected characters in the path when throwing the error
// Issue reported https://github.com/tschaub/mock-fs/issues/118
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(
`Unable to upload file(s) to OpenStack Swift. Error: Failed to read template directory: ENOENT, no such file or directory`,
),
});
await expect(fails).rejects.toMatchObject({
message: expect.stringContaining(wrongPathToGeneratedDirectory),
});
mockFs.restore();
});
});
describe('hasDocsBeenGenerated', () => {
it('should return true if docs has been generated', async () => {
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
mockFs({
[entityRootDir]: {
'index.html': 'file-content',
},
});
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(true);
mockFs.restore();
});
it('should return false if docs has not been generated', async () => {
const entity = createMockEntity();
expect(await publisher.hasDocsBeenGenerated(entity)).toBe(false);
});
});
describe('fetchTechDocsMetadata', () => {
it('should return tech docs metadata', async () => {
const entityNameMock = createMockEntityName();
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
mockFs({
[entityRootDir]: {
'techdocs_metadata.json':
'{"site_name": "backstage", "site_description": "site_content", "etag": "etag", "build_timestamp": 612741599}',
},
});
const expectedMetadata: TechDocsMetadata = {
site_name: 'backstage',
site_description: 'site_content',
etag: 'etag',
build_timestamp: 612741599,
};
expect(
await publisher.fetchTechDocsMetadata(entityNameMock),
).toStrictEqual(expectedMetadata);
mockFs.restore();
});
it('should return tech docs metadata when json encoded with single quotes', async () => {
const entityNameMock = createMockEntityName();
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
mockFs({
[entityRootDir]: {
'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag', 'build_timestamp': 612741599}`,
},
});
const expectedMetadata: TechDocsMetadata = {
site_name: 'backstage',
site_description: 'site_content',
etag: 'etag',
build_timestamp: 612741599,
};
expect(
await publisher.fetchTechDocsMetadata(entityNameMock),
).toStrictEqual(expectedMetadata);
mockFs.restore();
});
it('should return an error if the techdocs_metadata.json file is not present', async () => {
const entityNameMock = createMockEntityName();
const entity = createMockEntity();
const entityRootDir = getPosixEntityRootDir(entity);
const fails = publisher.fetchTechDocsMetadata(entityNameMock);
await expect(fails).rejects.toMatchObject({
message: `TechDocs metadata fetch failed, The file ${path.posix.join(
entityRootDir,
'techdocs_metadata.json',
)} does not exist !`,
});
});
});
describe('docsRouter', () => {
let app: express.Express;
const entity = createMockEntity();
const entityRootDir = getEntityRootDir(entity);
beforeEach(() => {
app = express().use(publisher.docsRouter());
mockFs.restore();
mockFs({
[entityRootDir]: {
html: {
'unsafe.html': '<html></html>',
},
img: {
'unsafe.svg': '<svg></svg>',
'with spaces.png': 'found it',
},
'some folder': {
'also with spaces.js': 'found it too',
},
},
});
});
afterEach(() => {
mockFs.restore();
});
it('should pass expected object path to bucket', async () => {
const {
kind,
metadata: { namespace, name },
} = entity;
// Ensures leading slash is trimmed and encoded path is decoded.
const pngResponse = await request(app).get(
`/${namespace}/${kind}/${name}/img/with%20spaces.png`,
);
expect(Buffer.from(pngResponse.body).toString('utf8')).toEqual(
'found it',
);
const jsResponse = await request(app).get(
`/${namespace}/${kind}/${name}/some%20folder/also%20with%20spaces.js`,
);
expect(jsResponse.text).toEqual('found it too');
});
it('should pass text/plain content-type for unsafe types', async () => {
const {
kind,
metadata: { namespace, name },
} = entity;
const htmlResponse = await request(app).get(
`/${namespace}/${kind}/${name}/html/unsafe.html`,
);
expect(htmlResponse.text).toEqual('<html></html>');
expect(htmlResponse.header).toMatchObject({
'content-type': 'text/plain; charset=utf-8',
});
const svgResponse = await request(app).get(
`/${namespace}/${kind}/${name}/img/unsafe.svg`,
);
expect(svgResponse.text).toEqual('<svg></svg>');
expect(svgResponse.header).toMatchObject({
'content-type': 'text/plain; charset=utf-8',
});
});
it('should return 404 if file is not found', async () => {
const {
kind,
metadata: { namespace, name },
} = entity;
const response = await request(app).get(
`/${namespace}/${kind}/${name}/not-found.html`,
);
expect(response.status).toBe(404);
expect(Buffer.from(response.text).toString('utf8')).toEqual(
'File Not Found',
);
});
});
});
@@ -0,0 +1,366 @@
/*
* 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 { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import express from 'express';
import fs from 'fs-extra';
import JSON5 from 'json5';
import createLimiter from 'p-limit';
import path from 'path';
import { SwiftClient } from '@trendyol-js/openstack-swift-sdk';
import { NotFound } from '@trendyol-js/openstack-swift-sdk/lib/types';
import { Stream, Readable } from 'stream';
import { Logger } from 'winston';
import {
getFileTreeRecursively,
getHeadersForFileExtension,
lowerCaseEntityTripletInStoragePath,
} from './helpers';
import {
PublisherBase,
PublishRequest,
PublishResponse,
ReadinessResponse,
TechDocsMetadata,
} from './types';
import { assertError, ForwardedError } from '@backstage/errors';
const streamToBuffer = (stream: Stream | Readable): Promise<Buffer> => {
return new Promise((resolve, reject) => {
try {
const chunks: any[] = [];
stream.on('data', chunk => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
} catch (e) {
throw new ForwardedError('Unable to parse the response data', e);
}
});
};
const bufferToStream = (buffer: Buffer): Readable => {
const stream = new Readable();
stream.push(buffer);
stream.push(null);
return stream;
};
export class OpenStackSwiftPublish implements PublisherBase {
private readonly storageClient: SwiftClient;
private readonly containerName: string;
private readonly logger: Logger;
constructor(options: {
storageClient: SwiftClient;
containerName: string;
logger: Logger;
}) {
this.storageClient = options.storageClient;
this.containerName = options.containerName;
this.logger = options.logger;
}
static fromConfig(config: Config, logger: Logger): PublisherBase {
let containerName = '';
try {
containerName = config.getString(
'techdocs.publisher.openStackSwift.containerName',
);
} catch (error) {
throw new Error(
"Since techdocs.publisher.type is set to 'openStackSwift' in your app config, " +
'techdocs.publisher.openStackSwift.containerName is required.',
);
}
const openStackSwiftConfig = config.getConfig(
'techdocs.publisher.openStackSwift',
);
const storageClient = new SwiftClient({
authEndpoint: openStackSwiftConfig.getString('authUrl'),
swiftEndpoint: openStackSwiftConfig.getString('swiftUrl'),
credentialId: openStackSwiftConfig.getString('credentials.id'),
secret: openStackSwiftConfig.getString('credentials.secret'),
});
return new OpenStackSwiftPublish({ storageClient, containerName, logger });
}
/*
* Check if the defined container exists. Being able to connect means the configuration is good
* and the storage client will work.
*/
async getReadiness(): Promise<ReadinessResponse> {
try {
const container = await this.storageClient.getContainerMetadata(
this.containerName,
);
if (!(container instanceof NotFound)) {
this.logger.info(
`Successfully connected to the OpenStack Swift container ${this.containerName}.`,
);
return {
isAvailable: true,
};
}
this.logger.error(
`Could not retrieve metadata about the OpenStack Swift container ${this.containerName}. ` +
'Make sure the container exists. Also make sure that authentication is setup either by ' +
'explicitly defining credentials and region in techdocs.publisher.openStackSwift in app config or ' +
'by using environment variables. Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage',
);
return {
isAvailable: false,
};
} catch (err) {
assertError(err);
this.logger.error(`from OpenStack client library: ${err.message}`);
return {
isAvailable: false,
};
}
}
/**
* Upload all the files from the generated `directory` to the OpenStack Swift container.
* Directory structure used in the bucket is - entityNamespace/entityKind/entityName/index.html
*/
async publish({
entity,
directory,
}: PublishRequest): Promise<PublishResponse> {
try {
const objects: string[] = [];
// Note: OpenStack Swift manages creation of parent directories if they do not exist.
// So collecting path of only the files is good enough.
const allFilesToUpload = await getFileTreeRecursively(directory);
const limiter = createLimiter(10);
const uploadPromises: Array<Promise<unknown>> = [];
for (const filePath of allFilesToUpload) {
// Remove the absolute path prefix of the source directory
// Path of all files to upload, relative to the root of the source directory
// e.g. ['index.html', 'sub-page/index.html', 'assets/images/favicon.png']
const relativeFilePath = path.relative(directory, filePath);
// Convert destination file path to a POSIX path for uploading.
// Swift expects / as path separator and relativeFilePath will contain \\ on Windows.
// https://docs.openstack.org/python-openstackclient/pike/cli/man/openstack.html
const relativeFilePathPosix = relativeFilePath
.split(path.sep)
.join(path.posix.sep);
// The / delimiter is intentional since it represents the cloud storage and not the local file system.
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
const destination = `${entityRootDir}/${relativeFilePathPosix}`; // Swift container file relative path
objects.push(destination);
// Rate limit the concurrent execution of file uploads to batches of 10 (per publish)
const uploadFile = limiter(async () => {
const fileBuffer = await fs.readFile(filePath);
const stream = bufferToStream(fileBuffer);
return this.storageClient.upload(
this.containerName,
destination,
stream,
);
});
uploadPromises.push(uploadFile);
}
await Promise.all(uploadPromises);
this.logger.info(
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`,
);
return { objects };
} catch (e) {
const errorMessage = `Unable to upload file(s) to OpenStack Swift. ${e}`;
this.logger.error(errorMessage);
throw new Error(errorMessage);
}
}
async fetchTechDocsMetadata(
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata> {
return await new Promise<TechDocsMetadata>(async (resolve, reject) => {
const entityRootDir = `${entityName.namespace}/${entityName.kind}/${entityName.name}`;
const downloadResponse = await this.storageClient.download(
this.containerName,
`${entityRootDir}/techdocs_metadata.json`,
);
if (!(downloadResponse instanceof NotFound)) {
const stream = downloadResponse.data;
try {
const techdocsMetadataJson = await streamToBuffer(stream);
if (!techdocsMetadataJson) {
throw new Error(
`Unable to parse the techdocs metadata file ${entityRootDir}/techdocs_metadata.json.`,
);
}
const techdocsMetadata = JSON5.parse(
techdocsMetadataJson.toString('utf-8'),
);
resolve(techdocsMetadata);
} catch (err) {
assertError(err);
this.logger.error(err.message);
reject(new Error(err.message));
}
} else {
reject({
message: `TechDocs metadata fetch failed, The file /rootDir/${entityRootDir}/techdocs_metadata.json does not exist !`,
});
}
});
}
/**
* Express route middleware to serve static files on a route in techdocs-backend.
*/
docsRouter(): express.Handler {
return async (req, res) => {
// Decode and trim the leading forward slash
// filePath example - /default/Component/documented-component/index.html
const filePath = decodeURI(req.path.replace(/^\//, ''));
// Files with different extensions (CSS, HTML) need to be served with different headers
const fileExtension = path.extname(filePath);
const responseHeaders = getHeadersForFileExtension(fileExtension);
const downloadResponse = await this.storageClient.download(
this.containerName,
filePath,
);
if (!(downloadResponse instanceof NotFound)) {
const stream = downloadResponse.data;
try {
// Inject response headers
for (const [headerKey, headerValue] of Object.entries(
responseHeaders,
)) {
res.setHeader(headerKey, headerValue);
}
res.send(await streamToBuffer(stream));
} catch (err) {
assertError(err);
this.logger.warn(
`TechDocs OpenStack swift router failed to serve content from container ${this.containerName} at path ${filePath}: ${err.message}`,
);
res.status(404).send('File Not Found');
}
} else {
this.logger.warn(
`TechDocs OpenStack swift router failed to serve content from container ${this.containerName} at path ${filePath}: Not found`,
);
res.status(404).send('File Not Found');
}
};
}
/**
* A helper function which checks if index.html of an Entity's docs site is available. This
* can be used to verify if there are any pre-generated docs available to serve.
*/
async hasDocsBeenGenerated(entity: Entity): Promise<boolean> {
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
try {
const fileResponse = await this.storageClient.getMetadata(
this.containerName,
`${entityRootDir}/index.html`,
);
if (!(fileResponse instanceof NotFound)) {
return true;
}
return false;
} catch (err) {
assertError(err);
this.logger.warn(err.message);
return false;
}
}
async migrateDocsCase({
removeOriginal = false,
concurrency = 25,
}): Promise<void> {
// Iterate through every file in the root of the publisher.
const allObjects = await this.getAllObjectsFromContainer();
const limiter = createLimiter(concurrency);
await Promise.all(
allObjects.map(f =>
limiter(async file => {
let newPath;
try {
newPath = lowerCaseEntityTripletInStoragePath(file);
} catch (e) {
assertError(e);
this.logger.warn(e.message);
return;
}
// If all parts are already lowercase, ignore.
if (file === newPath) {
return;
}
try {
this.logger.verbose(`Migrating ${file} to ${newPath}`);
await this.storageClient.copy(
this.containerName,
file,
this.containerName,
newPath,
);
if (removeOriginal) {
await this.storageClient.delete(this.containerName, file);
}
} catch (e) {
assertError(e);
this.logger.warn(`Unable to migrate ${file}: ${e.message}`);
}
}, f),
),
);
}
/**
* Returns a list of all object keys from the configured container.
*/
protected async getAllObjectsFromContainer(
{ prefix } = { prefix: '' },
): Promise<string[]> {
let objects: string[] = [];
const OSS_MAX_LIMIT = Math.pow(2, 31) - 1;
const allObjects = await this.storageClient.list(
this.containerName,
prefix,
OSS_MAX_LIMIT,
);
objects = allObjects.map((object: any) => object.name);
return objects;
}
}
@@ -0,0 +1,191 @@
/*
* 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 {
getVoidLogger,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { Publisher } from './publish';
import { LocalPublish } from './local';
import { GoogleGCSPublish } from './googleStorage';
import { AwsS3Publish } from './awsS3';
import { AzureBlobStoragePublish } from './azureBlobStorage';
import { OpenStackSwiftPublish } from './openStackSwift';
const logger = getVoidLogger();
const discovery: jest.Mocked<PluginEndpointDiscovery> = {
getBaseUrl: jest.fn().mockResolvedValueOnce('http://localhost:7007'),
getExternalBaseUrl: jest.fn(),
};
describe('Publisher', () => {
beforeEach(() => {
jest.resetModules(); // clear the cache
});
it('should create local publisher by default', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
},
});
const publisher = await Publisher.fromConfig(mockConfig, {
logger,
discovery,
});
expect(publisher).toBeInstanceOf(LocalPublish);
});
it('should create local publisher from config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'local',
},
},
});
const publisher = await Publisher.fromConfig(mockConfig, {
logger,
discovery,
});
expect(publisher).toBeInstanceOf(LocalPublish);
});
it('should create google gcs publisher from config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'googleGcs',
googleGcs: {
credentials: '{}',
bucketName: 'bucketName',
},
},
},
});
const publisher = await Publisher.fromConfig(mockConfig, {
logger,
discovery,
});
expect(publisher).toBeInstanceOf(GoogleGCSPublish);
});
it('should create AWS S3 publisher from config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'awsS3',
awsS3: {
credentials: {
accessKeyId: 'accessKeyId',
secretAccessKey: 'secretAccessKey',
},
bucketName: 'bucketName',
},
},
},
});
const publisher = await Publisher.fromConfig(mockConfig, {
logger,
discovery,
});
expect(publisher).toBeInstanceOf(AwsS3Publish);
});
it('should create Azure Blob Storage publisher from config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'azureBlobStorage',
azureBlobStorage: {
credentials: {
accountName: 'accountName',
accountKey: 'accountKey',
},
containerName: 'containerName',
},
},
},
});
const publisher = await Publisher.fromConfig(mockConfig, {
logger,
discovery,
});
expect(publisher).toBeInstanceOf(AzureBlobStoragePublish);
});
it('should create Azure Blob Storage publisher from environment variables', async () => {
process.env.AZURE_TENANT_ID = 'AZURE_TENANT_ID';
process.env.AZURE_CLIENT_ID = 'AZURE_CLIENT_ID';
process.env.AZURE_CLIENT_SECRET = 'AZURE_CLIENT_SECRET';
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'azureBlobStorage',
azureBlobStorage: {
credentials: {
accountName: 'accountName',
},
containerName: 'containerName',
},
},
},
});
const publisher = await Publisher.fromConfig(mockConfig, {
logger,
discovery,
});
expect(publisher).toBeInstanceOf(AzureBlobStoragePublish);
});
it('should create Open Stack Swift publisher from config', async () => {
const mockConfig = new ConfigReader({
techdocs: {
requestUrl: 'http://localhost:7007',
publisher: {
type: 'openStackSwift',
openStackSwift: {
credentials: {
id: 'mockId',
secret: 'mockSecret',
},
authUrl: 'mockauthurl',
swiftUrl: 'mockSwiftUrl',
containerName: 'mock',
},
},
},
});
const publisher = await Publisher.fromConfig(mockConfig, {
logger,
discovery,
});
expect(publisher).toBeInstanceOf(OpenStackSwiftPublish);
});
});
@@ -0,0 +1,69 @@
/*
* 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 { Config } from '@backstage/config';
import { AwsS3Publish } from './awsS3';
import { AzureBlobStoragePublish } from './azureBlobStorage';
import { GoogleGCSPublish } from './googleStorage';
import { LocalPublish } from './local';
import { OpenStackSwiftPublish } from './openStackSwift';
import { PublisherFactory, PublisherBase, PublisherType } from './types';
/**
* Factory class to create a TechDocs publisher based on defined publisher type in app config.
* Uses `techdocs.publisher.type`.
* @public
*/
export class Publisher {
/**
* Returns a instance of TechDocs publisher
* @param config - A Backstage configuration
* @param options - Options for configuring the publisher factory
*/
static async fromConfig(
config: Config,
{ logger, discovery }: PublisherFactory,
): Promise<PublisherBase> {
const publisherType = (config.getOptionalString(
'techdocs.publisher.type',
) ?? 'local') as PublisherType;
switch (publisherType) {
case 'googleGcs':
logger.info('Creating Google Storage Bucket publisher for TechDocs');
return GoogleGCSPublish.fromConfig(config, logger);
case 'awsS3':
logger.info('Creating AWS S3 Bucket publisher for TechDocs');
return AwsS3Publish.fromConfig(config, logger);
case 'azureBlobStorage':
logger.info(
'Creating Azure Blob Storage Container publisher for TechDocs',
);
return AzureBlobStoragePublish.fromConfig(config, logger);
case 'openStackSwift':
logger.info(
'Creating OpenStack Swift Container publisher for TechDocs',
);
return OpenStackSwiftPublish.fromConfig(config, logger);
case 'local':
logger.info('Creating Local publisher for TechDocs');
return LocalPublish.fromConfig(config, logger, discovery);
default:
logger.info('Creating Local publisher for TechDocs');
return LocalPublish.fromConfig(config, logger, discovery);
}
}
}
@@ -0,0 +1,159 @@
/*
* 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 { Entity, CompoundEntityRef } from '@backstage/catalog-model';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Logger } from 'winston';
import express from 'express';
/**
* Options for building publishers
* @public
*/
export type PublisherFactory = {
logger: Logger;
discovery: PluginEndpointDiscovery;
};
/**
* Key for all the different types of TechDocs publishers that are supported.
* @public
*/
export type PublisherType =
| 'local'
| 'googleGcs'
| 'awsS3'
| 'azureBlobStorage'
| 'openStackSwift';
/**
* Request publish definition
* @public
*/
export type PublishRequest = {
entity: Entity;
/* The Path to the directory where the generated files are stored. */
directory: string;
};
/**
* Response containing metadata about where files were published and what may
* have been published or updated.
* @public
*/
export type PublishResponse = {
/**
* The URL which serves files from the local publisher's static directory.
*/
remoteUrl?: string;
/**
* The list of objects (specifically their paths) that were published.
* Objects do not have a preceding slash, and match how one would load the
* object over the `/static/docs/*` TechDocs Backend Plugin endpoint.
*/
objects?: string[];
} | void;
/**
* Result for the validation check.
* @public
*/
export type ReadinessResponse = {
/** If true, the publisher is able to interact with the backing storage. */
isAvailable: boolean;
};
/**
* Type to hold metadata found in techdocs_metadata.json and associated with each site
* @param etag - ETag of the resource used to generate the site. Usually the latest commit sha of the source repository.
* @public
*/
export type TechDocsMetadata = {
site_name: string;
site_description: string;
etag: string;
build_timestamp: number;
files?: string[];
};
/**
* TechDocs entity triplet migration request
* @public
*/
export type MigrateRequest = {
/**
* Whether or not to remove the source file. Defaults to false (acting like a
* copy instead of a move).
*/
removeOriginal?: boolean;
/**
* Maximum number of files/objects to migrate at once. Defaults to 25.
*/
concurrency?: number;
};
/**
* Base class for a TechDocs publisher (e.g. Local, Google GCS Bucket, AWS S3, etc.)
* The publisher handles publishing of the generated static files after the prepare and generate steps of TechDocs.
* It also provides APIs to communicate with the storage service.
*
* @public
*/
export interface PublisherBase {
/**
* Check if the publisher is ready. This check tries to perform certain checks to see if the
* publisher is configured correctly and can be used to publish or read documentations.
* The different implementations might e.g. use the provided service credentials to access the
* target or check if a folder/bucket is available.
*/
getReadiness(): Promise<ReadinessResponse>;
/**
* Store the generated static files onto a storage service (either local filesystem or external service).
*
* @param request - Object containing the entity from the service
* catalog, and the directory that contains the generated static files from TechDocs.
*/
publish(request: PublishRequest): Promise<PublishResponse>;
/**
* Retrieve TechDocs Metadata about a site e.g. name, contributors, last updated, etc.
* This API uses the techdocs_metadata.json file that co-exists along with the generated docs.
*/
fetchTechDocsMetadata(
entityName: CompoundEntityRef,
): Promise<TechDocsMetadata>;
/**
* Route middleware to serve static documentation files for an entity.
*/
docsRouter(): express.Handler;
/**
* Check if the index.html is present for the Entity at the Storage location.
*/
hasDocsBeenGenerated(entityName: Entity): Promise<boolean>;
/**
* Migrates documentation objects with case sensitive entity triplets to
* lowercase entity triplets. This was (will be) a change introduced in
* `techdocs-cli` version `{0.x.y}` and `techdocs-backend` version `{0.x.y}`.
*
* Implementation of this method is unnecessary in publishers introduced
* after version `{0.x.y}` of `techdocs-common`.
*/
migrateDocsCase?(migrateRequest: MigrateRequest): Promise<void>;
}
@@ -0,0 +1,48 @@
/*
* 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 { IndexableDocument } from '@backstage/search-common';
/**
* TechDocs indexable document interface
* @public
*/
export interface TechDocsDocument extends IndexableDocument {
/**
* Entity kind
*/
kind: string;
/**
* Entity metadata namespace
*/
namespace: string;
/**
* Entity metadata name
*/
name: string;
/**
* Entity lifecycle
*/
lifecycle: string;
/**
* Entity owner
*/
owner: string;
/**
* Entity path
*/
path: string;
}
@@ -0,0 +1,60 @@
/*
* 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 os from 'os';
import path from 'path';
import fs from 'fs-extra';
import { IStorageFilesMock } from './types';
const rootDir: string = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
const encoding = 'utf8';
export class StorageFilesMock implements IStorageFilesMock {
static rootDir = rootDir;
private files: Record<string, string>;
constructor() {
this.files = {};
}
public emptyFiles(): void {
this.files = {};
}
public fileExists(targetPath: string): boolean {
const filePath = path.join(rootDir, targetPath);
const posixPath = filePath.split(path.posix.sep).join(path.sep);
return this.files[posixPath] !== undefined;
}
public readFile(targetPath: string): Buffer {
const filePath = path.join(rootDir, targetPath);
return Buffer.from(this.files[filePath] ?? '', encoding);
}
public writeFile(targetPath: string, sourcePath: string): void;
public writeFile(targetPath: string, sourceBuffer: Buffer): void;
public writeFile(targetPath: string, source: string | Buffer): void {
const filePath = path.join(rootDir, targetPath);
if (typeof source === 'string') {
this.files[filePath] = fs.readFileSync(source).toString(encoding);
} else {
this.files[filePath] = source.toString(encoding);
}
}
}
@@ -0,0 +1,24 @@
/*
* 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.
*/
export interface IStorageFilesMock {
emptyFiles(): void;
fileExists(targetPath: string): boolean;
readFile(targetPath: string): Buffer;
writeFile(targetPath: string, sourcePath: string): void;
writeFile(targetPath: string, sourceBuffer: Buffer): void;
writeFile(targetPath: string, source: string | Buffer): void;
}