Clean up of the contrib folder
Signed-off-by: Andre Wanlin <awanlin@spotify.com>
This commit is contained in:
@@ -1,459 +0,0 @@
|
||||
# Authenticate API requests
|
||||
|
||||
> [!CAUTION]
|
||||
> This entire guide MUST NOT BE USED by users of Backstage 1.26 and
|
||||
> newer. If you have applied the changes in this guide, you need to remove them
|
||||
> again as you upgrade to recent versions of Backstage. When [the new auth changes](https://github.com/backstage/backstage/tree/master/beps/0003-auth-architecture-evolution)
|
||||
> landed backends became natively secured through the framework, and the
|
||||
> instructions outlined in here can interfere with the backend functioning
|
||||
> correctly.
|
||||
|
||||
The Backstage backend APIs are by default available without authentication. To avoid evil-doers from accessing or modifying data, one might use a network protection mechanism such as a firewall or an authenticating reverse proxy. For Backstage instances that are available on the Internet one can instead use the experimental IdentityClient as outlined below.
|
||||
|
||||
API requests from frontend plugins include an authorization header with a Backstage identity token acquired when the user logs in. By adding a middleware that verifies said token to be valid and signed by Backstage, non-authenticated requests can be blocked with a 401 Unauthorized response.
|
||||
|
||||
**NOTE**: Enabling this means that Backstage will stop working for guests, as no token is issued for them. If you have not done so already, you will also need to implement [service-to-service auth](https://backstage.io/docs/auth/service-to-service-auth).
|
||||
|
||||
As techdocs HTML pages load assets without an Authorization header the code below also sets a token cookie when the user logs in (and when the token is about to expire).
|
||||
|
||||
## Old Backend System Setup
|
||||
|
||||
Create `packages/backend/src/authMiddleware.ts`:
|
||||
|
||||
```typescript
|
||||
import type { Config } from '@backstage/config';
|
||||
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
|
||||
import { NextFunction, Request, Response, RequestHandler } from 'express';
|
||||
import { decodeJwt } from 'jose';
|
||||
import { URL } from 'url';
|
||||
import { PluginEnvironment } from './types';
|
||||
|
||||
function setTokenCookie(
|
||||
res: Response,
|
||||
options: { token: string; secure: boolean; cookieDomain: string },
|
||||
) {
|
||||
try {
|
||||
const payload = decodeJwt(options.token);
|
||||
res.cookie('token', options.token, {
|
||||
expires: new Date(payload.exp ? payload.exp * 1000 : 0),
|
||||
secure: options.secure,
|
||||
sameSite: 'lax',
|
||||
domain: options.cookieDomain,
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
});
|
||||
} catch (_err) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
export const createAuthMiddleware = async (
|
||||
config: Config,
|
||||
appEnv: PluginEnvironment,
|
||||
) => {
|
||||
const baseUrl = config.getString('backend.baseUrl');
|
||||
const secure = baseUrl.startsWith('https://');
|
||||
const cookieDomain = new URL(baseUrl).hostname;
|
||||
const authMiddleware: RequestHandler = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
try {
|
||||
const token =
|
||||
getBearerTokenFromAuthorizationHeader(req.headers.authorization) ||
|
||||
(req.cookies?.token as string | undefined);
|
||||
if (!token) {
|
||||
res.status(401).send('Unauthorized');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
req.user = await appEnv.identity.getIdentity({ request: req });
|
||||
} catch {
|
||||
await appEnv.tokenManager.authenticate(token);
|
||||
}
|
||||
if (!req.headers.authorization) {
|
||||
// Authorization header may be forwarded by plugin requests
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
}
|
||||
if (token && token !== req.cookies?.token) {
|
||||
setTokenCookie(res, {
|
||||
token,
|
||||
secure,
|
||||
cookieDomain,
|
||||
});
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).send('Unauthorized');
|
||||
}
|
||||
};
|
||||
return authMiddleware;
|
||||
};
|
||||
```
|
||||
|
||||
Install cookie-parser:
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn --cwd packages/backend add cookie-parser
|
||||
```
|
||||
|
||||
Update routes in `packages/backend/src/index.ts`:
|
||||
|
||||
```typescript
|
||||
// packages/backend/src/index.ts from a create-app deployment
|
||||
|
||||
import { createAuthMiddleware } from './authMiddleware';
|
||||
import cookieParser from 'cookie-parser';
|
||||
|
||||
// ...
|
||||
|
||||
async function main() {
|
||||
// ...
|
||||
|
||||
const authMiddleware = await createAuthMiddleware(config, appEnv);
|
||||
|
||||
const apiRouter = Router();
|
||||
apiRouter.use(cookieParser());
|
||||
// The auth route must be publicly available as it is used during login
|
||||
apiRouter.use('/auth', await auth(authEnv));
|
||||
// Add a simple endpoint to be used when setting a token cookie
|
||||
apiRouter.use('/cookie', authMiddleware, (_req, res) => {
|
||||
res.status(200).send(`Coming right up`);
|
||||
});
|
||||
// Only authenticated requests are allowed to the routes below
|
||||
apiRouter.use('/catalog', authMiddleware, await catalog(catalogEnv));
|
||||
apiRouter.use('/techdocs', authMiddleware, await techdocs(techdocsEnv));
|
||||
apiRouter.use('/proxy', authMiddleware, await proxy(proxyEnv));
|
||||
apiRouter.use(authMiddleware, notFoundHandler());
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## New Backend System Setup
|
||||
|
||||
Create `packages/backend/src/authMiddlewareFactory.ts`:
|
||||
|
||||
```typescript
|
||||
import { HostDiscovery } from '@backstage/backend-app-api';
|
||||
import { ServerTokenManager } from '@backstage/backend-common';
|
||||
import {
|
||||
LoggerService,
|
||||
RootConfigService,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import {
|
||||
DefaultIdentityClient,
|
||||
getBearerTokenFromAuthorizationHeader,
|
||||
} from '@backstage/plugin-auth-node';
|
||||
import { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
import { decodeJwt } from 'jose';
|
||||
import lzstring from 'lz-string';
|
||||
import { URL } from 'url';
|
||||
|
||||
type AuthMiddlewareFactoryOptions = {
|
||||
config: RootConfigService;
|
||||
logger: LoggerService;
|
||||
};
|
||||
|
||||
export const authMiddlewareFactory = ({
|
||||
config,
|
||||
logger,
|
||||
}: AuthMiddlewareFactoryOptions): RequestHandler => {
|
||||
const baseUrl = config.getString('backend.baseUrl');
|
||||
const discovery = HostDiscovery.fromConfig(config);
|
||||
const identity = DefaultIdentityClient.create({ discovery });
|
||||
const tokenManager = ServerTokenManager.fromConfig(config, { logger });
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const fullPath = `${req.baseUrl}${req.path}`;
|
||||
|
||||
// Only apply auth to /api routes & skip auth for the following endpoints
|
||||
// Add any additional plugin routes you want to whitelist eg. events
|
||||
const nonAuthWhitelist = ['app', 'auth'];
|
||||
const nonAuthRegex = new RegExp(
|
||||
`^\/api\/(${nonAuthWhitelist.join('|')})(?=\/|$)\S*`,
|
||||
);
|
||||
if (!fullPath.startsWith('/api/') || nonAuthRegex.test(fullPath)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Token cookies are compressed to reduce size
|
||||
const cookieToken = lzstring.decompressFromEncodedURIComponent(
|
||||
req.cookies.token,
|
||||
);
|
||||
const token =
|
||||
getBearerTokenFromAuthorizationHeader(req.headers.authorization) ??
|
||||
cookieToken;
|
||||
|
||||
try {
|
||||
// Attempt to authenticate as a frontend request token
|
||||
await identity.authenticate(token);
|
||||
} catch (err) {
|
||||
// Attempt to authenticate as a backend request token
|
||||
await tokenManager.authenticate(token);
|
||||
}
|
||||
|
||||
if (!req.headers.authorization) {
|
||||
// Authorization header may be forwarded by plugin requests
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
if (token !== cookieToken) {
|
||||
try {
|
||||
const payload = decodeJwt(token);
|
||||
res.cookie('token', token, {
|
||||
// Compress token to reduce cookie size
|
||||
encode: lzstring.compressToEncodedURIComponent,
|
||||
expires: new Date((payload?.exp ?? 0) * 1000),
|
||||
secure: baseUrl.startsWith('https://'),
|
||||
sameSite: 'lax',
|
||||
domain: new URL(baseUrl).hostname,
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).send(`Unauthorized`);
|
||||
}
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Install cookie-parser:
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn --cwd packages/backend add cookie-parser @types/cookie-parser
|
||||
```
|
||||
|
||||
Create a custom configured `rootHttpRouterService` in `packages/backend/src/customRootHttpRouterService.ts`:
|
||||
|
||||
```typescript
|
||||
import { rootHttpRouterServiceFactory } from '@backstage/backend-app-api';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { authMiddlewareFactory } from './authMiddlewareFactory';
|
||||
|
||||
export default rootHttpRouterServiceFactory({
|
||||
configure: ({ app, config, logger, middleware, routes }) => {
|
||||
app.use(middleware.helmet());
|
||||
app.use(middleware.cors());
|
||||
app.use(middleware.compression());
|
||||
app.use(cookieParser());
|
||||
app.use(middleware.logging());
|
||||
|
||||
app.use(authMiddlewareFactory({ config, logger }));
|
||||
|
||||
// Simple handler to set auth cookie for user
|
||||
app.use('/api/cookie', (_, res) => {
|
||||
res.status(200).send();
|
||||
});
|
||||
|
||||
app.use(routes);
|
||||
|
||||
app.use(middleware.notFound());
|
||||
app.use(middleware.error());
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Update `packages/backend/src/index.ts` to add the custom `rootHttpRouterService` and override the default:
|
||||
|
||||
```typescript
|
||||
// ...
|
||||
const backend = createBackend();
|
||||
|
||||
backend.add(import('./customRootHttpRouterService'));
|
||||
|
||||
// ...
|
||||
```
|
||||
|
||||
## Frontend Setup
|
||||
|
||||
Create `packages/app/src/cookieAuth.ts`:
|
||||
|
||||
```typescript
|
||||
import type { IdentityApi } from '@backstage/core-plugin-api';
|
||||
|
||||
// Parses supplied JWT token and returns the payload
|
||||
function parseJwt(token: string): { exp: number } {
|
||||
const base64Url = token.split('.')[1];
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const jsonPayload = decodeURIComponent(
|
||||
atob(base64)
|
||||
.split('')
|
||||
.map(
|
||||
c =>
|
||||
// eslint-disable-next-line prefer-template
|
||||
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2),
|
||||
)
|
||||
.join(''),
|
||||
);
|
||||
|
||||
return JSON.parse(jsonPayload);
|
||||
}
|
||||
|
||||
// Returns milliseconds until the supplied JWT token expires
|
||||
function msUntilExpiry(token: string): number {
|
||||
const payload = parseJwt(token);
|
||||
const remaining =
|
||||
new Date(payload.exp * 1000).getTime() - new Date().getTime();
|
||||
return remaining;
|
||||
}
|
||||
|
||||
// Calls the specified url regularly using an auth token to set a token cookie
|
||||
// to authorize regular HTTP requests when loading techdocs
|
||||
export async function setTokenCookie(url: string, identityApi: IdentityApi) {
|
||||
const { token } = await identityApi.getCredentials();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(url, {
|
||||
mode: 'cors',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Call this function again a few minutes before the token expires
|
||||
const ms = msUntilExpiry(token) - 4 * 60 * 1000;
|
||||
setTimeout(
|
||||
() => {
|
||||
setTokenCookie(url, identityApi);
|
||||
},
|
||||
ms > 0 ? ms : 10000,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// required types and packages for example below
|
||||
|
||||
import type { IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { discoveryApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
// additional packages/app/src/App.tsx from a create-app deployment
|
||||
|
||||
import { setTokenCookie } from './cookieAuth';
|
||||
|
||||
// ...
|
||||
|
||||
const app = createApp({
|
||||
// ...
|
||||
|
||||
components: {
|
||||
SignInPage: props => {
|
||||
const discoveryApi = useApi(discoveryApiRef);
|
||||
return (
|
||||
<SignInPage
|
||||
{...props}
|
||||
providers={['guest', 'custom', ...providers]}
|
||||
title="Select a sign-in method"
|
||||
align="center"
|
||||
onSignInSuccess={async (identityApi: IdentityApi) => {
|
||||
setTokenCookie(
|
||||
await discoveryApi.getBaseUrl('cookie'),
|
||||
identityApi,
|
||||
);
|
||||
|
||||
props.onSignInSuccess(identityApi);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
// ...
|
||||
});
|
||||
|
||||
// ...
|
||||
```
|
||||
|
||||
**NOTE**: Most Backstage frontend plugins come with the support for the `IdentityApi`.
|
||||
In case you already have a dozen of internal ones, you may need to update those too.
|
||||
Assuming you follow the common plugin structure, the changes to your front-end may look like:
|
||||
|
||||
```diff
|
||||
// plugins/internal-plugin/src/api.ts
|
||||
- import { createApiRef } from '@backstage/core-plugin-api';
|
||||
+ import { createApiRef, IdentityApi } from '@backstage/core-plugin-api';
|
||||
import { Config } from '@backstage/config';
|
||||
// ...
|
||||
|
||||
type MyApiOptions = {
|
||||
configApi: Config;
|
||||
+ identityApi: IdentityApi;
|
||||
// ...
|
||||
}
|
||||
|
||||
interface MyInterface {
|
||||
getData(): Promise<MyData[]>;
|
||||
}
|
||||
|
||||
export class MyApi implements MyInterface {
|
||||
private configApi: Config;
|
||||
+ private identityApi: IdentityApi;
|
||||
// ...
|
||||
|
||||
constructor(options: MyApiOptions) {
|
||||
this.configApi = options.configApi;
|
||||
+ this.identityApi = options.identityApi;
|
||||
}
|
||||
|
||||
async getMyData() {
|
||||
const backendUrl = this.configApi.getString('backend.baseUrl');
|
||||
|
||||
+ const { token } = await this.identityApi.getCredentials();
|
||||
const requestUrl = `${backendUrl}/api/data/`;
|
||||
- const response = await fetch(requestUrl);
|
||||
+ const response = await fetch(
|
||||
requestUrl,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
```diff
|
||||
// plugins/internal-plugin/src/plugin.ts
|
||||
|
||||
import {
|
||||
configApiRef,
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
+ identityApiRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { myPluginPageRouteRef } from './routeRefs';
|
||||
import { MyApi, myApiRef } from './api';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'my-plugin',
|
||||
routes: {
|
||||
mainPage: myPluginPageRouteRef,
|
||||
},
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: myApiRef,
|
||||
deps: {
|
||||
configApi: configApiRef,
|
||||
+ identityApi: identityApiRef,
|
||||
},
|
||||
- factory: ({ configApi }) =>
|
||||
- new MyApi({ configApi }),
|
||||
+ factory: ({ configApi, identityApi }) =>
|
||||
+ new MyApi({ configApi, identityApi }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 252 KiB |
@@ -1,115 +0,0 @@
|
||||
# Prometheus
|
||||
|
||||
> [!NOTE]
|
||||
> The Prometheus metrics have been marked as deprecated and will be removed at a later point. The recommendation is to use the OpenTelemetry metrics by following the [Setup OpenTelemetry](https://backstage.io/docs/tutorials/setup-opentelemetry) documentation
|
||||
|
||||
## Overview
|
||||
|
||||
This is a small tutorial that goes over how to setup your Backstage instance to output metrics in a format that can be pulled in by Prometheus.
|
||||
|
||||
## How to Setup Prometheus Metrics
|
||||
|
||||
1. First we need to add the needed dependencies to the `package.json` in the `\packages\backend`:
|
||||
|
||||
```diff
|
||||
// packages/backend/package.json
|
||||
"dependencies": {
|
||||
+ "express-prom-bundle": "^7.0.0",
|
||||
+ "prom-client": "^15.0.0",
|
||||
```
|
||||
|
||||
2. Now we want to run `yarn install` from the root of the project to get those dependencies in place
|
||||
3. Then we need to add a handler for the metrics by creating a file called `metrics.ts` in the `\packages\backend\src` folder
|
||||
4. Next we add the following content to the `metrics.ts` file:
|
||||
|
||||
```ts
|
||||
// packages/backend/src/metrics.ts
|
||||
import { useHotCleanup } from '@backstage/backend-common';
|
||||
import { RequestHandler } from 'express';
|
||||
import promBundle from 'express-prom-bundle';
|
||||
import prom from 'prom-client';
|
||||
import * as url from 'url';
|
||||
|
||||
const rootRegEx = new RegExp('^/([^/]*)/.*');
|
||||
const apiRegEx = new RegExp('^/api/([^/]*)/.*');
|
||||
|
||||
export function normalizePath(req: any): string {
|
||||
const path = url.parse(req.originalUrl || req.url).pathname || '/';
|
||||
|
||||
// Capture /api/ and the plugin name
|
||||
if (apiRegEx.test(path)) {
|
||||
return path.replace(apiRegEx, '/api/$1');
|
||||
}
|
||||
|
||||
// Only the first path segment at root level
|
||||
return path.replace(rootRegEx, '/$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a /metrics endpoint, register default runtime metrics and instrument the router.
|
||||
*/
|
||||
export function metricsHandler(): RequestHandler {
|
||||
// We can only initialize the metrics once and have to clean them up between hot reloads
|
||||
useHotCleanup(module, () => prom.register.clear());
|
||||
|
||||
return promBundle({
|
||||
includeMethod: true,
|
||||
includePath: true,
|
||||
// Using includePath alone is problematic, as it will include path labels with high
|
||||
// cardinality (e.g. path params). Instead we would have to template them. However, this
|
||||
// is difficult, as every backend plugin might use different routes. Instead we only take
|
||||
// the first directory of the path, to have at least an idea how each plugin performs:
|
||||
normalizePath,
|
||||
promClient: { collectDefaultMetrics: {} },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
5. Now we will extend the router configuration with the `metricsHandler`:
|
||||
|
||||
```diff
|
||||
+import { metricsHandler } from './metrics';
|
||||
|
||||
...
|
||||
|
||||
const service = createServiceBuilder(module)
|
||||
.loadConfig(config)
|
||||
.addRouter('', await healthcheck(healthcheckEnv))
|
||||
+ .addRouter('', metricsHandler())
|
||||
.addRouter('/api', apiRouter);
|
||||
```
|
||||
|
||||
6. You now have everything setup, from the `\packages\backend` folder run `yarn start` this will start up the backend
|
||||
7. Now in a browser load up `http://localhost:7007/metrics`, if everything went smoothly you should see metrics in your browser something like this:
|
||||
|
||||

|
||||
|
||||
## Metrics
|
||||
|
||||
The following sections goes over the included and experimental metrics available once you have completed this tutorial
|
||||
|
||||
## Included
|
||||
|
||||
This tutorials uses the [`express-prom-bundle`](https://github.com/jochen-schweizer/express-prom-bundle) and the [`prom-client`](https://github.com/siimon/prom-client) to make this all work. They both come with some built in metrics:
|
||||
|
||||
- `express-prom-bundle` comes with 2 metrics:
|
||||
- `up`: this normally will be just 1
|
||||
- `http_request_duration_seconds`: http latency histogram/summary labeled with `status_code`, `method` and `path`
|
||||
- `prom-client` comes with a collection of metrics around memory, CPU, processes, etc. You can see the supported metrics in the `prom-client's` [`lib/metrics`](https://github.com/siimon/prom-client/tree/master/lib/metrics) folder.
|
||||
|
||||
### Experimental
|
||||
|
||||
There are some custom metrics that have been added to Backstage will be output for you, these are currently deemed experimental and may be changed or removed in a future release. Here is a rough list, again subject to changes:
|
||||
|
||||
- `catalog_entities_count`: Total amount of entities in the catalog
|
||||
- `catalog_registered_locations_count`: Total amount of registered locations in the catalog
|
||||
- `catalog_relations_count`: Total amount of relations between entities
|
||||
- `catalog_stitched_entities_count`: Amount of entities stitched
|
||||
- `catalog_processed_entities_count`: Amount of entities processed
|
||||
- `catalog_processing_duration_seconds`: Time spent executing the full processing flow
|
||||
- `catalog_processors_duration_seconds`: Time spent executing catalog processors
|
||||
- `catalog_processing_queue_delay_seconds`: The amount of delay between being scheduled for processing, and the start of actually being processed
|
||||
- `scaffolder_task_count`: Tracks successful task runs.
|
||||
- `scaffolder_task_duration`: a histogram which tracks the duration of a task run
|
||||
- `scaffolder_step_count`: a count that tracks each step run
|
||||
- `scaffolder_step_duration`: a histogram which tracks the duration of each step run
|
||||
@@ -1,53 +0,0 @@
|
||||
### Source repo: https://github.com/johnson-jesse/simple-backstage-app-plugin
|
||||
|
||||
ExampleComponent.tsx reference
|
||||
|
||||
```tsx
|
||||
import { Typography, Grid } from '@material-ui/core';
|
||||
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
InfoCard,
|
||||
Header,
|
||||
Page,
|
||||
Content,
|
||||
ContentHeader,
|
||||
HeaderLabel,
|
||||
SupportButton,
|
||||
} from '@backstage/core-components';
|
||||
import { ExampleFetchComponent } from '../ExampleFetchComponent';
|
||||
|
||||
export const ExampleComponent = () => {
|
||||
const identityApi = useApi(identityApiRef);
|
||||
const userId = identityApi.getUserId();
|
||||
const profile = identityApi.getProfile();
|
||||
|
||||
return (
|
||||
<Page themeId="tool">
|
||||
<Header
|
||||
title="Welcome to github-playground!"
|
||||
subtitle="Optional subtitle"
|
||||
>
|
||||
<HeaderLabel label="Owner" value="Team X" />
|
||||
<HeaderLabel label="Lifecycle" value="Alpha" />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="Plugin title">
|
||||
<SupportButton>A description of your plugin goes here.</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<InfoCard title={userId}>
|
||||
<Typography variant="body1">
|
||||
{`${profile.displayName} | ${profile.email}`}
|
||||
</Typography>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<ExampleFetchComponent />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -1,103 +0,0 @@
|
||||
### Source repo: https://github.com/johnson-jesse/simple-backstage-app-plugin
|
||||
|
||||
ExampleFetchComponent.tsx reference
|
||||
|
||||
```tsx
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { githubAuthApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { Table, TableColumn, Progress } from '@backstage/core-components';
|
||||
import { graphql } from '@octokit/graphql';
|
||||
|
||||
const query = `{
|
||||
viewer {
|
||||
repositories(first: 100) {
|
||||
totalCount
|
||||
nodes {
|
||||
name
|
||||
createdAt
|
||||
description
|
||||
diskUsage
|
||||
isFork
|
||||
}
|
||||
pageInfo {
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
type Node = {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
diskUsage: number;
|
||||
isFork: boolean;
|
||||
};
|
||||
|
||||
type Viewer = {
|
||||
repositories: {
|
||||
totalCount: number;
|
||||
nodes: Node[];
|
||||
pageInfo: {
|
||||
endCursor: string;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type DenseTableProps = {
|
||||
viewer: Viewer;
|
||||
};
|
||||
|
||||
export const DenseTable = ({ viewer }: DenseTableProps) => {
|
||||
const columns: TableColumn[] = [
|
||||
{ title: 'Name', field: 'name' },
|
||||
{ title: 'Created', field: 'createdAt' },
|
||||
{ title: 'Description', field: 'description' },
|
||||
{ title: 'Disk Usage', field: 'diskUsage' },
|
||||
{ title: 'Fork', field: 'isFork' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
title="List Of User's Repositories"
|
||||
options={{ search: false, paging: false }}
|
||||
columns={columns}
|
||||
data={viewer.repositories.nodes}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExampleFetchComponent = () => {
|
||||
const auth = useApi(githubAuthApiRef);
|
||||
|
||||
const { value, loading, error } = useAsync(async (): Promise<any> => {
|
||||
const token = await auth.getAccessToken();
|
||||
|
||||
const gqlEndpoint = graphql.defaults({
|
||||
// Uncomment baseUrl if using enterprise
|
||||
// baseUrl: 'https://github.MY-BIZ.com/api',
|
||||
headers: {
|
||||
authorization: `token ${token}`,
|
||||
},
|
||||
});
|
||||
const { viewer } = await gqlEndpoint(query);
|
||||
return viewer;
|
||||
}, []);
|
||||
|
||||
if (loading) return <Progress />;
|
||||
if (error) return <Alert severity="error">{error.message}</Alert>;
|
||||
if (value && value.repositories) return <DenseTable viewer={value} />;
|
||||
|
||||
return (
|
||||
<Table
|
||||
title="List Of User's Repositories"
|
||||
options={{ search: false, paging: false }}
|
||||
columns={[]}
|
||||
data={[]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -1,3 +1,6 @@
|
||||
# Basic Kubernetes example with Helm
|
||||
|
||||
> [!NOTE]
|
||||
> This documentation is deprecated and will be removed at a future date, please use the well maintained [Backstage Helm Charts](https://github.com/backstage/charts) for this.
|
||||
|
||||
Note that these examples aim to show a minimal setup and do not include best practices for secure Kubernetes deployments. See the [Kubernetes documentation](https://kubernetes.io/docs/concepts/security/) for more information, or resources provided by your own organization.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# Confluence
|
||||
|
||||
> [!NOTE]
|
||||
> This documentation is deprecated and will be removed at a future date, please use the well maintained [`@backstage-community/plugin-search-backend-module-confluence-collator` Community Plugin](https://github.com/backstage/community-plugins/tree/main/workspaces/confluence/plugins/search-backend-module-confluence-collator) for this.
|
||||
|
||||
These files help you add Confluence as a source to the Backstage Search plugin.
|
||||
To do so, add both files in this directory under the packages/backend/src/plugins/search/ pathway in your Backstage app.
|
||||
Then, add the following code to your packages/app/src/components/search/SearchPage.tsx:
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "backstage.io/v1alpha1",
|
||||
"type": "object",
|
||||
"title": "A JSON Schema for Backstage catalog entities.",
|
||||
"description": "Each descriptor file has a number of entities. This schema matches each of those.",
|
||||
"examples": [
|
||||
{
|
||||
"apiVersion": "backstage.io/v1alpha1",
|
||||
"kind": "Component",
|
||||
"metadata": {
|
||||
"name": "LoremService",
|
||||
"description": "Creates Lorems like a pro.",
|
||||
"labels": {
|
||||
"product_name": "Random value Generator"
|
||||
},
|
||||
"annnotations": {
|
||||
"docs": "https://github.com/..../tree/develop/doc"
|
||||
},
|
||||
"teams": [
|
||||
{
|
||||
"name": "Team super great",
|
||||
"email": "greatTeam@geemel.com"
|
||||
}
|
||||
]
|
||||
},
|
||||
"spec": {
|
||||
"type": "service",
|
||||
"lifecycle": "production",
|
||||
"owner": "tools@example.com"
|
||||
}
|
||||
}
|
||||
],
|
||||
"required": ["apiVersion", "kind", "metadata"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiVersion": {
|
||||
"type": "string",
|
||||
"description": "Version of the specification format for a particular file is written against.",
|
||||
"enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
|
||||
},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "High level entity type being described, from the Backstage system model.",
|
||||
"enum": ["Component"]
|
||||
},
|
||||
"metadata": {
|
||||
"$ref": "#/definitions/metadata"
|
||||
},
|
||||
"spec": {
|
||||
"$ref": "#/definitions/spec"
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "Metadata about the entity, i.e. things that aren't directly part of the entity specification itself.",
|
||||
"required": ["name"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9A-Z_.-]{1,63}$",
|
||||
"description": "The name of the entity. This name is both meant for human eyes to recognize the entity, and for machines and other components to reference the entity"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A human readable description of the entity, to be shown in Backstage. Should be kept short and informative."
|
||||
},
|
||||
"namespace": {
|
||||
"type": "string",
|
||||
"description": "The name of a namespace that the entity belongs to."
|
||||
},
|
||||
"labels": {
|
||||
"type": "object",
|
||||
"description": "Labels are optional key/value pairs of that are attached to the entity, and their use is identical to kubernetes object labels.",
|
||||
"additionalProperties": true,
|
||||
"patternProperties": {
|
||||
"^([a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\\.[a-zA-Z]{2,}/)?[a-z0-9A-Z_\\-\\.]{1,63}$": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9A-Z_.-]{1,63}$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"annnotations": {
|
||||
"type": "object",
|
||||
"description": "Arbitrary non-identifying metadata attached to the entity, identical in use to kubernetes object annotations.",
|
||||
"additionalProperties": true,
|
||||
"patternProperties": {
|
||||
"^([a-zA-Z0-9][a-zA-Z0-9-]{1,61}[a-zA-Z0-9]\\.[a-zA-Z]{2,}/)?[a-z0-9A-Z_\\-\\.]{1,63}$": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9A-Z_.-]{1,63}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"description": "Actual specification data that describes the entity. TODO: shape depend on `kind`",
|
||||
"required": ["type", "lifecycle", "owner"],
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "The type of component.",
|
||||
"examples": ["service"]
|
||||
},
|
||||
"lifecycle": {
|
||||
"type": "string",
|
||||
"description": "The lifecycle step that this component is in.",
|
||||
"examples": ["production"]
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"description": "The owner of the component.",
|
||||
"examples": ["tools@example.com"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
---
|
||||
id: quickstart-app-plugin
|
||||
title: Adding Custom Plugin to Existing Monorepo App
|
||||
description: Tutorial for adding a custom plugin to an existing Backstage monorepo application
|
||||
---
|
||||
|
||||
::::info
|
||||
This documentation is written for the new frontend system, which is the default
|
||||
in new Backstage apps. If your Backstage app still uses the old frontend system,
|
||||
read the [old frontend system version of this guide](./quickstart-app-plugin--old.md)
|
||||
instead.
|
||||
::::
|
||||
|
||||
> This document takes you through setting up a new plugin for your existing
|
||||
> monorepo with a _GitHub provider already setup_.
|
||||
>
|
||||
> This document does not cover authoring a plugin for sharing with the Backstage
|
||||
> community. That will have to be a later discussion.
|
||||
>
|
||||
> We start with a skeleton plugin install. And after verifying its
|
||||
> functionality, we add custom code to display GitHub repository information.
|
||||
|
||||
## The Skeleton Plugin
|
||||
|
||||
1. Start by using the built-in creator. From the terminal and root of your
|
||||
project run: `yarn new` and select `frontend-plugin`.
|
||||
1. Enter a plugin ID. We'll use `github-playground` for this tutorial.
|
||||
1. When the process finishes, let's start the backend:
|
||||
`yarn --cwd packages/backend start`
|
||||
1. If you see errors starting, refer to
|
||||
[Auth Configuration](https://backstage.io/docs/auth/) for more information on
|
||||
environment variables.
|
||||
1. And now the frontend, from a new terminal window and the root of your
|
||||
project: `yarn start`
|
||||
1. As usual, a browser window should popup loading the App.
|
||||
1. Now manually navigate to the plugin page from your browser:
|
||||
`http://localhost:3000/github-playground`
|
||||
1. You should see successful verbiage for this endpoint,
|
||||
`Welcome to github-playground!`
|
||||
|
||||
With the new frontend system, plugins are auto-discovered when installed as
|
||||
dependencies of your `packages/app` package. The plugin was already added there
|
||||
by `yarn new`, so the route and a sidebar item are available without any manual
|
||||
wiring in `App.tsx` or `Root.tsx`.
|
||||
|
||||
## The Identity
|
||||
|
||||
Our first modification will be to extract information from the Identity API.
|
||||
|
||||
1. Start by opening
|
||||
`root: plugins > github-playground > src > components > ExampleComponent > ExampleComponent.tsx`
|
||||
1. Add two new imports
|
||||
|
||||
```tsx
|
||||
import { identityApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
```
|
||||
|
||||
3. Adjust the ExampleComponent from inline to block
|
||||
|
||||
_from inline:_
|
||||
|
||||
```tsx
|
||||
const ExampleComponent = () => ( ... )
|
||||
```
|
||||
|
||||
_to block:_
|
||||
|
||||
```tsx
|
||||
const ExampleComponent = () => {
|
||||
|
||||
return (
|
||||
...
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
4. Now add our hook and const data before the return statement
|
||||
|
||||
```tsx
|
||||
const identityApi = useApi(identityApiRef);
|
||||
|
||||
const userId = identityApi.getUserId();
|
||||
const profile = identityApi.getProfile();
|
||||
```
|
||||
|
||||
5. Finally, update the InfoCard's jsx to use our new data
|
||||
|
||||
```tsx
|
||||
<InfoCard title={userId}>
|
||||
<Typography variant="body1">
|
||||
{`${profile.displayName} | ${profile.email}`}
|
||||
</Typography>
|
||||
</InfoCard>
|
||||
```
|
||||
|
||||
If everything is saved, you should see your name, id, and email on the
|
||||
github-playground page. Our data accessed is synchronous. So we just grab and
|
||||
go.
|
||||
|
||||
https://github.com/backstage/backstage/tree/master/contrib
|
||||
|
||||
6. Here is the entire file for reference
|
||||
[ExampleComponent.tsx](https://github.com/backstage/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleComponent.md)
|
||||
|
||||
## The Wipe
|
||||
|
||||
The last file we will touch is ExampleFetchComponent. Because of the number of
|
||||
changes, let's start by wiping this component clean.
|
||||
|
||||
1. Start by opening
|
||||
`root: plugins > github-playground > src > components > ExampleFetchComponent > ExampleFetchComponent.tsx`
|
||||
1. Replace everything in the file with the following:
|
||||
|
||||
```tsx
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { Table, TableColumn, Progress } from '@backstage/core-components';
|
||||
import { githubAuthApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { graphql } from '@octokit/graphql';
|
||||
|
||||
export const ExampleFetchComponent = () => {
|
||||
return <div>Nothing to see yet</div>;
|
||||
};
|
||||
```
|
||||
|
||||
3. Save that and ensure you see no errors. Comment out the unused imports if
|
||||
your linter gets in the way.
|
||||
|
||||
###### We will add a lot to this file for the sake of ease. Please don't do this in productional code!
|
||||
|
||||
## The Graph Model
|
||||
|
||||
GitHub has a GraphQL API available for interacting. Let's start by adding our
|
||||
basic repository query
|
||||
|
||||
1. Add the query const statement outside ExampleFetchComponent
|
||||
|
||||
```tsx
|
||||
const query = `{
|
||||
viewer {
|
||||
repositories(first: 100) {
|
||||
totalCount
|
||||
nodes {
|
||||
name
|
||||
createdAt
|
||||
description
|
||||
diskUsage
|
||||
isFork
|
||||
}
|
||||
pageInfo {
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
```
|
||||
|
||||
2. Using this structure as a guide, we will break our query into type parts
|
||||
3. Add the following outside of ExampleFetchComponent
|
||||
|
||||
```tsx
|
||||
type Node = {
|
||||
name: string;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
diskUsage: number;
|
||||
isFork: boolean;
|
||||
};
|
||||
|
||||
type Viewer = {
|
||||
repositories: {
|
||||
totalCount: number;
|
||||
nodes: Node[];
|
||||
pageInfo: {
|
||||
endCursor: string;
|
||||
hasNextPage: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## The Table Model
|
||||
|
||||
Using Backstage's own component library, let's define a custom table. This
|
||||
component will get used if we have data to display.
|
||||
|
||||
1. Add the following outside of ExampleFetchComponent
|
||||
|
||||
```tsx
|
||||
type DenseTableProps = {
|
||||
viewer: Viewer;
|
||||
};
|
||||
|
||||
export const DenseTable = ({ viewer }: DenseTableProps) => {
|
||||
const columns: TableColumn[] = [
|
||||
{ title: 'Name', field: 'name' },
|
||||
{ title: 'Created', field: 'createdAt' },
|
||||
{ title: 'Description', field: 'description' },
|
||||
{ title: 'Disk Usage', field: 'diskUsage' },
|
||||
{ title: 'Fork', field: 'isFork' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
title="List Of User's Repositories"
|
||||
options={{ search: false, paging: false }}
|
||||
columns={columns}
|
||||
data={viewer.repositories.nodes}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## The Fetch
|
||||
|
||||
We're ready to flush out our fetch component
|
||||
|
||||
1. Add our api hook inside ExampleFetchComponent
|
||||
|
||||
```tsx
|
||||
const auth = useApi(githubAuthApiRef);
|
||||
```
|
||||
|
||||
2. The access token we need to make our GitHub request and the request itself is
|
||||
obtained in an asynchronous manner.
|
||||
3. Add the `useAsync` block inside the ExampleFetchComponent
|
||||
|
||||
```tsx
|
||||
const { value, loading, error } = useAsync(async (): Promise<any> => {
|
||||
const token = await auth.getAccessToken();
|
||||
|
||||
const gqlEndpoint = graphql.defaults({
|
||||
// Uncomment baseUrl if using enterprise
|
||||
// baseUrl: 'https://github.MY-BIZ.com/api',
|
||||
headers: {
|
||||
authorization: `token ${token}`,
|
||||
},
|
||||
});
|
||||
const { viewer } = await gqlEndpoint(query);
|
||||
return viewer;
|
||||
}, []);
|
||||
```
|
||||
|
||||
4. The resolved data is conveniently destructured with `value` containing our
|
||||
Viewer type. `loading` as a boolean, self explanatory. And `error` which is
|
||||
present only if necessary. So let's use those as the first 3 of 4 multi
|
||||
return statements.
|
||||
5. Add the _if return_ blocks below our async block
|
||||
|
||||
```tsx
|
||||
if (loading) return <Progress />;
|
||||
if (error) return <Alert severity="error">{error.message}</Alert>;
|
||||
if (value && value.repositories) return <DenseTable viewer={value} />;
|
||||
```
|
||||
|
||||
6. The third line here utilizes our custom table accepting our Viewer type.
|
||||
7. Finally, we add our _else return_ block to catch any other scenarios.
|
||||
|
||||
```tsx
|
||||
return (
|
||||
<Table
|
||||
title="List Of User's Repositories"
|
||||
options={{ search: false, paging: false }}
|
||||
columns={[]}
|
||||
data={[]}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
8. After saving that, and given we don't have any errors, you should see a table
|
||||
with basic information on your repositories.
|
||||
9. Here is the entire file for reference
|
||||
[ExampleFetchComponent.tsx](https://github.com/backstage/backstage/tree/master/contrib/docs/tutorials/quickstart-app-plugin/ExampleFetchComponent.md)
|
||||
10. We finished! You should see your own GitHub repository's information
|
||||
displayed in a basic table. If you run into issues, you can compare the repo
|
||||
that backs this document,
|
||||
[simple-backstage-app-plugin](https://github.com/johnson-jesse/simple-backstage-app-plugin)
|
||||
|
||||
## Where to go from here
|
||||
|
||||
> Break apart ExampleFetchComponent into smaller logical parts contained in
|
||||
> their own files. Rename your components to something other than ExampleXxx.
|
||||
>
|
||||
> You might be really proud of a plugin you develop. Consider sharing it with
|
||||
> the Backstage community by contributing to the [community-plugins repository](https://github.com/backstage/community-plugins).
|
||||
Reference in New Issue
Block a user