Merge branch 'master' of github.com:backstage/backstage into update-org-cards
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Pass on plugin database management instance that is now required by the scaffolder plugin.
|
||||
|
||||
To apply this change to an existing application, add the following to `src/plugins/scaffolder.ts`:
|
||||
|
||||
```diff
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
+ database,
|
||||
}: PluginEnvironment) {
|
||||
|
||||
// ...omitted...
|
||||
|
||||
return await createRouter({
|
||||
preparers,
|
||||
templaters,
|
||||
publishers,
|
||||
logger,
|
||||
config,
|
||||
dockerClient,
|
||||
entityClient,
|
||||
+ database,
|
||||
});
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cost-insights': patch
|
||||
---
|
||||
|
||||
Fix snooze quarter option
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-circleci': patch
|
||||
---
|
||||
|
||||
Migrated to new composability API, exporting the plugin instance as `circleCIPlugin`, the entity page content as `EntityCircleCIContent`, and entity conditional as `isCircleCIAvailable`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search': minor
|
||||
---
|
||||
|
||||
Migrated to new composability API, exporting the plugin instance as `searchPlugin`, and page as `SearchPage`. Due to the old router component also being called `SearchPage`, this is a breaking change. The old page component is now exported as `Router`, which can be used to maintain the old behavior.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
use child logger, if provided, to log single location refresh
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Don't respond to a request twice if an entity has not been found.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-cloudbuild': patch
|
||||
---
|
||||
|
||||
Migrate to new composability API, exporting the plugin instance as `cloudbuildPlugin`, the entity content as `EntityCloudbuildContent`, the entity conditional as `isCloudbuildAvailable`, and entity cards as `EntityLatestCloudbuildRunCard` and `EntityLatestCloudbuildsForBranchCard`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-register-component': patch
|
||||
---
|
||||
|
||||
Migrated to new composability API, exporting the plugin instance as `registerComponentPlugin`, and page as `RegisterComponentPage`.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
---
|
||||
|
||||
Introduced `v2` Scaffolder REST API, which uses an implementation that is database backed, making the scaffolder instances stateless. The `createRouter` function now requires a `PluginDatabaseManager` instance to be passed in, commonly available as `database` in the plugin environment in the backend.
|
||||
|
||||
This API should be considered unstable until used by the scaffolder frontend.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Migrated to new composability API, exporting the plugin instance as `techdocsPlugin`, the top-level page as `TechdocsPage`, and the entity content as `EntityTechdocsContent`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Set explicit content-type in error handler responses.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-common': patch
|
||||
---
|
||||
|
||||
Slight refactoring in support of a future search implementation in `UrlReader`. Mostly moving code around.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-pagerduty': patch
|
||||
---
|
||||
|
||||
Migrated to new composability API, exporting the plugin instance as `pagerDutyPlugin`, entity card as `EntityPagerDutyCard`, and entity conditional as `isPagerDutyAvailable`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/test-utils': patch
|
||||
---
|
||||
|
||||
Added `mountedRoutes` option to `wrapInTestApp`, allowing routes to be associated to concrete paths to make `useRouteRef` usable in tested components.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-jenkins': patch
|
||||
---
|
||||
|
||||
Migrate to new composability API, exporting the plugin instance as `jenkinsPlugin`, the entity content as `EntityJenkinsContent`, the entity conditional as `isJenkinsAvailable`, and the entity card as `EntityLatestJenkinsRunCard`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-github-actions': patch
|
||||
---
|
||||
|
||||
Migrate to new composability API, exporting the plugin instance as `githubActionsPlugin`, the entity content as `EntityGithubActionsContent`, entity conditional as `isGithubActionsAvailable`, and entity cards as `EntityLatestGithubActionRunCard`, `EntityLatestGithubActionsForBranchCard`, and `EntityRecentGithubActionsRunsCard`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
---
|
||||
|
||||
Refactored route response handling to use more explicit types and throw errors.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Enhance API calls to support trapping 500 errors from techdocs-backend
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core': patch
|
||||
---
|
||||
|
||||
Fixed type inference of `createRouteRef`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-gitops-profiles': patch
|
||||
---
|
||||
|
||||
Migrated to new composability API, exporting the plugin instance as `gitopsProfilesPlugin` and pages as `GitopsProfilesClusterListPage`, `GitopsProfilesClusterPage`, and `GitopsProfilesCreatePage`.
|
||||
@@ -80,6 +80,10 @@ Create a `/docs` folder in the root of the project with at least an `index.md`
|
||||
file. _(If you add more markdown files, make sure to update the nav in the
|
||||
mkdocs.yml file to get a proper navigation for your documentation.)_
|
||||
|
||||
> Note - Although `docs` is a popular directory name for storing documentation,
|
||||
> it can be renamed to something else and can be configured by `mkdocs.yml`. See
|
||||
> https://www.mkdocs.org/user-guide/configuration/#docs_dir
|
||||
|
||||
The `docs/index.md` can for example have the following content:
|
||||
|
||||
```md
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"@backstage/config": "^0.1.2",
|
||||
"@backstage/config-loader": "^0.5.1",
|
||||
"@backstage/integration": "^0.3.2",
|
||||
"@octokit/rest": "^18.0.12",
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.6",
|
||||
"archiver": "^5.0.2",
|
||||
|
||||
@@ -63,10 +63,10 @@ export function errorHandler(
|
||||
return (
|
||||
error: Error,
|
||||
_request: Request,
|
||||
response: Response,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (response.headersSent) {
|
||||
if (res.headersSent) {
|
||||
// If the headers have already been sent, do not send the response again
|
||||
// as this will throw an error in the backend.
|
||||
next(error);
|
||||
@@ -80,7 +80,9 @@ export function errorHandler(
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
response.status(status).send(message);
|
||||
res.status(status);
|
||||
res.setHeader('content-type', 'text/plain');
|
||||
res.send(message);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,12 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
GitHubIntegrationConfig,
|
||||
readGitHubIntegrationConfigs,
|
||||
getGitHubFileFetchUrl,
|
||||
GithubCredentialsProvider,
|
||||
GitHubIntegrationConfig,
|
||||
readGitHubIntegrationConfigs,
|
||||
} from '@backstage/integration';
|
||||
import { RestEndpointMethodTypes } from '@octokit/rest';
|
||||
import fetch from 'cross-fetch';
|
||||
import parseGitUrl from 'git-url-parse';
|
||||
import { Readable } from 'stream';
|
||||
@@ -98,74 +99,26 @@ export class GithubUrlReader implements UrlReader {
|
||||
url: string,
|
||||
options?: ReadTreeOptions,
|
||||
): Promise<ReadTreeResponse> {
|
||||
const { ref, filepath, full_name } = parseGitUrl(url);
|
||||
// Caveat: The ref will totally be incorrect if the branch name includes a /
|
||||
// Thus, readTree can not work on url containing branch name that has a /
|
||||
|
||||
const { headers } = await this.deps.credentialsProvider.getCredentials({
|
||||
url,
|
||||
});
|
||||
|
||||
// Get GitHub API urls for the repository
|
||||
const repoGitHubResponse = await fetch(
|
||||
new URL(`${this.config.apiBaseUrl}/repos/${full_name}`).toString(),
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
if (!repoGitHubResponse.ok) {
|
||||
const message = `Failed to read tree (repository) from ${url}, ${repoGitHubResponse.status} ${repoGitHubResponse.statusText}`;
|
||||
if (repoGitHubResponse.status === 404) {
|
||||
throw new NotFoundError(message);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const repoResponseJson = await repoGitHubResponse.json();
|
||||
|
||||
// ref is an empty string if no branch is set in provided url to readTree.
|
||||
// Use GitHub API to get the default branch of the repository.
|
||||
const branch = ref || repoResponseJson.default_branch;
|
||||
const branchesApiUrl = repoResponseJson.branches_url;
|
||||
const archiveApiUrl = repoResponseJson.archive_url;
|
||||
|
||||
// Fetch the latest commit in the provided or default branch to compare against
|
||||
// the provided sha.
|
||||
const branchGitHubResponse = await fetch(
|
||||
// branchesApiUrl looks like "https://api.github.com/repos/owner/repo/branches{/branch}"
|
||||
branchesApiUrl.replace('{/branch}', `/${branch}`),
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
if (!branchGitHubResponse.ok) {
|
||||
const message = `Failed to read tree (branch) from ${url}, ${branchGitHubResponse.status} ${branchGitHubResponse.statusText}`;
|
||||
if (branchGitHubResponse.status === 404) {
|
||||
throw new NotFoundError(message);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const commitSha = (await branchGitHubResponse.json()).commit.sha;
|
||||
const repoDetails = await this.getRepoDetails(url);
|
||||
const commitSha = repoDetails.branch.commit.sha!;
|
||||
|
||||
if (options?.etag && options.etag === commitSha) {
|
||||
throw new NotModifiedError();
|
||||
}
|
||||
|
||||
const archive = await fetch(
|
||||
// archiveApiUrl looks like "https://api.github.com/repos/owner/repo/{archive_format}{/ref}"
|
||||
archiveApiUrl
|
||||
const { headers } = await this.deps.credentialsProvider.getCredentials({
|
||||
url,
|
||||
});
|
||||
|
||||
// archive_url looks like "https://api.github.com/repos/owner/repo/{archive_format}{/ref}"
|
||||
const archive = await this.fetchResponse(
|
||||
repoDetails.repo.archive_url
|
||||
.replace('{archive_format}', 'tarball')
|
||||
.replace('{/ref}', `/${commitSha}`),
|
||||
{ headers },
|
||||
);
|
||||
if (!archive.ok) {
|
||||
const message = `Failed to read tree (archive) from ${url}, ${archive.status} ${archive.statusText}`;
|
||||
if (archive.status === 404) {
|
||||
throw new NotFoundError(message);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const { filepath } = parseGitUrl(url);
|
||||
return await this.deps.treeResponseFactory.fromTarArchive({
|
||||
// TODO(Rugvip): Underlying implementation of fetch will be node-fetch, we probably want
|
||||
// to stick to using that in exclusively backend code.
|
||||
@@ -180,4 +133,59 @@ export class GithubUrlReader implements UrlReader {
|
||||
const { host, token } = this.config;
|
||||
return `github{host=${host},authed=${Boolean(token)}}`;
|
||||
}
|
||||
|
||||
private async getRepoDetails(
|
||||
url: string,
|
||||
): Promise<{
|
||||
repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
|
||||
branch: RestEndpointMethodTypes['repos']['getBranch']['response']['data'];
|
||||
}> {
|
||||
const parsed = parseGitUrl(url);
|
||||
const { ref, full_name } = parsed;
|
||||
|
||||
// Caveat: The ref will totally be incorrect if the branch name includes a
|
||||
// slash. Thus, some operations can not work on URLs containing branch
|
||||
// names that have a slash in them.
|
||||
|
||||
const { headers } = await this.deps.credentialsProvider.getCredentials({
|
||||
url,
|
||||
});
|
||||
|
||||
const repo: RestEndpointMethodTypes['repos']['get']['response']['data'] = await this.fetchJson(
|
||||
`${this.config.apiBaseUrl}/repos/${full_name}`,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
// branches_url looks like "https://api.github.com/repos/owner/repo/branches{/branch}"
|
||||
const branch: RestEndpointMethodTypes['repos']['getBranch']['response']['data'] = await this.fetchJson(
|
||||
repo.branches_url.replace('{/branch}', `/${ref || repo.default_branch}`),
|
||||
{ headers },
|
||||
);
|
||||
|
||||
return { repo, branch };
|
||||
}
|
||||
|
||||
private async fetchResponse(
|
||||
url: string | URL,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
const urlAsString = url.toString();
|
||||
|
||||
const response = await fetch(urlAsString, init);
|
||||
|
||||
if (!response.ok) {
|
||||
const message = `Request failed for ${urlAsString}, ${response.status} ${response.statusText}`;
|
||||
if (response.status === 404) {
|
||||
throw new NotFoundError(message);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async fetchJson(url: string | URL, init: RequestInit): Promise<any> {
|
||||
const response = await this.fetchResponse(url, init);
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,11 @@ function withRetries(count: number, fn: () => Promise<void>) {
|
||||
error = err;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
if (!error.message.match(/rate limit|Too Many Requests/)) {
|
||||
throw error;
|
||||
} else {
|
||||
console.warn('Request was rate limited', error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import tar, { Parse, ParseStream, ReadEntry } from 'tar';
|
||||
import platformPath from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import { Readable, pipeline as pipelineCb } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
import concatStream from 'concat-stream';
|
||||
import fs from 'fs-extra';
|
||||
import platformPath from 'path';
|
||||
import { pipeline as pipelineCb, Readable } from 'stream';
|
||||
import tar, { Parse, ParseStream, ReadEntry } from 'tar';
|
||||
import { promisify } from 'util';
|
||||
import {
|
||||
ReadTreeResponse,
|
||||
ReadTreeResponseFile,
|
||||
ReadTreeResponseDirOptions,
|
||||
ReadTreeResponseFile,
|
||||
} from '../types';
|
||||
|
||||
// Tar types for `Parse` is not a proper constructor, but it should be
|
||||
|
||||
@@ -14,15 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import platformPath from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import unzipper, { Entry } from 'unzipper';
|
||||
import archiver from 'archiver';
|
||||
import fs from 'fs-extra';
|
||||
import platformPath from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import unzipper, { Entry } from 'unzipper';
|
||||
import {
|
||||
ReadTreeResponse,
|
||||
ReadTreeResponseFile,
|
||||
ReadTreeResponseDirOptions,
|
||||
ReadTreeResponseFile,
|
||||
} from '../types';
|
||||
|
||||
// Matches a directory name + one `/` at the start of any string,
|
||||
|
||||
@@ -18,6 +18,32 @@ import { Logger } from 'winston';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ReadTreeResponseFactory } from './tree';
|
||||
|
||||
/**
|
||||
* A generic interface for fetching plain data from URLs.
|
||||
*/
|
||||
export type UrlReader = {
|
||||
read(url: string): Promise<Buffer>;
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
};
|
||||
|
||||
export type UrlReaderPredicateTuple = {
|
||||
predicate: (url: URL) => boolean;
|
||||
reader: UrlReader;
|
||||
};
|
||||
|
||||
/**
|
||||
* A factory function that can read config to construct zero or more
|
||||
* UrlReaders along with a predicate for when it should be used.
|
||||
*/
|
||||
export type ReaderFactory = (options: {
|
||||
config: Config;
|
||||
logger: Logger;
|
||||
treeResponseFactory: ReadTreeResponseFactory;
|
||||
}) => UrlReaderPredicateTuple[];
|
||||
|
||||
/**
|
||||
* An options object for readTree operations.
|
||||
*/
|
||||
export type ReadTreeOptions = {
|
||||
/**
|
||||
* A filter that can be used to select which files should be included.
|
||||
@@ -47,39 +73,6 @@ export type ReadTreeOptions = {
|
||||
etag?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A generic interface for fetching plain data from URLs.
|
||||
*/
|
||||
export type UrlReader = {
|
||||
read(url: string): Promise<Buffer>;
|
||||
readTree(url: string, options?: ReadTreeOptions): Promise<ReadTreeResponse>;
|
||||
};
|
||||
|
||||
export type UrlReaderPredicateTuple = {
|
||||
predicate: (url: URL) => boolean;
|
||||
reader: UrlReader;
|
||||
};
|
||||
|
||||
/**
|
||||
* A factory function that can read config to construct zero or more
|
||||
* UrlReaders along with a predicate for when it should be used.
|
||||
*/
|
||||
export type ReaderFactory = (options: {
|
||||
config: Config;
|
||||
logger: Logger;
|
||||
treeResponseFactory: ReadTreeResponseFactory;
|
||||
}) => UrlReaderPredicateTuple[];
|
||||
|
||||
export type ReadTreeResponseFile = {
|
||||
path: string;
|
||||
content(): Promise<Buffer>;
|
||||
};
|
||||
|
||||
export type ReadTreeResponseDirOptions = {
|
||||
/** The directory to write files to. Defaults to the OS tmpdir or `backend.workingDirectory` if set in config */
|
||||
targetDir?: string;
|
||||
};
|
||||
|
||||
export type ReadTreeResponse = {
|
||||
/**
|
||||
* files() returns an array of all the files inside the tree and corresponding functions to read their content.
|
||||
@@ -97,3 +90,16 @@ export type ReadTreeResponse = {
|
||||
*/
|
||||
etag: string;
|
||||
};
|
||||
|
||||
export type ReadTreeResponseDirOptions = {
|
||||
/** The directory to write files to. Defaults to the OS tmpdir or `backend.workingDirectory` if set in config */
|
||||
targetDir?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a single file in a readTree response.
|
||||
*/
|
||||
export type ReadTreeResponseFile = {
|
||||
path: string;
|
||||
content(): Promise<Buffer>;
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ import Docker from 'dockerode';
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
}: PluginEnvironment) {
|
||||
const cookiecutterTemplater = new CookieCutter();
|
||||
const craTemplater = new CreateReactAppTemplater();
|
||||
@@ -54,5 +55,6 @@ export default async function createPlugin({
|
||||
config,
|
||||
dockerClient,
|
||||
entityClient,
|
||||
database,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
"rollup-plugin-esbuild": "2.6.x",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.2",
|
||||
"rollup-plugin-postcss": "^3.1.1",
|
||||
"rollup-plugin-typescript2": "^0.27.3",
|
||||
"rollup-plugin-typescript2": "^0.29.0",
|
||||
"rollup-pluginutils": "^2.8.2",
|
||||
"semver": "^7.3.2",
|
||||
"start-server-webpack-plugin": "^2.2.5",
|
||||
|
||||
@@ -14,7 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { RouteRefConfig, RouteRef } from './types';
|
||||
import { RouteRef } from './types';
|
||||
import { IconComponent } from '../icons';
|
||||
|
||||
export type RouteRefConfig<Params extends { [param in string]: string }> = {
|
||||
params?: Array<keyof Params>;
|
||||
/** @deprecated Route refs no longer decide their own path */
|
||||
path?: string;
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export class AbsoluteRouteRef<Params extends { [param in string]: string }> {
|
||||
constructor(private readonly config: RouteRefConfig<Params>) {}
|
||||
@@ -38,9 +47,20 @@ export class AbsoluteRouteRef<Params extends { [param in string]: string }> {
|
||||
}
|
||||
|
||||
export function createRouteRef<
|
||||
ParamKeys extends string,
|
||||
Params extends { [param in string]: string } = { [name in ParamKeys]: string }
|
||||
>(config: RouteRefConfig<Params>): RouteRef<Params> {
|
||||
// Params is the type that we care about and the one to be embedded in the route ref.
|
||||
// For example, given the params ['name', 'kind'], Params will be {name: string, kind: string}
|
||||
Params extends { [param in ParamKey]: string },
|
||||
// ParamKey is here to make sure the Params type properly has its keys narrowed down
|
||||
// to only the elements of params. Defaulting to never makes sure we end up with
|
||||
// Param = {} if the params array is empty.
|
||||
ParamKey extends string = never
|
||||
>(config: {
|
||||
params?: ParamKey[];
|
||||
/** @deprecated Route refs no longer decide their own path */
|
||||
path?: string;
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
}): RouteRef<Params> {
|
||||
return new AbsoluteRouteRef<Params>(config);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,9 @@ import {
|
||||
createRouteRef,
|
||||
createExternalRouteRef,
|
||||
ExternalRouteRef,
|
||||
RouteRefConfig,
|
||||
} from './RouteRef';
|
||||
import { RouteRef, RouteRefConfig } from './types';
|
||||
import { RouteRef } from './types';
|
||||
|
||||
const mockConfig = (extra?: Partial<RouteRefConfig<{}>>) => ({
|
||||
path: '/unused',
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
|
||||
export type {
|
||||
RouteRef,
|
||||
RouteRefConfig,
|
||||
AbsoluteRouteRef,
|
||||
ConcreteRoute,
|
||||
MutableRouteRef,
|
||||
} from './types';
|
||||
export { FlatRoutes } from './FlatRoutes';
|
||||
export { createRouteRef } from './RouteRef';
|
||||
export type { RouteRefConfig } from './RouteRef';
|
||||
export { useRouteRef } from './hooks';
|
||||
|
||||
@@ -45,14 +45,6 @@ export type AbsoluteRouteRef = RouteRef<{}>;
|
||||
*/
|
||||
export type MutableRouteRef = RouteRef<{}>;
|
||||
|
||||
export type RouteRefConfig<Params extends { [param in string]: string }> = {
|
||||
params?: Array<keyof Params>;
|
||||
/** @deprecated Route refs no longer decide their own path */
|
||||
path?: string;
|
||||
icon?: IconComponent;
|
||||
title: string;
|
||||
};
|
||||
|
||||
// A duplicate of the react-router RouteObject, but with routeRef added
|
||||
export interface BackstageRouteObject {
|
||||
caseSensitive: boolean;
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 019fe39a0: `@backstage/plugin-catalog` stopped exporting hooks and helpers for other
|
||||
plugins. They are migrated to `@backstage/plugin-catalog-react`.
|
||||
Change both your dependencies and imports to the new package.
|
||||
- 019fe39a0: **BREAKING CHANGE**: The `useEntity` hook has been moved from `@backstage/plugin-catalog` to `@backstage/plugin-catalog-react`.
|
||||
To apply this change to an existing app, add `@backstage/plugin-catalog-react` to your dependencies in `packages/app/package.json`, and update
|
||||
the import inside `packages/app/src/components/catalog/EntityPage.tsx` as well as any other places you were using `useEntity` or any other functions that were moved to `@backstage/plugin-catalog-react`.
|
||||
- 436ca3f62: Remove techdocs.requestUrl and techdocs.storageUrl from app-config.yaml
|
||||
- Updated dependencies [ceef4dd89]
|
||||
- Updated dependencies [720149854]
|
||||
|
||||
@@ -14,6 +14,7 @@ import Docker from 'dockerode';
|
||||
export default async function createPlugin({
|
||||
logger,
|
||||
config,
|
||||
database,
|
||||
}: PluginEnvironment) {
|
||||
const cookiecutterTemplater = new CookieCutter();
|
||||
const craTemplater = new CreateReactAppTemplater();
|
||||
@@ -38,5 +39,6 @@ export default async function createPlugin({
|
||||
config,
|
||||
dockerClient,
|
||||
entityClient,
|
||||
database,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class Cache {
|
||||
|
||||
/**
|
||||
* This accept header is required when calling App APIs in GitHub Enterprise.
|
||||
* It has no effect on calls to github.com and can probably be removed entierly
|
||||
* It has no effect on calls to github.com and can probably be removed entirely
|
||||
* once GitHub Apps is out of preview.
|
||||
*/
|
||||
const HEADERS = {
|
||||
|
||||
@@ -21,9 +21,11 @@ import { Route, Routes } from 'react-router';
|
||||
import { withLogCollector } from '@backstage/test-utils-core';
|
||||
import {
|
||||
useApi,
|
||||
useRouteRef,
|
||||
errorApiRef,
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
createRouteRef,
|
||||
} from '@backstage/core-api';
|
||||
import { MockErrorApi } from './apis';
|
||||
|
||||
@@ -113,4 +115,29 @@ describe('wrapInTestApp', () => {
|
||||
expect(rendered.getByText('foo')).toBeInTheDocument();
|
||||
expect(mockErrorApi.getErrors()).toEqual([{ error: new Error('NOPE') }]);
|
||||
});
|
||||
|
||||
it('should allow route refs to be mounted on specific paths', async () => {
|
||||
const aRouteRef = createRouteRef({ title: 'A' });
|
||||
const bRouteRef = createRouteRef({ title: 'B', params: ['name'] });
|
||||
|
||||
const MyComponent = () => {
|
||||
const a = useRouteRef(aRouteRef);
|
||||
const b = useRouteRef(bRouteRef);
|
||||
return (
|
||||
<div>
|
||||
<div>Link A: {a()}</div>
|
||||
<div>Link B: {b({ name: 'x' })}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const rendered = await renderInTestApp(<MyComponent />, {
|
||||
mountedRoutes: {
|
||||
'/my-a-path': aRouteRef,
|
||||
'/my-b-path/:name': bRouteRef,
|
||||
},
|
||||
});
|
||||
expect(rendered.getByText('Link A: /my-a-path')).toBeInTheDocument();
|
||||
expect(rendered.getByText('Link B: /my-b-path/x')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,9 @@ import { lightTheme } from '@backstage/theme';
|
||||
import privateExports, {
|
||||
defaultSystemIcons,
|
||||
BootErrorPageProps,
|
||||
RouteRef,
|
||||
createPlugin,
|
||||
createRoutableExtension,
|
||||
} from '@backstage/core-api';
|
||||
import { RenderResult } from '@testing-library/react';
|
||||
import { renderWithEffects } from '@backstage/test-utils-core';
|
||||
@@ -44,6 +47,22 @@ type TestAppOptions = {
|
||||
* Initial route entries to pass along as `initialEntries` to the router.
|
||||
*/
|
||||
routeEntries?: string[];
|
||||
|
||||
/**
|
||||
* An object of paths to mount route ref on, with the key being the path and the value
|
||||
* being the RouteRef that the path will be bound to. This allows the route refs to be
|
||||
* used by `useRouteRef` in the rendered elements.
|
||||
*
|
||||
* @example
|
||||
* wrapInTestApp(<MyComponent />, {
|
||||
* mountedRoutes: {
|
||||
* '/my-path': myRouteRef,
|
||||
* }
|
||||
* })
|
||||
* // ...
|
||||
* const link = useRouteRef(myRouteRef)
|
||||
*/
|
||||
mountedRoutes?: { [path: string]: RouteRef };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -90,12 +109,27 @@ export function wrapInTestApp(
|
||||
Wrapper = () => Component as React.ReactElement;
|
||||
}
|
||||
|
||||
const routePlugin = createPlugin({ id: 'mock-route-plugin' });
|
||||
const routeElements = Object.entries(options.mountedRoutes ?? {}).map(
|
||||
([path, routeRef]) => {
|
||||
const PageComponent = () => <div>Mounted at {path}</div>;
|
||||
const Page = routePlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: async () => PageComponent,
|
||||
mountPoint: routeRef,
|
||||
}),
|
||||
);
|
||||
return <Route key={path} path={path} element={<Page />} />;
|
||||
},
|
||||
);
|
||||
|
||||
const AppProvider = app.getProvider();
|
||||
const AppRouter = app.getRouter();
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<AppRouter>
|
||||
{routeElements}
|
||||
{/* The path of * here is needed to be set as a catch all, so it will render the wrapper element
|
||||
* and work with nested routes if they exist too */}
|
||||
<Route path="*" element={<Wrapper />} />
|
||||
|
||||
@@ -130,7 +130,7 @@ export class HigherOrderOperations implements HigherOrderOperation {
|
||||
`Locations Refresh: Refreshing location ${location.type}:${location.target}`,
|
||||
);
|
||||
try {
|
||||
await this.refreshSingleLocation(location);
|
||||
await this.refreshSingleLocation(location, logger);
|
||||
await this.locationsCatalog.logUpdateSuccess(location.id, undefined);
|
||||
} catch (e) {
|
||||
logger.warn(
|
||||
@@ -148,8 +148,12 @@ export class HigherOrderOperations implements HigherOrderOperation {
|
||||
}
|
||||
|
||||
// Performs a full refresh of a single location
|
||||
private async refreshSingleLocation(location: Location) {
|
||||
private async refreshSingleLocation(
|
||||
location: Location,
|
||||
optionalLogger?: Logger,
|
||||
) {
|
||||
let startTimestamp = process.hrtime();
|
||||
const logger = optionalLogger || this.logger;
|
||||
|
||||
const readerOutput = await this.locationReader.read({
|
||||
type: location.type,
|
||||
@@ -157,12 +161,12 @@ export class HigherOrderOperations implements HigherOrderOperation {
|
||||
});
|
||||
|
||||
for (const item of readerOutput.errors) {
|
||||
this.logger.warn(
|
||||
logger.warn(
|
||||
`Failed item in location ${item.location.type}:${item.location.target}, ${item.error.stack}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.info(
|
||||
logger.info(
|
||||
`Read ${readerOutput.entities.length} entities from location ${
|
||||
location.type
|
||||
}:${location.target} in ${durationText(startTimestamp)}`,
|
||||
@@ -186,14 +190,14 @@ export class HigherOrderOperations implements HigherOrderOperation {
|
||||
throw e;
|
||||
}
|
||||
|
||||
this.logger.debug(`Posting update success markers`);
|
||||
logger.debug(`Posting update success markers`);
|
||||
|
||||
await this.locationsCatalog.logUpdateSuccess(
|
||||
location.id,
|
||||
readerOutput.entities.map(e => e.entity.metadata.name),
|
||||
);
|
||||
|
||||
this.logger.info(
|
||||
logger.info(
|
||||
`Wrote ${readerOutput.entities.length} entities from location ${
|
||||
location.type
|
||||
}:${location.target} in ${durationText(startTimestamp)}`,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorHandler } from '@backstage/backend-common';
|
||||
import { errorHandler, NotFoundError } from '@backstage/backend-common';
|
||||
import {
|
||||
locationSpecSchema,
|
||||
analyzeLocationSchema,
|
||||
@@ -57,7 +57,7 @@ export async function createRouter(
|
||||
const filter = EntityFilters.ofQuery(req.query);
|
||||
const fieldMapper = translateQueryToFieldMapper(req.query);
|
||||
const entities = await entitiesCatalog.entities(filter);
|
||||
res.status(200).send(entities.map(fieldMapper));
|
||||
res.status(200).json(entities.map(fieldMapper));
|
||||
})
|
||||
.post('/entities', async (req, res) => {
|
||||
const body = await requireRequestBody(req);
|
||||
@@ -67,7 +67,7 @@ export async function createRouter(
|
||||
const [entity] = await entitiesCatalog.entities(
|
||||
EntityFilters.ofMatchers({ 'metadata.uid': result.entityId }),
|
||||
);
|
||||
res.status(200).send(entity);
|
||||
res.status(200).json(entity);
|
||||
})
|
||||
.get('/entities/by-uid/:uid', async (req, res) => {
|
||||
const { uid } = req.params;
|
||||
@@ -75,14 +75,14 @@ export async function createRouter(
|
||||
EntityFilters.ofMatchers({ 'metadata.uid': uid }),
|
||||
);
|
||||
if (!entities.length) {
|
||||
res.status(404).send(`No entity with uid ${uid}`);
|
||||
throw new NotFoundError(`No entity with uid ${uid}`);
|
||||
}
|
||||
res.status(200).send(entities[0]);
|
||||
res.status(200).json(entities[0]);
|
||||
})
|
||||
.delete('/entities/by-uid/:uid', async (req, res) => {
|
||||
const { uid } = req.params;
|
||||
await entitiesCatalog.removeEntityByUid(uid);
|
||||
res.status(204).send();
|
||||
res.status(204).end();
|
||||
})
|
||||
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
|
||||
const { kind, namespace, name } = req.params;
|
||||
@@ -94,13 +94,11 @@ export async function createRouter(
|
||||
}),
|
||||
);
|
||||
if (!entities.length) {
|
||||
res
|
||||
.status(404)
|
||||
.send(
|
||||
`No entity with kind ${kind} namespace ${namespace} name ${name}`,
|
||||
);
|
||||
throw new NotFoundError(
|
||||
`No entity with kind ${kind} namespace ${namespace} name ${name}`,
|
||||
);
|
||||
}
|
||||
res.status(200).send(entities[0]);
|
||||
res.status(200).json(entities[0]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,7 +107,7 @@ export async function createRouter(
|
||||
const input = await validateRequestBody(req, locationSpecSchema);
|
||||
const dryRun = yn(req.query.dryRun, { default: false });
|
||||
const output = await higherOrderOperation.addLocation(input, { dryRun });
|
||||
res.status(201).send(output);
|
||||
res.status(201).json(output);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -117,22 +115,22 @@ export async function createRouter(
|
||||
router
|
||||
.get('/locations', async (_req, res) => {
|
||||
const output = await locationsCatalog.locations();
|
||||
res.status(200).send(output);
|
||||
res.status(200).json(output);
|
||||
})
|
||||
.get('/locations/:id/history', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const output = await locationsCatalog.locationHistory(id);
|
||||
res.status(200).send(output);
|
||||
res.status(200).json(output);
|
||||
})
|
||||
.get('/locations/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const output = await locationsCatalog.location(id);
|
||||
res.status(200).send(output);
|
||||
res.status(200).json(output);
|
||||
})
|
||||
.delete('/locations/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
await locationsCatalog.removeLocation(id);
|
||||
res.status(204).send();
|
||||
res.status(204).end();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,7 +138,7 @@ export async function createRouter(
|
||||
router.post('/analyze-location', async (req, res) => {
|
||||
const input = await validateRequestBody(req, analyzeLocationSchema);
|
||||
const output = await locationAnalyzer.analyzeLocation(input);
|
||||
res.status(200).send(output);
|
||||
res.status(200).json(output);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
*/
|
||||
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
import { circleCIPlugin } from '../src/plugin';
|
||||
|
||||
createDevApp().registerPlugin(plugin).render();
|
||||
createDevApp().registerPlugin(circleCIPlugin).render();
|
||||
|
||||
@@ -21,15 +21,25 @@ import { BuildWithStepsPage } from './BuildWithStepsPage/';
|
||||
import { BuildsPage } from './BuildsPage';
|
||||
import { CIRCLECI_ANNOTATION } from '../constants';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { MissingAnnotationEmptyState } from '@backstage/core';
|
||||
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
export const isCircleCIAvailable = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[CIRCLECI_ANNOTATION]);
|
||||
|
||||
export const Router = ({ entity }: { entity: Entity }) =>
|
||||
!isPluginApplicableToEntity(entity) ? (
|
||||
<MissingAnnotationEmptyState annotation={CIRCLECI_ANNOTATION} />
|
||||
) : (
|
||||
type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
};
|
||||
|
||||
export const Router = (_props: Props) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
if (!isCircleCIAvailable(entity)) {
|
||||
return <MissingAnnotationEmptyState annotation={CIRCLECI_ANNOTATION} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={`/${circleCIRouteRef.path}`} element={<BuildsPage />} />
|
||||
<Route
|
||||
@@ -38,3 +48,4 @@ export const Router = ({ entity }: { entity: Entity }) =>
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,8 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export {
|
||||
circleCIPlugin,
|
||||
circleCIPlugin as plugin,
|
||||
EntityCircleCIContent,
|
||||
} from './plugin';
|
||||
export * from './api';
|
||||
export * from './route-refs';
|
||||
export { Router, isPluginApplicableToEntity } from './components/Router';
|
||||
export {
|
||||
Router,
|
||||
isCircleCIAvailable,
|
||||
isCircleCIAvailable as isPluginApplicableToEntity,
|
||||
} from './components/Router';
|
||||
export { CIRCLECI_ANNOTATION } from './constants';
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { plugin } from './plugin';
|
||||
import { circleCIPlugin } from './plugin';
|
||||
|
||||
describe('circleci', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
expect(circleCIPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,10 +18,12 @@ import {
|
||||
createPlugin,
|
||||
createApiFactory,
|
||||
discoveryApiRef,
|
||||
createRoutableExtension,
|
||||
} from '@backstage/core';
|
||||
import { circleCIApiRef, CircleCIApi } from './api';
|
||||
import { circleCIRouteRef } from './route-refs';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
export const circleCIPlugin = createPlugin({
|
||||
id: 'circleci',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
@@ -31,3 +33,10 @@ export const plugin = createPlugin({
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const EntityCircleCIContent = circleCIPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () => import('./components/Router').then(m => m.Router),
|
||||
mountPoint: circleCIRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
import { cloudbuildPlugin } from '../src/plugin';
|
||||
|
||||
createDevApp().registerPlugin(plugin).render();
|
||||
createDevApp().registerPlugin(cloudbuildPlugin).render();
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.7.1",
|
||||
"@backstage/plugin-catalog-react": "^0.0.2",
|
||||
"@backstage/core": "^0.6.0",
|
||||
"@backstage/theme": "^0.2.3",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
|
||||
@@ -17,6 +17,7 @@ import React, { useEffect } from 'react';
|
||||
import { useWorkflowRuns } from '../useWorkflowRuns';
|
||||
import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { WorkflowRunStatus } from '../WorkflowRunStatus';
|
||||
import { Link, Theme, makeStyles, LinearProgress } from '@material-ui/core';
|
||||
import {
|
||||
@@ -72,12 +73,13 @@ const WidgetContent = ({
|
||||
};
|
||||
|
||||
export const LatestWorkflowRunCard = ({
|
||||
entity,
|
||||
branch = 'master',
|
||||
}: {
|
||||
entity: Entity;
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
branch: string;
|
||||
}) => {
|
||||
const { entity } = useEntity();
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const projectId = entity?.metadata.annotations?.[CLOUDBUILD_ANNOTATION] || '';
|
||||
|
||||
@@ -104,13 +106,17 @@ export const LatestWorkflowRunCard = ({
|
||||
};
|
||||
|
||||
export const LatestWorkflowsForBranchCard = ({
|
||||
entity,
|
||||
branch = 'master',
|
||||
}: {
|
||||
entity: Entity;
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
branch: string;
|
||||
}) => (
|
||||
<InfoCard title={`Last ${branch} build`}>
|
||||
<WorkflowRunsTable entity={entity} />
|
||||
</InfoCard>
|
||||
);
|
||||
}) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
return (
|
||||
<InfoCard title={`Last ${branch} build`}>
|
||||
<WorkflowRunsTable entity={entity} />
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Routes, Route } from 'react-router';
|
||||
import { rootRouteRef, buildRouteRef } from '../plugin';
|
||||
import { WorkflowRunDetails } from './WorkflowRunDetails';
|
||||
@@ -22,14 +23,22 @@ import { WorkflowRunsTable } from './WorkflowRunsTable';
|
||||
import { CLOUDBUILD_ANNOTATION } from './useProjectName';
|
||||
import { MissingAnnotationEmptyState } from '@backstage/core';
|
||||
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
export const isCloudbuildAvailable = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[CLOUDBUILD_ANNOTATION]);
|
||||
|
||||
export const Router = ({ entity }: { entity: Entity }) =>
|
||||
// TODO(shmidt-i): move warning to a separate standardized component
|
||||
!isPluginApplicableToEntity(entity) ? (
|
||||
<MissingAnnotationEmptyState annotation={CLOUDBUILD_ANNOTATION} />
|
||||
) : (
|
||||
type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
};
|
||||
|
||||
export const Router = (_props: Props) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
if (!isCloudbuildAvailable(entity)) {
|
||||
// TODO(shmidt-i): move warning to a separate standardized component
|
||||
return <MissingAnnotationEmptyState annotation={CLOUDBUILD_ANNOTATION} />;
|
||||
}
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path={`/${rootRouteRef.path}`}
|
||||
@@ -42,3 +51,4 @@ export const Router = ({ entity }: { entity: Entity }) =>
|
||||
)
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,8 +13,18 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { plugin } from './plugin';
|
||||
export {
|
||||
cloudbuildPlugin,
|
||||
cloudbuildPlugin as plugin,
|
||||
EntityCloudbuildContent,
|
||||
EntityLatestCloudbuildRunCard,
|
||||
EntityLatestCloudbuildsForBranchCard,
|
||||
} from './plugin';
|
||||
export * from './api';
|
||||
export { Router, isPluginApplicableToEntity } from './components/Router';
|
||||
export {
|
||||
Router,
|
||||
isCloudbuildAvailable,
|
||||
isCloudbuildAvailable as isPluginApplicableToEntity,
|
||||
} from './components/Router';
|
||||
export * from './components/Cards';
|
||||
export { CLOUDBUILD_ANNOTATION } from './components/useProjectName';
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { plugin } from './plugin';
|
||||
import { cloudbuildPlugin } from './plugin';
|
||||
|
||||
describe('cloudbuild', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
expect(cloudbuildPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
createRouteRef,
|
||||
createApiFactory,
|
||||
googleAuthApiRef,
|
||||
createRoutableExtension,
|
||||
createComponentExtension,
|
||||
} from '@backstage/core';
|
||||
import { cloudbuildApiRef, CloudbuildClient } from './api';
|
||||
|
||||
@@ -31,7 +33,7 @@ export const buildRouteRef = createRouteRef({
|
||||
title: 'Cloudbuild Run',
|
||||
});
|
||||
|
||||
export const plugin = createPlugin({
|
||||
export const cloudbuildPlugin = createPlugin({
|
||||
id: 'cloudbuild',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
@@ -42,4 +44,32 @@ export const plugin = createPlugin({
|
||||
},
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
entityContent: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
export const EntityCloudbuildContent = cloudbuildPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () => import('./components/Router').then(m => m.Router),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityLatestCloudbuildRunCard = cloudbuildPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/Cards').then(m => m.LatestWorkflowRunCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityLatestCloudbuildsForBranchCard = cloudbuildPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/Cards').then(m => m.LatestWorkflowsForBranchCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -149,7 +149,7 @@ export const AlertSnoozeOptions: AlertSnoozeOption[] = [
|
||||
label: '1 Month',
|
||||
},
|
||||
{
|
||||
duration: Duration.P3M,
|
||||
duration: Duration.P90D,
|
||||
label: '1 Quarter',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
*/
|
||||
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
import { githubActionsPlugin } from '../src/plugin';
|
||||
|
||||
createDevApp().registerPlugin(plugin).render();
|
||||
createDevApp().registerPlugin(githubActionsPlugin).render();
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.7.1",
|
||||
"@backstage/plugin-catalog-react": "^0.0.2",
|
||||
"@backstage/core": "^0.6.0",
|
||||
"@backstage/integration": "^0.3.2",
|
||||
"@backstage/theme": "^0.2.3",
|
||||
|
||||
@@ -17,6 +17,7 @@ import React, { useEffect } from 'react';
|
||||
import { useWorkflowRuns } from '../useWorkflowRuns';
|
||||
import { WorkflowRun, WorkflowRunsTable } from '../WorkflowRunsTable';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { readGitHubIntegrationConfigs } from '@backstage/integration';
|
||||
import { WorkflowRunStatus } from '../WorkflowRunStatus';
|
||||
import {
|
||||
@@ -81,11 +82,11 @@ const WidgetContent = ({
|
||||
};
|
||||
|
||||
export const LatestWorkflowRunCard = ({
|
||||
entity,
|
||||
branch = 'master',
|
||||
// Display the card full height suitable for
|
||||
variant,
|
||||
}: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const config = useApi(configApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
// TODO: Get github hostname from metadata annotation
|
||||
@@ -121,17 +122,21 @@ export const LatestWorkflowRunCard = ({
|
||||
};
|
||||
|
||||
type Props = {
|
||||
entity: Entity;
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
branch: string;
|
||||
variant?: string;
|
||||
};
|
||||
|
||||
export const LatestWorkflowsForBranchCard = ({
|
||||
entity,
|
||||
branch = 'master',
|
||||
variant,
|
||||
}: Props) => (
|
||||
<InfoCard title={`Last ${branch} build`} variant={variant}>
|
||||
<WorkflowRunsTable branch={branch} entity={entity} />
|
||||
</InfoCard>
|
||||
);
|
||||
}: Props) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
return (
|
||||
<InfoCard title={`Last ${branch} build`} variant={variant}>
|
||||
<WorkflowRunsTable branch={branch} entity={entity} />
|
||||
</InfoCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ConfigApi,
|
||||
ConfigReader,
|
||||
} from '@backstage/core';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { render } from '@testing-library/react';
|
||||
@@ -84,7 +85,9 @@ describe('<RecentWorkflowRunsCard />', () => {
|
||||
configApi,
|
||||
)}
|
||||
>
|
||||
<RecentWorkflowRunsCard {...props} />
|
||||
<EntityProvider entity={props.entity!}>
|
||||
<RecentWorkflowRunsCard {...props} />
|
||||
</EntityProvider>
|
||||
</ApiProvider>
|
||||
</MemoryRouter>
|
||||
</ThemeProvider>,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { readGitHubIntegrationConfigs } from '@backstage/integration';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Button, Link } from '@material-ui/core';
|
||||
import React, { useEffect } from 'react';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
@@ -33,7 +34,8 @@ import { WorkflowRunStatus } from '../WorkflowRunStatus';
|
||||
const firstLine = (message: string): string => message.split('\n')[0];
|
||||
|
||||
export type Props = {
|
||||
entity: Entity;
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
branch?: string;
|
||||
dense?: boolean;
|
||||
limit?: number;
|
||||
@@ -41,12 +43,12 @@ export type Props = {
|
||||
};
|
||||
|
||||
export const RecentWorkflowRunsCard = ({
|
||||
entity,
|
||||
branch,
|
||||
dense = false,
|
||||
limit = 5,
|
||||
variant,
|
||||
}: Props) => {
|
||||
const { entity } = useEntity();
|
||||
const config = useApi(configApiRef);
|
||||
const errorApi = useApi(errorApiRef);
|
||||
// TODO: Get github hostname from metadata annotation
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { Routes, Route } from 'react-router';
|
||||
import { rootRouteRef, buildRouteRef } from '../plugin';
|
||||
import { WorkflowRunDetails } from './WorkflowRunDetails';
|
||||
@@ -22,13 +23,23 @@ import { WorkflowRunsTable } from './WorkflowRunsTable';
|
||||
import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName';
|
||||
import { MissingAnnotationEmptyState } from '@backstage/core';
|
||||
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
export const isGithubActionsAvailable = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]);
|
||||
|
||||
export const Router = ({ entity }: { entity: Entity }) =>
|
||||
!isPluginApplicableToEntity(entity) ? (
|
||||
<MissingAnnotationEmptyState annotation={GITHUB_ACTIONS_ANNOTATION} />
|
||||
) : (
|
||||
type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
};
|
||||
|
||||
export const Router = (_props: Props) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
if (!isGithubActionsAvailable(entity)) {
|
||||
return (
|
||||
<MissingAnnotationEmptyState annotation={GITHUB_ACTIONS_ANNOTATION} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path={`/${rootRouteRef.path}`}
|
||||
@@ -41,3 +52,4 @@ export const Router = ({ entity }: { entity: Entity }) =>
|
||||
)
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,8 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export {
|
||||
githubActionsPlugin,
|
||||
githubActionsPlugin as plugin,
|
||||
EntityGithubActionsContent,
|
||||
EntityLatestGithubActionRunCard,
|
||||
EntityLatestGithubActionsForBranchCard,
|
||||
EntityRecentGithubActionsRunsCard,
|
||||
} from './plugin';
|
||||
export * from './api';
|
||||
export { Router, isPluginApplicableToEntity } from './components/Router';
|
||||
export {
|
||||
Router,
|
||||
isGithubActionsAvailable,
|
||||
isGithubActionsAvailable as isPluginApplicableToEntity,
|
||||
} from './components/Router';
|
||||
export * from './components/Cards';
|
||||
export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName';
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { plugin } from './plugin';
|
||||
import { githubActionsPlugin } from './plugin';
|
||||
|
||||
describe('github-actions', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
expect(githubActionsPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
createRouteRef,
|
||||
createApiFactory,
|
||||
githubAuthApiRef,
|
||||
createRoutableExtension,
|
||||
createComponentExtension,
|
||||
} from '@backstage/core';
|
||||
import { githubActionsApiRef, GithubActionsClient } from './api';
|
||||
|
||||
@@ -34,7 +36,7 @@ export const buildRouteRef = createRouteRef({
|
||||
title: 'GitHub Actions Workflow Run',
|
||||
});
|
||||
|
||||
export const plugin = createPlugin({
|
||||
export const githubActionsPlugin = createPlugin({
|
||||
id: 'github-actions',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
@@ -44,4 +46,41 @@ export const plugin = createPlugin({
|
||||
new GithubActionsClient({ configApi, githubAuthApi }),
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
entityContent: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
export const EntityGithubActionsContent = githubActionsPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () => import('./components/Router').then(m => m.Router),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityLatestGithubActionRunCard = githubActionsPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/Cards').then(m => m.LatestWorkflowRunCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityLatestGithubActionsForBranchCard = githubActionsPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/Cards').then(m => m.LatestWorkflowsForBranchCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntityRecentGithubActionsRunsCard = githubActionsPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/Cards').then(m => m.RecentWorkflowRunsCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
*/
|
||||
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
import { gitopsProfilesPlugin } from '../src/plugin';
|
||||
|
||||
createDevApp().registerPlugin(plugin).render();
|
||||
createDevApp().registerPlugin(gitopsProfilesPlugin).render();
|
||||
|
||||
@@ -14,5 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export {
|
||||
gitopsProfilesPlugin,
|
||||
gitopsProfilesPlugin as plugin,
|
||||
GitopsProfilesClusterListPage,
|
||||
GitopsProfilesClusterPage,
|
||||
GitopsProfilesCreatePage,
|
||||
} from './plugin';
|
||||
export * from './api';
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { plugin } from './plugin';
|
||||
import { gitopsProfilesPlugin } from './plugin';
|
||||
|
||||
describe('gitops-profiles', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
expect(gitopsProfilesPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPlugin, createApiFactory } from '@backstage/core';
|
||||
import {
|
||||
createPlugin,
|
||||
createApiFactory,
|
||||
createRoutableExtension,
|
||||
} from '@backstage/core';
|
||||
import ProfileCatalog from './components/ProfileCatalog';
|
||||
import ClusterPage from './components/ClusterPage';
|
||||
import ClusterList from './components/ClusterList';
|
||||
@@ -25,7 +29,7 @@ import {
|
||||
} from './routes';
|
||||
import { gitOpsApiRef, GitOpsRestApi } from './api';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
export const gitopsProfilesPlugin = createPlugin({
|
||||
id: 'gitops-profiles',
|
||||
apis: [
|
||||
createApiFactory(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')),
|
||||
@@ -35,4 +39,30 @@ export const plugin = createPlugin({
|
||||
router.addRoute(gitOpsClusterDetailsRoute, ClusterPage);
|
||||
router.addRoute(gitOpsClusterCreateRoute, ProfileCatalog);
|
||||
},
|
||||
routes: {
|
||||
listPage: gitOpsClusterListRoute,
|
||||
detailsPage: gitOpsClusterDetailsRoute,
|
||||
createPage: gitOpsClusterCreateRoute,
|
||||
},
|
||||
});
|
||||
|
||||
export const GitopsProfilesClusterListPage = gitopsProfilesPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () => import('./components/ClusterList').then(m => m.default),
|
||||
mountPoint: gitOpsClusterListRoute,
|
||||
}),
|
||||
);
|
||||
|
||||
export const GitopsProfilesClusterPage = gitopsProfilesPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () => import('./components/ClusterPage').then(m => m.default),
|
||||
mountPoint: gitOpsClusterDetailsRoute,
|
||||
}),
|
||||
);
|
||||
|
||||
export const GitopsProfilesCreatePage = gitopsProfilesPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () => import('./components/ProfileCatalog').then(m => m.default),
|
||||
mountPoint: gitOpsClusterCreateRoute,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -28,6 +28,7 @@ export const gitOpsClusterDetailsRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/gitops-cluster/:owner/:repo',
|
||||
title: 'GitOps Cluster details',
|
||||
params: ['owner', 'repo'],
|
||||
});
|
||||
|
||||
export const gitOpsClusterCreateRoute = createRouteRef({
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
*/
|
||||
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
import { jenkinsPlugin } from '../src/plugin';
|
||||
|
||||
createDevApp().registerPlugin(plugin).render();
|
||||
createDevApp().registerPlugin(jenkinsPlugin).render();
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { buildRouteRef, rootRouteRef } from '../plugin';
|
||||
import { DetailedViewPage } from './BuildWithStepsPage/';
|
||||
import { JENKINS_ANNOTATION } from '../constants';
|
||||
@@ -22,13 +23,22 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { MissingAnnotationEmptyState } from '@backstage/core';
|
||||
import { CITable } from './BuildsPage/lib/CITable';
|
||||
|
||||
export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
export const isJenkinsAvailable = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[JENKINS_ANNOTATION]);
|
||||
|
||||
export const Router = ({ entity }: { entity: Entity }) => {
|
||||
return !isPluginApplicableToEntity(entity) ? (
|
||||
<MissingAnnotationEmptyState annotation={JENKINS_ANNOTATION} />
|
||||
) : (
|
||||
type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
};
|
||||
|
||||
export const Router = (_props: Props) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
if (!isJenkinsAvailable(entity)) {
|
||||
return <MissingAnnotationEmptyState annotation={JENKINS_ANNOTATION} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={`/${rootRouteRef.path}`} element={<CITable />} />
|
||||
<Route path={`/${buildRouteRef.path}`} element={<DetailedViewPage />} />
|
||||
|
||||
@@ -14,8 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export {
|
||||
jenkinsPlugin,
|
||||
jenkinsPlugin as plugin,
|
||||
EntityJenkinsContent,
|
||||
EntityLatestJenkinsRunCard,
|
||||
} from './plugin';
|
||||
export { LatestRunCard } from './components/Cards';
|
||||
export { Router, isPluginApplicableToEntity } from './components/Router';
|
||||
export {
|
||||
Router,
|
||||
isJenkinsAvailable,
|
||||
isJenkinsAvailable as isPluginApplicableToEntity,
|
||||
} from './components/Router';
|
||||
export { JENKINS_ANNOTATION } from './constants';
|
||||
export * from './api';
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { plugin } from './plugin';
|
||||
import { jenkinsPlugin } from './plugin';
|
||||
|
||||
describe('jenkins', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
expect(jenkinsPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
createRouteRef,
|
||||
createApiFactory,
|
||||
discoveryApiRef,
|
||||
createRoutableExtension,
|
||||
createComponentExtension,
|
||||
} from '@backstage/core';
|
||||
import { jenkinsApiRef, JenkinsApi } from './api';
|
||||
|
||||
@@ -32,7 +34,7 @@ export const buildRouteRef = createRouteRef({
|
||||
title: 'Jenkins run',
|
||||
});
|
||||
|
||||
export const plugin = createPlugin({
|
||||
export const jenkinsPlugin = createPlugin({
|
||||
id: 'jenkins',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
@@ -41,4 +43,21 @@ export const plugin = createPlugin({
|
||||
factory: ({ discoveryApi }) => new JenkinsApi({ discoveryApi }),
|
||||
}),
|
||||
],
|
||||
routes: {
|
||||
entityContent: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
export const EntityJenkinsContent = jenkinsPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () => import('./components/Router').then(m => m.Router),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
export const EntityLatestJenkinsRunCard = jenkinsPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () => import('./components/Cards').then(m => m.LatestRunCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import AuditList from './components/AuditList';
|
||||
import AuditView, { AuditViewContent } from './components/AuditView';
|
||||
import CreateAudit, { CreateAuditContent } from './components/CreateAudit';
|
||||
@@ -35,15 +36,27 @@ export const Router = () => (
|
||||
</Routes>
|
||||
);
|
||||
|
||||
export const EmbeddedRouter = ({ entity }: { entity: Entity }) =>
|
||||
!isLighthouseAvailable(entity) ? (
|
||||
<MissingAnnotationEmptyState
|
||||
annotation={LIGHTHOUSE_WEBSITE_URL_ANNOTATION}
|
||||
/>
|
||||
) : (
|
||||
type Props = {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
};
|
||||
|
||||
export const EmbeddedRouter = (_props: Props) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
if (!isLighthouseAvailable(entity)) {
|
||||
return (
|
||||
<MissingAnnotationEmptyState
|
||||
annotation={LIGHTHOUSE_WEBSITE_URL_ANNOTATION}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<AuditListForEntity />} />
|
||||
<Route path="/audit/:id" element={<AuditViewContent />} />
|
||||
<Route path="/create-audit" element={<CreateAuditContent />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Avatar, InfoCard } from '@backstage/core';
|
||||
import {
|
||||
getEntityRelations,
|
||||
entityRouteParams,
|
||||
useEntity,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Box,
|
||||
@@ -45,26 +46,29 @@ import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
const GroupLink = ({
|
||||
groupName,
|
||||
index = 0,
|
||||
entity,
|
||||
}: {
|
||||
groupName: string;
|
||||
index?: number;
|
||||
entity: Entity;
|
||||
}) => (
|
||||
<>
|
||||
{index >= 1 ? ', ' : ''}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(
|
||||
`/catalog/:namespace/group/${groupName}`,
|
||||
entityRouteParams(entity),
|
||||
)}
|
||||
>
|
||||
[{groupName}]
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
}) => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
return (
|
||||
<>
|
||||
{index >= 1 ? ', ' : ''}
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={generatePath(
|
||||
`/catalog/:namespace/group/${groupName}`,
|
||||
entityRouteParams(entity),
|
||||
)}
|
||||
>
|
||||
[{groupName}]
|
||||
</Link>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const CardTitle = ({ title }: { title: string }) => (
|
||||
<Box display="flex" alignItems="center">
|
||||
<GroupIcon fontSize="inherit" />
|
||||
@@ -73,12 +77,13 @@ const CardTitle = ({ title }: { title: string }) => (
|
||||
);
|
||||
|
||||
export const GroupProfileCard = ({
|
||||
entity: group,
|
||||
variant,
|
||||
}: {
|
||||
entity: GroupEntity;
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: GroupEntity;
|
||||
variant: string;
|
||||
}) => {
|
||||
const group = useEntity().entity as GroupEntity;
|
||||
const {
|
||||
metadata: { name, description },
|
||||
spec: { profile },
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
|
||||
import { Entity, GroupEntity } from '@backstage/catalog-model';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core';
|
||||
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { MembersListCard } from './MembersListCard';
|
||||
@@ -78,7 +82,10 @@ describe('MemberTab Test', () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<MembersListCard entity={groupEntity} />
|
||||
<EntityProvider entity={groupEntity}>
|
||||
<MembersListCard />
|
||||
</EntityProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@backstage/catalog-model';
|
||||
import { Avatar, InfoCard, Progress, useApi } from '@backstage/core';
|
||||
import {
|
||||
useEntity,
|
||||
catalogApiRef,
|
||||
entityRouteParams,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
@@ -105,11 +106,11 @@ const MemberComponent = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const MembersListCard = ({
|
||||
entity: groupEntity,
|
||||
}: {
|
||||
entity: GroupEntity;
|
||||
export const MembersListCard = (_props: {
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: GroupEntity;
|
||||
}) => {
|
||||
const groupEntity = useEntity().entity as GroupEntity;
|
||||
const {
|
||||
metadata: { name: groupName },
|
||||
spec: { profile },
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { InfoCard, Progress, useApi } from '@backstage/core';
|
||||
import { catalogApiRef, isOwnerOf } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
catalogApiRef,
|
||||
isOwnerOf,
|
||||
useEntity,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { pageTheme } from '@backstage/theme';
|
||||
import {
|
||||
Box,
|
||||
@@ -113,12 +117,13 @@ const EntityCountTile = ({
|
||||
};
|
||||
|
||||
export const OwnershipCard = ({
|
||||
entity,
|
||||
variant,
|
||||
}: {
|
||||
entity: Entity;
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
variant: string;
|
||||
}) => {
|
||||
const { entity } = useEntity();
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const {
|
||||
loading,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { UserProfileCard } from './UserProfileCard';
|
||||
@@ -48,7 +49,11 @@ describe('UserSummary Test', () => {
|
||||
|
||||
it('Display Profile Card', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(<UserProfileCard entity={userEntity} variant="gridItem" />),
|
||||
wrapInTestApp(
|
||||
<EntityProvider entity={userEntity}>
|
||||
<UserProfileCard entity={userEntity} variant="gridItem" />
|
||||
</EntityProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(rendered.getByText('calum-leavy@example.com')).toBeInTheDocument();
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Avatar, InfoCard } from '@backstage/core';
|
||||
import { entityRouteParams } from '@backstage/plugin-catalog-react';
|
||||
import { entityRouteParams, useEntity } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
@@ -69,12 +69,13 @@ const CardTitle = ({ title }: { title?: string }) =>
|
||||
) : null;
|
||||
|
||||
export const UserProfileCard = ({
|
||||
entity: user,
|
||||
variant,
|
||||
}: {
|
||||
entity: UserEntity;
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: UserEntity;
|
||||
variant: string;
|
||||
}) => {
|
||||
const user = useEntity().entity as UserEntity;
|
||||
const {
|
||||
metadata: { name: metaName },
|
||||
spec: { profile },
|
||||
|
||||
@@ -14,6 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
import { pagerDutyPlugin } from '../src/plugin';
|
||||
|
||||
createDevApp().registerPlugin(plugin).render();
|
||||
createDevApp().registerPlugin(pagerDutyPlugin).render();
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/catalog-model": "^0.7.1",
|
||||
"@backstage/plugin-catalog-react": "^0.0.2",
|
||||
"@backstage/core": "^0.6.0",
|
||||
"@backstage/theme": "^0.2.3",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
|
||||
@@ -17,6 +17,7 @@ import React from 'react';
|
||||
import { render, waitFor, fireEvent, act } from '@testing-library/react';
|
||||
import { PagerDutyCard } from './PagerDutyCard';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityProvider } from '@backstage/plugin-catalog-react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import {
|
||||
alertApiRef,
|
||||
@@ -80,7 +81,9 @@ describe('PageDutyCard', () => {
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<PagerDutyCard entity={entity} />
|
||||
<EntityProvider entity={entity}>
|
||||
<PagerDutyCard />
|
||||
</EntityProvider>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
@@ -99,7 +102,9 @@ describe('PageDutyCard', () => {
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<PagerDutyCard entity={entity} />
|
||||
<EntityProvider entity={entity}>
|
||||
<PagerDutyCard />
|
||||
</EntityProvider>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
@@ -114,7 +119,9 @@ describe('PageDutyCard', () => {
|
||||
const { getByText, queryByTestId } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<PagerDutyCard entity={entity} />
|
||||
<EntityProvider entity={entity}>
|
||||
<PagerDutyCard />
|
||||
</EntityProvider>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
@@ -134,7 +141,9 @@ describe('PageDutyCard', () => {
|
||||
const { getByText, queryByTestId, getByTestId, getByRole } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<PagerDutyCard entity={entity} />
|
||||
<EntityProvider entity={entity}>
|
||||
<PagerDutyCard />
|
||||
</EntityProvider>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useApi, Progress, HeaderIconLinkRow } from '@backstage/core';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
Button,
|
||||
makeStyles,
|
||||
@@ -56,11 +57,13 @@ export const isPluginApplicableToEntity = (entity: Entity) =>
|
||||
Boolean(entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY]);
|
||||
|
||||
type Props = {
|
||||
entity: Entity;
|
||||
/** @deprecated The entity is now grabbed from context instead */
|
||||
entity?: Entity;
|
||||
};
|
||||
|
||||
export const PagerDutyCard = ({ entity }: Props) => {
|
||||
export const PagerDutyCard = (_props: Props) => {
|
||||
const classes = useStyles();
|
||||
const { entity } = useEntity();
|
||||
const api = useApi(pagerDutyApiRef);
|
||||
const [showDialog, setShowDialog] = useState<boolean>(false);
|
||||
const [refreshIncidents, setRefreshIncidents] = useState<boolean>(false);
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export { plugin } from './plugin';
|
||||
export {
|
||||
pagerDutyPlugin,
|
||||
pagerDutyPlugin as plugin,
|
||||
EntityPagerDutyCard,
|
||||
} from './plugin';
|
||||
export {
|
||||
isPluginApplicableToEntity,
|
||||
isPluginApplicableToEntity as isPagerDutyAvailable,
|
||||
PagerDutyCard,
|
||||
} from './components/PagerDutyCard';
|
||||
export {
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { plugin } from './plugin';
|
||||
import { pagerDutyPlugin } from './plugin';
|
||||
|
||||
describe('pagerduty', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
expect(pagerDutyPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
createRouteRef,
|
||||
discoveryApiRef,
|
||||
configApiRef,
|
||||
createComponentExtension,
|
||||
} from '@backstage/core';
|
||||
import { pagerDutyApiRef, PagerDutyClient } from './api';
|
||||
|
||||
@@ -27,7 +28,7 @@ export const rootRouteRef = createRouteRef({
|
||||
title: 'pagerduty',
|
||||
});
|
||||
|
||||
export const plugin = createPlugin({
|
||||
export const pagerDutyPlugin = createPlugin({
|
||||
id: 'pagerduty',
|
||||
apis: [
|
||||
createApiFactory({
|
||||
@@ -38,3 +39,12 @@ export const plugin = createPlugin({
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const EntityPagerDutyCard = pagerDutyPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/PagerDutyCard').then(m => m.PagerDutyCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,6 @@
|
||||
*/
|
||||
|
||||
import { createDevApp } from '@backstage/dev-utils';
|
||||
import { plugin } from '../src/plugin';
|
||||
import { registerComponentPlugin } from '../src/plugin';
|
||||
|
||||
createDevApp().registerPlugin(plugin).render();
|
||||
createDevApp().registerPlugin(registerComponentPlugin).render();
|
||||
|
||||
@@ -14,5 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { plugin } from './plugin';
|
||||
export {
|
||||
registerComponentPlugin,
|
||||
registerComponentPlugin as plugin,
|
||||
RegisterComponentPage,
|
||||
} from './plugin';
|
||||
export { Router } from './components/Router';
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { plugin } from './plugin';
|
||||
import { registerComponentPlugin } from './plugin';
|
||||
|
||||
describe('register-component', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
expect(registerComponentPlugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,29 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import {
|
||||
createPlugin,
|
||||
createRoutableExtension,
|
||||
createRouteRef,
|
||||
} from '@backstage/core';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'register-component',
|
||||
const rootRouteRef = createRouteRef({
|
||||
title: 'Register Component',
|
||||
});
|
||||
|
||||
export const registerComponentPlugin = createPlugin({
|
||||
id: 'register-component',
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
export const RegisterComponentPage = registerComponentPlugin.provide(
|
||||
createRoutableExtension({
|
||||
component: () =>
|
||||
import('./components/RegisterComponentPage').then(
|
||||
m => m.RegisterComponentPage,
|
||||
),
|
||||
mountPoint: rootRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {import('knex')} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.createTable('tasks', table => {
|
||||
table.comment('The table of scaffolder tasks');
|
||||
table.uuid('id').primary().notNullable().comment('The ID of the task');
|
||||
table
|
||||
.text('spec')
|
||||
.notNullable()
|
||||
.comment('A JSON encoded task specification');
|
||||
table
|
||||
.text('status')
|
||||
.notNullable()
|
||||
.comment('The current status of the task');
|
||||
table
|
||||
.dateTime('created_at')
|
||||
.defaultTo(knex.fn.now())
|
||||
.notNullable()
|
||||
.comment('The timestamp when this task was created');
|
||||
table
|
||||
.dateTime('last_heartbeat_at')
|
||||
.nullable()
|
||||
.comment('The last timestamp when a heartbeat was received');
|
||||
});
|
||||
|
||||
await knex.schema.createTable('task_events', table => {
|
||||
table.comment('The event stream a given task');
|
||||
table
|
||||
.bigIncrements('id')
|
||||
.primary()
|
||||
.notNullable()
|
||||
.comment('The ID of the event');
|
||||
table
|
||||
.uuid('task_id')
|
||||
.references('id')
|
||||
.inTable('tasks')
|
||||
.notNullable()
|
||||
.onDelete('CASCADE')
|
||||
.comment('The task that generated the event');
|
||||
table
|
||||
.text('body')
|
||||
.notNullable()
|
||||
.comment('The JSON encoded body of the event');
|
||||
table.text('event_type').notNullable().comment('The type of event');
|
||||
table
|
||||
.timestamp('created_at')
|
||||
.defaultTo(knex.fn.now())
|
||||
.notNullable()
|
||||
.comment('The timestamp when this event was generated');
|
||||
|
||||
table.index(['task_id'], 'task_events_task_id_idx');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('knex')} knex
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
if (knex.client.config.client !== 'sqlite3') {
|
||||
await knex.schema.alterTable('task_events', table => {
|
||||
table.dropIndex([], 'ctask_events_task_id_idx');
|
||||
});
|
||||
}
|
||||
await knex.schema.dropTable('task_events');
|
||||
await knex.schema.dropTable('tasks');
|
||||
};
|
||||
@@ -53,6 +53,7 @@
|
||||
"helmet": "^4.0.0",
|
||||
"isomorphic-git": "^1.8.0",
|
||||
"jsonschema": "^1.2.6",
|
||||
"knex": "^0.21.6",
|
||||
"morgan": "^1.10.0",
|
||||
"uuid": "^8.2.0",
|
||||
"winston": "^3.2.1",
|
||||
@@ -71,6 +72,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations",
|
||||
"config.d.ts"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 { TemplateActionRegistry } from '../tasks/TemplateConverter';
|
||||
import { FilePreparer, PreparerBuilder } from './prepare';
|
||||
import Docker from 'dockerode';
|
||||
import { TemplaterBuilder, TemplaterValues } from './templater';
|
||||
import { PublisherBuilder } from './publish';
|
||||
|
||||
type Options = {
|
||||
dockerClient: Docker;
|
||||
preparers: PreparerBuilder;
|
||||
templaters: TemplaterBuilder;
|
||||
publishers: PublisherBuilder;
|
||||
};
|
||||
|
||||
export function registerLegacyActions(
|
||||
registry: TemplateActionRegistry,
|
||||
options: Options,
|
||||
) {
|
||||
const { dockerClient, preparers, templaters, publishers } = options;
|
||||
|
||||
registry.register({
|
||||
id: 'legacy:prepare',
|
||||
async handler(ctx) {
|
||||
const { protocol, url } = ctx.parameters;
|
||||
const preparer =
|
||||
protocol === 'file' ? new FilePreparer() : preparers.get(url as string);
|
||||
|
||||
ctx.logger.info('Prepare the skeleton');
|
||||
await preparer.prepare({
|
||||
url: url as string,
|
||||
logger: ctx.logger,
|
||||
workspacePath: ctx.workspacePath,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
id: 'legacy:template',
|
||||
async handler(ctx) {
|
||||
const { logger } = ctx;
|
||||
|
||||
const templater = templaters.get(ctx.parameters.templater as string);
|
||||
|
||||
logger.info('Run the templater');
|
||||
await templater.run({
|
||||
workspacePath: ctx.workspacePath,
|
||||
dockerClient,
|
||||
logStream: ctx.logStream,
|
||||
values: ctx.parameters.values as TemplaterValues,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
registry.register({
|
||||
id: 'legacy:publish',
|
||||
async handler(ctx) {
|
||||
const { values } = ctx.parameters;
|
||||
if (
|
||||
typeof values !== 'object' ||
|
||||
values === null ||
|
||||
Array.isArray(values)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid values passed to publish, got ${typeof values}`,
|
||||
);
|
||||
}
|
||||
const storePath = values.storePath as unknown;
|
||||
if (typeof storePath !== 'string') {
|
||||
throw new Error(
|
||||
`Invalid store path passed to publish, got ${typeof storePath}`,
|
||||
);
|
||||
}
|
||||
const owner = values.owner as unknown;
|
||||
if (typeof owner !== 'string') {
|
||||
throw new Error(`Invalid owner passed to publish, got ${typeof owner}`);
|
||||
}
|
||||
|
||||
const publisher = publishers.get(storePath);
|
||||
ctx.logger.info('Will now store the template');
|
||||
const { remoteUrl, catalogInfoUrl } = await publisher.publish({
|
||||
values: {
|
||||
...values,
|
||||
owner,
|
||||
storePath,
|
||||
},
|
||||
workspacePath: ctx.workspacePath,
|
||||
logger: ctx.logger,
|
||||
});
|
||||
ctx.output('remoteUrl', remoteUrl);
|
||||
if (catalogInfoUrl) {
|
||||
ctx.output('catalogInfoUrl', catalogInfoUrl);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 { JsonObject } from '@backstage/config';
|
||||
import {
|
||||
ConflictError,
|
||||
NotFoundError,
|
||||
resolvePackagePath,
|
||||
} from '@backstage/backend-common';
|
||||
import Knex from 'knex';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
DbTaskEventRow,
|
||||
DbTaskRow,
|
||||
Status,
|
||||
TaskEventType,
|
||||
TaskSpec,
|
||||
TaskStore,
|
||||
TaskStoreEmitOptions,
|
||||
TaskStoreGetEventsOptions,
|
||||
} from './types';
|
||||
|
||||
const migrationsDir = resolvePackagePath(
|
||||
'@backstage/plugin-scaffolder-backend',
|
||||
'migrations',
|
||||
);
|
||||
|
||||
export type RawDbTaskRow = {
|
||||
id: string;
|
||||
spec: string;
|
||||
status: Status;
|
||||
last_heartbeat_at?: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type RawDbTaskEventRow = {
|
||||
id: number;
|
||||
task_id: string;
|
||||
body: string;
|
||||
event_type: TaskEventType;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export class DatabaseTaskStore implements TaskStore {
|
||||
static async create(knex: Knex): Promise<DatabaseTaskStore> {
|
||||
await knex.migrate.latest({
|
||||
directory: migrationsDir,
|
||||
});
|
||||
return new DatabaseTaskStore(knex);
|
||||
}
|
||||
|
||||
constructor(private readonly db: Knex) {}
|
||||
|
||||
async get(taskId: string): Promise<DbTaskRow> {
|
||||
const [result] = await this.db<RawDbTaskRow>('tasks')
|
||||
.where({ id: taskId })
|
||||
.select();
|
||||
if (!result) {
|
||||
throw new NotFoundError(`No task with id '${taskId}' found`);
|
||||
}
|
||||
try {
|
||||
const spec = JSON.parse(result.spec);
|
||||
return {
|
||||
id: result.id,
|
||||
spec,
|
||||
status: result.status,
|
||||
lastHeartbeatAt: result.last_heartbeat_at,
|
||||
createdAt: result.created_at,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse spec of task '${taskId}', ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async createTask(spec: TaskSpec): Promise<{ taskId: string }> {
|
||||
const taskId = uuid();
|
||||
await this.db<RawDbTaskRow>('tasks').insert({
|
||||
id: taskId,
|
||||
spec: JSON.stringify(spec),
|
||||
status: 'open',
|
||||
});
|
||||
return { taskId };
|
||||
}
|
||||
|
||||
async claimTask(): Promise<DbTaskRow | undefined> {
|
||||
return this.db.transaction(async tx => {
|
||||
const [task] = await tx<RawDbTaskRow>('tasks')
|
||||
.where({
|
||||
status: 'open',
|
||||
})
|
||||
.limit(1)
|
||||
.select();
|
||||
|
||||
if (!task) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updateCount = await tx<RawDbTaskRow>('tasks')
|
||||
.where({ id: task.id, status: 'open' })
|
||||
.update({
|
||||
status: 'processing',
|
||||
last_heartbeat_at: this.db.fn.now(),
|
||||
});
|
||||
|
||||
if (updateCount < 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const spec = JSON.parse(task.spec);
|
||||
return {
|
||||
id: task.id,
|
||||
spec,
|
||||
status: 'processing',
|
||||
lastHeartbeatAt: task.last_heartbeat_at,
|
||||
createdAt: task.created_at,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse spec of task '${task.id}', ${error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async heartbeatTask(taskId: string): Promise<void> {
|
||||
const updateCount = await this.db<RawDbTaskRow>('tasks')
|
||||
.where({ id: taskId, status: 'processing' })
|
||||
.update({
|
||||
last_heartbeat_at: this.db.fn.now(),
|
||||
});
|
||||
if (updateCount === 0) {
|
||||
throw new ConflictError(`No running task with taskId ${taskId} found`);
|
||||
}
|
||||
}
|
||||
|
||||
async listStaleTasks({
|
||||
timeoutS,
|
||||
}: {
|
||||
timeoutS: number;
|
||||
}): Promise<{
|
||||
tasks: { taskId: string }[];
|
||||
}> {
|
||||
const rawRows = await this.db<RawDbTaskRow>('tasks')
|
||||
.where('status', 'processing')
|
||||
.andWhere(
|
||||
'last_heartbeat_at',
|
||||
'<=',
|
||||
this.db.client.config.client === 'sqlite3'
|
||||
? this.db.raw(`datetime('now', ?)`, [`-${timeoutS} seconds`])
|
||||
: this.db.raw(`dateadd('second', ?, ?)`, [
|
||||
`-${timeoutS}`,
|
||||
this.db.fn.now(),
|
||||
]),
|
||||
);
|
||||
const tasks = rawRows.map(row => ({
|
||||
taskId: row.id,
|
||||
}));
|
||||
return { tasks };
|
||||
}
|
||||
|
||||
async completeTask({
|
||||
taskId,
|
||||
status,
|
||||
eventBody,
|
||||
}: {
|
||||
taskId: string;
|
||||
status: Status;
|
||||
eventBody: JsonObject;
|
||||
}): Promise<void> {
|
||||
let oldStatus: string;
|
||||
if (status === 'failed' || status === 'completed') {
|
||||
oldStatus = 'processing';
|
||||
} else {
|
||||
throw new Error(
|
||||
`Invalid status update of run '${taskId}' to status '${status}'`,
|
||||
);
|
||||
}
|
||||
await this.db.transaction(async tx => {
|
||||
const [task] = await tx<RawDbTaskRow>('tasks')
|
||||
.where({
|
||||
id: taskId,
|
||||
})
|
||||
.limit(1)
|
||||
.select();
|
||||
|
||||
if (!task) {
|
||||
throw new Error(`No task with taskId ${taskId} found`);
|
||||
}
|
||||
if (task.status !== oldStatus) {
|
||||
throw new ConflictError(
|
||||
`Refusing to update status of run '${taskId}' to status '${status}' ` +
|
||||
`as it is currently '${task.status}', expected '${oldStatus}'`,
|
||||
);
|
||||
}
|
||||
const updateCount = await tx<RawDbTaskRow>('tasks')
|
||||
.where({
|
||||
id: taskId,
|
||||
status: oldStatus,
|
||||
})
|
||||
.update({
|
||||
status,
|
||||
});
|
||||
if (updateCount !== 1) {
|
||||
throw new ConflictError(
|
||||
`Failed to update status to '${status}' for taskId ${taskId}`,
|
||||
);
|
||||
}
|
||||
|
||||
await tx<RawDbTaskEventRow>('task_events').insert({
|
||||
task_id: taskId,
|
||||
event_type: 'completion',
|
||||
body: JSON.stringify(eventBody),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async emitLogEvent({ taskId, body }: TaskStoreEmitOptions): Promise<void> {
|
||||
const serliazedBody = JSON.stringify(body);
|
||||
await this.db<RawDbTaskEventRow>('task_events').insert({
|
||||
task_id: taskId,
|
||||
event_type: 'log',
|
||||
body: serliazedBody,
|
||||
});
|
||||
}
|
||||
|
||||
async listEvents({
|
||||
taskId,
|
||||
after,
|
||||
}: TaskStoreGetEventsOptions): Promise<{ events: DbTaskEventRow[] }> {
|
||||
const rawEvents = await this.db<RawDbTaskEventRow>('task_events')
|
||||
.where({
|
||||
task_id: taskId,
|
||||
})
|
||||
.andWhere(builder => {
|
||||
if (typeof after === 'number') {
|
||||
builder.where('id', '>', after).orWhere('event_type', 'completion');
|
||||
}
|
||||
})
|
||||
.orderBy('id')
|
||||
.select();
|
||||
|
||||
const events = rawEvents.map(event => {
|
||||
try {
|
||||
const body = JSON.parse(event.body) as JsonObject;
|
||||
return {
|
||||
id: event.id,
|
||||
taskId,
|
||||
body,
|
||||
type: event.event_type,
|
||||
createdAt: event.created_at,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse event body from event taskId=${taskId} id=${event.id}, ${error}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
return { events };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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,
|
||||
SingleConnectionDatabaseManager,
|
||||
} from '@backstage/backend-common';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { DatabaseTaskStore } from './DatabaseTaskStore';
|
||||
import { StorageTaskBroker, TaskAgent } from './StorageTaskBroker';
|
||||
import { TaskSpec, DbTaskEventRow } from './types';
|
||||
|
||||
async function createStore(): Promise<DatabaseTaskStore> {
|
||||
const manager = SingleConnectionDatabaseManager.fromConfig(
|
||||
new ConfigReader({
|
||||
backend: {
|
||||
database: {
|
||||
client: 'sqlite3',
|
||||
connection: ':memory:',
|
||||
},
|
||||
},
|
||||
}),
|
||||
).forPlugin('scaffolder');
|
||||
return await DatabaseTaskStore.create(await manager.getClient());
|
||||
}
|
||||
|
||||
describe('StorageTaskBroker', () => {
|
||||
let storage: DatabaseTaskStore;
|
||||
|
||||
beforeAll(async () => {
|
||||
storage = await createStore();
|
||||
});
|
||||
|
||||
const logger = getVoidLogger();
|
||||
it('should claim a dispatched work item', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
await broker.dispatch({ steps: [] });
|
||||
await expect(broker.claim()).resolves.toEqual(expect.any(TaskAgent));
|
||||
});
|
||||
|
||||
it('should wait for a dispatched work item', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
const promise = broker.claim();
|
||||
|
||||
await expect(Promise.race([promise, 'waiting'])).resolves.toBe('waiting');
|
||||
|
||||
await broker.dispatch({ steps: [] });
|
||||
await expect(promise).resolves.toEqual(expect.any(TaskAgent));
|
||||
});
|
||||
|
||||
it('should dispatch multiple items and claim them in order', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
await broker.dispatch({ steps: [{ id: 'a' }] } as TaskSpec);
|
||||
await broker.dispatch({ steps: [{ id: 'b' }] } as TaskSpec);
|
||||
await broker.dispatch({ steps: [{ id: 'c' }] } as TaskSpec);
|
||||
|
||||
const taskA = await broker.claim();
|
||||
const taskB = await broker.claim();
|
||||
const taskC = await broker.claim();
|
||||
await expect(taskA).toEqual(expect.any(TaskAgent));
|
||||
await expect(taskB).toEqual(expect.any(TaskAgent));
|
||||
await expect(taskC).toEqual(expect.any(TaskAgent));
|
||||
await expect(taskA.spec.steps[0].id).toBe('a');
|
||||
await expect(taskB.spec.steps[0].id).toBe('b');
|
||||
await expect(taskC.spec.steps[0].id).toBe('c');
|
||||
});
|
||||
|
||||
it('should complete a task', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
const dispatchResult = await broker.dispatch({ steps: [] });
|
||||
const task = await broker.claim();
|
||||
await task.complete('completed');
|
||||
const taskRow = await storage.get(dispatchResult.taskId);
|
||||
expect(taskRow.status).toBe('completed');
|
||||
}, 10000);
|
||||
|
||||
it('should fail a task', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
const dispatchResult = await broker.dispatch({ steps: [] });
|
||||
const task = await broker.claim();
|
||||
await task.complete('failed');
|
||||
const taskRow = await storage.get(dispatchResult.taskId);
|
||||
expect(taskRow.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('multiple brokers should be able to observe a single task', async () => {
|
||||
const broker1 = new StorageTaskBroker(storage, logger);
|
||||
const broker2 = new StorageTaskBroker(storage, logger);
|
||||
|
||||
const { taskId } = await broker1.dispatch({ steps: [] });
|
||||
|
||||
const logPromise = new Promise<DbTaskEventRow[]>(resolve => {
|
||||
const observedEvents = new Array<DbTaskEventRow>();
|
||||
|
||||
broker2.observe({ taskId, after: undefined }, (_err, { events }) => {
|
||||
observedEvents.push(...events);
|
||||
if (events.some(e => e.type === 'completion')) {
|
||||
resolve(observedEvents);
|
||||
}
|
||||
});
|
||||
});
|
||||
const task = await broker1.claim();
|
||||
await task.emitLog('log 1');
|
||||
await task.emitLog('log 2');
|
||||
await task.emitLog('log 3');
|
||||
await task.complete('completed');
|
||||
|
||||
const logs = await logPromise;
|
||||
expect(logs.map(l => l.body.message, logger)).toEqual([
|
||||
'log 1',
|
||||
'log 2',
|
||||
'log 3',
|
||||
'Run completed with status: completed',
|
||||
]);
|
||||
|
||||
const afterLogs = await new Promise<string[]>(resolve => {
|
||||
broker2.observe({ taskId, after: logs[1].id }, (_err, { events }) =>
|
||||
resolve(events.map(e => e.body.message as string)),
|
||||
);
|
||||
});
|
||||
expect(afterLogs).toEqual([
|
||||
'log 3',
|
||||
'Run completed with status: completed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should heartbeat', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
const { taskId } = await broker.dispatch({ steps: [] });
|
||||
const task = await broker.claim();
|
||||
|
||||
const initialTask = await storage.get(taskId);
|
||||
|
||||
for (;;) {
|
||||
const maybeTask = await storage.get(taskId);
|
||||
if (maybeTask.lastHeartbeatAt !== initialTask.lastHeartbeatAt) {
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
await task.complete('completed');
|
||||
expect.assertions(0);
|
||||
});
|
||||
|
||||
it('should be update the status to failed if heartbeat fails', async () => {
|
||||
const broker = new StorageTaskBroker(storage, logger);
|
||||
const { taskId } = await broker.dispatch({ steps: [] });
|
||||
const task = await broker.claim();
|
||||
|
||||
jest
|
||||
.spyOn((task as any).storage, 'heartbeatTask')
|
||||
.mockRejectedValue(new Error('nah m8'));
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
broker.vacuumTasks({ timeoutS: 2 }).catch(fail);
|
||||
}, 500);
|
||||
|
||||
for (;;) {
|
||||
const maybeTask = await storage.get(taskId);
|
||||
if (maybeTask.status === 'failed') {
|
||||
break;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
clearInterval(intervalId);
|
||||
|
||||
expect(task.done).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
CompletedTaskState,
|
||||
Task,
|
||||
TaskSpec,
|
||||
TaskStore,
|
||||
TaskBroker,
|
||||
DispatchResult,
|
||||
DbTaskEventRow,
|
||||
} from './types';
|
||||
|
||||
export class TaskAgent implements Task {
|
||||
private isDone = false;
|
||||
|
||||
private heartbeatTimeoutId?: ReturnType<typeof setInterval>;
|
||||
|
||||
static create(state: TaskState, storage: TaskStore, logger: Logger) {
|
||||
const agent = new TaskAgent(state, storage, logger);
|
||||
agent.startTimeout();
|
||||
return agent;
|
||||
}
|
||||
|
||||
// Runs heartbeat internally
|
||||
private constructor(
|
||||
private readonly state: TaskState,
|
||||
private readonly storage: TaskStore,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
get spec() {
|
||||
return this.state.spec;
|
||||
}
|
||||
|
||||
async getWorkspaceName() {
|
||||
return this.state.taskId;
|
||||
}
|
||||
|
||||
get done() {
|
||||
return this.isDone;
|
||||
}
|
||||
|
||||
async emitLog(message: string): Promise<void> {
|
||||
await this.storage.emitLogEvent({
|
||||
taskId: this.state.taskId,
|
||||
body: { message },
|
||||
});
|
||||
}
|
||||
|
||||
async complete(result: CompletedTaskState): Promise<void> {
|
||||
await this.storage.completeTask({
|
||||
taskId: this.state.taskId,
|
||||
status: result === 'failed' ? 'failed' : 'completed',
|
||||
eventBody: { message: `Run completed with status: ${result}` },
|
||||
});
|
||||
this.isDone = true;
|
||||
if (this.heartbeatTimeoutId) {
|
||||
clearTimeout(this.heartbeatTimeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
private startTimeout() {
|
||||
this.heartbeatTimeoutId = setTimeout(async () => {
|
||||
try {
|
||||
await this.storage.heartbeatTask(this.state.taskId);
|
||||
this.startTimeout();
|
||||
} catch (error) {
|
||||
this.isDone = true;
|
||||
|
||||
this.logger.error(
|
||||
`Heartbeat for task ${this.state.taskId} failed`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
interface TaskState {
|
||||
spec: TaskSpec;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
function defer() {
|
||||
let resolve = () => {};
|
||||
const promise = new Promise<void>(_resolve => {
|
||||
resolve = _resolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
export class StorageTaskBroker implements TaskBroker {
|
||||
constructor(
|
||||
private readonly storage: TaskStore,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
private deferredDispatch = defer();
|
||||
|
||||
async claim(): Promise<Task> {
|
||||
for (;;) {
|
||||
const pendingTask = await this.storage.claimTask();
|
||||
if (pendingTask) {
|
||||
return TaskAgent.create(
|
||||
{
|
||||
taskId: pendingTask.id,
|
||||
spec: pendingTask.spec,
|
||||
},
|
||||
this.storage,
|
||||
this.logger,
|
||||
);
|
||||
}
|
||||
|
||||
await this.waitForDispatch();
|
||||
}
|
||||
}
|
||||
|
||||
async dispatch(spec: TaskSpec): Promise<DispatchResult> {
|
||||
const taskRow = await this.storage.createTask(spec);
|
||||
this.signalDispatch();
|
||||
return {
|
||||
taskId: taskRow.taskId,
|
||||
};
|
||||
}
|
||||
|
||||
observe(
|
||||
options: {
|
||||
taskId: string;
|
||||
after: number | undefined;
|
||||
},
|
||||
callback: (
|
||||
error: Error | undefined,
|
||||
result: { events: DbTaskEventRow[] },
|
||||
) => void,
|
||||
): () => void {
|
||||
const { taskId } = options;
|
||||
|
||||
let cancelled = false;
|
||||
const unsubscribe = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
let after = options.after;
|
||||
while (!cancelled) {
|
||||
const result = await this.storage.listEvents({ taskId, after: after });
|
||||
const { events } = result;
|
||||
if (events.length) {
|
||||
after = events[events.length - 1].id;
|
||||
try {
|
||||
callback(undefined, result);
|
||||
} catch (error) {
|
||||
callback(error, { events: [] });
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
})();
|
||||
|
||||
return unsubscribe;
|
||||
}
|
||||
|
||||
async vacuumTasks(timeoutS: { timeoutS: number }): Promise<void> {
|
||||
const { tasks } = await this.storage.listStaleTasks(timeoutS);
|
||||
await Promise.all(
|
||||
tasks.map(async task => {
|
||||
try {
|
||||
await this.storage.completeTask({
|
||||
taskId: task.taskId,
|
||||
status: 'failed',
|
||||
eventBody: {
|
||||
message:
|
||||
'The task was cancelled because the task worker lost connection to the task broker',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to cancel task '${task.taskId}', ${error}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private waitForDispatch() {
|
||||
return this.deferredDispatch.promise;
|
||||
}
|
||||
|
||||
private signalDispatch() {
|
||||
this.deferredDispatch.resolve();
|
||||
this.deferredDispatch = defer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 { PassThrough } from 'stream';
|
||||
import { Logger } from 'winston';
|
||||
import * as winston from 'winston';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { TaskBroker, Task } from './types';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { TemplateActionRegistry } from './TemplateConverter';
|
||||
|
||||
type Options = {
|
||||
logger: Logger;
|
||||
taskBroker: TaskBroker;
|
||||
workingDirectory: string;
|
||||
actionRegistry: TemplateActionRegistry;
|
||||
};
|
||||
|
||||
export class TaskWorker {
|
||||
constructor(private readonly options: Options) {}
|
||||
|
||||
start() {
|
||||
(async () => {
|
||||
for (;;) {
|
||||
const task = await this.options.taskBroker.claim();
|
||||
await this.runOneTask(task);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async runOneTask(task: Task) {
|
||||
try {
|
||||
const { actionRegistry, logger } = this.options;
|
||||
|
||||
const workspacePath = path.join(
|
||||
this.options.workingDirectory,
|
||||
await task.getWorkspaceName(),
|
||||
);
|
||||
await fs.ensureDir(workspacePath);
|
||||
|
||||
const taskLogger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.timestamp(),
|
||||
winston.format.simple(),
|
||||
),
|
||||
defaultMeta: {},
|
||||
});
|
||||
|
||||
const stream = new PassThrough();
|
||||
stream.on('data', data => {
|
||||
const message = data.toString().trim();
|
||||
if (message?.length > 1) task.emitLog(message);
|
||||
});
|
||||
|
||||
taskLogger.add(new winston.transports.Stream({ stream }));
|
||||
|
||||
// Give us some time to curl observe
|
||||
task.emitLog('Task claimed, waiting ...');
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
task.emitLog(`Starting up work with ${task.spec.steps.length} steps`);
|
||||
|
||||
const outputs: { [name: string]: JsonValue } = {};
|
||||
|
||||
for (const step of task.spec.steps) {
|
||||
task.emitLog(`Beginning step ${step.name}`);
|
||||
|
||||
const action = actionRegistry.get(step.action);
|
||||
if (!action) {
|
||||
throw new Error(`Action '${step.action}' does not exist`);
|
||||
}
|
||||
|
||||
// TODO: substitute any placeholders with output from previous steps
|
||||
const parameters = step.parameters!;
|
||||
|
||||
await action.handler({
|
||||
logger,
|
||||
logStream: stream,
|
||||
parameters,
|
||||
workspacePath,
|
||||
output(name: string, value: JsonValue) {
|
||||
outputs[name] = value;
|
||||
},
|
||||
});
|
||||
|
||||
task.emitLog(`Finished step ${step.name}`);
|
||||
}
|
||||
|
||||
await task.complete('completed');
|
||||
} catch (error) {
|
||||
task.emitLog(String(error.stack));
|
||||
await task.complete('failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 { resolve as resolvePath } from 'path';
|
||||
import { JsonValue } from '@backstage/config';
|
||||
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
|
||||
import { Logger } from 'winston';
|
||||
import type { Writable } from 'stream';
|
||||
|
||||
import { TaskSpec } from './types';
|
||||
import { ConflictError, NotFoundError } from '@backstage/backend-common';
|
||||
import {
|
||||
getTemplaterKey,
|
||||
joinGitUrlPath,
|
||||
parseLocationAnnotation,
|
||||
TemplaterValues,
|
||||
} from '../stages';
|
||||
|
||||
export function templateEntityToSpec(
|
||||
template: TemplateEntityV1alpha1,
|
||||
values: TemplaterValues,
|
||||
): TaskSpec {
|
||||
const steps: TaskSpec['steps'] = [];
|
||||
|
||||
const { protocol, location } = parseLocationAnnotation(template);
|
||||
|
||||
let url: string;
|
||||
if (protocol === 'file') {
|
||||
const path = resolvePath(location, template.spec.path || '.');
|
||||
|
||||
url = `file://${path}`;
|
||||
} else {
|
||||
url = joinGitUrlPath(location, template.spec.path);
|
||||
}
|
||||
const templater = getTemplaterKey(template);
|
||||
|
||||
steps.push({
|
||||
id: 'prepare',
|
||||
name: 'Prepare',
|
||||
action: 'legacy:prepare',
|
||||
parameters: {
|
||||
protocol,
|
||||
url,
|
||||
},
|
||||
});
|
||||
|
||||
steps.push({
|
||||
id: 'template',
|
||||
name: 'Template',
|
||||
action: 'legacy:template',
|
||||
parameters: {
|
||||
templater,
|
||||
values,
|
||||
},
|
||||
});
|
||||
|
||||
steps.push({
|
||||
id: 'publish',
|
||||
name: 'Publishing',
|
||||
action: 'legacy:publish',
|
||||
parameters: {
|
||||
values,
|
||||
},
|
||||
});
|
||||
|
||||
return { steps };
|
||||
}
|
||||
|
||||
type ActionContext = {
|
||||
logger: Logger;
|
||||
logStream: Writable;
|
||||
|
||||
workspacePath: string;
|
||||
parameters: { [name: string]: JsonValue };
|
||||
output(name: string, value: JsonValue): void;
|
||||
};
|
||||
|
||||
type TemplateAction = {
|
||||
id: string;
|
||||
handler: (ctx: ActionContext) => Promise<void>;
|
||||
};
|
||||
|
||||
export class TemplateActionRegistry {
|
||||
private readonly actions = new Map<string, TemplateAction>();
|
||||
|
||||
register(action: TemplateAction) {
|
||||
if (this.actions.has(action.id)) {
|
||||
throw new ConflictError(
|
||||
`Template action with ID '${action.id}' has already been registered`,
|
||||
);
|
||||
}
|
||||
this.actions.set(action.id, action);
|
||||
}
|
||||
|
||||
get(actionId: string): TemplateAction {
|
||||
const action = this.actions.get(actionId);
|
||||
if (!action) {
|
||||
throw new NotFoundError(
|
||||
`Template action with ID '${actionId}' is not registered.`,
|
||||
);
|
||||
}
|
||||
return action;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright 2021 Spotify AB
|
||||
*
|
||||
* 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 { DatabaseTaskStore } from './DatabaseTaskStore';
|
||||
export { StorageTaskBroker } from './StorageTaskBroker';
|
||||
export { TaskWorker } from './TaskWorker';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user