Merge pull request #9567 from Niksko/docs-build-strategy

Add a new interface: DocsBuildStrategy
This commit is contained in:
Emma Indal
2022-03-02 15:42:37 +01:00
committed by GitHub
11 changed files with 260 additions and 24 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': patch
---
Added a new interface that allows for customization of when to build techdocs
+18
View File
@@ -46,6 +46,24 @@ between `techdocs-backend` and the storage)
[TechDocs Backend](https://github.com/backstage/backstage/tree/master/plugins/techdocs-backend)
## TechDocs Build Strategy
To accommodate more complex logic surrounding whether or not to build TechDocs, the TechDocs backend
supports selecting a Build Strategy.
The Build Strategy is responsible for deciding whether the documentation requested should be built locally
by the TechDocs backend or not.
Customization of the Build Strategy allows for more complex behaviour regarding whether the TechDocs backend
is responsible for building TechDocs, whether an external process is responsible, or whether a combination
of local builds and an external process is responsible, on an entity-by-entity basis.
The default Build Strategy results in the TechDocs backend building documentation locally if the
`techdocs.builder` configuration option is set to `'local'`, and skipping any building otherwise.
However any logic that satisfies the Build Strategy interface can be implemented, using the Backstage
config as well as the entity being processed to make a decision.
For an example of how the Build Strategy can be used to implement a 'hybrid' build model, refer to
the [How to implement a hybrid build strategy](./how-to-guides.md#how-to-implement-a-hybrid-build-strategy) guide.
## TechDocs Container
The TechDocs container is a Docker container available at
+10 -5
View File
@@ -38,11 +38,16 @@ techdocs:
pullImage: true
# techdocs.builder can be either 'local' or 'external.
# If builder is set to 'local' and you open a TechDocs page, techdocs-backend will try to generate the docs, publish to storage
# and show the generated docs afterwords. This is the "Basic" setup of the TechDocs Architecture.
# If builder is set to 'external', techdocs-backend will only fetch the docs and will NOT try to generate and publish. In this case of 'external',
# we assume that docs are being built by an external process (e.g. in the CI/CD pipeline of the repository). This is the "Recommended" setup of
# the architecture. Read more here https://backstage.io/docs/features/techdocs/architecture
# Using the default build strategy, if builder is set to 'local' and you open a TechDocs page,
# techdocs-backend will try to generate the docs, publish to storage and show the generated docs afterwords.
# This is the "Basic" setup of the TechDocs Architecture.
# Using the default build strategy, if builder is set to 'external' (or anything other than 'local'), techdocs-backend
# will only fetch the docs and will NOT try to generate and publish.
# In this case, we assume that docs are being built by an external process (e.g. in the CI/CD pipeline of the repository).
# This is the "Recommended" setup of the architecture.
# Note that custom build strategies may alter this behaviour.
# Read more about the "Basic" and "Recommended" setups here https://backstage.io/docs/features/techdocs/architecture
# Read more about build strategies here: https://backstage.io/docs/features/techdocs/concepts#techdocs-build-strategy
builder: 'local'
+55
View File
@@ -538,3 +538,58 @@ Done! Now you have a support of the following diagrams along with mermaid:
- `Vega`
- `Vega-Lite`
- `WaveDrom`
## How to implement a hybrid build strategy
One limitation of the [Recommended deployment](./architecture.md#recommended-deployment) is that
the experience for users requires modifying their CI/CD process to publish
their TechDocs. For some users, this may be unnecessary, and provides a barrier
to entry for onboarding users to Backstage. However, a purely local TechDocs
build restricts TechDocs creators to using the tooling provided in Backstage,
as well as the plugins and features provided in the Backstage-included `mkdocs`
installation.
To accommodate both of these use-cases, users can implement a custom [Build Strategy](./concepts.md#techdocs-build-strategy)
with logic to encode which TechDocs should be built locally, and which will be
built externally.
To achieve this hybrid build model:
1. In your Backstage instance's `app-config.yaml`, set `techdocs.builder` to
`'local'`. This ensures that Backstage will build docs for users who want the
'out-of-the-box' experience.
2. Configure external storage of TechDocs as normal for a production deployment.
This allows Backstage to publish documentation to your storage, as well as
allowing other users to publish documentation from their CI/CD pipelines.
3. Create a custom build strategy, that implements the `DocsBuildStrategy` interface,
and which implements your custom logic for determining whether to build docs for
a given entity.
For example, to only build docs when an entity has the `company.com/techdocs-builder`
annotation set to `'local'`:
```typescript
export class AnnotationBasedBuildStrategy {
private readonly config: Config;
constructor(config: Config) {
this.config = config;
}
async shouldBuild(_: Entity): Promise<boolean> {
return (
this.entity.metadata?.annotations?.['company.com/techdocs-builder'] ===
'local'
);
}
}
```
4. Pass an instance of this Build Strategy as the `docsBuildStrategy` parameter of the
TechDocs backend `createRouter` method.
Users should now be able to choose to have their documentation built and published by
the TechDocs backend by adding the `company.com/techdocs-builder` annotation to their
entity. If the value of this annotation is `'local'`, the TechDocs backend will build
and publish the documentation for them. If the value of the `company.com/techdocs-builder`
annotation is anything other than `'local'`, the user is responsible for publishing
documentation to the appropriate location in the TechDocs external storage.
+14
View File
@@ -6,6 +6,7 @@
import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { DocumentCollator } from '@backstage/search-common';
import { Entity } from '@backstage/catalog-model';
import express from 'express';
import { GeneratorBuilder } from '@backstage/techdocs-common';
import { Knex } from 'knex';
@@ -41,6 +42,12 @@ export class DefaultTechDocsCollator implements DocumentCollator {
readonly visibilityPermission: Permission;
}
// @public
export interface DocsBuildStrategy {
// (undocumented)
shouldBuild(params: ShouldBuildParameters): Promise<boolean>;
}
// @public
export type OutOfTheBoxDeploymentOptions = {
preparers: PreparerBuilder;
@@ -51,6 +58,7 @@ export type OutOfTheBoxDeploymentOptions = {
database?: Knex;
config: Config;
cache: PluginCacheManager;
docsBuildStrategy?: DocsBuildStrategy;
};
// @public
@@ -60,6 +68,7 @@ export type RecommendedDeploymentOptions = {
discovery: PluginEndpointDiscovery;
config: Config;
cache: PluginCacheManager;
docsBuildStrategy?: DocsBuildStrategy;
};
// @public
@@ -67,6 +76,11 @@ export type RouterOptions =
| RecommendedDeploymentOptions
| OutOfTheBoxDeploymentOptions;
// @public
export type ShouldBuildParameters = {
entity: Entity;
};
// @public
export type TechDocsCollatorOptions = {
discovery: PluginEndpointDiscovery;
+2
View File
@@ -25,6 +25,8 @@ export type {
RouterOptions,
RecommendedDeploymentOptions,
OutOfTheBoxDeploymentOptions,
DocsBuildStrategy,
ShouldBuildParameters,
} from './service';
export { DefaultTechDocsCollator } from './search';
@@ -0,0 +1,65 @@
/*
* 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 { DefaultDocsBuildStrategy } from './DocsBuildStrategy';
import { ConfigReader } from '@backstage/config';
const MockedConfigReader = ConfigReader as jest.MockedClass<
typeof ConfigReader
>;
jest.mock('@backstage/config');
describe('DefaultDocsBuildStrategy', () => {
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
uid: '0',
name: 'test',
},
};
const config = new ConfigReader({});
beforeEach(() => {
jest.resetAllMocks();
});
describe('shouldBuild', () => {
it('should return true when techdocs.build is set to local', async () => {
const defaultDocsBuildStrategy =
DefaultDocsBuildStrategy.fromConfig(config);
MockedConfigReader.prototype.getString.mockReturnValue('local');
const result = await defaultDocsBuildStrategy.shouldBuild({ entity });
expect(result).toBe(true);
});
it('should return false when techdocs.build is set to external', async () => {
const defaultDocsBuildStrategy =
DefaultDocsBuildStrategy.fromConfig(config);
MockedConfigReader.prototype.getString.mockReturnValue('external');
const result = await defaultDocsBuildStrategy.shouldBuild({ entity });
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,51 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { Config } from '@backstage/config';
/**
* Parameters passed to the shouldBuild method on the DocsBuildStrategy interface
*
* @public
*/
export type ShouldBuildParameters = {
entity: Entity;
};
/**
* A strategy for when to build TechDocs locally, and when to skip building TechDocs (allowing for an external build)
*
* @public
*/
export interface DocsBuildStrategy {
shouldBuild(params: ShouldBuildParameters): Promise<boolean>;
}
export class DefaultDocsBuildStrategy {
private readonly config: Config;
private constructor(config: Config) {
this.config = config;
}
static fromConfig(config: Config): DefaultDocsBuildStrategy {
return new DefaultDocsBuildStrategy(config);
}
async shouldBuild(_: ShouldBuildParameters): Promise<boolean> {
return this.config.getString('techdocs.builder') === 'local';
}
}
@@ -20,3 +20,7 @@ export type {
RecommendedDeploymentOptions,
OutOfTheBoxDeploymentOptions,
} from './router';
export type {
DocsBuildStrategy,
ShouldBuildParameters,
} from './DocsBuildStrategy';
@@ -38,6 +38,7 @@ import {
RouterOptions,
} from './router';
import { TechDocsCache } from '../cache';
import { DocsBuildStrategy } from './DocsBuildStrategy';
jest.mock('@backstage/catalog-client');
jest.mock('@backstage/config');
@@ -120,6 +121,9 @@ describe('createRouter', () => {
const cache: jest.Mocked<PluginCacheManager> = {
getClient: jest.fn(),
};
const docsBuildStrategy: jest.Mocked<DocsBuildStrategy> = {
shouldBuild: jest.fn(),
};
const outOfTheBoxOptions = {
preparers,
generators,
@@ -128,6 +132,7 @@ describe('createRouter', () => {
logger: getVoidLogger(),
discovery,
cache,
docsBuildStrategy,
};
const recommendedOptions = {
publisher,
@@ -135,6 +140,7 @@ describe('createRouter', () => {
logger: getVoidLogger(),
discovery,
cache,
docsBuildStrategy,
};
beforeEach(() => {
@@ -181,10 +187,10 @@ describe('createRouter', () => {
expect(response.status).toBe(404);
});
it('should not check for an update without local builder', async () => {
it('should not check for an update when shouldBuild returns false', async () => {
const app = await createApp(outOfTheBoxOptions);
MockedConfigReader.prototype.getString.mockReturnValue('external');
docsBuildStrategy.shouldBuild.mockResolvedValue(false);
MockCachedEntityLoader.prototype.load.mockResolvedValue(entity);
MockDocsSynchronizer.prototype.doCacheSync.mockImplementation(
async ({ responseHandler }) =>
@@ -198,10 +204,10 @@ describe('createRouter', () => {
expect(response.status).toBe(304);
});
it('should error if missing builder', async () => {
it('should error if build is required and is missing preparer', async () => {
const app = await createApp(recommendedOptions);
MockedConfigReader.prototype.getString.mockReturnValue('local');
docsBuildStrategy.shouldBuild.mockResolvedValue(true);
MockCachedEntityLoader.prototype.load.mockResolvedValue(entity);
const response = await request(app)
@@ -210,7 +216,7 @@ describe('createRouter', () => {
expect(response.status).toBe(500);
expect(response.text).toMatch(
/Invalid configuration\. 'techdocs\.builder' was set to 'local' but no 'preparer' was provided to the router initialization/,
/Invalid configuration\. docsBuildStrategy\.shouldBuild returned 'true', but no 'preparer' was provided to the router initialization./,
);
expect(MockDocsSynchronizer.prototype.doSync).toBeCalledTimes(0);
@@ -219,7 +225,7 @@ describe('createRouter', () => {
it('should execute synchronization', async () => {
const app = await createApp(outOfTheBoxOptions);
MockedConfigReader.prototype.getString.mockReturnValue('local');
docsBuildStrategy.shouldBuild.mockResolvedValue(true);
MockCachedEntityLoader.prototype.load.mockResolvedValue(entity);
MockDocsSynchronizer.prototype.doSync.mockImplementation(
async ({ responseHandler }) =>
@@ -244,7 +250,7 @@ describe('createRouter', () => {
it('should return on updated', async () => {
const app = await createApp(outOfTheBoxOptions);
MockedConfigReader.prototype.getString.mockReturnValue('local');
docsBuildStrategy.shouldBuild.mockResolvedValue(true);
MockCachedEntityLoader.prototype.load.mockResolvedValue(entity);
MockDocsSynchronizer.prototype.doSync.mockImplementation(
async ({ responseHandler }) => {
@@ -297,10 +303,10 @@ describe('createRouter', () => {
expect(response.status).toBe(404);
});
it('should not check for an update without local builder', async () => {
it('should not check for an update when shouldBuild returns false', async () => {
const app = await createApp(outOfTheBoxOptions);
MockedConfigReader.prototype.getString.mockReturnValue('external');
docsBuildStrategy.shouldBuild.mockResolvedValue(false);
MockCachedEntityLoader.prototype.load.mockResolvedValue(entity);
MockDocsSynchronizer.prototype.doCacheSync.mockImplementation(
async ({ responseHandler }) =>
@@ -322,10 +328,10 @@ data: {"updated":false}
);
});
it('should error if missing builder', async () => {
it('should error if build is required and is missing preparer', async () => {
const app = await createApp(recommendedOptions);
MockedConfigReader.prototype.getString.mockReturnValue('local');
docsBuildStrategy.shouldBuild.mockResolvedValue(true);
MockCachedEntityLoader.prototype.load.mockResolvedValue(entity);
const response = await request(app)
@@ -337,7 +343,7 @@ data: {"updated":false}
expect(response.get('content-type')).toBe('text/event-stream');
expect(response.text).toEqual(
`event: error
data: "Invalid configuration. 'techdocs.builder' was set to 'local' but no 'preparer' was provided to the router initialization."
data: "Invalid configuration. docsBuildStrategy.shouldBuild returned 'true', but no 'preparer' was provided to the router initialization."
`,
);
@@ -348,7 +354,7 @@ data: "Invalid configuration. 'techdocs.builder' was set to 'local' but no 'prep
it('should execute synchronization', async () => {
const app = await createApp(outOfTheBoxOptions);
MockedConfigReader.prototype.getString.mockReturnValue('local');
docsBuildStrategy.shouldBuild.mockResolvedValue(true);
MockCachedEntityLoader.prototype.load.mockResolvedValue(entity);
MockDocsSynchronizer.prototype.doSync.mockImplementation(
async ({ responseHandler }) =>
@@ -376,7 +382,7 @@ data: "Invalid configuration. 'techdocs.builder' was set to 'local' but no 'prep
it('should return an event-stream', async () => {
const app = await createApp(outOfTheBoxOptions);
MockedConfigReader.prototype.getString.mockReturnValue('local');
docsBuildStrategy.shouldBuild.mockResolvedValue(true);
MockCachedEntityLoader.prototype.load.mockResolvedValue(entity);
MockDocsSynchronizer.prototype.doSync.mockImplementation(
async ({ responseHandler }) => {
+16 -5
View File
@@ -35,6 +35,10 @@ import { ScmIntegrations } from '@backstage/integration';
import { DocsSynchronizer, DocsSynchronizerSyncOpts } from './DocsSynchronizer';
import { createCacheMiddleware, TechDocsCache } from '../cache';
import { CachedEntityLoader } from './CachedEntityLoader';
import {
DefaultDocsBuildStrategy,
DocsBuildStrategy,
} from './DocsBuildStrategy';
/**
* Required dependencies for running TechDocs in the "out-of-the-box"
@@ -51,6 +55,7 @@ export type OutOfTheBoxDeploymentOptions = {
database?: Knex; // TODO: Make database required when we're implementing database stuff.
config: Config;
cache: PluginCacheManager;
docsBuildStrategy?: DocsBuildStrategy;
};
/**
@@ -65,6 +70,7 @@ export type RecommendedDeploymentOptions = {
discovery: PluginEndpointDiscovery;
config: Config;
cache: PluginCacheManager;
docsBuildStrategy?: DocsBuildStrategy;
};
/**
@@ -99,6 +105,8 @@ export async function createRouter(
const router = Router();
const { publisher, config, logger, discovery } = options;
const catalogClient = new CatalogClient({ discoveryApi: discovery });
const docsBuildStrategy =
options.docsBuildStrategy ?? DefaultDocsBuildStrategy.fromConfig(config);
// Entities are cached to optimize the /static/docs request path, which can be called many times
// when loading a single techdocs page.
@@ -210,10 +218,13 @@ export async function createRouter(
responseHandler = createEventStream(res);
}
// techdocs-backend will only try to build documentation for an entity if techdocs.builder is set to 'local'
// 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') {
// By default, techdocs-backend will only try to build documentation for an entity if techdocs.builder is set to
// 'local'. 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.
// Altering the implementation of the injected docsBuildStrategy allows for more complex behaviours, based on
// either config or the properties of the entity (e.g. annotations, labels, spec fields etc.).
const shouldBuild = await docsBuildStrategy.shouldBuild({ entity });
if (!shouldBuild) {
// However, if caching is enabled, take the opportunity to check and
// invalidate stale cache entries.
if (cache) {
@@ -244,7 +255,7 @@ export async function createRouter(
responseHandler.error(
new Error(
"Invalid configuration. 'techdocs.builder' was set to 'local' but no 'preparer' was provided to the router initialization.",
"Invalid configuration. docsBuildStrategy.shouldBuild returned 'true', but no 'preparer' was provided to the router initialization.",
),
);
});