Merge pull request #6335 from backstage/iameap/techdocs-cache

[TechDocs] Optional static resource caching
This commit is contained in:
Otto Sichert
2021-12-06 10:38:30 +01:00
committed by GitHub
33 changed files with 1232 additions and 138 deletions
@@ -0,0 +1,7 @@
---
'@backstage/techdocs-common': minor
'@backstage/plugin-techdocs-backend': minor
---
Added the ability for the TechDocs Backend to (optionally) leverage a cache
store to improve performance when reading files from a cloud storage provider.
+42
View File
@@ -0,0 +1,42 @@
---
'@backstage/create-app': patch
---
TechDocs Backend may now (optionally) leverage a cache store to improve
performance when reading content from a cloud storage provider.
To apply this change to an existing app, pass the cache manager from the plugin
environment to the `createRouter` function in your backend:
```diff
// packages/backend/src/plugins/techdocs.ts
export default async function createPlugin({
logger,
config,
discovery,
reader,
+ cache,
}: PluginEnvironment): Promise<Router> {
// ...
return await createRouter({
preparers,
generators,
publisher,
logger,
config,
discovery,
+ cache,
});
```
If your `PluginEnvironment` does not include a cache manager, be sure you've
applied [the cache management change][cm-change] to your backend as well.
[Additional configuration][td-rec-arch] is required if you wish to enable
caching in TechDocs.
[cm-change]: https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md#patch-changes-6
[td-rec-arch]: https://backstage.io/docs/features/techdocs/architecture#recommended-deployment
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 64 KiB

+7 -7
View File
@@ -40,7 +40,7 @@ storage system (e.g. AWS S3, GCS or Azure Blob Storage). Read more in
## Recommended deployment
This is how we recommend deploying TechDocs in production environment.
This is how we recommend deploying TechDocs in a production environment.
<img data-zoomable src="../../assets/techdocs/architecture-recommended.drawio.svg" alt="TechDocs Architecture diagram" />
@@ -58,12 +58,12 @@ Similar to how it is done in the Basic setup, the TechDocs Reader requests
your configured storage solution for the necessary files and returns them to
TechDocs Reader.
Note about caching: We have noticed internally that some storage providers can
be quite slow, which is why we are recommending a cache that sits between the
TechDocs Reader and the Storage.
_Feel free to suggest better ideas to us in #docs-like-code channel in Discord
or via a GitHub issue._
Depending on your chosen cloud storage provider and its real-world proximity to
your backend server, there may be a comparably high amount of latency when
loading TechDocs sites using this deployment approach. If you encounter this,
you can optionally configure the `techdocs-backend` to cache responses in a
cache store
[supported by Backstage](../../overview/architecture-overview.md#cache).
### Security consideration
+16
View File
@@ -135,6 +135,22 @@ techdocs:
# the old, case-sensitive entity triplet behavior.
legacyUseCaseSensitiveTripletPaths: false
# techdocs.cache is optional, and is only recommended when you've configured
# an external techdocs.publisher.type above. Also requires backend.cache to
# be configured with a valid cache store.
cache:
# Represents the number of milliseconds a statically built asset should
# stay cached. Cache invalidation is handled automatically by the frontend,
# which compares the build times in cached metadata vs. canonical storage,
# allowing long TTLs (e.g. 1 month/year)
ttl: 3600000
# (Optional) The time (in milliseconds) that the TechDocs backend will wait
# for a cache service to respond before continuing on as though the cached
# object was not found (e.g. when the cache sercice is unavailable). The
# default value is 1000
readTimeout: 500
# (Optional and Legacy) TechDocs makes API calls to techdocs-backend using this URL. e.g. get docs of an entity, get metadata, etc.
# You don't have to specify this anymore.
+2
View File
@@ -29,6 +29,7 @@ export default async function createPlugin({
config,
discovery,
reader,
cache,
}: PluginEnvironment): Promise<Router> {
// Preparers are responsible for fetching source files for documentation.
const preparers = await Preparers.fromConfig(config, {
@@ -64,5 +65,6 @@ export default async function createPlugin({
logger,
config,
discovery,
cache,
});
}
@@ -14,6 +14,7 @@ export default async function createPlugin({
config,
discovery,
reader,
cache,
}: PluginEnvironment): Promise<Router> {
// Preparers are responsible for fetching source files for documentation.
const preparers = await Preparers.fromConfig(config, {
@@ -49,5 +50,6 @@ export default async function createPlugin({
logger,
config,
discovery,
cache,
});
}
+2
View File
@@ -287,6 +287,8 @@ export type TechDocsMetadata = {
site_name: string;
site_description: string;
etag: string;
build_timestamp: number;
files?: string[];
};
// Warning: (ae-missing-release-tag) "transformDirLocation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -23,7 +23,7 @@ import os from 'os';
import path, { resolve as resolvePath } from 'path';
import { ParsedLocationAnnotation } from '../../helpers';
import {
addBuildTimestampMetadata,
createOrUpdateMetadata,
getGeneratorKey,
getMkdocsYml,
getRepoUrlFromLocationAnnotation,
@@ -369,13 +369,15 @@ describe('helpers', () => {
});
describe('addBuildTimestampMetadata', () => {
const mockFiles = {
'invalid_techdocs_metadata.json': 'dsds',
'techdocs_metadata.json': '{"site_name": "Tech Docs"}',
};
beforeEach(() => {
mockFs.restore();
mockFs({
[rootDir]: {
'invalid_techdocs_metadata.json': 'dsds',
'techdocs_metadata.json': '{"site_name": "Tech Docs"}',
},
[rootDir]: mockFiles,
});
});
@@ -385,7 +387,7 @@ describe('helpers', () => {
it('should create the file if it does not exist', async () => {
const filePath = path.join(rootDir, 'wrong_techdocs_metadata.json');
await addBuildTimestampMetadata(filePath, mockLogger);
await createOrUpdateMetadata(filePath, mockLogger);
// Check if the file exists
await expect(
@@ -397,18 +399,28 @@ describe('helpers', () => {
const filePath = path.join(rootDir, 'invalid_techdocs_metadata.json');
await expect(
addBuildTimestampMetadata(filePath, mockLogger),
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 addBuildTimestampMetadata(filePath, mockLogger);
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', () => {
@@ -27,6 +27,7 @@ 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 {
@@ -345,14 +346,20 @@ export const patchIndexPreBuild = async ({
};
/**
* Update the techdocs_metadata.json to add a new build timestamp metadata. Create the .json file if it doesn't exist.
* Create or update the techdocs_metadata.json. Values initialized/updated are:
* - The build_timestamp (now)
* - The list of files generated
*
* @param {string} techdocsMetadataPath File path to techdocs_metadata.json
*/
export const addBuildTimestampMetadata = async (
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);
@@ -372,6 +379,19 @@ export const addBuildTimestampMetadata = async (
}
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}/`, ''),
);
} catch (err) {
assertError(err);
json.files = [];
logger.warn(`Unable to add files list to metadata: ${err.message}`);
}
await fs.writeJson(techdocsMetadataPath, json);
return;
};
@@ -23,7 +23,7 @@ import {
ScmIntegrations,
} from '@backstage/integration';
import {
addBuildTimestampMetadata,
createOrUpdateMetadata,
getMkdocsYml,
patchIndexPreBuild,
patchMkdocsYmlPreBuild,
@@ -164,9 +164,9 @@ export class TechdocsGenerator implements GeneratorBase {
* Post Generate steps
*/
// Add build timestamp to techdocs_metadata.json
// Add build timestamp and files to techdocs_metadata.json
// Creates techdocs_metadata.json if file does not exist.
await addBuildTimestampMetadata(
await createOrUpdateMetadata(
path.join(outputDir, 'techdocs_metadata.json'),
childLogger,
);
@@ -95,6 +95,7 @@ describe('AwsS3Publish', () => {
site_name: 'backstage',
site_description: 'site_content',
etag: 'etag',
build_timestamp: 612741599,
};
const directory = getEntityRootDir(entity);
@@ -149,21 +150,39 @@ describe('AwsS3Publish', () => {
describe('publish', () => {
it('should publish a directory', async () => {
const publisher = createPublisherFromConfig();
expect(await publisher.publish({ entity, directory })).toBeUndefined();
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 })).toBeUndefined();
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 })).toBeUndefined();
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 () => {
@@ -171,14 +190,26 @@ describe('AwsS3Publish', () => {
bucketRootPath: 'backstage-data/techdocs',
legacyUseCaseSensitiveTripletPaths: true,
});
expect(await publisher.publish({ entity, directory })).toBeUndefined();
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 })).toBeUndefined();
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 () => {
@@ -39,6 +39,7 @@ import {
import {
PublisherBase,
PublishRequest,
PublishResponse,
ReadinessResponse,
TechDocsMetadata,
} from './types';
@@ -214,7 +215,11 @@ export class AwsS3Publish implements PublisherBase {
* 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<void> {
async publish({
entity,
directory,
}: PublishRequest): Promise<PublishResponse> {
const objects: string[] = [];
const useLegacyPathCasing = this.legacyPathCasing;
const bucketRootPath = this.bucketRootPath;
const sse = this.sse;
@@ -263,6 +268,7 @@ export class AwsS3Publish implements PublisherBase {
...(sse && { ServerSideEncryption: sse }),
} as aws.S3.PutObjectRequest;
objects.push(params.Key);
return this.storageClient.upload(params).promise();
},
absoluteFilesToUpload,
@@ -311,6 +317,7 @@ export class AwsS3Publish implements PublisherBase {
const errorMessage = `Unable to delete file(s) from AWS S3. ${error}`;
this.logger.error(errorMessage);
}
return { objects };
}
async fetchTechDocsMetadata(
@@ -89,6 +89,7 @@ describe('AzureBlobStoragePublish', () => {
site_name: 'backstage',
site_description: 'site_content',
etag: 'etag',
build_timestamp: 612741599,
};
const directory = getEntityRootDir(entity);
@@ -154,14 +155,26 @@ describe('AzureBlobStoragePublish', () => {
describe('publish', () => {
it('should publish a directory', async () => {
const publisher = createPublisherFromConfig();
expect(await publisher.publish({ entity, directory })).toBeUndefined();
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 })).toBeUndefined();
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 () => {
@@ -39,6 +39,7 @@ import {
import {
PublisherBase,
PublishRequest,
PublishResponse,
ReadinessResponse,
TechDocsMetadata,
} from './types';
@@ -156,7 +157,11 @@ export class AzureBlobStoragePublish implements PublisherBase {
* 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<void> {
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
@@ -194,14 +199,14 @@ export class AzureBlobStoragePublish implements PublisherBase {
const relativeFilePath = path.normalize(
path.relative(directory, absoluteFilePath),
);
const remotePath = getCloudPathForLocalPath(
entity,
relativeFilePath,
useLegacyPathCasing,
);
objects.push(remotePath);
const response = await container
.getBlockBlobClient(
getCloudPathForLocalPath(
entity,
relativeFilePath,
useLegacyPathCasing,
),
)
.getBlockBlobClient(remotePath)
.uploadFile(absoluteFilePath);
if (response._response.status >= 400) {
@@ -264,6 +269,8 @@ export class AzureBlobStoragePublish implements PublisherBase {
const errorMessage = `Unable to delete file(s) from Azure. ${error}`;
this.logger.error(errorMessage);
}
return { objects };
}
private download(containerName: string, blobPath: string): Promise<Buffer> {
@@ -88,6 +88,7 @@ describe('GoogleGCSPublish', () => {
site_name: 'backstage',
site_description: 'site_content',
etag: 'etag',
build_timestamp: 612741599,
};
const directory = getEntityRootDir(entity);
@@ -142,21 +143,39 @@ describe('GoogleGCSPublish', () => {
describe('publish', () => {
it('should publish a directory', async () => {
const publisher = createPublisherFromConfig();
expect(await publisher.publish({ entity, directory })).toBeUndefined();
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 })).toBeUndefined();
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 })).toBeUndefined();
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 () => {
@@ -164,7 +183,13 @@ describe('GoogleGCSPublish', () => {
bucketRootPath: 'backstage-data/techdocs',
legacyUseCaseSensitiveTripletPaths: true,
});
expect(await publisher.publish({ entity, directory })).toBeUndefined();
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 () => {
@@ -36,6 +36,7 @@ import { MigrateWriteStream } from './migrations';
import {
PublisherBase,
PublishRequest,
PublishResponse,
ReadinessResponse,
TechDocsMetadata,
} from './types';
@@ -145,7 +146,11 @@ export class GoogleGCSPublish implements PublisherBase {
* 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<void> {
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;
@@ -178,14 +183,14 @@ export class GoogleGCSPublish implements PublisherBase {
await bulkStorageOperation(
async absoluteFilePath => {
const relativeFilePath = path.relative(directory, absoluteFilePath);
return await bucket.upload(absoluteFilePath, {
destination: getCloudPathForLocalPath(
entity,
relativeFilePath,
useLegacyPathCasing,
bucketRootPath,
),
});
const destination = getCloudPathForLocalPath(
entity,
relativeFilePath,
useLegacyPathCasing,
bucketRootPath,
);
objects.push(destination);
return await bucket.upload(absoluteFilePath, { destination });
},
absoluteFilesToUpload,
{ concurrencyLimit: 10 },
@@ -228,6 +233,8 @@ export class GoogleGCSPublish implements PublisherBase {
const errorMessage = `Unable to delete file(s) from Google Cloud Storage. ${error}`;
this.logger.error(errorMessage);
}
return { objects };
}
fetchTechDocsMetadata(entityName: EntityName): Promise<TechDocsMetadata> {
@@ -98,7 +98,10 @@ export class LocalPublish implements PublisherBase {
};
}
publish({ entity, directory }: PublishRequest): Promise<PublishResponse> {
async publish({
entity,
directory,
}: PublishRequest): Promise<PublishResponse> {
const entityNamespace = entity.metadata.namespace ?? 'default';
const publishDir = this.staticEntityPathJoin(
@@ -112,27 +115,30 @@ export class LocalPublish implements PublisherBase {
fs.mkdirSync(publishDir, { recursive: true });
}
return new Promise((resolve, reject) => {
fs.copy(directory, publishDir, err => {
if (err) {
this.logger.debug(
`Failed to copy docs from ${directory} to ${publishDir}`,
);
reject(err);
}
this.logger.info(`Published site stored at ${publishDir}`);
this.discovery
.getBaseUrl('techdocs')
.then(techdocsApiUrl => {
resolve({
remoteUrl: `${techdocsApiUrl}/static/docs/${entity.metadata.name}`,
});
})
.catch(reason => {
reject(reason);
});
});
});
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(
@@ -170,7 +170,13 @@ describe('OpenStackSwiftPublish', () => {
entity,
directory: entityRootDir,
}),
).toBeUndefined();
).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 () => {
@@ -241,7 +247,7 @@ describe('OpenStackSwiftPublish', () => {
mockFs({
[entityRootDir]: {
'techdocs_metadata.json':
'{"site_name": "backstage", "site_description": "site_content", "etag": "etag"}',
'{"site_name": "backstage", "site_description": "site_content", "etag": "etag", "build_timestamp": 612741599}',
},
});
@@ -249,6 +255,7 @@ describe('OpenStackSwiftPublish', () => {
site_name: 'backstage',
site_description: 'site_content',
etag: 'etag',
build_timestamp: 612741599,
};
expect(
await publisher.fetchTechDocsMetadata(entityNameMock),
@@ -263,7 +270,7 @@ describe('OpenStackSwiftPublish', () => {
mockFs({
[entityRootDir]: {
'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag'}`,
'techdocs_metadata.json': `{'site_name': 'backstage', 'site_description': 'site_content', 'etag': 'etag', 'build_timestamp': 612741599}`,
},
});
@@ -271,6 +278,7 @@ describe('OpenStackSwiftPublish', () => {
site_name: 'backstage',
site_description: 'site_content',
etag: 'etag',
build_timestamp: 612741599,
};
expect(
await publisher.fetchTechDocsMetadata(entityNameMock),
@@ -32,6 +32,7 @@ import {
import {
PublisherBase,
PublishRequest,
PublishResponse,
ReadinessResponse,
TechDocsMetadata,
} from './types';
@@ -139,8 +140,13 @@ export class OpenStackSwiftPublish implements PublisherBase {
* 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<void> {
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);
@@ -161,6 +167,7 @@ export class OpenStackSwiftPublish implements PublisherBase {
// 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 () => {
@@ -178,7 +185,7 @@ export class OpenStackSwiftPublish implements PublisherBase {
this.logger.info(
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`,
);
return;
return { objects };
} catch (e) {
const errorMessage = `Unable to upload file(s) to OpenStack Swift. ${e}`;
this.logger.error(errorMessage);
@@ -32,9 +32,21 @@ export type PublishRequest = {
directory: string;
};
/* `remoteUrl` is the URL which serves files from the local publisher's static directory. */
/**
* Response containing metadata about where files were published and what may
* have been published or updated.
*/
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;
/**
@@ -53,6 +65,8 @@ export type TechDocsMetadata = {
site_name: string;
site_description: string;
etag: string;
build_timestamp: number;
files?: string[];
};
export type MigrateRequest = {
+1
View File
@@ -10,6 +10,7 @@ import express from 'express';
import { GeneratorBuilder } from '@backstage/techdocs-common';
import { Knex } from 'knex';
import { Logger as Logger_2 } from 'winston';
import { PluginCacheManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PreparerBuilder } from '@backstage/techdocs-common';
import { PublisherBase } from '@backstage/techdocs-common';
+26
View File
@@ -226,6 +226,32 @@ export interface Config {
};
};
/**
* @example http://localhost:7007/api/techdocs
* Techdocs cache information
*/
cache?: {
/**
* The cache time-to-live for TechDocs sites (in milliseconds). Set this
* to a non-zero value to cache TechDocs sites and assets as they are
* read from storage.
*
* Note: you must also configure `backend.cache` appropriately as well,
* and to pass a PluginCacheManager instance to TechDocs Backend's
* createRouter method in your backend.
*/
ttl: number;
/**
* The time (in milliseconds) that the TechDocs backend will wait for
* a cache service to respond before continuing on as though the cached
* object was not found (e.g. when the cache sercice is unavailable).
*
* Defaults to 1000 milliseconds.
*/
readTimeout?: number;
};
/**
* @example http://localhost:7007/api/techdocs
* @visibility frontend
+1
View File
@@ -40,6 +40,7 @@
"@backstage/search-common": "^0.2.1",
"@backstage/techdocs-common": "^0.10.8",
"@types/express": "^4.17.6",
"cross-fetch": "^3.0.6",
"dockerode": "^3.3.1",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
@@ -36,6 +36,7 @@ import path from 'path';
import { Writable } from 'stream';
import { Logger } from 'winston';
import { BuildMetadataStorage } from './BuildMetadataStorage';
import { TechDocsCache } from '../cache';
type DocsBuilderArguments = {
preparers: PreparerBuilder;
@@ -46,6 +47,7 @@ type DocsBuilderArguments = {
config: Config;
scmIntegrations: ScmIntegrationRegistry;
logStream?: Writable;
cache?: TechDocsCache;
};
export class DocsBuilder {
@@ -57,6 +59,7 @@ export class DocsBuilder {
private config: Config;
private scmIntegrations: ScmIntegrationRegistry;
private logStream: Writable | undefined;
private cache?: TechDocsCache;
constructor({
preparers,
@@ -67,6 +70,7 @@ export class DocsBuilder {
config,
scmIntegrations,
logStream,
cache,
}: DocsBuilderArguments) {
this.preparer = preparers.get(entity);
this.generator = generators.get(entity);
@@ -76,6 +80,7 @@ export class DocsBuilder {
this.config = config;
this.scmIntegrations = scmIntegrations;
this.logStream = logStream;
this.cache = cache;
}
/**
@@ -210,11 +215,19 @@ export class DocsBuilder {
)}`,
);
await this.publisher.publish({
const published = await this.publisher.publish({
entity: this.entity,
directory: outputDir,
});
// Invalidate the cache for any published objects.
if (this.cache && published && published?.objects?.length) {
this.logger.debug(
`Invalidating ${published.objects.length} cache objects`,
);
await this.cache.invalidateMultiple(published.objects);
}
try {
// Not a blocker hence no need to await this.
fs.remove(outputDir);
+168
View File
@@ -0,0 +1,168 @@
/*
* 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 { CacheClient, getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { CacheInvalidationError, TechDocsCache } from './TechDocsCache';
const cached = (str: string): string => {
return Buffer.from(str).toString('base64');
};
describe('TechDocsCache', () => {
let CacheUnderTest: TechDocsCache;
let MockClient: jest.Mocked<CacheClient>;
beforeEach(() => {
MockClient = {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
};
CacheUnderTest = TechDocsCache.fromConfig(new ConfigReader({}), {
cache: MockClient,
logger: getVoidLogger(),
});
});
describe('get', () => {
it('returns undefined if no response', async () => {
const expectedPath = 'some/index.html';
MockClient.get.mockResolvedValueOnce(undefined);
const actual = await CacheUnderTest.get(expectedPath);
expect(MockClient.get).toHaveBeenCalledWith(expectedPath);
expect(actual).toBe(undefined);
});
it('returns undefined if cache get throws', async () => {
const expectedPath = 'some/index.html';
MockClient.get.mockRejectedValueOnce(new Error());
const actual = await CacheUnderTest.get(expectedPath);
expect(actual).toBe(undefined);
});
it('returns undefined if no response after 1s by default', async () => {
const expectedPath = 'some/index.html';
MockClient.get.mockImplementationOnce(() => {
return new Promise(resolve => {
setTimeout(() => resolve(cached('value')), 1500);
});
});
const actual = await CacheUnderTest.get(expectedPath);
expect(actual).toBe(undefined);
});
it('returns undefined if no response after configured readTimeout', async () => {
const expectedPath = 'some/index.html';
MockClient.get.mockImplementationOnce(() => {
return new Promise(resolve => {
setTimeout(() => resolve(cached('value')), 20);
});
});
CacheUnderTest = TechDocsCache.fromConfig(
new ConfigReader({
techdocs: { cache: { readTimeout: 10 } },
}),
{
cache: MockClient,
logger: getVoidLogger(),
},
);
const actual = await CacheUnderTest.get(expectedPath);
expect(actual).toBe(undefined);
});
it('returns data if cache get returns it', async () => {
const expectedPath = 'some/index.html';
MockClient.get.mockResolvedValueOnce(cached('expected value'));
const actual = await CacheUnderTest.get(expectedPath);
expect(actual?.toString()).toBe('expected value');
});
});
describe('set', () => {
it('sets a base64-encoded string', async () => {
const expectedPath = 'some/index.html';
MockClient.set.mockResolvedValueOnce(undefined);
await CacheUnderTest.set(expectedPath, Buffer.from('some data'));
expect(MockClient.set).toHaveBeenCalledWith(
expectedPath,
cached('some data'),
);
});
it('does not throw if client throws', () => {
MockClient.set.mockRejectedValueOnce(new Error());
expect(() => CacheUnderTest.set('i.html', Buffer.from(''))).not.toThrow();
});
});
describe('invalidate', () => {
it('calls delete on client', async () => {
const expectedPath = 'some/index.html';
MockClient.delete.mockResolvedValueOnce(undefined);
await CacheUnderTest.invalidate(expectedPath);
expect(MockClient.delete).toHaveBeenCalledWith(expectedPath);
});
});
describe('invalidateMultiple', () => {
it('calls delete once per given path', async () => {
const expectedPaths = ['one/index.html', 'two/index.html'];
MockClient.delete.mockResolvedValue(undefined);
await CacheUnderTest.invalidateMultiple(expectedPaths);
expect(MockClient.delete).toHaveBeenNthCalledWith(1, expectedPaths[0]);
expect(MockClient.delete).toHaveBeenNthCalledWith(2, expectedPaths[1]);
});
it('returns an array of as many paths provided', async () => {
const expectedPaths = ['one/index.html', 'two/index.html'];
MockClient.delete.mockResolvedValue(undefined);
const actual = await CacheUnderTest.invalidateMultiple(expectedPaths);
expect(actual.length).toBe(2);
});
it('calls delete on all paths even if the first rejects', async () => {
const expectedPaths = ['one/index.html', 'two/index.html'];
MockClient.delete.mockRejectedValueOnce(new Error());
MockClient.delete.mockResolvedValueOnce(undefined);
await expect(
CacheUnderTest.invalidateMultiple(expectedPaths),
).rejects.toThrowError(CacheInvalidationError);
expect(MockClient.delete).toHaveBeenCalledTimes(2);
});
it('rejects with invalidations error response', async () => {
const expectedPaths = ['one/index.html', 'two/index.html'];
MockClient.delete.mockResolvedValueOnce(undefined);
MockClient.delete.mockRejectedValueOnce(new Error());
await expect(
CacheUnderTest.invalidateMultiple.bind(CacheUnderTest, expectedPaths),
).rejects.toThrow(CacheInvalidationError);
});
});
});
+105
View File
@@ -0,0 +1,105 @@
/*
* 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 { CacheClient } from '@backstage/backend-common';
import { assertError, CustomErrorBase } from '@backstage/errors';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
export class CacheInvalidationError extends CustomErrorBase {}
export class TechDocsCache {
protected readonly cache: CacheClient;
protected readonly logger: Logger;
protected readonly readTimeout: number;
private constructor({
cache,
logger,
readTimeout,
}: {
cache: CacheClient;
logger: Logger;
readTimeout: number;
}) {
this.cache = cache;
this.logger = logger;
this.readTimeout = readTimeout;
}
static fromConfig(
config: Config,
{ cache, logger }: { cache: CacheClient; logger: Logger },
) {
const timeout = config.getOptionalNumber('techdocs.cache.readTimeout');
const readTimeout = timeout === undefined ? 1000 : timeout;
return new TechDocsCache({ cache, logger, readTimeout });
}
async get(path: string): Promise<Buffer | undefined> {
try {
// Promise.race ensures we don't hang the client for long if the cache is
// temporarily unreachable.
const response = (await Promise.race([
this.cache.get(path),
new Promise(cancelAfter => setTimeout(cancelAfter, this.readTimeout)),
])) as string | undefined;
if (response !== undefined) {
this.logger.debug(`Cache hit: ${path}`);
return Buffer.from(response, 'base64');
}
this.logger.debug(`Cache miss: ${path}`);
return response;
} catch (e) {
assertError(e);
this.logger.warn(`Error getting cache entry ${path}: ${e.message}`);
this.logger.debug(e.stack);
return undefined;
}
}
async set(path: string, data: Buffer): Promise<void> {
this.logger.debug(`Writing cache entry for ${path}`);
this.cache
.set(path, data.toString('base64'))
.catch(e => this.logger.error('write error', e));
}
async invalidate(path: string): Promise<void> {
return this.cache.delete(path);
}
async invalidateMultiple(
paths: string[],
): Promise<PromiseSettledResult<void>[]> {
const settled = await Promise.allSettled(
paths.map(path => this.cache.delete(path)),
);
const rejected = settled.filter(
s => s.status === 'rejected',
) as PromiseRejectedResult[];
if (rejected.length) {
throw new CacheInvalidationError(
'TechDocs cache invalidation error',
rejected,
);
}
return settled;
}
}
@@ -0,0 +1,109 @@
/*
* 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 { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import request from 'supertest';
import { createCacheMiddleware, TechDocsCache } from '.';
/**
* Mocks cached HTTP response.
*/
const getMockHttpResponseFor = (content: string): Buffer => {
return Buffer.concat([
Buffer.from(`HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Accept-Ranges: bytes
Cache-Control: public, max-age=0
Last-Modified: Sat, 1 Jul 2021 12:00:00 GMT
Date: Sat, 1 Jul 2021 12:00:00 GMT
Connection: close
Content-Length: ${content.length}\n\n`),
Buffer.from(content),
]);
};
/**
* Wait for the socket to close. Works because, above, we set connection: close
*/
const waitForSocketClose = () => new Promise(resolve => setTimeout(resolve, 0));
describe('createCacheMiddleware', () => {
let cache: jest.Mocked<TechDocsCache>;
let app: express.Express;
beforeEach(async () => {
cache = {
get: jest.fn().mockResolvedValue(undefined),
set: jest.fn().mockResolvedValue(undefined),
invalidate: jest.fn().mockResolvedValue(undefined),
invalidateMultiple: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<TechDocsCache>;
const router = await createCacheMiddleware({
logger: getVoidLogger(),
cache,
});
app = express().use(router);
app.use((req, res, next) => {
// By default, send cacheable content.
if (req.path !== '/static/docs/error.png') {
res.send('default-response');
} else {
next(new Error());
}
});
});
describe('middleware', () => {
it('does not apply to non-static/docs paths', async () => {
await request(app)
.get('/static/not-docs')
.expect(200, 'default-response');
expect(cache.set).not.toHaveBeenCalled();
});
it('responds with cached response', async () => {
cache.get.mockResolvedValueOnce(getMockHttpResponseFor('xyz'));
await request(app).get('/static/docs/foo.html').expect(200, 'xyz');
await waitForSocketClose();
expect(cache.set).not.toHaveBeenCalled();
});
it('sets cache when content is cacheable', async () => {
const expectedPath = 'default/api/xyz/index.html';
await request(app)
.get(`/static/docs/${expectedPath}`)
.expect(200, 'default-response');
await waitForSocketClose();
expect(cache.set).toHaveBeenCalled();
const [actualPath, actualBuffer] = (cache.set as jest.Mock).mock.calls[0];
expect(actualPath).toBe(expectedPath);
expect(actualBuffer.toString()).toContain('default-response');
});
it('does not set cache on error', async () => {
await request(app).get('/static/docs/error.png').expect(500);
await waitForSocketClose();
expect(cache.set).not.toHaveBeenCalled();
});
});
});
+94
View File
@@ -0,0 +1,94 @@
/*
* 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 { Router } from 'express';
import router from 'express-promise-router';
import { Logger } from 'winston';
import { TechDocsCache } from './TechDocsCache';
type CacheMiddlewareOptions = {
cache: TechDocsCache;
logger: Logger;
};
type ErrorCallback = (err?: Error) => void;
export const createCacheMiddleware = ({
cache,
}: CacheMiddlewareOptions): Router => {
const cacheMiddleware = router();
// Middleware that, through socket monkey patching, captures responses as
// they're sent over /static/docs/* and caches them. Subsequent requests are
// loaded from cache. Cache key is the object's path (after `/static/docs/`).
cacheMiddleware.use(async (req, res, next) => {
const socket = res.socket;
const isCacheable = req.path.startsWith('/static/docs/');
// Continue early if this is non-cacheable, or there's no socket.
if (!isCacheable || !socket) {
next();
return;
}
// Make concrete references to these things.
const reqPath = decodeURI(req.path.match(/\/static\/docs\/(.*)$/)![1]);
const realEnd = socket.end.bind(socket);
const realWrite = socket.write.bind(socket);
let writeToCache = true;
const chunks: Buffer[] = [];
// Monkey-patch the response's socket to keep track of chunks as they are
// written over the wire.
socket.write = (
data: string | Uint8Array,
encoding?: BufferEncoding | ErrorCallback,
callback?: ErrorCallback,
) => {
chunks.push(Buffer.from(data));
if (typeof encoding === 'function') {
return realWrite(data, encoding);
}
return realWrite(data, encoding, callback);
};
// When a socket is closed, if there were no errors and the data written
// over the socket should be cached, cache it!
socket.on('close', async hadError => {
const content = Buffer.concat(chunks);
const head = content.toString('utf8', 0, 12);
if (writeToCache && !hadError && head.match(/HTTP\/\d\.\d 200/)) {
await cache.set(reqPath, content);
}
});
// Attempt to retrieve data from the cache.
const cached = await cache.get(reqPath);
// If there is a cache hit, write it out on the socket, ensure we don't re-
// cache the data, and prevent going back to canonical storage by never
// calling next().
if (cached) {
writeToCache = false;
realEnd(cached);
return;
}
// No data retrieved from cache: allow retrieval from canonical storage.
next();
});
return cacheMiddleware;
};
+17
View File
@@ -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 { createCacheMiddleware } from './cacheMiddleware';
export { TechDocsCache } from './TechDocsCache';
@@ -25,11 +25,25 @@ import {
PreparerBuilder,
PublisherBase,
} from '@backstage/techdocs-common';
import { TechDocsCache } from '../cache';
import { DocsBuilder, shouldCheckForUpdate } from '../DocsBuilder';
import { DocsSynchronizer, DocsSynchronizerSyncOpts } from './DocsSynchronizer';
jest.mock('../DocsBuilder');
jest.mock('cross-fetch', () => ({
__esModule: true,
default: async () => {
return {
json: async () => {
return {
build_timestamp: 123,
};
},
};
},
}));
const MockedDocsBuilder = DocsBuilder as jest.MockedClass<typeof DocsBuilder>;
describe('DocsSynchronizer', () => {
@@ -52,6 +66,12 @@ describe('DocsSynchronizer', () => {
getBaseUrl: jest.fn(),
getExternalBaseUrl: jest.fn(),
};
const cache: jest.Mocked<TechDocsCache> = {
get: jest.fn(),
set: jest.fn(),
invalidate: jest.fn(),
invalidateMultiple: jest.fn(),
} as unknown as jest.Mocked<TechDocsCache>;
let docsSynchronizer: DocsSynchronizer;
const mockResponseHandler: jest.Mocked<DocsSynchronizerSyncOpts> = {
@@ -71,6 +91,7 @@ describe('DocsSynchronizer', () => {
config: new ConfigReader({}),
logger: getVoidLogger(),
scmIntegrations: ScmIntegrations.fromConfig(new ConfigReader({})),
cache,
});
});
@@ -193,4 +214,112 @@ describe('DocsSynchronizer', () => {
expect(mockResponseHandler.error).toBeCalledWith(error);
});
});
describe('doCacheSync', () => {
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
uid: '0',
name: 'test',
namespace: 'default',
},
};
it('should not check metadata too often', async () => {
(shouldCheckForUpdate as jest.Mock).mockReturnValue(false);
await docsSynchronizer.doCacheSync({
responseHandler: mockResponseHandler,
discovery,
token: undefined,
entity,
});
expect(mockResponseHandler.finish).toBeCalledWith({ updated: false });
expect(shouldCheckForUpdate).toBeCalledTimes(1);
});
it('should do nothing if source/cached metadata matches', async () => {
(shouldCheckForUpdate as jest.Mock).mockReturnValue(true);
(publisher.fetchTechDocsMetadata as jest.Mock).mockResolvedValue({
build_timestamp: 123,
});
await docsSynchronizer.doCacheSync({
responseHandler: mockResponseHandler,
discovery,
token: undefined,
entity,
});
expect(mockResponseHandler.finish).toBeCalledWith({ updated: false });
});
it('should invalidate expected files when source/cached metadata differ', async () => {
(shouldCheckForUpdate as jest.Mock).mockReturnValue(true);
(publisher.fetchTechDocsMetadata as jest.Mock).mockResolvedValue({
build_timestamp: 456,
files: ['index.html'],
});
await docsSynchronizer.doCacheSync({
responseHandler: mockResponseHandler,
discovery,
token: undefined,
entity,
});
expect(mockResponseHandler.finish).toBeCalledWith({ updated: true });
expect(cache.invalidateMultiple).toHaveBeenCalledWith([
'default/component/test/index.html',
]);
});
it('should invalidate expected files when source/cached metadata differ with legacy casing', async () => {
(shouldCheckForUpdate as jest.Mock).mockReturnValue(true);
(publisher.fetchTechDocsMetadata as jest.Mock).mockResolvedValue({
build_timestamp: 456,
files: ['index.html'],
});
const docsSynchronizerWithLegacy = new DocsSynchronizer({
publisher,
config: new ConfigReader({
techdocs: { legacyUseCaseSensitiveTripletPaths: true },
}),
logger: getVoidLogger(),
scmIntegrations: ScmIntegrations.fromConfig(new ConfigReader({})),
cache,
});
await docsSynchronizerWithLegacy.doCacheSync({
responseHandler: mockResponseHandler,
discovery,
token: undefined,
entity,
});
expect(mockResponseHandler.finish).toBeCalledWith({ updated: true });
expect(cache.invalidateMultiple).toHaveBeenCalledWith([
'default/Component/test/index.html',
]);
});
it('should gracefully handle errors', async () => {
(shouldCheckForUpdate as jest.Mock).mockReturnValue(true);
(publisher.fetchTechDocsMetadata as jest.Mock).mockRejectedValue(
new Error(),
);
await docsSynchronizer.doCacheSync({
responseHandler: mockResponseHandler,
discovery,
token: undefined,
entity,
});
expect(mockResponseHandler.finish).toBeCalledWith({ updated: false });
});
});
});
@@ -14,7 +14,8 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
import { assertError, NotFoundError } from '@backstage/errors';
import { ScmIntegrationRegistry } from '@backstage/integration';
@@ -23,9 +24,15 @@ import {
PreparerBuilder,
PublisherBase,
} from '@backstage/techdocs-common';
import fetch from 'cross-fetch';
import { PassThrough } from 'stream';
import * as winston from 'winston';
import { DocsBuilder, shouldCheckForUpdate } from '../DocsBuilder';
import { TechDocsCache } from '../cache';
import {
BuildMetadataStorage,
DocsBuilder,
shouldCheckForUpdate,
} from '../DocsBuilder';
export type DocsSynchronizerSyncOpts = {
log: (message: string) => void;
@@ -38,22 +45,26 @@ export class DocsSynchronizer {
private readonly logger: winston.Logger;
private readonly config: Config;
private readonly scmIntegrations: ScmIntegrationRegistry;
private readonly cache: TechDocsCache | undefined;
constructor({
publisher,
logger,
config,
scmIntegrations,
cache,
}: {
publisher: PublisherBase;
logger: winston.Logger;
config: Config;
scmIntegrations: ScmIntegrationRegistry;
cache: TechDocsCache | undefined;
}) {
this.config = config;
this.logger = logger;
this.publisher = publisher;
this.scmIntegrations = scmIntegrations;
this.cache = cache;
}
async doSync({
@@ -104,6 +115,7 @@ export class DocsSynchronizer {
config: this.config,
scmIntegrations: this.scmIntegrations,
logStream,
cache: this.cache,
});
const updated = await docsBuilder.build();
@@ -145,4 +157,76 @@ export class DocsSynchronizer {
finish({ updated: true });
}
async doCacheSync({
responseHandler: { finish },
discovery,
token,
entity,
}: {
responseHandler: DocsSynchronizerSyncOpts;
discovery: PluginEndpointDiscovery;
token: string | undefined;
entity: Entity;
}) {
// Check if the last update check was too recent.
if (!shouldCheckForUpdate(entity.metadata.uid!) || !this.cache) {
finish({ updated: false });
return;
}
// Fetch techdocs_metadata.json from the publisher and from cache.
const baseUrl = await discovery.getBaseUrl('techdocs');
const namespace = entity.metadata?.namespace || ENTITY_DEFAULT_NAMESPACE;
const kind = entity.kind;
const name = entity.metadata.name;
const legacyPathCasing =
this.config.getOptionalBoolean(
'techdocs.legacyUseCaseSensitiveTripletPaths',
) || false;
const tripletPath = `${namespace}/${kind}/${name}`;
const entityTripletPath = `${
legacyPathCasing ? tripletPath : tripletPath.toLocaleLowerCase('en-US')
}`;
try {
const [sourceMetadata, cachedMetadata] = await Promise.all([
this.publisher.fetchTechDocsMetadata({ namespace, kind, name }),
fetch(
`${baseUrl}/static/docs/${entityTripletPath}/techdocs_metadata.json`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
).then(
f =>
f.json().catch(() => undefined) as ReturnType<
PublisherBase['fetchTechDocsMetadata']
>,
),
]);
// If build timestamps differ, merge their files[] lists and invalidate all objects.
if (sourceMetadata.build_timestamp !== cachedMetadata.build_timestamp) {
const files = [
...new Set([
...(sourceMetadata.files || []),
...(cachedMetadata.files || []),
]),
].map(f => `${entityTripletPath}/${f}`);
await this.cache.invalidateMultiple(files);
finish({ updated: true });
} else {
finish({ updated: false });
}
} catch (e) {
assertError(e);
// In case of error, log and allow the user to go about their business.
this.logger.error(
`Error syncing cache for ${entityTripletPath}: ${e.message}`,
);
finish({ updated: false });
} finally {
// Update the last check time for the entity
new BuildMetadataStorage(entity.metadata.uid!).setLastUpdated();
}
}
}
+32 -1
View File
@@ -13,7 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import {
PluginEndpointDiscovery,
PluginCacheManager,
} from '@backstage/backend-common';
import { CatalogClient } from '@backstage/catalog-client';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
@@ -31,6 +34,7 @@ import { Knex } from 'knex';
import { Logger } from 'winston';
import { ScmIntegrations } from '@backstage/integration';
import { DocsSynchronizer, DocsSynchronizerSyncOpts } from './DocsSynchronizer';
import { createCacheMiddleware, TechDocsCache } from '../cache';
/**
* All of the required dependencies for running TechDocs in the "out-of-the-box"
@@ -44,6 +48,7 @@ type OutOfTheBoxDeploymentOptions = {
discovery: PluginEndpointDiscovery;
database?: Knex; // TODO: Make database required when we're implementing database stuff.
config: Config;
cache?: PluginCacheManager;
};
/**
@@ -80,12 +85,22 @@ export async function createRouter(
const router = Router();
const { publisher, config, logger, discovery } = options;
const catalogClient = new CatalogClient({ discoveryApi: discovery });
// Set up a cache client if configured.
let cache: TechDocsCache | undefined;
const defaultTtl = config.getOptionalNumber('techdocs.cache.ttl');
if (isOutOfTheBoxOption(options) && options.cache && defaultTtl) {
const cacheClient = options.cache.getClient({ defaultTtl });
cache = TechDocsCache.fromConfig(config, { cache: cacheClient, logger });
}
const scmIntegrations = ScmIntegrations.fromConfig(config);
const docsSynchronizer = new DocsSynchronizer({
publisher,
logger,
config,
scmIntegrations,
cache,
});
router.get('/metadata/techdocs/:namespace/:kind/:name', async (req, res) => {
@@ -175,6 +190,17 @@ export async function createRouter(
// If set to 'external', it will assume that an external process (e.g. CI/CD pipeline
// of the repository) is responsible for building and publishing documentation to the storage provider
if (config.getString('techdocs.builder') !== 'local') {
// However, if caching is enabled, take the opportunity to check and
// invalidate stale cache entries.
if (cache) {
await docsSynchronizer.doCacheSync({
responseHandler,
discovery,
token,
entity,
});
return;
}
responseHandler.finish({ updated: false });
return;
}
@@ -199,6 +225,11 @@ export async function createRouter(
);
});
// If a cache manager was provided, attach the cache middleware.
if (cache) {
router.use(createCacheMiddleware({ logger, cache }));
}
// Route middleware which serves files from the storage set in the publisher.
router.use('/static/docs', publisher.docsRouter());