Merge branch 'backstage:master' into topic/add-homepagetimer-to-new-homepage

This commit is contained in:
Andre Wanlin
2021-09-08 11:24:04 -05:00
committed by GitHub
15 changed files with 137 additions and 90 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Fixing issue with `AzureUrlReader` that doesn't do `subpath` directories correctly
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': patch
---
Disable all buttons in the final step when 'Create' button is clicked in template.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-org': patch
---
Make ownership card style customizable via custom `theme.getPageTheme()`.
+21
View File
@@ -0,0 +1,21 @@
---
'@backstage/plugin-catalog-backend': patch
---
Use `ScmIntegrationRegistry#resolveUrl` in the placeholder processors instead of a custom implementation.
If you manually instantiate the `PlaceholderProcessor` (you most probably don't), add the new required constructor parameter:
```diff
+ import { ScmIntegrations } from '@backstage/integration';
// ...
+ const integrations = ScmIntegrations.fromConfig(config);
// ...
new PlaceholderProcessor({
resolvers: placeholderResolvers,
reader,
+ integrations,
});
```
All custom `PlaceholderResolver` can use the new `resolveUrl` parameter to resolve relative URLs.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-org': patch
---
Use correct `Link` in ownership card to avoid a full reload of the app while navigating.
@@ -220,6 +220,21 @@ describe('AzureUrlReader', () => {
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('returns the wanted files from an archive when a subpath is passed through', async () => {
const response = await processor.readTree(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs',
);
expect(response.etag).toBe('123abc2');
const files = await response.files();
expect(files.length).toBe(1);
const indexMarkdownFile = await files[0].content();
expect(indexMarkdownFile.toString()).toBe('# Test\n');
});
it('creates a directory with the wanted files', async () => {
const response = await processor.readTree(
'https://dev.azure.com/organization/project/_git/repository',
@@ -94,8 +94,6 @@ export class AzureUrlReader implements UrlReader {
url: string,
options?: ReadTreeOptions,
): Promise<ReadTreeResponse> {
// TODO: Support filepath based reading tree feature like other providers
// Get latest commit SHA
const commitsAzureResponse = await fetch(
@@ -129,10 +127,13 @@ export class AzureUrlReader implements UrlReader {
throw new Error(message);
}
const { filepath } = parseGitUrl(url);
return await this.deps.treeResponseFactory.fromZipArchive({
stream: archiveAzureResponse.body as unknown as Readable,
etag: commitSha,
filter: options?.filter,
subpath: filepath,
});
}
@@ -62,36 +62,6 @@ describe('ZipArchiveResponse', () => {
]);
});
it('should read files and strip root dir if requested', async () => {
const stream = fs.createReadStream('/test-archive-with-extra-root-dir.zip');
const res = new ZipArchiveResponse(
stream,
'',
'/tmp',
'etag',
undefined,
true,
);
const files = await res.files();
expect(files).toEqual([
{
path: 'mkdocs.yml',
content: expect.any(Function),
},
{
path: 'docs/index.md',
content: expect.any(Function),
},
]);
const contents = await Promise.all(files.map(f => f.content()));
expect(contents.map(c => c.toString('utf8').trim())).toEqual([
'site_name: Test',
'# Test',
]);
});
it('should read files with filter', async () => {
const stream = fs.createReadStream('/test-archive.zip');
@@ -24,7 +24,6 @@ import {
ReadTreeResponseDirOptions,
ReadTreeResponseFile,
} from '../types';
import { stripFirstDirectoryFromPath } from './util';
/**
* Wraps a zip archive stream into a tree response reader.
@@ -38,7 +37,6 @@ export class ZipArchiveResponse implements ReadTreeResponse {
private readonly workDir: string,
public readonly etag: string,
private readonly filter?: (path: string, info: { size: number }) => boolean,
private readonly stripFirstDirectory?: boolean,
) {
if (subPath) {
if (!subPath.endsWith('/')) {
@@ -68,12 +66,8 @@ export class ZipArchiveResponse implements ReadTreeResponse {
}
private shouldBeIncluded(entry: Entry): boolean {
const strippedPath = this.stripFirstDirectory
? stripFirstDirectoryFromPath(entry.path)
: entry.path;
if (this.subPath) {
if (!strippedPath.startsWith(this.subPath)) {
if (!entry.path.startsWith(this.subPath)) {
return false;
}
}
@@ -102,11 +96,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
if (this.shouldBeIncluded(entry)) {
files.push({
path: this.getInnerPath(
this.stripFirstDirectory
? stripFirstDirectoryFromPath(entry.path)
: entry.path,
),
path: this.getInnerPath(entry.path),
content: () => entry.buffer(),
});
} else {
@@ -154,11 +144,7 @@ export class ZipArchiveResponse implements ReadTreeResponse {
// Ignore directory entries since we handle that with the file entries
// as a zip can have files with directories without directory entries
if (entry.type === 'File' && this.shouldBeIncluded(entry)) {
const entryPath = this.getInnerPath(
this.stripFirstDirectory
? stripFirstDirectoryFromPath(entry.path)
: entry.path,
);
const entryPath = this.getInnerPath(entry.path);
const dirname = platformPath.dirname(entryPath);
if (dirname) {
await fs.mkdirp(platformPath.join(dir, dirname));
@@ -15,6 +15,8 @@
*/
import { UrlReader } from '@backstage/backend-common';
import { Entity } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import {
jsonPlaceholderResolver,
PlaceholderProcessor,
@@ -25,6 +27,8 @@ import {
yamlPlaceholderResolver,
} from './PlaceholderProcessor';
const integrations = ScmIntegrations.fromConfig(new ConfigReader({}));
describe('PlaceholderProcessor', () => {
const read: jest.MockedFunction<ResolverRead> = jest.fn();
const reader: UrlReader = { read, readTree: jest.fn(), search: jest.fn() };
@@ -44,6 +48,7 @@ describe('PlaceholderProcessor', () => {
foo: async () => 'replaced',
},
reader,
integrations,
});
await expect(
processor.preProcessEntity(input, { type: 't', target: 'l' }),
@@ -59,6 +64,7 @@ describe('PlaceholderProcessor', () => {
upper: upperResolver,
},
reader,
integrations,
});
await expect(
@@ -95,6 +101,7 @@ describe('PlaceholderProcessor', () => {
bar: jest.fn(),
},
reader,
integrations,
});
const entity: Entity = {
apiVersion: 'a',
@@ -115,6 +122,7 @@ describe('PlaceholderProcessor', () => {
bar: jest.fn(),
},
reader,
integrations,
});
const entity: Entity = {
apiVersion: 'a',
@@ -134,6 +142,7 @@ describe('PlaceholderProcessor', () => {
const processor = new PlaceholderProcessor({
resolvers: { text: textPlaceholderResolver },
reader,
integrations,
});
await expect(
@@ -169,6 +178,7 @@ describe('PlaceholderProcessor', () => {
const processor = new PlaceholderProcessor({
resolvers: { json: jsonPlaceholderResolver },
reader,
integrations,
});
await expect(
@@ -202,6 +212,7 @@ describe('PlaceholderProcessor', () => {
const processor = new PlaceholderProcessor({
resolvers: { yaml: yamlPlaceholderResolver },
reader,
integrations,
});
await expect(
@@ -235,6 +246,7 @@ describe('PlaceholderProcessor', () => {
const processor = new PlaceholderProcessor({
resolvers: { text: textPlaceholderResolver },
reader,
integrations,
});
await expect(
@@ -272,6 +284,7 @@ describe('PlaceholderProcessor', () => {
const processor = new PlaceholderProcessor({
resolvers: { text: textPlaceholderResolver },
reader,
integrations,
});
await expect(
@@ -311,6 +324,7 @@ describe('PlaceholderProcessor', () => {
const processor = new PlaceholderProcessor({
resolvers: { text: textPlaceholderResolver },
reader,
integrations,
});
await expect(
@@ -331,7 +345,7 @@ describe('PlaceholderProcessor', () => {
},
),
).rejects.toThrow(
'Placeholder $text could not form a URL out of ./a/b/catalog-info.yaml and ../c/catalog-info.yaml',
'Placeholder $text could not form a URL out of ./a/b/catalog-info.yaml and ../c/catalog-info.yaml, TypeError: Invalid base URL: ./a/b/catalog-info.yaml',
);
expect(read).not.toBeCalled();
@@ -345,6 +359,7 @@ describe('yamlPlaceholderResolver', () => {
value: './file.yaml',
baseUrl: 'https://github.com/backstage/backstage/a/b/catalog-info.yaml',
read,
resolveUrl: (url, base) => integrations.resolveUrl({ url, base }),
};
beforeEach(() => {
@@ -389,6 +404,7 @@ describe('jsonPlaceholderResolver', () => {
value: './file.json',
baseUrl: 'https://github.com/backstage/backstage/a/b/catalog-info.yaml',
read,
resolveUrl: (url, base) => integrations.resolveUrl({ url, base }),
};
beforeEach(() => {
@@ -17,16 +17,19 @@
import { UrlReader } from '@backstage/backend-common';
import { Entity, LocationSpec } from '@backstage/catalog-model';
import { JsonValue } from '@backstage/config';
import { ScmIntegrationRegistry } from '@backstage/integration';
import yaml from 'yaml';
import { CatalogProcessor } from './types';
export type ResolverRead = (url: string) => Promise<Buffer>;
export type ResolverResolveUrl = (url: string, base: string) => string;
export type ResolverParams = {
key: string;
value: JsonValue;
baseUrl: string;
read: ResolverRead;
resolveUrl: ResolverResolveUrl;
};
export type PlaceholderResolver = (
@@ -36,6 +39,7 @@ export type PlaceholderResolver = (
type Options = {
resolvers: Record<string, PlaceholderResolver>;
reader: UrlReader;
integrations: ScmIntegrationRegistry;
};
/**
@@ -103,12 +107,19 @@ export class PlaceholderProcessor implements CatalogProcessor {
return this.options.reader.read(url);
};
const resolveUrl = (url: string, base: string): string =>
this.options.integrations.resolveUrl({
url,
base,
});
return [
await resolver({
key: resolverKey,
value: resolverValue,
baseUrl: location.target,
read,
resolveUrl,
}),
true,
];
@@ -191,31 +202,27 @@ async function readTextLocation(params: ResolverParams): Promise<string> {
}
}
function relativeUrl({ key, value, baseUrl }: ResolverParams): string {
function relativeUrl({
key,
value,
baseUrl,
resolveUrl,
}: ResolverParams): string {
if (typeof value !== 'string') {
throw new Error(
`Placeholder \$${key} expected a string value parameter, in the form of an absolute URL or a relative path`,
);
}
let url: URL;
try {
// The two-value form of the URL constructor handles relative paths for us
url = new URL(value, baseUrl);
} catch {
try {
// Check whether value is a valid absolute URL on it's own, if not fail.
url = new URL(value);
} catch {
// The only remaining case that isn't support is a relative file path that should be
// resolved using a relative file location. Accessing local file paths can lead to
// path traversal attacks and access to any file on the host system. Implementing this
// would require additional security measures.
throw new Error(
`Placeholder \$${key} could not form a URL out of ${baseUrl} and ${value}`,
);
}
return resolveUrl(value, baseUrl);
} catch (e) {
// The only remaining case that isn't support is a relative file path that should be
// resolved using a relative file location. Accessing local file paths can lead to
// path traversal attacks and access to any file on the host system. Implementing this
// would require additional security measures.
throw new Error(
`Placeholder \$${key} could not form a URL out of ${baseUrl} and ${value}, ${e}`,
);
}
return url.toString();
}
@@ -385,7 +385,11 @@ export class NextCatalogBuilder {
// These are always there no matter what
const processors: CatalogProcessor[] = [
new PlaceholderProcessor({ resolvers: placeholderResolvers, reader }),
new PlaceholderProcessor({
resolvers: placeholderResolvers,
reader,
integrations,
}),
new BuiltinKindsEntityProcessor(),
];
@@ -306,7 +306,11 @@ export class CatalogBuilder {
// These are always there no matter what
const processors: CatalogProcessor[] = [
StaticLocationProcessor.fromConfig(config),
new PlaceholderProcessor({ resolvers: placeholderResolvers, reader }),
new PlaceholderProcessor({
resolvers: placeholderResolvers,
reader,
integrations,
}),
new BuiltinKindsEntityProcessor(),
];
@@ -18,6 +18,7 @@ import { Entity } from '@backstage/catalog-model';
import {
InfoCard,
InfoCardVariants,
Link,
Progress,
ResponseErrorPanel,
} from '@backstage/core-components';
@@ -29,18 +30,16 @@ import {
isOwnerOf,
useEntity,
} from '@backstage/plugin-catalog-react';
import { BackstageTheme, genPageTheme } from '@backstage/theme';
import { BackstageTheme } from '@backstage/theme';
import {
Box,
createStyles,
Grid,
Link,
makeStyles,
Typography,
} from '@material-ui/core';
import qs from 'qs';
import React from 'react';
import { generatePath } from 'react-router';
import { useAsync } from 'react-use';
type EntityTypeProps = {
@@ -49,16 +48,6 @@ type EntityTypeProps = {
count: number;
};
const createPageTheme = (
theme: BackstageTheme,
shapeKey: string,
colorsKey: string,
) => {
const { colors } = theme.getPageTheme({ themeId: colorsKey });
const { shape } = theme.getPageTheme({ themeId: shapeKey });
return genPageTheme(colors, shape).backgroundImage;
};
const useStyles = makeStyles((theme: BackstageTheme) =>
createStyles({
card: {
@@ -77,7 +66,7 @@ const useStyles = makeStyles((theme: BackstageTheme) =>
},
entityTypeBox: {
background: (props: { type: string }) =>
createPageTheme(theme, props.type, props.type),
theme.getPageTheme({ themeId: props.type }).backgroundImage,
},
}),
);
@@ -96,7 +85,7 @@ const EntityCountTile = ({
const classes = useStyles({ type });
return (
<Link href={url} variant="body2">
<Link to={url} variant="body2">
<Box
className={`${classes.card} ${classes.entityTypeBox}`}
display="flex"
@@ -218,7 +207,7 @@ export const OwnershipCard = ({
counter={c.counter}
type={c.type}
name={c.name}
url={generatePath(`${catalogLink()}/?${c.queryParams}`)}
url={`${catalogLink()}/?${c.queryParams}`}
/>
</Grid>
))}
@@ -112,6 +112,7 @@ export const MultistepJsonForm = ({
widgets,
}: Props) => {
const [activeStep, setActiveStep] = useState(0);
const [disableButtons, setDisableButtons] = useState(false);
const handleReset = () => {
setActiveStep(0);
@@ -121,6 +122,10 @@ export const MultistepJsonForm = ({
setActiveStep(Math.min(activeStep + 1, steps.length));
};
const handleBack = () => setActiveStep(Math.max(activeStep - 1, 0));
const handleCreate = () => {
setDisableButtons(true);
onFinish();
};
return (
<>
@@ -172,9 +177,18 @@ export const MultistepJsonForm = ({
metadata={getReviewData(formData, steps)}
/>
<Box mb={4} />
<Button onClick={handleBack}>Back</Button>
<Button onClick={handleReset}>Reset</Button>
<Button variant="contained" color="primary" onClick={onFinish}>
<Button onClick={handleBack} disabled={disableButtons}>
Back
</Button>
<Button onClick={handleReset} disabled={disableButtons}>
Reset
</Button>
<Button
variant="contained"
color="primary"
onClick={handleCreate}
disabled={disableButtons}
>
Create
</Button>
</Paper>