Merge pull request #6335 from backstage/iameap/techdocs-cache
[TechDocs] Optional static resource caching
This commit is contained in:
@@ -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';
|
||||
|
||||
Vendored
+26
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user