Merge remote-tracking branch 'origin/master' into task-action-idempotency
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-app-api': patch
|
||||
---
|
||||
|
||||
Updated the default error handling middleware to filter out certain known error types that should never be returned in responses. The errors are instead logged along with a correlation ID, which is also returned in the response. Initially only PostgreSQL protocol errors from the `pg-protocol` package are filtered out.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend-module-azure': patch
|
||||
---
|
||||
|
||||
Fixed issue where specifying a branch for discovery did not work
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-backend': minor
|
||||
'@backstage/plugin-catalog-node': minor
|
||||
---
|
||||
|
||||
Adds support for supplying field validators to the new backend's catalog plugin. If you're using entity policies, you should use the new `transformLegacyPolicyToProcessor` function to install them as processors instead.
|
||||
|
||||
```ts
|
||||
import {
|
||||
catalogProcessingExtensionPoint,
|
||||
catalogModelExtensionPoint,
|
||||
} from '@backstage/plugin-catalog-node/alpha';
|
||||
import {myPolicy} from './my-policy';
|
||||
|
||||
export const catalogModulePolicyProvider = createBackendModule({
|
||||
pluginId: 'catalog',
|
||||
moduleId: 'internal-policy-provider',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: {
|
||||
modelExtensions: catalogModelExtensionPoint,
|
||||
processingExtensions: catalogProcessingExtensionPoint,
|
||||
},
|
||||
async init({ modelExtensions, processingExtensions }) {
|
||||
modelExtensions.setFieldValidators({
|
||||
...
|
||||
});
|
||||
processingExtensions.addProcessors(transformLegacyPolicyToProcessor(myPolicy))
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend-module-gitea': patch
|
||||
---
|
||||
|
||||
- Fix issue for infinite loop when repository already exists
|
||||
- Log the root cause of error reported by `checkGiteaOrg`
|
||||
@@ -30,7 +30,8 @@ As an example, we will create a component that validates whether a string is in
|
||||
```tsx
|
||||
//packages/app/src/scaffolder/ValidateKebabCase/ValidateKebabCaseExtension.tsx
|
||||
import React from 'react';
|
||||
import { FieldProps, FieldValidation } from '@rjsf/core';
|
||||
import { FieldExtensionComponentProps } from '@backstage/plugin-scaffolder-react';
|
||||
import type { FieldValidation } from '@rjsf/utils';
|
||||
import FormControl from '@material-ui/core/FormControl';
|
||||
/*
|
||||
This is the actual component that will get rendered in the form
|
||||
@@ -40,7 +41,7 @@ export const ValidateKebabCase = ({
|
||||
rawErrors,
|
||||
required,
|
||||
formData,
|
||||
}: FieldProps<string>) => {
|
||||
}: FieldExtensionComponentProps<string>) => {
|
||||
return (
|
||||
<FormControl
|
||||
margin="normal"
|
||||
|
||||
@@ -93,6 +93,8 @@ _Note:_
|
||||
5. In the window that appears, enter the name of the branch you want to add and click "Add".
|
||||
6. The added branch will now appear in the "Searchable branches" list.
|
||||
|
||||
It may take some time before the branch is indexed and searchable.
|
||||
|
||||
As this provider is not one of the default providers, you will first need to install
|
||||
the Azure catalog plugin:
|
||||
|
||||
@@ -160,11 +162,14 @@ catalog:
|
||||
# Or use a custom file format and location
|
||||
- type: azure-discovery
|
||||
target: https://dev.azure.com/myorg/myproject/_git/*?path=/src/*/catalog-info.yaml
|
||||
# And optionally provide a specific branch name using the version parameter
|
||||
- type: azure-discovery
|
||||
target: https://dev.azure.com/myorg/myproject/_git/*?path=/catalog-info.yaml&version=GBtopic/catalog-info
|
||||
```
|
||||
|
||||
Note the `azure-discovery` type, as this is not a regular `url` processor.
|
||||
|
||||
When using a custom pattern, the target is composed of five parts:
|
||||
When using a custom pattern, the target is composed of these parts:
|
||||
|
||||
- The base instance URL, `https://dev.azure.com` in this case
|
||||
- The organization name which is required, `myorg` in this case
|
||||
@@ -175,3 +180,4 @@ When using a custom pattern, the target is composed of five parts:
|
||||
- The path within each repository to find the catalog YAML file. This will
|
||||
usually be `/catalog-info.yaml`, `/src/*/catalog-info.yaml` or a similar
|
||||
variation for catalog files stored in the root directory of each repository.
|
||||
- The repository branch to scan which is optional, `topic/catalog-info` in this case. If omitted, the repo's default branch will be scanned. The `GB` prefix is required, as this is how Azure DevOps identifies the version as a branch.
|
||||
|
||||
@@ -192,6 +192,43 @@ describe('MiddlewareFactory', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out internal errors', async () => {
|
||||
const app = express();
|
||||
|
||||
const grandChildLogger = {
|
||||
error: jest.fn(),
|
||||
};
|
||||
childLogger.child.mockReturnValue(grandChildLogger);
|
||||
|
||||
class DatabaseError extends Error {}
|
||||
const thrownError = new DatabaseError('some error');
|
||||
|
||||
app.use('/breaks', () => {
|
||||
throw thrownError;
|
||||
});
|
||||
app.use(middleware.error());
|
||||
|
||||
await request(app).get('/breaks');
|
||||
|
||||
const [{ logId }] = childLogger.child.mock.calls[0];
|
||||
|
||||
expect(logId).toMatch(/^[0-9a-f]+$/);
|
||||
expect(childLogger.error).toHaveBeenCalledWith(
|
||||
'Request failed with status 500',
|
||||
expect.objectContaining({
|
||||
message: expect.stringMatching(
|
||||
`An internal error occurred logId=${logId}`,
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(grandChildLogger.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
`Filtered internal error with logId=${logId} from response`,
|
||||
),
|
||||
thrownError,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not log 400 errors', async () => {
|
||||
const app = express();
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
serializeError,
|
||||
} from '@backstage/errors';
|
||||
import { NotImplementedError } from '@backstage/errors';
|
||||
import { applyInternalErrorFilter } from './applyInternalErrorFilter';
|
||||
|
||||
/**
|
||||
* Options used to create a {@link MiddlewareFactory}.
|
||||
@@ -209,7 +210,14 @@ export class MiddlewareFactory {
|
||||
type: 'errorHandler',
|
||||
});
|
||||
|
||||
return (error: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
return (
|
||||
rawError: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const error = applyInternalErrorFilter(rawError, logger);
|
||||
|
||||
const statusCode = getStatusCode(error);
|
||||
if (options.logAllErrors || statusCode >= 500) {
|
||||
logger.error(`Request failed with status ${statusCode}`, error);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LoggerService } from '@backstage/backend-plugin-api';
|
||||
import { assertError } from '@backstage/errors';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
function handleBadError(error: Error, logger: LoggerService) {
|
||||
const logId = randomBytes(10).toString('hex');
|
||||
logger
|
||||
.child({ logId })
|
||||
.error(`Filtered internal error with logId=${logId} from response`, error);
|
||||
const newError = new Error(`An internal error occurred logId=${logId}`);
|
||||
delete newError.stack; // Trim the stack since it's not particularly useful
|
||||
return newError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out certain known error types that should never be returned in responses.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function applyInternalErrorFilter(
|
||||
error: unknown,
|
||||
logger: LoggerService,
|
||||
): Error {
|
||||
try {
|
||||
assertError(error);
|
||||
} catch (assertionError: unknown) {
|
||||
assertError(assertionError);
|
||||
return handleBadError(assertionError, logger);
|
||||
}
|
||||
|
||||
const constructorName = error.constructor.name;
|
||||
|
||||
// DatabaseError are thrown by the pg-protocol module
|
||||
if (constructorName === 'DatabaseError') {
|
||||
return handleBadError(error, logger);
|
||||
}
|
||||
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
site_name: Test3
|
||||
@@ -31,6 +31,12 @@ const file2 = fs.readFileSync(
|
||||
path.resolve(__filename, '../../__fixtures__/awsS3/awsS3-mock-object2.yaml'),
|
||||
);
|
||||
|
||||
const dir1 = 'dir1';
|
||||
const name3 = `file3.yaml`;
|
||||
const file3 = fs.readFileSync(
|
||||
path.resolve(__filename, '../../__fixtures__/awsS3/awsS3-mock-object3.yaml'),
|
||||
);
|
||||
|
||||
describe('ReadableArrayResponse', () => {
|
||||
const sourceDir = createMockDirectory();
|
||||
const targetDir = createMockDirectory();
|
||||
@@ -39,6 +45,9 @@ describe('ReadableArrayResponse', () => {
|
||||
sourceDir.setContent({
|
||||
[name1]: file1,
|
||||
[name2]: file2,
|
||||
[dir1]: {
|
||||
[name3]: file3,
|
||||
},
|
||||
});
|
||||
targetDir.clear();
|
||||
});
|
||||
@@ -56,11 +65,13 @@ describe('ReadableArrayResponse', () => {
|
||||
|
||||
const path1 = sourceDir.resolve(name1);
|
||||
const path2 = sourceDir.resolve(name2);
|
||||
const path3 = sourceDir.resolve(`${dir1}/${name3}`);
|
||||
|
||||
it('should read files', async () => {
|
||||
const arr: FromReadableArrayOptions = [
|
||||
{ data: createReadStream(path1), path: path1 },
|
||||
{ data: createReadStream(path2), path: path2 },
|
||||
{ data: createReadStream(path3), path: path3 },
|
||||
];
|
||||
|
||||
const res = new ReadableArrayResponse(arr, targetDir.path, 'etag');
|
||||
@@ -69,18 +80,21 @@ describe('ReadableArrayResponse', () => {
|
||||
expect(files).toEqual([
|
||||
{ path: path1, content: expect.any(Function) },
|
||||
{ path: path2, content: expect.any(Function) },
|
||||
{ path: path3, content: expect.any(Function) },
|
||||
]);
|
||||
const contents = await Promise.all(files.map(f => f.content()));
|
||||
expect(contents).toEqual([file1, file2]);
|
||||
expect(contents).toEqual([file1, file2, file3]);
|
||||
});
|
||||
|
||||
it('should extract entire archive into directory', async () => {
|
||||
const relativePath1 = relative(sourceDir.path, path1);
|
||||
const relativePath2 = relative(sourceDir.path, path2);
|
||||
const relativePath3 = relative(sourceDir.path, path3);
|
||||
|
||||
const arr: FromReadableArrayOptions = [
|
||||
{ data: createReadStream(path1), path: relativePath1 },
|
||||
{ data: createReadStream(path2), path: relativePath2 },
|
||||
{ data: createReadStream(path3), path: relativePath3 },
|
||||
];
|
||||
|
||||
const res = new ReadableArrayResponse(arr, targetDir.path, 'etag');
|
||||
@@ -89,6 +103,9 @@ describe('ReadableArrayResponse', () => {
|
||||
expect(targetDir.content({ path: dir })).toEqual({
|
||||
[name1]: file1.toString('utf8'),
|
||||
[name2]: file2.toString('utf8'),
|
||||
[dir1]: {
|
||||
[name3]: file3.toString('utf8'),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +89,7 @@ describe('azure', () => {
|
||||
'engineering',
|
||||
'',
|
||||
'/catalog-info.yaml',
|
||||
'',
|
||||
),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
@@ -154,6 +155,7 @@ describe('azure', () => {
|
||||
'engineering',
|
||||
'',
|
||||
'/catalog-info.yaml',
|
||||
'',
|
||||
),
|
||||
).resolves.toEqual(response.results);
|
||||
});
|
||||
@@ -210,6 +212,67 @@ describe('azure', () => {
|
||||
'engineering',
|
||||
'backstage',
|
||||
'/catalog-info.yaml',
|
||||
'',
|
||||
),
|
||||
).resolves.toEqual(response.results);
|
||||
});
|
||||
|
||||
it('searches in specific branch if parameter is set', async () => {
|
||||
const response: CodeSearchResponse = {
|
||||
count: 1,
|
||||
results: [
|
||||
{
|
||||
fileName: 'catalog-info.yaml',
|
||||
path: '/catalog-info.yaml',
|
||||
project: {
|
||||
name: '*',
|
||||
},
|
||||
repository: {
|
||||
name: 'backstage',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.post(
|
||||
`https://almsearch.dev.azure.com/shopify/_apis/search/codesearchresults`,
|
||||
(req, res, ctx) => {
|
||||
expect(req.headers.get('Authorization')).toBe('Basic OkFCQw==');
|
||||
expect(req.body).toEqual({
|
||||
searchText:
|
||||
'path:/catalog-info.yaml repo:backstage proj:engineering',
|
||||
$orderBy: [
|
||||
{
|
||||
field: 'path',
|
||||
sortOrder: 'ASC',
|
||||
},
|
||||
],
|
||||
$skip: 0,
|
||||
$top: 1000,
|
||||
filters: {
|
||||
Branch: ['topic/catalog-info'],
|
||||
},
|
||||
});
|
||||
return res(ctx.json(response));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const { credentialsProvider, azureConfig } = createFixture(
|
||||
'dev.azure.com',
|
||||
'ABC',
|
||||
);
|
||||
|
||||
await expect(
|
||||
codeSearch(
|
||||
credentialsProvider,
|
||||
azureConfig,
|
||||
'shopify',
|
||||
'engineering',
|
||||
'backstage',
|
||||
'/catalog-info.yaml',
|
||||
'topic/catalog-info',
|
||||
),
|
||||
).resolves.toEqual(response.results);
|
||||
});
|
||||
@@ -265,6 +328,7 @@ describe('azure', () => {
|
||||
'engineering',
|
||||
'',
|
||||
'/catalog-info.yaml',
|
||||
'',
|
||||
),
|
||||
).resolves.toEqual(response.results);
|
||||
});
|
||||
@@ -324,6 +388,7 @@ describe('azure', () => {
|
||||
'engineering',
|
||||
'backstage',
|
||||
'/catalog-info.yaml',
|
||||
'',
|
||||
),
|
||||
).resolves.toHaveLength(totalCount);
|
||||
});
|
||||
|
||||
@@ -37,6 +37,16 @@ export interface CodeSearchResultItem {
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
interface CodeSearchRequest {
|
||||
searchText: string;
|
||||
$orderBy: Array<{ field: string; sortOrder: string }>;
|
||||
$skip: number;
|
||||
$top: number;
|
||||
filters?: {
|
||||
Branch: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const isCloud = (host: string) => host === 'dev.azure.com';
|
||||
const PAGE_SIZE = 1000;
|
||||
|
||||
@@ -48,6 +58,7 @@ export async function codeSearch(
|
||||
project: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
branch: string,
|
||||
): Promise<CodeSearchResultItem[]> {
|
||||
const searchBaseUrl = isCloud(azureConfig.host)
|
||||
? 'https://almsearch.dev.azure.com'
|
||||
@@ -62,23 +73,29 @@ export async function codeSearch(
|
||||
url: `https://${azureConfig.host}/${org}`,
|
||||
});
|
||||
|
||||
const searchRequestBody: CodeSearchRequest = {
|
||||
searchText: `path:${path} repo:${repo || '*'} proj:${project || '*'}`,
|
||||
$orderBy: [
|
||||
{
|
||||
field: 'path',
|
||||
sortOrder: 'ASC',
|
||||
},
|
||||
],
|
||||
$skip: items.length,
|
||||
$top: PAGE_SIZE,
|
||||
};
|
||||
|
||||
if (branch) {
|
||||
searchRequestBody.filters = { Branch: [branch] };
|
||||
}
|
||||
|
||||
const response = await fetch(searchUrl, {
|
||||
headers: {
|
||||
...credentials?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
searchText: `path:${path} repo:${repo || '*'} proj:${project || '*'}`,
|
||||
$orderBy: [
|
||||
{
|
||||
field: 'path',
|
||||
sortOrder: 'ASC',
|
||||
},
|
||||
],
|
||||
$skip: items.length,
|
||||
$top: PAGE_SIZE,
|
||||
}),
|
||||
body: JSON.stringify(searchRequestBody),
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
|
||||
+76
@@ -35,6 +35,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
project: 'my-proj',
|
||||
repo: '',
|
||||
catalogPath: '/catalog-info.yaml',
|
||||
branch: '',
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -47,6 +48,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
project: 'engineering',
|
||||
repo: 'backstage',
|
||||
catalogPath: '/catalog.yaml',
|
||||
branch: '',
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -59,6 +61,33 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
project: 'engineering',
|
||||
repo: 'backstage',
|
||||
catalogPath: '/src/*/catalog.yaml',
|
||||
branch: '',
|
||||
});
|
||||
|
||||
expect(
|
||||
parseUrl(
|
||||
'https://azuredevops.mycompany.com/spotify/engineering/_git/backstage?path=/src/*/catalog.yaml&version=GBtopic/catalog-info',
|
||||
),
|
||||
).toEqual({
|
||||
baseUrl: 'https://azuredevops.mycompany.com',
|
||||
org: 'spotify',
|
||||
project: 'engineering',
|
||||
repo: 'backstage',
|
||||
catalogPath: '/src/*/catalog.yaml',
|
||||
branch: 'topic/catalog-info',
|
||||
});
|
||||
|
||||
expect(
|
||||
parseUrl(
|
||||
'https://azuredevops.mycompany.com/spotify/engineering/_git/backstage?path=/src/*/catalog.yaml&version=GBtopic%2Fcatalog-info',
|
||||
),
|
||||
).toEqual({
|
||||
baseUrl: 'https://azuredevops.mycompany.com',
|
||||
org: 'spotify',
|
||||
project: 'engineering',
|
||||
repo: 'backstage',
|
||||
catalogPath: '/src/*/catalog.yaml',
|
||||
branch: 'topic/catalog-info',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,6 +193,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
'engineering',
|
||||
'',
|
||||
'/catalog-info.yaml',
|
||||
'',
|
||||
);
|
||||
expect(emitter).toHaveBeenCalledTimes(2);
|
||||
expect(emitter).toHaveBeenCalledWith({
|
||||
@@ -214,6 +244,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
'engineering',
|
||||
'backstage',
|
||||
'/catalog-info.yaml',
|
||||
'',
|
||||
);
|
||||
expect(emitter).toHaveBeenCalledTimes(1);
|
||||
expect(emitter).toHaveBeenCalledWith({
|
||||
@@ -227,6 +258,49 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('output locations with branch if specified in target', async () => {
|
||||
const location: LocationSpec = {
|
||||
type: 'azure-discovery',
|
||||
target:
|
||||
'https://dev.azure.com/shopify/engineering/_git/backstage?path=/catalog-info.yaml&version=GBtopic/catalog-info',
|
||||
};
|
||||
mockCodeSearch.mockResolvedValueOnce([
|
||||
{
|
||||
fileName: 'catalog-info.yaml',
|
||||
path: '/catalog-info.yaml',
|
||||
repository: {
|
||||
name: 'backstage',
|
||||
},
|
||||
project: {
|
||||
name: '*',
|
||||
},
|
||||
},
|
||||
]);
|
||||
const emitter = jest.fn();
|
||||
|
||||
await processor.readLocation(location, false, emitter);
|
||||
|
||||
expect(mockCodeSearch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
{ host: 'dev.azure.com' },
|
||||
'shopify',
|
||||
'engineering',
|
||||
'backstage',
|
||||
'/catalog-info.yaml',
|
||||
'topic/catalog-info',
|
||||
);
|
||||
expect(emitter).toHaveBeenCalledTimes(1);
|
||||
expect(emitter).toHaveBeenCalledWith({
|
||||
type: 'location',
|
||||
location: {
|
||||
type: 'url',
|
||||
target:
|
||||
'https://dev.azure.com/shopify/engineering/_git/backstage?path=/catalog-info.yaml&version=GBtopic/catalog-info',
|
||||
presence: 'optional',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('output single locations with different file name from code search', async () => {
|
||||
const location: LocationSpec = {
|
||||
type: 'azure-discovery',
|
||||
@@ -256,6 +330,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
'engineering',
|
||||
'',
|
||||
'/src/*/catalog.yaml',
|
||||
'',
|
||||
);
|
||||
expect(emitter).toHaveBeenCalledTimes(1);
|
||||
expect(emitter).toHaveBeenCalledWith({
|
||||
@@ -286,6 +361,7 @@ describe('AzureDevOpsDiscoveryProcessor', () => {
|
||||
'engineering',
|
||||
'backstage',
|
||||
'/catalog-info.yaml',
|
||||
'',
|
||||
);
|
||||
expect(emitter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+18
-2
@@ -92,7 +92,7 @@ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
|
||||
);
|
||||
}
|
||||
|
||||
const { baseUrl, org, project, repo, catalogPath } = parseUrl(
|
||||
const { baseUrl, org, project, repo, catalogPath, branch } = parseUrl(
|
||||
location.target,
|
||||
);
|
||||
this.logger.info(
|
||||
@@ -106,6 +106,7 @@ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
|
||||
project,
|
||||
repo,
|
||||
catalogPath,
|
||||
branch,
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
@@ -113,10 +114,16 @@ export class AzureDevOpsDiscoveryProcessor implements CatalogProcessor {
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
let target = `${baseUrl}/${org}/${project}/_git/${file.repository.name}?path=${file.path}`;
|
||||
|
||||
if (branch) {
|
||||
target += `&version=GB${branch}`;
|
||||
}
|
||||
|
||||
emit(
|
||||
processingResult.location({
|
||||
type: 'url',
|
||||
target: `${baseUrl}/${org}/${project}/_git/${file.repository.name}?path=${file.path}`,
|
||||
target,
|
||||
// Not all locations may actually exist, since the user defined them as a wildcard pattern.
|
||||
// Thus, we emit them as optional and let the downstream processor find them while not outputting
|
||||
// an error if it couldn't.
|
||||
@@ -138,11 +145,18 @@ export function parseUrl(urlString: string): {
|
||||
project: string;
|
||||
repo: string;
|
||||
catalogPath: string;
|
||||
branch: string;
|
||||
} {
|
||||
const url = new URL(urlString);
|
||||
const path = url.pathname.slice(1).split('/');
|
||||
|
||||
const catalogPath = url.searchParams.get('path') || '/catalog-info.yaml';
|
||||
let branch = url.searchParams.get('version') || '';
|
||||
|
||||
if (branch.startsWith('GB')) {
|
||||
// DevOps prefixes branch names with 'GB' in URLs
|
||||
branch = branch.slice(2);
|
||||
}
|
||||
|
||||
if (path.length === 2 && path[0].length && path[1].length) {
|
||||
return {
|
||||
@@ -151,6 +165,7 @@ export function parseUrl(urlString: string): {
|
||||
project: decodeURIComponent(path[1]),
|
||||
repo: '',
|
||||
catalogPath,
|
||||
branch,
|
||||
};
|
||||
} else if (
|
||||
path.length === 4 &&
|
||||
@@ -165,6 +180,7 @@ export function parseUrl(urlString: string): {
|
||||
project: decodeURIComponent(path[1]),
|
||||
repo: decodeURIComponent(path[3]),
|
||||
catalogPath,
|
||||
branch,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ export class AzureDevOpsEntityProvider implements EntityProvider {
|
||||
this.config.project,
|
||||
this.config.repository,
|
||||
this.config.path,
|
||||
this.config.branch || '',
|
||||
);
|
||||
|
||||
logger.info(`Discovered ${files.length} catalog files`);
|
||||
|
||||
Generated
+5
@@ -450,6 +450,11 @@ export const processingResult: Readonly<{
|
||||
// @public @deprecated (undocumented)
|
||||
export type ScmLocationAnalyzer = ScmLocationAnalyzer_2;
|
||||
|
||||
// @public
|
||||
export function transformLegacyPolicyToProcessor(
|
||||
policy: EntityPolicy,
|
||||
): CatalogProcessor_2;
|
||||
|
||||
// @public (undocumented)
|
||||
export class UrlReaderProcessor implements CatalogProcessor_2 {
|
||||
constructor(options: { reader: UrlReader; logger: Logger });
|
||||
|
||||
@@ -24,3 +24,4 @@ export { PlaceholderProcessor } from './PlaceholderProcessor';
|
||||
export type { PlaceholderProcessorOptions } from './PlaceholderProcessor';
|
||||
export { UrlReaderProcessor } from './UrlReaderProcessor';
|
||||
export { parseEntityYaml } from '../util/parse';
|
||||
export { transformLegacyPolicyToProcessor } from './transformLegacyPolicyToProcessor';
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity, EntityPolicy } from '@backstage/catalog-model';
|
||||
import { transformLegacyPolicyToProcessor } from './transformLegacyPolicyToProcessor';
|
||||
import { clone } from 'lodash';
|
||||
|
||||
describe('transformLegacyPolicyToProcessor', () => {
|
||||
const entityToProcess: Entity = {
|
||||
apiVersion: 'backstage.io/v1alpha',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
},
|
||||
};
|
||||
it('modifies the entity if the policy modifies the entity', async () => {
|
||||
const policy: EntityPolicy = {
|
||||
async enforce(entity) {
|
||||
entity.kind = 'Group';
|
||||
return entity;
|
||||
},
|
||||
};
|
||||
const processor = transformLegacyPolicyToProcessor(policy);
|
||||
const clonedEntity = clone(entityToProcess);
|
||||
const entity = await processor.preProcessEntity?.(
|
||||
clonedEntity,
|
||||
{} as any,
|
||||
jest.fn(),
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
expect(entity).toBeTruthy();
|
||||
expect(entity?.kind).toBe('Group');
|
||||
expect(entity?.apiVersion).toBe('backstage.io/v1alpha');
|
||||
expect(entity?.metadata.name).toBe('test');
|
||||
});
|
||||
|
||||
it('does not modify the entity if the policy returns undefined', async () => {
|
||||
const policy: EntityPolicy = {
|
||||
async enforce() {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
const processor = transformLegacyPolicyToProcessor(policy);
|
||||
const clonedEntity = clone(entityToProcess);
|
||||
const entity = await processor.preProcessEntity?.(
|
||||
clonedEntity,
|
||||
{} as any,
|
||||
jest.fn(),
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
expect(entity).toBeTruthy();
|
||||
expect(entity?.kind).toBe('Component');
|
||||
expect(entity?.apiVersion).toBe('backstage.io/v1alpha');
|
||||
expect(entity?.metadata.name).toBe('test');
|
||||
});
|
||||
|
||||
it('bubbles up processor error', async () => {
|
||||
const policy: EntityPolicy = {
|
||||
async enforce() {
|
||||
throw new TypeError('Invalid value for metadata.name');
|
||||
},
|
||||
};
|
||||
const processor = transformLegacyPolicyToProcessor(policy);
|
||||
const clonedEntity = clone(entityToProcess);
|
||||
await expect(
|
||||
processor.preProcessEntity?.(
|
||||
clonedEntity,
|
||||
{} as any,
|
||||
jest.fn(),
|
||||
{} as any,
|
||||
{} as any,
|
||||
),
|
||||
).rejects.toThrow(/Invalid value for metadata.name/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2024 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EntityPolicy } from '@backstage/catalog-model';
|
||||
import { CatalogProcessor } from '@backstage/plugin-catalog-node';
|
||||
|
||||
/**
|
||||
* Transform a given entity policy to an entity processor.
|
||||
* @param policy - The policy to transform
|
||||
* @returns A new entity processor that uses the entity policy.
|
||||
* @public
|
||||
*/
|
||||
export function transformLegacyPolicyToProcessor(
|
||||
policy: EntityPolicy,
|
||||
): CatalogProcessor {
|
||||
return {
|
||||
getProcessorName() {
|
||||
return policy.constructor.name;
|
||||
},
|
||||
async preProcessEntity(entity) {
|
||||
// If enforcing the policy fails, throw the policy error.
|
||||
const result = await policy.enforce(entity);
|
||||
if (!result) {
|
||||
return entity;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
createBackendPlugin,
|
||||
coreServices,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, Validators } from '@backstage/catalog-model';
|
||||
import { CatalogBuilder, CatalogPermissionRuleInput } from './CatalogBuilder';
|
||||
import {
|
||||
CatalogAnalysisExtensionPoint,
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
catalogProcessingExtensionPoint,
|
||||
CatalogPermissionExtensionPoint,
|
||||
catalogPermissionExtensionPoint,
|
||||
CatalogModelExtensionPoint,
|
||||
catalogModelExtensionPoint,
|
||||
} from '@backstage/plugin-catalog-node/alpha';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
ScmLocationAnalyzer,
|
||||
} from '@backstage/plugin-catalog-node';
|
||||
import { loggerToWinstonLogger } from '@backstage/backend-common';
|
||||
import { merge } from 'lodash';
|
||||
|
||||
class CatalogProcessingExtensionPointImpl
|
||||
implements CatalogProcessingExtensionPoint
|
||||
@@ -124,6 +127,18 @@ class CatalogPermissionExtensionPointImpl
|
||||
}
|
||||
}
|
||||
|
||||
class CatalogModelExtensionPointImpl implements CatalogModelExtensionPoint {
|
||||
#fieldValidators: Partial<Validators> = {};
|
||||
|
||||
setFieldValidators(validators: Partial<Validators>): void {
|
||||
merge(this.#fieldValidators, validators);
|
||||
}
|
||||
|
||||
get fieldValidators() {
|
||||
return this.#fieldValidators;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog plugin
|
||||
* @alpha
|
||||
@@ -150,6 +165,9 @@ export const catalogPlugin = createBackendPlugin({
|
||||
permissionExtensions,
|
||||
);
|
||||
|
||||
const modelExtensions = new CatalogModelExtensionPointImpl();
|
||||
env.registerExtensionPoint(catalogModelExtensionPoint, modelExtensions);
|
||||
|
||||
env.registerInit({
|
||||
deps: {
|
||||
logger: coreServices.logger,
|
||||
@@ -192,6 +210,7 @@ export const catalogPlugin = createBackendPlugin({
|
||||
);
|
||||
builder.addLocationAnalyzers(...analysisExtensions.locationAnalyzers);
|
||||
builder.addPermissionRules(...permissionExtensions.permissionRules);
|
||||
builder.setFieldFormatValidators(modelExtensions.fieldValidators);
|
||||
|
||||
const { processingEngine, router } = await builder.build();
|
||||
|
||||
|
||||
Generated
+9
@@ -14,6 +14,7 @@ import { PermissionRuleParams } from '@backstage/plugin-permission-common';
|
||||
import { PlaceholderResolver } from '@backstage/plugin-catalog-node';
|
||||
import { ScmLocationAnalyzer } from '@backstage/plugin-catalog-node';
|
||||
import { ServiceRef } from '@backstage/backend-plugin-api';
|
||||
import { Validators } from '@backstage/catalog-model';
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface CatalogAnalysisExtensionPoint {
|
||||
@@ -24,6 +25,14 @@ export interface CatalogAnalysisExtensionPoint {
|
||||
// @alpha (undocumented)
|
||||
export const catalogAnalysisExtensionPoint: ExtensionPoint<CatalogAnalysisExtensionPoint>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface CatalogModelExtensionPoint {
|
||||
setFieldValidators(validators: Partial<Validators>): void;
|
||||
}
|
||||
|
||||
// @alpha (undocumented)
|
||||
export const catalogModelExtensionPoint: ExtensionPoint<CatalogModelExtensionPoint>;
|
||||
|
||||
// @alpha (undocumented)
|
||||
export interface CatalogPermissionExtensionPoint {
|
||||
// (undocumented)
|
||||
|
||||
@@ -22,3 +22,5 @@ export { catalogAnalysisExtensionPoint } from './extensions';
|
||||
export type { CatalogPermissionRuleInput } from './extensions';
|
||||
export type { CatalogPermissionExtensionPoint } from './extensions';
|
||||
export { catalogPermissionExtensionPoint } from './extensions';
|
||||
export type { CatalogModelExtensionPoint } from './extensions';
|
||||
export { catalogModelExtensionPoint } from './extensions';
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { createExtensionPoint } from '@backstage/backend-plugin-api';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, Validators } from '@backstage/catalog-model';
|
||||
import {
|
||||
CatalogProcessor,
|
||||
EntitiesSearchFilter,
|
||||
@@ -45,6 +45,18 @@ export interface CatalogProcessingExtensionPoint {
|
||||
): void;
|
||||
}
|
||||
|
||||
/** @alpha */
|
||||
export interface CatalogModelExtensionPoint {
|
||||
/**
|
||||
* Sets the validator function to use for one or more special fields of an
|
||||
* entity. This is useful if the default rules for formatting of fields are
|
||||
* not sufficient.
|
||||
*
|
||||
* @param validators - The (subset of) validators to set
|
||||
*/
|
||||
setFieldValidators(validators: Partial<Validators>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
@@ -68,6 +80,12 @@ export const catalogAnalysisExtensionPoint =
|
||||
id: 'catalog.analysis',
|
||||
});
|
||||
|
||||
/** @alpha */
|
||||
export const catalogModelExtensionPoint =
|
||||
createExtensionPoint<CatalogModelExtensionPoint>({
|
||||
id: 'catalog.model',
|
||||
});
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
|
||||
@@ -28,8 +28,8 @@ integrations:
|
||||
password: '<GITEA_LOCALHOST_PASSWORD>'
|
||||
```
|
||||
|
||||
**NOTE**: As backstage will issue HTTPS/TLS requests to the gitea instance, it is needed to configure `gitea` with a valid certificate or at least with a
|
||||
self-signed certificate `gitea cert --host localhost -ca`. Don't forget to set the env var `NODE_EXTRA_CA_CERTS` to point to the CA file before launching backstage !
|
||||
**Important**: As backstage will issue HTTPS/TLS requests to the gitea instance, it is needed to configure `gitea` with a valid certificate or at least with a
|
||||
self-signed certificate `gitea cert --host localhost -ca` trusted by a CA authority. Don't forget to set the env var `NODE_EXTRA_CA_CERTS` to point to the CA file before launching backstage !
|
||||
|
||||
When done, you can create a template which:
|
||||
|
||||
@@ -69,18 +69,8 @@ spec:
|
||||
action: fetch:template
|
||||
input:
|
||||
url: ./skeleton
|
||||
copyWithoutTemplating:
|
||||
- .github/workflows/*
|
||||
values:
|
||||
component_id: ${{ parameters.component_id }}
|
||||
namespace: ${{ parameters.component_id }}-dev
|
||||
description: ${{ parameters.description }}
|
||||
group_id: ${{ parameters.group_id }}
|
||||
artifact_id: ${{ parameters.artifact_id }}
|
||||
java_package_name: ${{ parameters.java_package_name }}
|
||||
owner: ${{ parameters.owner }}
|
||||
destination: ${{ (parameters.repoUrl | parseRepoUrl).owner }}/${{ (parameters.repoUrl | parseRepoUrl).repo }
|
||||
port: 8080
|
||||
name: ${{ parameters.name }}
|
||||
|
||||
- id: publish
|
||||
name: Publishing to a gitea git repository
|
||||
|
||||
@@ -80,7 +80,9 @@ const checkGiteaOrg = async (
|
||||
getOptions,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Unable to get the Organization: ${owner}, ${e}`);
|
||||
throw new Error(
|
||||
`Unable to get the Organization: ${owner}; Error cause: ${e.cause.message}`,
|
||||
);
|
||||
}
|
||||
if (response.status !== 200) {
|
||||
throw new Error(
|
||||
@@ -178,6 +180,9 @@ async function checkAvailabilityGiteaRepository(
|
||||
if (response.status !== 200) {
|
||||
// Repository is not yet available/accessible ...
|
||||
await sleep(1000);
|
||||
} else {
|
||||
// Gitea repository exists !
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user