Merge branch 'master' of github.com:backstage/backstage into mob/scaffolder-frontend
* 'master' of github.com:backstage/backstage: (118 commits) cli: Fix handling of dynamic imports in esm.js files minor typo in migration chore(deps): bump archiver from 5.1.0 to 5.2.0 dockerfile: mention build-image command Apply suggestions from code review update backend Dockerfile to use config example and fix comment docs: add full docker deployment docs chore: fix code review chore: fixing syntax docs: fixing custom implementations of utitiy apis a small start to the integrations section of the config TechDocs: Add changeset about Docker permission fix Updated unit tests for the new UI TechDocs: Pass user and group ID when invoking docker container Replace logging erro and return undefined for a throw new Error @types/react 16 not 17 Use a more strict type for `variant` of cards docs(TechDocs): Add more context with AWS docs hyperlinks Added missing dep on @types/react Removed unused import ...
This commit is contained in:
@@ -16,8 +16,10 @@
|
||||
|
||||
import {
|
||||
ApiEntity,
|
||||
DomainEntity,
|
||||
Entity,
|
||||
GroupEntity,
|
||||
SystemEntity,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { EmptyState } from '@backstage/core';
|
||||
@@ -25,15 +27,19 @@ import {
|
||||
ApiDefinitionCard,
|
||||
ConsumedApisCard,
|
||||
ConsumingComponentsCard,
|
||||
EntityHasApisCard,
|
||||
ProvidedApisCard,
|
||||
ProvidingComponentsCard,
|
||||
} from '@backstage/plugin-api-docs';
|
||||
import {
|
||||
AboutCard,
|
||||
EntityHasComponentsCard,
|
||||
EntityHasSubcomponentsCard,
|
||||
EntityHasSystemsCard,
|
||||
EntityLinksCard,
|
||||
EntityPageLayout,
|
||||
} from '@backstage/plugin-catalog';
|
||||
import { useEntity } from '@backstage/plugin-catalog-react';
|
||||
import { EntityProvider, useEntity } from '@backstage/plugin-catalog-react';
|
||||
import {
|
||||
isPluginApplicableToEntity as isCircleCIAvailable,
|
||||
Router as CircleCIRouter,
|
||||
@@ -178,7 +184,9 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
|
||||
</Grid>
|
||||
{isPagerDutyAvailable(entity) && (
|
||||
<Grid item md={6}>
|
||||
<PagerDutyCard entity={entity} />
|
||||
<EntityProvider entity={entity}>
|
||||
<PagerDutyCard />
|
||||
</EntityProvider>
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item md={4} sm={6}>
|
||||
@@ -206,6 +214,9 @@ const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
|
||||
<PullRequestsStatsCard entity={entity} />
|
||||
</Grid>
|
||||
)}
|
||||
<Grid item md={6}>
|
||||
<EntityHasSubcomponentsCard variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -425,6 +436,51 @@ const GroupEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
const SystemOverviewContent = ({ entity }: { entity: SystemEntity }) => (
|
||||
<Grid container spacing={3} alignItems="stretch">
|
||||
<Grid item md={6}>
|
||||
<AboutCard entity={entity} variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item md={6}>
|
||||
<EntityHasComponentsCard variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item md={6}>
|
||||
<EntityHasApisCard variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const SystemEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
<EntityPageLayout>
|
||||
<EntityPageLayout.Content
|
||||
path="/*"
|
||||
title="Overview"
|
||||
element={<SystemOverviewContent entity={entity as SystemEntity} />}
|
||||
/>
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
const DomainOverviewContent = ({ entity }: { entity: DomainEntity }) => (
|
||||
<Grid container spacing={3} alignItems="stretch">
|
||||
<Grid item md={6}>
|
||||
<AboutCard entity={entity} variant="gridItem" />
|
||||
</Grid>
|
||||
<Grid item md={6}>
|
||||
<EntityHasSystemsCard variant="gridItem" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
const DomainEntityPage = ({ entity }: { entity: Entity }) => (
|
||||
<EntityPageLayout>
|
||||
<EntityPageLayout.Content
|
||||
path="/*"
|
||||
title="Overview"
|
||||
element={<DomainOverviewContent entity={entity as DomainEntity} />}
|
||||
/>
|
||||
</EntityPageLayout>
|
||||
);
|
||||
|
||||
export const EntityPage = () => {
|
||||
const { entity } = useEntity();
|
||||
|
||||
@@ -437,6 +493,10 @@ export const EntityPage = () => {
|
||||
return <GroupEntityPage entity={entity} />;
|
||||
case 'user':
|
||||
return <UserEntityPage entity={entity} />;
|
||||
case 'system':
|
||||
return <SystemEntityPage entity={entity} />;
|
||||
case 'domain':
|
||||
return <DomainEntityPage entity={entity} />;
|
||||
default:
|
||||
return <DefaultEntityPage entity={entity} />;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
FROM node:14-buster
|
||||
# This dockerfile builds an image for the backend package.
|
||||
# It should be executed with the root of the repo as docker context.
|
||||
#
|
||||
# Before building this image, be sure to have run the following commands in the repo root:
|
||||
#
|
||||
# yarn install
|
||||
# yarn tsc
|
||||
# yarn build
|
||||
#
|
||||
# Once the commands have been run, you can build the image using `yarn build-image`
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
FROM node:14-buster-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy repo skeleton first, to avoid unnecessary docker cache invalidation.
|
||||
# The skeleton contains the package.json of each package in the monorepo,
|
||||
# and along with yarn.lock and the root package.json, that's enough to run yarn install.
|
||||
ADD yarn.lock package.json skeleton.tar ./
|
||||
ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./
|
||||
|
||||
RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)"
|
||||
|
||||
# This will copy the contents of the dist-workspace when running the build-image command.
|
||||
# Do not use this Dockerfile outside of that command, as it will copy in the source code instead.
|
||||
COPY . .
|
||||
# Then copy the rest of the backend bundle, along with any other files we might want.
|
||||
ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./
|
||||
|
||||
CMD ["node", "packages/backend"]
|
||||
CMD ["node", "packages/backend", "--config", "app-config.yaml"]
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"backstage"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "backstage-cli backend:build",
|
||||
"build-image": "backstage-cli backend:build-image --build --tag example-backend",
|
||||
"build": "backstage-cli backend:bundle",
|
||||
"build-image": "docker build ../.. -f Dockerfile --tag example-backend",
|
||||
"start": "backstage-cli backend:dev",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
|
||||
@@ -83,10 +83,8 @@ async function getConfig() {
|
||||
},
|
||||
},
|
||||
|
||||
// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed
|
||||
// TODO: jest is working on module support, it's possible that we can remove this in the future
|
||||
transform: {
|
||||
'\\.esm\\.js$': require.resolve('jest-esm-transformer'),
|
||||
'\\.esm\\.js$': require.resolve('./jestEsmTransform.js'), // See jestEsmTransform.js
|
||||
'\\.(js|jsx|ts|tsx)$': require.resolve('ts-jest'),
|
||||
'\\.(bmp|gif|jpg|jpeg|png|frag|xml|svg)$': require.resolve(
|
||||
'./jestFileTransform.js',
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2020 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.
|
||||
*/
|
||||
|
||||
const babel = require('@babel/core');
|
||||
|
||||
// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed
|
||||
// TODO: jest is working on module support, it's possible that we can remove this in the future
|
||||
module.exports = {
|
||||
process(src) {
|
||||
const result = babel.transform(src, {
|
||||
babelrc: false,
|
||||
compact: false,
|
||||
plugins: [
|
||||
// This transforms the regular ESM syntax, import and export statements
|
||||
require.resolve('@babel/plugin-transform-modules-commonjs'),
|
||||
// This transforms dynamic `import()`, which is not supported yet in the Node.js VM API
|
||||
require.resolve('babel-plugin-dynamic-import-node'),
|
||||
],
|
||||
});
|
||||
|
||||
return result.code;
|
||||
},
|
||||
};
|
||||
@@ -28,6 +28,8 @@
|
||||
"backstage-cli": "bin/backstage-cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.4.4",
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.4.4",
|
||||
"@backstage/cli-common": "^0.1.1",
|
||||
"@backstage/config": "^0.1.2",
|
||||
"@backstage/config-loader": "^0.5.1",
|
||||
@@ -53,6 +55,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^v4.14.0",
|
||||
"@typescript-eslint/parser": "^v4.14.0",
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"bfj": "^7.0.2",
|
||||
"chalk": "^4.0.0",
|
||||
"chokidar": "^3.3.1",
|
||||
@@ -78,7 +81,6 @@
|
||||
"inquirer": "^7.0.4",
|
||||
"jest": "^26.0.1",
|
||||
"jest-css-modules": "^2.1.0",
|
||||
"jest-esm-transformer": "^1.0.0",
|
||||
"lodash": "^4.17.19",
|
||||
"mini-css-extract-plugin": "^0.9.0",
|
||||
"ora": "^4.0.3",
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
|
||||
import React from 'react';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { InfoCard } from '../../layout/InfoCard';
|
||||
import { InfoCard, InfoCardVariants } from '../../layout/InfoCard';
|
||||
import { BottomLinkProps } from '../../layout/BottomLink';
|
||||
import { Gauge } from './Gauge';
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
subheader?: string;
|
||||
variant?: string;
|
||||
variant?: InfoCardVariants;
|
||||
/** Progress in % specified as decimal, e.g. "0.23" */
|
||||
progress: number;
|
||||
deepLink?: BottomLinkProps;
|
||||
|
||||
@@ -14,8 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { Table, SubvalueCell, TableColumn } from './';
|
||||
import { Link } from '../Link';
|
||||
import { SubvalueCell, Table, TableColumn } from './';
|
||||
import { TableFilter } from './Table';
|
||||
|
||||
export default {
|
||||
@@ -23,7 +25,16 @@ export default {
|
||||
component: Table,
|
||||
};
|
||||
|
||||
const containerStyle = { width: 850 };
|
||||
const useStyles = makeStyles(theme => ({
|
||||
container: {
|
||||
width: 850,
|
||||
},
|
||||
empty: {
|
||||
padding: theme.spacing(2),
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
const generateTestData: (number: number) => Array<{}> = (rows = 10) => {
|
||||
const data: Array<{}> = [];
|
||||
@@ -43,6 +54,7 @@ const generateTestData: (number: number) => Array<{}> = (rows = 10) => {
|
||||
const testData10 = generateTestData(10);
|
||||
|
||||
export const DefaultTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -66,7 +78,7 @@ export const DefaultTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false }}
|
||||
data={testData10}
|
||||
@@ -77,7 +89,8 @@ export const DefaultTable = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SubtitleTable = () => {
|
||||
export const EmptyTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -101,7 +114,49 @@ export const SubtitleTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false }}
|
||||
data={[]}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<div className={classes.empty}>
|
||||
No data was added yet,
|
||||
<Link to="http://backstage.io/">learn how to add data</Link>.
|
||||
</div>
|
||||
}
|
||||
title="Backstage Table"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SubtitleTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
field: 'col1',
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
title: 'Column 2',
|
||||
field: 'col2',
|
||||
},
|
||||
{
|
||||
title: 'Numeric value',
|
||||
field: 'number',
|
||||
type: 'numeric',
|
||||
},
|
||||
{
|
||||
title: 'A Date',
|
||||
field: 'date',
|
||||
type: 'date',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false }}
|
||||
data={testData10}
|
||||
@@ -114,6 +169,7 @@ export const SubtitleTable = () => {
|
||||
};
|
||||
|
||||
export const HiddenSearchTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -137,7 +193,7 @@ export const HiddenSearchTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false, search: false }}
|
||||
data={testData10}
|
||||
@@ -148,6 +204,7 @@ export const HiddenSearchTable = () => {
|
||||
};
|
||||
|
||||
export const SubvalueTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -181,13 +238,14 @@ export const SubvalueTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table options={{ paging: false }} data={testData10} columns={columns} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DenseTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -211,7 +269,7 @@ export const DenseTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false, padding: 'dense' }}
|
||||
data={testData10}
|
||||
@@ -223,6 +281,7 @@ export const DenseTable = () => {
|
||||
};
|
||||
|
||||
export const FilterTable = () => {
|
||||
const classes = useStyles();
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Column 1',
|
||||
@@ -261,7 +320,7 @@ export const FilterTable = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<div className={classes.container}>
|
||||
<Table
|
||||
options={{ paging: false, padding: 'dense' }}
|
||||
data={testData10}
|
||||
|
||||
@@ -53,4 +53,16 @@ describe('<Table />', () => {
|
||||
);
|
||||
expect(rendered.getByText('subtitle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom empty component if empty', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<Table
|
||||
subtitle="subtitle"
|
||||
emptyContent={<div>EMPTY</div>}
|
||||
columns={minProps.columns}
|
||||
data={[]}
|
||||
/>,
|
||||
);
|
||||
expect(rendered.getByText('EMPTY')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,12 +42,14 @@ import MTable, {
|
||||
Column,
|
||||
Icons,
|
||||
MaterialTableProps,
|
||||
MTableBody,
|
||||
MTableHeader,
|
||||
MTableToolbar,
|
||||
Options,
|
||||
} from 'material-table';
|
||||
import React, {
|
||||
forwardRef,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -202,6 +204,7 @@ export interface TableProps<T extends object = {}>
|
||||
subtitle?: string;
|
||||
filters?: TableFilter[];
|
||||
initialState?: TableState;
|
||||
emptyContent?: ReactNode;
|
||||
onStateChange?: (state: TableState) => any;
|
||||
}
|
||||
|
||||
@@ -212,6 +215,7 @@ export function Table<T extends object = {}>({
|
||||
subtitle,
|
||||
filters,
|
||||
initialState,
|
||||
emptyContent,
|
||||
onStateChange,
|
||||
...props
|
||||
}: TableProps<T>) {
|
||||
@@ -423,6 +427,23 @@ export function Table<T extends object = {}>({
|
||||
],
|
||||
);
|
||||
|
||||
const Body = useCallback(
|
||||
bodyProps => {
|
||||
if (emptyContent && data.length === 0) {
|
||||
return (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={columns.length}>{emptyContent}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
}
|
||||
|
||||
return <MTableBody {...bodyProps} />;
|
||||
},
|
||||
[data, emptyContent, columns],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={tableClasses.root}>
|
||||
{filtersOpen && data && filters?.length && (
|
||||
@@ -438,6 +459,7 @@ export function Table<T extends object = {}>({
|
||||
<MTableHeader classes={headerClasses} {...headerProps} />
|
||||
),
|
||||
Toolbar,
|
||||
Body,
|
||||
}}
|
||||
options={{ ...defaultOptions, ...options }}
|
||||
columns={MTColumns}
|
||||
|
||||
@@ -87,6 +87,8 @@ const VARIANT_STYLES = {
|
||||
},
|
||||
};
|
||||
|
||||
export type InfoCardVariants = 'flex' | 'fullHeight' | 'gridItem';
|
||||
|
||||
/**
|
||||
* InfoCard is used to display a paper-styled block on the screen, similar to a panel.
|
||||
*
|
||||
@@ -111,7 +113,7 @@ type Props = {
|
||||
divider?: boolean;
|
||||
deepLink?: BottomLinkProps;
|
||||
slackChannel?: string;
|
||||
variant?: string;
|
||||
variant?: InfoCardVariants;
|
||||
style?: object;
|
||||
cardStyle?: object;
|
||||
children?: ReactNode;
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export { InfoCard } from './InfoCard';
|
||||
export type { InfoCardVariants } from './InfoCard';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
node_modules
|
||||
packages
|
||||
!packages/backend/dist
|
||||
plugins
|
||||
@@ -1,16 +1,26 @@
|
||||
FROM node:12-buster
|
||||
# This dockerfile builds an image for the backend package.
|
||||
# It should be executed with the root of the repo as docker context.
|
||||
#
|
||||
# Before building this image, be sure to have run the following commands in the repo root:
|
||||
#
|
||||
# yarn install
|
||||
# yarn tsc
|
||||
# yarn build
|
||||
#
|
||||
# Once the commands have been run, you can build the image using `yarn build-image`
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
FROM node:14-buster-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy repo skeleton first, to avoid unnecessary docker cache invalidation.
|
||||
# The skeleton contains the package.json of each package in the monorepo,
|
||||
# and along with yarn.lock and the root package.json, that's enough to run yarn install.
|
||||
ADD yarn.lock package.json skeleton.tar ./
|
||||
ADD yarn.lock package.json packages/backend/dist/skeleton.tar.gz ./
|
||||
|
||||
RUN yarn install --frozen-lockfile --production --network-timeout 300000 && rm -rf "$(yarn cache dir)"
|
||||
|
||||
# This will copy the contents of the dist-workspace when running the build-image command.
|
||||
# Do not use this Dockerfile outside of that command, as it will copy in the source code instead.
|
||||
COPY . .
|
||||
# Then copy the rest of the backend bundle, along with any other files we might want.
|
||||
ADD packages/backend/dist/bundle.tar.gz app-config.yaml ./
|
||||
|
||||
CMD ["node", "packages/backend", "--config", "app-config.yaml", "--config", "app-config.production.yaml"]
|
||||
CMD ["node", "packages/backend", "--config", "app-config.yaml"]
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"node": "12 || 14"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "backstage-cli backend:build",
|
||||
"build-image": "backstage-cli backend:build-image --build --tag backstage",
|
||||
"build": "backstage-cli backend:bundle",
|
||||
"build-image": "docker build ../.. -f Dockerfile --tag backstage",
|
||||
"start": "backstage-cli backend:dev",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
|
||||
@@ -14,6 +14,10 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import type {
|
||||
BlobUploadCommonResponse,
|
||||
ContainerGetPropertiesResponse,
|
||||
} from '@azure/storage-blob';
|
||||
|
||||
export class BlockBlobClient {
|
||||
private readonly blobName;
|
||||
@@ -22,23 +26,33 @@ export class BlockBlobClient {
|
||||
this.blobName = blobName;
|
||||
}
|
||||
|
||||
uploadFile(source: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs.existsSync(source)) {
|
||||
reject('');
|
||||
} else {
|
||||
resolve('');
|
||||
}
|
||||
uploadFile(source: string): Promise<BlobUploadCommonResponse> {
|
||||
return Promise.resolve({
|
||||
_response: {
|
||||
request: {
|
||||
url: `https://example.blob.core.windows.net`,
|
||||
} as any,
|
||||
status: 200,
|
||||
headers: {} as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
exists() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (fs.existsSync(this.blobName)) {
|
||||
resolve(true);
|
||||
} else {
|
||||
reject({ message: 'The object doest not exist !' });
|
||||
}
|
||||
return Promise.resolve(fs.existsSync(this.blobName));
|
||||
}
|
||||
}
|
||||
|
||||
class BlockBlobClientFailUpload extends BlockBlobClient {
|
||||
uploadFile(source: string): Promise<BlobUploadCommonResponse> {
|
||||
return Promise.resolve({
|
||||
_response: {
|
||||
request: {
|
||||
url: `https://example.blob.core.windows.net`,
|
||||
} as any,
|
||||
status: 500,
|
||||
headers: {} as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -50,9 +64,16 @@ export class ContainerClient {
|
||||
this.containerName = containerName;
|
||||
}
|
||||
|
||||
getProperties() {
|
||||
return new Promise(resolve => {
|
||||
resolve('');
|
||||
getProperties(): Promise<ContainerGetPropertiesResponse> {
|
||||
return Promise.resolve({
|
||||
_response: {
|
||||
request: {
|
||||
url: `https://example.blob.core.windows.net`,
|
||||
} as any,
|
||||
status: 200,
|
||||
headers: {} as any,
|
||||
parsedHeaders: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,6 +82,27 @@ export class ContainerClient {
|
||||
}
|
||||
}
|
||||
|
||||
class ContainerClientFailGetProperties extends ContainerClient {
|
||||
getProperties(): Promise<ContainerGetPropertiesResponse> {
|
||||
return Promise.resolve({
|
||||
_response: {
|
||||
request: {
|
||||
url: `https://example.blob.core.windows.net`,
|
||||
} as any,
|
||||
status: 404,
|
||||
headers: {} as any,
|
||||
parsedHeaders: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ContainerClientFailUpload extends ContainerClient {
|
||||
getBlockBlobClient(blobName: string) {
|
||||
return new BlockBlobClientFailUpload(blobName);
|
||||
}
|
||||
}
|
||||
|
||||
export class BlobServiceClient {
|
||||
private readonly url;
|
||||
private readonly credential;
|
||||
@@ -71,6 +113,12 @@ export class BlobServiceClient {
|
||||
}
|
||||
|
||||
getContainerClient(containerName: string) {
|
||||
if (containerName === 'bad_container') {
|
||||
return new ContainerClientFailGetProperties(containerName);
|
||||
}
|
||||
if (this.credential.accountName === 'failupload') {
|
||||
return new ContainerClientFailUpload(containerName);
|
||||
}
|
||||
return new ContainerClient(containerName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
patchMkdocsYmlPreBuild,
|
||||
runDockerContainer,
|
||||
storeEtagMetadata,
|
||||
UserOptions,
|
||||
} from './helpers';
|
||||
|
||||
const mockEntity = {
|
||||
@@ -114,7 +115,7 @@ describe('helpers', () => {
|
||||
imageName,
|
||||
args,
|
||||
expect.any(Stream),
|
||||
{
|
||||
expect.objectContaining({
|
||||
Volumes: {
|
||||
'/content': {},
|
||||
'/result': {},
|
||||
@@ -123,7 +124,7 @@ describe('helpers', () => {
|
||||
HostConfig: {
|
||||
Binds: [`${docsDir}:/content`, `${outputDir}:/result`],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -139,6 +140,30 @@ describe('helpers', () => {
|
||||
expect(mockDocker.ping).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass through the user and group id from the host machine and set the home dir', async () => {
|
||||
await runDockerContainer({
|
||||
imageName,
|
||||
args,
|
||||
docsDir,
|
||||
outputDir,
|
||||
dockerClient: mockDocker,
|
||||
});
|
||||
|
||||
const userOptions: UserOptions = {};
|
||||
if (process.getuid && process.getgid) {
|
||||
userOptions.User = `${process.getuid()}:${process.getgid()}`;
|
||||
}
|
||||
|
||||
expect(mockDocker.run).toHaveBeenCalledWith(
|
||||
imageName,
|
||||
args,
|
||||
expect.any(Stream),
|
||||
expect.objectContaining({
|
||||
...userOptions,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('where docker is unavailable', () => {
|
||||
const dockerError = 'a docker error';
|
||||
|
||||
|
||||
@@ -51,6 +51,12 @@ export type RunCommandOptions = {
|
||||
logStream?: Writable;
|
||||
};
|
||||
|
||||
export type UserOptions = {
|
||||
User?: string;
|
||||
};
|
||||
|
||||
// To be replaced by a runDockerContainer from backend-common
|
||||
// shared between Scaffolder and TechDocs and any other plugin.
|
||||
export async function runDockerContainer({
|
||||
imageName,
|
||||
args,
|
||||
@@ -78,6 +84,17 @@ export async function runDockerContainer({
|
||||
});
|
||||
});
|
||||
|
||||
const userOptions: UserOptions = {};
|
||||
// @ts-ignore
|
||||
if (process.getuid && process.getgid) {
|
||||
// Files that are created inside the Docker container will be owned by
|
||||
// root on the host system on non Mac systems, because of reasons. Mainly the fact that
|
||||
// volume sharing is done using NFS on Mac and actual mounts in Linux world.
|
||||
// So we set the user in the container as the same user and group id as the host.
|
||||
// On Windows we don't have process.getuid nor process.getgid
|
||||
userOptions.User = `${process.getuid()}:${process.getgid()}`;
|
||||
}
|
||||
|
||||
const [{ Error: error, StatusCode: statusCode }] = await dockerClient.run(
|
||||
imageName,
|
||||
args,
|
||||
@@ -91,6 +108,7 @@ export async function runDockerContainer({
|
||||
HostConfig: {
|
||||
Binds: [`${docsDir}:/content`, `${outputDir}:/result`],
|
||||
},
|
||||
...userOptions,
|
||||
...createOptions,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -52,10 +52,13 @@ export class AwsS3Publish implements PublisherBase {
|
||||
);
|
||||
}
|
||||
|
||||
// Credentials is an optional config. If missing, default AWS environment variables
|
||||
// or AWS shared credentials file at ~/.aws/credentials will be used to authenticate
|
||||
// https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-environment.html
|
||||
// https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/loading-node-credentials-shared.html
|
||||
// Credentials is an optional config. If missing, the default ways of authenticating AWS SDK V2 will be used.
|
||||
// 1. AWS environment variables
|
||||
// https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-environment.html
|
||||
// 2. AWS shared credentials file at ~/.aws/credentials
|
||||
// https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-shared.html
|
||||
// 3. IAM Roles for EC2
|
||||
// https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/loading-node-credentials-iam.html
|
||||
const credentials = config.getOptionalConfig(
|
||||
'techdocs.publisher.awsS3.credentials',
|
||||
);
|
||||
@@ -67,9 +70,7 @@ export class AwsS3Publish implements PublisherBase {
|
||||
}
|
||||
|
||||
// AWS Region is an optional config. If missing, default AWS env variable AWS_REGION
|
||||
// or AWS shared credentials file at ~/.aws/credentials will be used. Any way, AWS SDK v3 client needs
|
||||
// to have the AWS Region information for it to work.
|
||||
// https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-region.html
|
||||
// or AWS shared credentials file at ~/.aws/credentials will be used.
|
||||
const region = config.getOptionalString('techdocs.publisher.awsS3.region');
|
||||
|
||||
const storageClient = new aws.S3({
|
||||
|
||||
@@ -19,6 +19,7 @@ import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { AzureBlobStoragePublish } from './azureBlobStorage';
|
||||
import { PublisherBase } from './types';
|
||||
import type { Entity } from '@backstage/catalog-model';
|
||||
import type { Logger } from 'winston';
|
||||
|
||||
const createMockEntity = (annotations = {}) => {
|
||||
return {
|
||||
@@ -43,33 +44,40 @@ const getEntityRootDir = (entity: Entity) => {
|
||||
return entityRootDir;
|
||||
};
|
||||
|
||||
const logger = getVoidLogger();
|
||||
jest.spyOn(logger, 'info').mockReturnValue(logger);
|
||||
jest.spyOn(logger, 'error').mockReturnValue(logger);
|
||||
function createLogger() {
|
||||
const logger = getVoidLogger();
|
||||
jest.spyOn(logger, 'info').mockReturnValue(logger);
|
||||
jest.spyOn(logger, 'error').mockReturnValue(logger);
|
||||
return logger;
|
||||
}
|
||||
|
||||
let publisher: PublisherBase;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockConfig = new ConfigReader({
|
||||
techdocs: {
|
||||
requestUrl: 'http://localhost:7000',
|
||||
publisher: {
|
||||
type: 'azureBlobStorage',
|
||||
azureBlobStorage: {
|
||||
credentials: {
|
||||
accountName: 'accountName',
|
||||
accountKey: 'accountKey',
|
||||
describe('publishing with valid credentials', () => {
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockConfig = new ConfigReader({
|
||||
techdocs: {
|
||||
requestUrl: 'http://localhost:7000',
|
||||
publisher: {
|
||||
type: 'azureBlobStorage',
|
||||
azureBlobStorage: {
|
||||
credentials: {
|
||||
accountName: 'accountName',
|
||||
accountKey: 'accountKey',
|
||||
},
|
||||
containerName: 'containerName',
|
||||
},
|
||||
containerName: 'containerName',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger = createLogger();
|
||||
|
||||
publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger);
|
||||
});
|
||||
|
||||
publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger);
|
||||
});
|
||||
|
||||
describe('AzureBlobStoragePublish', () => {
|
||||
describe('publish', () => {
|
||||
it('should publish a directory', async () => {
|
||||
const entity = createMockEntity();
|
||||
@@ -116,7 +124,7 @@ describe('AzureBlobStoragePublish', () => {
|
||||
})
|
||||
.catch(error =>
|
||||
expect(error.message).toContain(
|
||||
'Unable to upload file(s) to Azure Blob Storage. Error Failed to read template directory: ENOENT, no such file or directory',
|
||||
'Unable to upload file(s) to Azure Blob Storage. Failed to read template directory: ENOENT, no such file or directory',
|
||||
),
|
||||
);
|
||||
mockFs.restore();
|
||||
@@ -145,3 +153,92 @@ describe('AzureBlobStoragePublish', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('error reporting', () => {
|
||||
it('reports an error when unable to read container properties', async () => {
|
||||
const mockConfig = new ConfigReader({
|
||||
techdocs: {
|
||||
requestUrl: 'http://localhost:7000',
|
||||
publisher: {
|
||||
type: 'azureBlobStorage',
|
||||
azureBlobStorage: {
|
||||
credentials: {
|
||||
accountName: 'accountName',
|
||||
},
|
||||
containerName: 'bad_container',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
let error;
|
||||
try {
|
||||
publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Could not retrieve metadata about the Azure Blob Storage container bad_container.`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('reports an error when bad account credentials', async () => {
|
||||
const mockConfig = new ConfigReader({
|
||||
techdocs: {
|
||||
requestUrl: 'http://localhost:7000',
|
||||
publisher: {
|
||||
type: 'azureBlobStorage',
|
||||
azureBlobStorage: {
|
||||
credentials: {
|
||||
accountName: 'failupload',
|
||||
accountKey: 'accountKey',
|
||||
},
|
||||
containerName: 'containerName',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
publisher = await AzureBlobStoragePublish.fromConfig(mockConfig, logger);
|
||||
|
||||
const entity = createMockEntity();
|
||||
const entityRootDir = getEntityRootDir(entity);
|
||||
|
||||
mockFs({
|
||||
[entityRootDir]: {
|
||||
'index.html': '',
|
||||
},
|
||||
});
|
||||
|
||||
let error;
|
||||
try {
|
||||
await publisher.publish({
|
||||
entity,
|
||||
directory: entityRootDir,
|
||||
});
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error.message).toContain(
|
||||
`Unable to upload file(s) to Azure Blob Storage.`,
|
||||
);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Unable to upload file(s) to Azure Blob Storage. Upload failed for test-namespace/TestKind/test-component-name/index.html with status code 500`,
|
||||
),
|
||||
);
|
||||
|
||||
mockFs.restore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,6 @@ import platformPath from 'path';
|
||||
import express from 'express';
|
||||
import {
|
||||
BlobServiceClient,
|
||||
BlobUploadCommonResponse,
|
||||
StorageSharedKeyCredential,
|
||||
} from '@azure/storage-blob';
|
||||
import { DefaultAzureCredential } from '@azure/identity';
|
||||
@@ -79,25 +78,25 @@ export class AzureBlobStoragePublish implements PublisherBase {
|
||||
credential,
|
||||
);
|
||||
|
||||
await storageClient
|
||||
.getContainerClient(containerName)
|
||||
.getProperties()
|
||||
.then(() => {
|
||||
logger.info(
|
||||
`Successfully connected to the Azure Blob Storage container ${containerName}.`,
|
||||
);
|
||||
})
|
||||
.catch(reason => {
|
||||
logger.error(
|
||||
`Could not retrieve metadata about the Azure Blob Storage container ${containerName}. ` +
|
||||
'Make sure that the Azure project and container exist and the access key is setup correctly ' +
|
||||
'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' +
|
||||
'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage',
|
||||
);
|
||||
try {
|
||||
const response = await storageClient
|
||||
.getContainerClient(containerName)
|
||||
.getProperties();
|
||||
|
||||
if (response._response.status >= 400) {
|
||||
throw new Error(
|
||||
`from Azure Blob Storage client library: ${reason.message}`,
|
||||
`Failed to retrieve metadata from ${response._response.request.url} with status code ${response._response.status}.`,
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
`Could not retrieve metadata about the Azure Blob Storage container ${containerName}. ` +
|
||||
'Make sure that the Azure project and container exist and the access key is setup correctly ' +
|
||||
'techdocs.publisher.azureBlobStorage.credentials defined in app config has correct permissions. ' +
|
||||
'Refer to https://backstage.io/docs/features/techdocs/using-cloud-storage',
|
||||
);
|
||||
throw new Error(`from Azure Blob Storage client library: ${e.message}`);
|
||||
}
|
||||
|
||||
return new AzureBlobStoragePublish(storageClient, containerName, logger);
|
||||
}
|
||||
@@ -122,8 +121,6 @@ export class AzureBlobStoragePublish implements PublisherBase {
|
||||
// So collecting path of only the files is good enough.
|
||||
const allFilesToUpload = await getFileTreeRecursively(directory);
|
||||
|
||||
const uploadPromises: Array<Promise<BlobUploadCommonResponse>> = [];
|
||||
|
||||
// Bound the number of concurrent batches. We want a bit of concurrency for
|
||||
// performance reasons, but not so much that we starve the connection pool
|
||||
// or start thrashing.
|
||||
@@ -140,23 +137,43 @@ export class AzureBlobStoragePublish implements PublisherBase {
|
||||
); // Azure Blob Storage Container file relative path
|
||||
|
||||
return limiter(async () => {
|
||||
await uploadPromises.push(
|
||||
this.storageClient
|
||||
.getContainerClient(this.containerName)
|
||||
.getBlockBlobClient(destination)
|
||||
.uploadFile(filePath),
|
||||
);
|
||||
const response = await this.storageClient
|
||||
.getContainerClient(this.containerName)
|
||||
.getBlockBlobClient(destination)
|
||||
.uploadFile(filePath);
|
||||
|
||||
if (response._response.status >= 400) {
|
||||
return {
|
||||
...response,
|
||||
error: new Error(
|
||||
`Upload failed for ${filePath} with status code ${response._response.status}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...response,
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(promises).then(() => {
|
||||
const responses = await Promise.all(promises);
|
||||
|
||||
const failed = responses.filter(r => r.error);
|
||||
if (failed.length === 0) {
|
||||
this.logger.info(
|
||||
`Successfully uploaded all the generated files for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`,
|
||||
`Successfully uploaded the ${responses.length} generated file(s) for Entity ${entity.metadata.name}. Total number of files: ${allFilesToUpload.length}`,
|
||||
);
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
throw new Error(
|
||||
failed
|
||||
.map(r => r.error?.message)
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const errorMessage = `Unable to upload file(s) to Azure Blob Storage. Error ${e.message}`;
|
||||
const errorMessage = `Unable to upload file(s) to Azure Blob Storage. ${e.message}`;
|
||||
this.logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
@@ -241,19 +258,11 @@ export class AzureBlobStoragePublish implements PublisherBase {
|
||||
* A helper function which checks if index.html of an Entity's docs site is available. This
|
||||
* can be used to verify if there are any pre-generated docs available to serve.
|
||||
*/
|
||||
async hasDocsBeenGenerated(entity: Entity): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
|
||||
this.storageClient
|
||||
.getContainerClient(this.containerName)
|
||||
.getBlockBlobClient(`${entityRootDir}/index.html`)
|
||||
.exists()
|
||||
.then((response: boolean) => {
|
||||
resolve(response);
|
||||
})
|
||||
.catch(() => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
hasDocsBeenGenerated(entity: Entity): Promise<boolean> {
|
||||
const entityRootDir = `${entity.metadata.namespace}/${entity.kind}/${entity.metadata.name}`;
|
||||
return this.storageClient
|
||||
.getContainerClient(this.containerName)
|
||||
.getBlockBlobClient(`${entityRootDir}/index.html`)
|
||||
.exists();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user