Merge branch 'backstage:master' into feature/upgrade-aws-sdk-elasticsearch

This commit is contained in:
Andrew Ochsner
2023-05-03 12:21:26 -05:00
committed by GitHub
28 changed files with 970 additions and 319 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Ensure that entity cache state is only written to the database when actually changed
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': patch
---
Fixed a bug in the `queryEntities` endpoint that was causing filtered entities to be included in cursor requests.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-gitlab': minor
---
Add a new scaffolder action for gitlab to ensure a group exists
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-openapi-utils': patch
---
Corrected resolution of parameter nested schema to use central schemas.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-react': patch
---
Fix text-overflow UI issue for Lifecycle spans in SearchFilter checkbox labels.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-backend': patch
---
Added an OpenAPI 3.0 spec and enforced schema-first model on the router.
+1 -12
View File
@@ -333,7 +333,6 @@ declare namespace internal {
ImmutableSchemaObject,
DocParameter,
DocParameters,
ResolveDocParameterSchema,
ParameterSchema,
MapToSchema,
ParametersSchema,
@@ -412,7 +411,7 @@ type OptionalMap<
type ParameterSchema<
Doc extends RequiredDoc,
Schema extends ImmutableParameterObject['schema'],
> = ResolveDocParameterSchema<Doc, Schema> extends infer R
> = SchemaRef<Doc, Schema> extends infer R
? R extends ImmutableSchemaObject
? R extends JSONSchema7
? FromSchema<R>
@@ -549,16 +548,6 @@ type RequiredMap<
[P in Exclude<PickRequiredKeys<T>, undefined>]: NonNullable<T[P]>;
};
// @public (undocumented)
type ResolveDocParameterSchema<
Doc extends RequiredDoc,
Schema extends ImmutableParameterObject['schema'],
> = Schema extends ImmutableReferenceObject
? 'parameters' extends ComponentTypes<Doc>
? ComponentRef<Doc, 'parameters', Schema>
: never
: Schema;
// @public (undocumented)
type Response_2<
Doc extends RequiredDoc,
@@ -34,6 +34,7 @@ import {
MapDiscriminatedUnion,
PathTemplate,
RequiredDoc,
SchemaRef,
} from './common';
import { FromSchema, JSONSchema7 } from 'json-schema-to-ts';
@@ -76,25 +77,13 @@ export type DocParameters<
}
: never;
/**
* @public
*/
export type ResolveDocParameterSchema<
Doc extends RequiredDoc,
Schema extends ImmutableParameterObject['schema'],
> = Schema extends ImmutableReferenceObject
? 'parameters' extends ComponentTypes<Doc>
? ComponentRef<Doc, 'parameters', Schema>
: never
: Schema;
/**
* @public
*/
export type ParameterSchema<
Doc extends RequiredDoc,
Schema extends ImmutableParameterObject['schema'],
> = ResolveDocParameterSchema<Doc, Schema> extends infer R
> = SchemaRef<Doc, Schema> extends infer R
? R extends ImmutableSchemaObject
? R extends JSONSchema7
? FromSchema<R>
@@ -150,7 +150,10 @@ export class DefaultCatalogProcessingEngine implements CatalogProcessingEngine {
track.markProcessorsCompleted(result);
if (result.ok) {
if (stableStringify(state) !== stableStringify(result.state)) {
const { ttl: _, ...stateWithoutTtl } = state ?? {};
if (
stableStringify(stateWithoutTtl) !== stableStringify(result.state)
) {
await this.processingDatabase.transaction(async tx => {
await this.processingDatabase.updateEntityCache(tx, {
id,
@@ -1418,6 +1418,87 @@ describe('DefaultEntitiesCatalog', () => {
},
);
it.each(databases.eachSupportedId())(
'should exclude filtered entities when paginating, %p',
async databaseId => {
await createDatabase(databaseId);
await Promise.all([
addEntityToSearch(entityFrom('AA', { uid: '1', kind: 'included' })),
addEntityToSearch(
entityFrom('AA', {
namespace: 'namespace2',
kind: 'included',
uid: '2',
}),
),
addEntityToSearch(
entityFrom('AA', {
namespace: 'ns',
kind: 'excluded',
uid: '3',
}),
),
addEntityToSearch(
entityFrom('AA', {
namespace: 'namespace3',
uid: '4',
kind: 'included',
}),
),
addEntityToSearch(
entityFrom('AA', {
namespace: 'namespace4',
uid: '5',
kind: 'included',
}),
),
addEntityToSearch(entityFrom('CC', { uid: '6', kind: 'included' })),
addEntityToSearch(entityFrom('DD', { uid: '7', kind: 'included' })),
]);
const catalog = new DefaultEntitiesCatalog({
database: knex,
logger: getVoidLogger(),
stitcher,
});
const limit = 2;
// initial request
const request1: QueryEntitiesInitialRequest = {
limit,
filter: {
key: 'kind',
values: ['included'],
},
orderFields: [{ field: 'metadata.name', order: 'asc' }],
};
const response1 = await catalog.queryEntities(request1);
expect(response1.items).toMatchObject([
entityFrom('AA', { uid: '1', kind: 'included' }),
entityFrom('AA', { uid: '2', kind: 'included' }),
]);
expect(response1.pageInfo.nextCursor).toBeDefined();
expect(response1.pageInfo.prevCursor).toBeUndefined();
expect(response1.totalItems).toBe(6);
// second request (forward)
const request2: QueryEntitiesCursorRequest = {
cursor: response1.pageInfo.nextCursor!,
limit,
};
const response2 = await catalog.queryEntities(request2);
expect(response2.items).toMatchObject([
entityFrom('AA', { uid: '4', kind: 'included' }),
entityFrom('AA', { uid: '5', kind: 'included' }),
]);
expect(response2.pageInfo.nextCursor).toBeDefined();
expect(response2.pageInfo.prevCursor).toBeDefined();
expect(response2.totalItems).toBe(6);
},
);
it.each(databases.eachSupportedId())(
'should paginate results without sort fields, %p',
async databaseId => {
@@ -1754,11 +1835,12 @@ function entityFrom(
uid,
namespace,
title,
}: { uid?: string; namespace?: string; title?: string } = {},
kind = 'k',
}: { uid?: string; namespace?: string; title?: string; kind?: string } = {},
) {
return {
apiVersion: 'a',
kind: 'k',
kind,
metadata: {
name,
...(!!namespace && { namespace }),
@@ -413,17 +413,18 @@ export class DefaultEntitiesCatalog implements EntitiesCatalog {
const isOrderingDescending = sortField.order === 'desc';
if (prevItemOrderFieldValue) {
dbQuery.andWhere(
'value',
isFetchingBackwards !== isOrderingDescending ? '<' : '>',
prevItemOrderFieldValue,
);
dbQuery.orWhere(function nested() {
this.where('value', '=', prevItemOrderFieldValue).andWhere(
'search.entity_id',
dbQuery.andWhere(function nested() {
this.where(
'value',
isFetchingBackwards !== isOrderingDescending ? '<' : '>',
prevItemUid,
);
prevItemOrderFieldValue,
)
.orWhere('value', '=', prevItemOrderFieldValue)
.andWhere(
'search.entity_id',
isFetchingBackwards !== isOrderingDescending ? '<' : '>',
prevItemUid,
);
});
}
@@ -45,6 +45,9 @@ const actions = [
createGitlabProjectDeployTokenAction({
integrations: integrations,
}),
createGitlabGroupEnsureExistsAction({
integrations: integrations,
}),
];
// Create Scaffolder Router
@@ -104,13 +107,22 @@ spec:
url: https://github.com/TEMPLATE
values:
name: ${{ parameters.name }}
- id: createGitlabGroup
name: Ensure Gitlab group exists
action: gitlab:group:ensureExists
input:
repoUrl: ${{ parameters.repoUrl }}
path:
- path
- to
- group
- id: publish
name: Publish
action: publish:gitlab
input:
description: This is ${{ parameters.name }}
repoUrl: ${{ parameters.repoUrl }}
repoUrl: ${{ parameters.repoUrl }}?owner=${{ steps.createGitlabGroup.output.groupId }}
sourcePath: pimcore
defaultBranch: main
@@ -7,19 +7,37 @@ import { JsonObject } from '@backstage/types';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
// @public
export const createGitlabGroupEnsureExistsAction: (options: {
integrations: ScmIntegrationRegistry;
}) => TemplateAction<
{
repoUrl: string;
token?: string | undefined;
} & {
path: string[];
},
{
groupId?: number | undefined;
}
>;
// @public
export const createGitlabProjectAccessTokenAction: (options: {
integrations: ScmIntegrationRegistry;
}) => TemplateAction<
{
repoUrl: string;
projectId: string | number;
name: string;
accessLevel: number;
scopes: string[];
token?: string | undefined;
} & {
projectId: string | number;
name?: string | undefined;
accessLevel?: number | undefined;
scopes?: string[] | undefined;
},
JsonObject
{
access_token: string;
}
>;
// @public
@@ -28,13 +46,17 @@ export const createGitlabProjectDeployTokenAction: (options: {
}) => TemplateAction<
{
repoUrl: string;
projectId: string | number;
name: string;
username: string;
scopes: string[];
token?: string | undefined;
} & {
name: string;
projectId: string | number;
username?: string | undefined;
scopes?: string[] | undefined;
},
JsonObject
{
user: string;
deploy_token: string;
}
>;
// @public
@@ -43,15 +65,16 @@ export const createGitlabProjectVariableAction: (options: {
}) => TemplateAction<
{
repoUrl: string;
projectId: string | number;
token?: string | undefined;
} & {
key: string;
value: string;
projectId: string | number;
variableType: string;
variableProtected: boolean;
masked: boolean;
raw: boolean;
environmentScope: string;
token?: string | undefined;
variableProtected?: boolean | undefined;
masked?: boolean | undefined;
raw?: boolean | undefined;
environmentScope?: string | undefined;
},
JsonObject
>;
@@ -35,7 +35,8 @@
"@backstage/errors": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-scaffolder-node": "workspace:^",
"@gitbeaker/node": "^35.8.0"
"@gitbeaker/node": "^35.8.0",
"zod": "^3.21.4"
},
"devDependencies": {
"@backstage/backend-common": "workspace:^",
@@ -0,0 +1,140 @@
/*
* Copyright 2021 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 { PassThrough } from 'stream';
import { createGitlabGroupEnsureExistsAction } from './createGitlabGroupEnsureExistsAction';
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/core-app-api';
import { ScmIntegrations } from '@backstage/integration';
const mockGitlabClient = {
Groups: {
search: jest.fn(),
create: jest.fn(),
},
};
jest.mock('@gitbeaker/node', () => ({
Gitlab: class {
constructor() {
return mockGitlabClient;
}
},
}));
describe('gitlab:group:ensureExists', () => {
const mockContext = {
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
afterEach(() => {
jest.resetAllMocks();
});
it('should create a new group if it does not exists', async () => {
mockGitlabClient.Groups.search.mockResolvedValue([
{
id: 1,
full_path: 'repos/bar',
},
{
id: 2,
full_path: 'repos/foo',
},
]);
mockGitlabClient.Groups.create.mockResolvedValue({
id: 3,
full_path: 'repos/foo/bar',
});
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createGitlabGroupEnsureExistsAction({ integrations });
await action.handler({
...mockContext,
input: {
repoUrl: 'gitlab.com',
path: ['foo', 'bar'],
},
});
expect(mockGitlabClient.Groups.create).toHaveBeenCalledWith('bar', 'bar', {
parent_id: 2,
});
expect(mockContext.output).toHaveBeenCalledWith('groupId', 3);
});
it('should return existing group if it does exists', async () => {
mockGitlabClient.Groups.search.mockResolvedValue([
{
id: 1,
full_path: 'repos/bar',
},
{
id: 2,
full_path: 'repos/foo',
},
{
id: 42,
full_path: 'repos/foo/bar',
},
]);
const config = new ConfigReader({
integrations: {
gitlab: [
{
host: 'gitlab.com',
token: 'tokenlols',
apiBaseUrl: 'https://api.gitlab.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createGitlabGroupEnsureExistsAction({ integrations });
await action.handler({
...mockContext,
input: {
repoUrl: 'gitlab.com',
path: ['foo', 'bar'],
},
});
expect(mockGitlabClient.Groups.create).not.toHaveBeenCalled();
expect(mockContext.output).toHaveBeenCalledWith('groupId', 42);
});
});
@@ -0,0 +1,94 @@
/*
* Copyright 2021 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 { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { Gitlab } from '@gitbeaker/node';
import { GroupSchema } from '@gitbeaker/core/dist/types/resources/Groups';
import commonGitlabConfig from '../commonGitlabConfig';
import { getToken } from '../util';
import { z } from 'zod';
/**
* Creates an `gitlab:group:ensureExists` Scaffolder action.
*
* @public
*/
export const createGitlabGroupEnsureExistsAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction({
id: 'gitlab:group:ensureExists',
description: 'Ensures a Gitlab group exists',
schema: {
input: commonGitlabConfig.and(
z.object({
path: z
.array(z.string(), {
description: 'A path of group names that is ensured to exist',
})
.min(1),
}),
),
output: z.object({
groupId: z
.number({ description: 'The id of the innermost sub-group' })
.optional(),
}),
},
async handler(ctx) {
const { path } = ctx.input;
const { token, integrationConfig } = getToken(ctx.input, integrations);
const api = new Gitlab({
host: integrationConfig.config.baseUrl,
token: token,
});
let currentPath: string = 'repos';
let parent: GroupSchema | null = null;
for (const pathElement of path) {
const fullPath = `${currentPath}/${pathElement}`;
const result = (await api.Groups.search(
fullPath,
)) as any as Array<GroupSchema>; // recast since the return type for search is wrong in the gitbeaker typings
const subGroup = result.find(
searchPathElem => searchPathElem.full_path === fullPath,
);
if (!subGroup) {
ctx.logger.info(`creating missing group ${fullPath}`);
parent = await api.Groups.create(
pathElement,
pathElement,
parent
? {
parent_id: parent.id,
}
: {},
);
} else {
parent = subGroup;
}
currentPath = fullPath;
}
if (parent !== null) {
ctx.output('groupId', parent?.id);
}
},
});
};
@@ -16,10 +16,12 @@
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import commonGitlabConfig from '../commonGitlabConfig';
import { getToken } from '../util';
import { z } from 'zod';
/**
* Creates a `gitlab:create-project-access-token` Scaffolder action.
* Creates a `gitlab:projectAccessToken:create` Scaffolder action.
*
* @param options - Templating configuration.
* @public
@@ -28,65 +30,29 @@ export const createGitlabProjectAccessTokenAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
projectId: string | number;
name: string;
accessLevel: number;
scopes: string[];
token?: string;
}>({
return createTemplateAction({
id: 'gitlab:projectAccessToken:create',
schema: {
input: {
required: ['projectId', 'repoUrl'],
type: 'object',
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
projectId: {
title: 'Project ID',
type: ['string', 'number'],
},
name: {
title: 'Deploy Token Name',
type: 'string',
},
accessLevel: {
title: 'Access Level of the Token',
type: 'number',
},
scopes: {
title: 'Scopes',
type: 'array',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitLab',
},
},
},
output: {
type: 'object',
properties: {
access_token: {
title: 'Access Token',
type: 'string',
},
},
},
input: commonGitlabConfig.and(
z.object({
projectId: z.union([z.number(), z.string()], {
description: 'Project ID',
}),
name: z.string({ description: 'Deploy Token Name' }).optional(),
accessLevel: z
.number({ description: 'Access Level of the Token' })
.optional(),
scopes: z.array(z.string(), { description: 'Scopes' }).optional(),
}),
),
output: z.object({
access_token: z.string({ description: 'Access Token' }),
}),
},
async handler(ctx) {
ctx.logger.info(`Creating Token for Project "${ctx.input.projectId}"`);
const { repoUrl, projectId, name, accessLevel, scopes } = ctx.input;
const { token, integrationConfig } = getToken(
repoUrl,
ctx.input.token,
integrations,
);
const { projectId, name, accessLevel, scopes } = ctx.input;
const { token, integrationConfig } = getToken(ctx.input, integrations);
const response = await fetch(
`${integrationConfig.config.baseUrl}/api/v4/projects/${projectId}/access_tokens`,
@@ -18,11 +18,13 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { Gitlab } from '@gitbeaker/node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { DeployTokenScope } from '@gitbeaker/core/dist/types/templates/ResourceDeployTokens';
import commonGitlabConfig from '../commonGitlabConfig';
import { getToken } from '../util';
import { InputError } from '@backstage/errors';
import { z } from 'zod';
/**
* Creates a `gitlab:create-project-deploy-token` Scaffolder action.
* Creates a `gitlab:projectDeployToken:create` Scaffolder action.
*
* @param options - Templating configuration.
* @public
@@ -31,69 +33,30 @@ export const createGitlabProjectDeployTokenAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
projectId: string | number;
name: string;
username: string;
scopes: string[];
token?: string;
}>({
return createTemplateAction({
id: 'gitlab:projectDeployToken:create',
schema: {
input: {
required: ['projectId', 'repoUrl'],
type: 'object',
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
projectId: {
title: 'Project ID',
type: ['string', 'number'],
},
name: {
title: 'Deploy Token Name',
type: 'string',
},
username: {
title: 'Deploy Token Username',
type: 'string',
},
scopes: {
title: 'Scopes',
type: 'array',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitLab',
},
},
},
output: {
type: 'object',
properties: {
deploy_token: {
title: 'Deploy Token',
type: 'string',
},
user: {
title: 'User',
type: 'string',
},
},
},
input: commonGitlabConfig.and(
z.object({
projectId: z.union([z.number(), z.string()], {
description: 'Project ID',
}),
name: z.string({ description: 'Deploy Token Name' }),
username: z
.string({ description: 'Deploy Token Username' })
.optional(),
scopes: z.array(z.string(), { description: 'Scopes' }).optional(),
}),
),
output: z.object({
deploy_token: z.string({ description: 'Deploy Token' }),
user: z.string({ description: 'User' }),
}),
},
async handler(ctx) {
ctx.logger.info(`Creating Token for Project "${ctx.input.projectId}"`);
const { repoUrl, projectId, name, username, scopes } = ctx.input;
const { token, integrationConfig } = getToken(
repoUrl,
ctx.input.token,
integrations,
);
const { projectId, name, username, scopes } = ctx.input;
const { token, integrationConfig } = getToken(ctx.input, integrations);
const api = new Gitlab({
host: integrationConfig.config.baseUrl,
@@ -16,11 +16,13 @@
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { getToken } from '../util';
import { Gitlab } from '@gitbeaker/node';
import { getToken } from '../util';
import commonGitlabConfig from '../commonGitlabConfig';
import { z } from 'zod';
/**
* Creates a `gitlab:create-project-variable` Scaffolder action.
* Creates a `gitlab:projectVariable:create` Scaffolder action.
*
* @param options - Templating configuration.
* @public
@@ -29,96 +31,55 @@ export const createGitlabProjectVariableAction = (options: {
integrations: ScmIntegrationRegistry;
}) => {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
projectId: string | number;
key: string;
value: string;
variableType: string;
variableProtected: boolean;
masked: boolean;
raw: boolean;
environmentScope: string;
token?: string;
}>({
return createTemplateAction({
id: 'gitlab:projectVariable:create',
schema: {
input: {
required: [
'repoUrl',
'projectId',
'key',
'value',
'variableType',
'variableProtected',
'masked',
'raw',
'environmentScope',
],
type: 'object',
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
projectId: {
title: 'Project ID',
type: ['string', 'number'],
},
key: {
title:
'The key of a variable; must have no more than 255 characters; only A-Z, a-z, 0-9, and _ are allowed',
type: 'string',
},
value: {
title: 'The value of a variable',
type: 'string',
},
variableType: {
title: 'Variable Type (env_var or file)',
type: 'string',
},
variableProtected: {
title: 'Whether the variable is protected. Default: false',
type: 'boolean',
},
masked: {
title: 'Whether the variable is masked. Default: false',
type: 'boolean',
},
raw: {
title: 'Whether the variable is expandable. Default: false',
type: 'boolean',
},
environmentScope: {
title: 'The environment_scope of the variable. Default: *',
type: 'string',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitLab',
},
},
},
input: commonGitlabConfig.and(
z.object({
projectId: z.union([z.number(), z.string()], {
description: 'Project ID',
}),
key: z
.string({
description:
'The key of a variable; must have no more than 255 characters; only A-Z, a-z, 0-9, and _ are allowed',
})
.regex(/^[A-Za-z0-9_]{1,255}$/),
value: z.string({ description: 'The value of a variable' }),
variableType: z.string({
description: 'Variable Type (env_var or file)',
}),
variableProtected: z
.boolean({ description: 'Whether the variable is protected' })
.default(false)
.optional(),
masked: z
.boolean({ description: 'Whether the variable is masked' })
.default(false)
.optional(),
raw: z
.boolean({ description: 'Whether the variable is expandable' })
.default(false)
.optional(),
environmentScope: z
.string({ description: 'The environment_scope of the variable' })
.default('*')
.optional(),
}),
),
},
async handler(ctx) {
const {
repoUrl,
projectId,
key,
value,
variableType,
variableProtected,
masked,
raw,
environmentScope,
variableProtected = false,
masked = false,
raw = false,
environmentScope = '*',
} = ctx.input;
const { token, integrationConfig } = getToken(
repoUrl,
ctx.input.token,
integrations,
);
const { token, integrationConfig } = getToken(ctx.input, integrations);
const api = new Gitlab({
host: integrationConfig.config.baseUrl,
@@ -0,0 +1,26 @@
/*
* Copyright 2021 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 { z } from 'zod';
const commonGitlabConfig = z.object({
repoUrl: z.string({ description: 'Repository Location' }),
token: z
.string({ description: 'The token to use for authorization to GitLab' })
.optional(),
});
export default commonGitlabConfig;
@@ -13,12 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* A module for the scaffolder backend that lets you create gitlab project access tokens or deploy tokens
* A module for the scaffolder backend that lets you interact with gitlab
*
* @packageDocumentation
*/
export * from './actions/createGitlabGroupEnsureExistsAction';
export * from './actions/createGitlabProjectDeployTokenAction';
export * from './actions/createGitlabProjectAccessTokenAction';
export * from './actions/createGitlabProjectVariableAction';
@@ -19,6 +19,8 @@ import {
GitLabIntegration,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { z } from 'zod';
import commonGitlabConfig from './commonGitlabConfig';
export const parseRepoHost = (repoUrl: string): string => {
let parsed;
@@ -33,11 +35,10 @@ export const parseRepoHost = (repoUrl: string): string => {
};
export const getToken = (
repoUrl: string,
inputToken: string | null | undefined,
config: z.infer<typeof commonGitlabConfig>,
integrations: ScmIntegrationRegistry,
): { token: string; integrationConfig: GitLabIntegration } => {
const host = parseRepoHost(repoUrl);
const host = parseRepoHost(config.repoUrl);
const integrationConfig = integrations.gitlab.byHost(host);
if (!integrationConfig) {
@@ -46,8 +47,8 @@ export const getToken = (
);
}
const token = inputToken || integrationConfig.config.token!;
const tokenType = inputToken ? 'oauthToken' : 'token';
const token = config.token || integrationConfig.config.token!;
const tokenType = config.token ? 'oauthToken' : 'token';
if (tokenType === 'oauthToken') {
throw new InputError(`OAuth Token is currently not supported`);
+1
View File
@@ -57,6 +57,7 @@
"zod": "^3.21.4"
},
"devDependencies": {
"@backstage/backend-openapi-utils": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/supertest": "^2.0.8",
@@ -0,0 +1,211 @@
/*
* Copyright 2023 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.
*/
// ******************************************************************
// * THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. *
// ******************************************************************
export default {
openapi: '3.0.3',
info: {
title: '@backstage/plugin-search-backend',
version: '1',
description:
'The Backstage backend plugin that provides search functionality.',
license: {
name: 'Apache-2.0',
url: 'http://www.apache.org/licenses/LICENSE-2.0.html',
},
contact: {},
},
servers: [
{
url: '/',
},
],
components: {
examples: {},
headers: {},
parameters: {},
requestBodies: {},
responses: {},
schemas: {
JsonObject: {
type: 'object',
properties: {},
additionalProperties: {},
},
},
securitySchemes: {
JWT: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
},
paths: {
'/query': {
get: {
operationId: 'Query',
responses: {
'200': {
description: 'Ok',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
results: {
type: 'array',
items: {
type: 'object',
properties: {
type: {
type: 'string',
description: 'The "type" of the given document.',
},
document: {
type: 'object',
description:
'The raw value of the document, as indexed.',
properties: {
title: {
type: 'string',
description:
'The primary name of the document (e.g. name, title, identifier, etc).',
},
text: {
type: 'string',
description:
'Free-form text of the document (e.g. description, content, etc).',
},
location: {
type: 'string',
description:
'The relative or absolute URL of the document (target when a search result is clicked).',
},
},
},
highlight: {
type: 'object',
description:
'Optional result highlight. Useful for improving the search result\ndisplay/experience.',
},
rank: {
type: 'integer',
description:
'Optional result rank, where 1 is the first/top result returned. \nUseful for understanding search effectiveness in analytics.',
},
},
required: ['type', 'document'],
additionalProperties: false,
},
},
nextPageCursor: {
type: 'string',
},
previousPageCursor: {
type: 'string',
},
numberOfResults: {
type: 'integer',
},
},
required: ['results'],
},
},
},
},
'400': {
description: 'Bad request',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
error: {
type: 'object',
properties: {
message: {
type: 'string',
},
},
},
},
},
},
},
},
},
security: [
{},
{
JWT: [],
},
],
parameters: [
{
name: 'term',
in: 'query',
required: false,
schema: {
type: 'string',
default: '',
},
},
{
name: 'filters',
in: 'query',
required: false,
style: 'deepObject',
explode: true,
schema: {
$ref: '#/components/schemas/JsonObject',
},
},
{
name: 'types',
in: 'query',
required: false,
schema: {
type: 'array',
items: {
type: 'string',
},
},
},
{
name: 'pageCursor',
in: 'query',
required: false,
schema: {
type: 'string',
},
},
{
name: 'pageLimit',
in: 'query',
required: false,
schema: {
type: 'integer',
},
},
],
},
},
},
} as const;
@@ -0,0 +1,135 @@
openapi: 3.0.3
info:
title: '@backstage/plugin-search-backend'
version: '1'
description: The Backstage backend plugin that provides search functionality.
license:
name: Apache-2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
contact: {}
servers:
- url: /
components:
examples: {}
headers: {}
parameters: {}
requestBodies: {}
responses: {}
schemas:
JsonObject:
type: object
properties: {}
# Free form object.
additionalProperties: {}
securitySchemes:
JWT:
type: http
scheme: bearer
bearerFormat: JWT
paths:
/query:
get:
operationId: Query
responses:
'200':
description: Ok
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
type:
type: string
description: The "type" of the given document.
document:
type: object
description: The raw value of the document, as indexed.
properties:
title:
type: string
description: The primary name of the document (e.g. name, title, identifier, etc).
text:
type: string
description: Free-form text of the document (e.g. description, content, etc).
location:
type: string
description: The relative or absolute URL of the document (target when a search result is clicked).
highlight:
type: object
description: |-
Optional result highlight. Useful for improving the search result
display/experience.
rank:
type: integer
description: |-
Optional result rank, where 1 is the first/top result returned.
Useful for understanding search effectiveness in analytics.
required:
- type
- document
additionalProperties: false
nextPageCursor:
type: string
previousPageCursor:
type: string
numberOfResults:
type: integer
required:
- results
'400':
description: Bad request
content:
application/json:
schema:
type: object
properties:
error:
type: object
properties:
message:
type: string
security:
- {}
- JWT: []
parameters:
- name: term
in: query
required: false
schema:
type: string
default: ''
- name: filters
in: query
required: false
style: deepObject
explode: true
schema:
# JsonObject is used here instead of the full ZOD schema definition as
# resolution of recursive schemas isn't possible with the library we're using
# and it causes a performance hit when _trying_ to resolve them.
$ref: '#/components/schemas/JsonObject'
- name: types
in: query
required: false
schema:
type: array
items:
type: string
- name: pageCursor
in: query
required: false
schema:
type: string
- name: pageLimit
in: query
required: false
schema:
type: integer
+36 -40
View File
@@ -19,7 +19,7 @@ import Router from 'express-promise-router';
import { Logger } from 'winston';
import { z } from 'zod';
import { errorHandler } from '@backstage/backend-common';
import { ErrorResponseBody, InputError } from '@backstage/errors';
import { InputError } from '@backstage/errors';
import { Config } from '@backstage/config';
import { JsonObject, JsonValue } from '@backstage/types';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
@@ -35,6 +35,8 @@ import {
} from '@backstage/plugin-search-common';
import { SearchEngine } from '@backstage/plugin-search-common';
import { AuthorizedSearchEngine } from './AuthorizedSearchEngine';
import type { ApiRouter } from '@backstage/backend-openapi-utils';
import spec from '../schema/openapi.generated';
const jsonObjectSchema: z.ZodSchema<JsonObject> = z.lazy(() => {
const jsonValueSchema: z.ZodSchema<JsonValue> = z.lazy(() =>
@@ -146,49 +148,43 @@ export async function createRouter(
})),
});
const router = Router();
router.get(
'/query',
async (
req: express.Request,
res: express.Response<SearchResultSet | ErrorResponseBody>,
) => {
const parseResult = requestSchema.passthrough().safeParse(req.query);
const router = Router() as ApiRouter<typeof spec>;
router.get('/query', async (req, res) => {
const parseResult = requestSchema.passthrough().safeParse(req.query);
if (!parseResult.success) {
throw new InputError(`Invalid query string: ${parseResult.error}`);
if (!parseResult.success) {
throw new InputError(`Invalid query string: ${parseResult.error}`);
}
const query = parseResult.data;
logger.info(
`Search request received: term="${query.term}", filters=${JSON.stringify(
query.filters,
)}, types=${query.types ? query.types.join(',') : ''}, pageCursor=${
query.pageCursor ?? ''
}`,
);
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
try {
const resultSet = await engine?.query(query, { token });
res.json(filterResultSet(toSearchResults(resultSet)));
} catch (error) {
if (error.name === 'MissingIndexError') {
// re-throw and let the default error handler middleware captures it and serializes it with the right response code on the standard form
throw error;
}
const query = parseResult.data;
logger.info(
`Search request received: term="${
query.term
}", filters=${JSON.stringify(query.filters)}, types=${
query.types ? query.types.join(',') : ''
}, pageCursor=${query.pageCursor ?? ''}`,
throw new Error(
`There was a problem performing the search query: ${error.message}`,
);
const token = getBearerTokenFromAuthorizationHeader(
req.header('authorization'),
);
try {
const resultSet = await engine?.query(query, { token });
res.json(filterResultSet(toSearchResults(resultSet)));
} catch (error) {
if (error.name === 'MissingIndexError') {
// re-throw and let the default error handler middleware captures it and serializes it with the right response code on the standard form
throw error;
}
throw new Error(
`There was a problem performing the search query: ${error.message}`,
);
}
},
);
}
});
router.use(errorHandler());
@@ -24,6 +24,7 @@ import {
Select,
MenuItem,
FormLabel,
Typography,
} from '@material-ui/core';
import { useSearch } from '../../context';
@@ -37,6 +38,16 @@ const useStyles = makeStyles({
label: {
textTransform: 'capitalize',
},
checkboxWrapper: {
display: 'flex',
alignItems: 'center',
width: '100%',
},
textWrapper: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
});
/**
@@ -119,6 +130,11 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => {
{values.map((value: string) => (
<FormControlLabel
key={value}
classes={{
root: classes.checkboxWrapper,
label: classes.textWrapper,
}}
label={value}
control={
<Checkbox
color="primary"
@@ -130,7 +146,6 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => {
checked={((filters[name] as string[]) ?? []).includes(value)}
/>
}
label={value}
/>
))}
</FormControl>
@@ -197,7 +212,9 @@ export const SelectFilter = (props: SearchFilterComponentProps) => {
</MenuItem>
{values.map((value: string) => (
<MenuItem key={value} value={value}>
{value}
<Typography variant="inherit" noWrap>
{value}
</Typography>
</MenuItem>
))}
</Select>
+27 -18
View File
@@ -182,8 +182,8 @@ __metadata:
linkType: hard
"@apollo/server@npm:^4.0.0":
version: 4.7.0
resolution: "@apollo/server@npm:4.7.0"
version: 4.7.1
resolution: "@apollo/server@npm:4.7.1"
dependencies:
"@apollo/cache-control-types": ^1.0.2
"@apollo/server-gateway-interface": ^1.1.0
@@ -213,7 +213,7 @@ __metadata:
whatwg-mimetype: ^3.0.0
peerDependencies:
graphql: ^16.6.0
checksum: 68f94a3859b99b931d8c6ae36f8684ec85eb7524a74e2a814b36ae3ae87773ff73562657021eaa2c2d382ec32bb6b1d9f9f7975fdc0a4509f24cddd71d33953e
checksum: 6c8bdd240a4c688641bfec0520375db0eb78d2495286f30f962d7b6e97d7db70f105c09364e56103e1b93543e5760621666655eaa8ae7218312ac54ca7200d4b
languageName: node
linkType: hard
@@ -8314,6 +8314,7 @@ __metadata:
"@backstage/integration": "workspace:^"
"@backstage/plugin-scaffolder-node": "workspace:^"
"@gitbeaker/node": ^35.8.0
zod: ^3.21.4
languageName: unknown
linkType: soft
@@ -8723,6 +8724,7 @@ __metadata:
resolution: "@backstage/plugin-search-backend@workspace:plugins/search-backend"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-openapi-utils": "workspace:^"
"@backstage/backend-plugin-api": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/cli": "workspace:^"
@@ -14049,8 +14051,8 @@ __metadata:
linkType: hard
"@roadiehq/backstage-plugin-github-pull-requests@npm:^2.2.7":
version: 2.5.9
resolution: "@roadiehq/backstage-plugin-github-pull-requests@npm:2.5.9"
version: 2.5.10
resolution: "@roadiehq/backstage-plugin-github-pull-requests@npm:2.5.10"
dependencies:
"@backstage/catalog-model": ^1.3.0
"@backstage/core-components": ^0.13.0
@@ -14073,7 +14075,7 @@ __metadata:
react: ^16.13.1 || ^17.0.0
react-dom: ^16.13.1 || ^17.0.0
react-router: 6.0.0-beta.0 || ^6.3.0
checksum: 5a60f2c67c095a0fbf081b4097e0dfd4d6c22f59514e66800eba8460ef32ee43cf1eb7105a36a56f7b63d8ac44f5343f7e4ec66279ba2009945b828e6698e00e
checksum: 0f273ede63024635f850fc9df662941589f5f8848307507e3f6e376ea0bd9b7e17026e1182808d5c193be90ee9a2725c83a0850475547b364cd3d7cf35bf7b74
languageName: node
linkType: hard
@@ -31170,6 +31172,13 @@ __metadata:
languageName: node
linkType: hard
"minipass@npm:^5.0.0":
version: 5.0.0
resolution: "minipass@npm:5.0.0"
checksum: 425dab288738853fded43da3314a0b5c035844d6f3097a8e3b5b29b328da8f3c1af6fc70618b32c29ff906284cf6406b6841376f21caaadd0793c1d5a6a620ea
languageName: node
linkType: hard
"minizlib@npm:^2.0.0, minizlib@npm:^2.1.1, minizlib@npm:^2.1.2":
version: 2.1.2
resolution: "minizlib@npm:2.1.2"
@@ -31456,12 +31465,12 @@ __metadata:
languageName: node
linkType: hard
"nanoid@npm:^3.1.23, nanoid@npm:^3.3.4":
version: 3.3.4
resolution: "nanoid@npm:3.3.4"
"nanoid@npm:^3.1.23, nanoid@npm:^3.3.4, nanoid@npm:^3.3.6":
version: 3.3.6
resolution: "nanoid@npm:3.3.6"
bin:
nanoid: bin/nanoid.cjs
checksum: 2fddd6dee994b7676f008d3ffa4ab16035a754f4bb586c61df5a22cf8c8c94017aadd360368f47d653829e0569a92b129979152ff97af23a558331e47e37cd9c
checksum: 7d0eda657002738aa5206107bd0580aead6c95c460ef1bdd0b1a87a9c7ae6277ac2e9b945306aaa5b32c6dcb7feaf462d0f552e7f8b5718abfc6ead5c94a71b3
languageName: node
linkType: hard
@@ -33910,13 +33919,13 @@ __metadata:
linkType: hard
"postcss@npm:^8.1.0, postcss@npm:^8.4.19":
version: 8.4.21
resolution: "postcss@npm:8.4.21"
version: 8.4.23
resolution: "postcss@npm:8.4.23"
dependencies:
nanoid: ^3.3.4
nanoid: ^3.3.6
picocolors: ^1.0.0
source-map-js: ^1.0.2
checksum: e39ac60ccd1542d4f9d93d894048aac0d686b3bb38e927d8386005718e6793dbbb46930f0a523fe382f1bbd843c6d980aaea791252bf5e176180e5a4336d9679
checksum: 8bb9d1b2ea6e694f8987d4f18c94617971b2b8d141602725fedcc2222fdc413b776a6e1b969a25d627d7b2681ca5aabb56f59e727ef94072e1b6ac8412105a2f
languageName: node
linkType: hard
@@ -38339,16 +38348,16 @@ __metadata:
linkType: hard
"tar@npm:^6.0.2, tar@npm:^6.1.0, tar@npm:^6.1.11, tar@npm:^6.1.12, tar@npm:^6.1.2":
version: 6.1.13
resolution: "tar@npm:6.1.13"
version: 6.1.14
resolution: "tar@npm:6.1.14"
dependencies:
chownr: ^2.0.0
fs-minipass: ^2.0.0
minipass: ^4.0.0
minipass: ^5.0.0
minizlib: ^2.1.1
mkdirp: ^1.0.3
yallist: ^4.0.0
checksum: 8a278bed123aa9f53549b256a36b719e317c8b96fe86a63406f3c62887f78267cea9b22dc6f7007009738509800d4a4dccc444abd71d762287c90f35b002eb1c
checksum: a1be0815a9bdc97dfca7c6c2d71d1b836f8ba9314684e2c412832f0f59cc226d4c13da303d6bc30925e82f634cc793f40da79ae72f3e96fb87c23d0f4efd5207
languageName: node
linkType: hard