Merge pull request #11308 from backstage/blam/user-info-in-scaffolder
scaffolder: Added the ability to reference the user that created the task in the templates
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
'@backstage/plugin-scaffolder-common': minor
|
||||
---
|
||||
|
||||
Added the ability to reference the user in the `template.yaml` manifest
|
||||
@@ -342,6 +342,7 @@ export type CreateWorkerOptions = {
|
||||
|
||||
// @public
|
||||
export interface CurrentClaimedTask {
|
||||
createdBy?: string;
|
||||
secrets?: TaskSecrets;
|
||||
spec: TaskSpec;
|
||||
taskId: string;
|
||||
@@ -476,6 +477,7 @@ export type SerializedTask = {
|
||||
status: TaskStatus;
|
||||
createdAt: string;
|
||||
lastHeartbeatAt?: string;
|
||||
createdBy?: string;
|
||||
secrets?: TaskSecrets;
|
||||
};
|
||||
|
||||
@@ -510,6 +512,7 @@ export interface TaskBroker {
|
||||
export type TaskBrokerDispatchOptions = {
|
||||
spec: TaskSpec;
|
||||
secrets?: TaskSecrets;
|
||||
createdBy?: string;
|
||||
};
|
||||
|
||||
// @public
|
||||
@@ -525,6 +528,8 @@ export interface TaskContext {
|
||||
// (undocumented)
|
||||
complete(result: TaskCompletionState, metadata?: JsonObject): Promise<void>;
|
||||
// (undocumented)
|
||||
createdBy?: string;
|
||||
// (undocumented)
|
||||
done: boolean;
|
||||
// (undocumented)
|
||||
emitLog(message: string, logMetadata?: JsonObject): Promise<void>;
|
||||
@@ -550,6 +555,8 @@ export class TaskManager implements TaskContext {
|
||||
logger: Logger,
|
||||
): TaskManager;
|
||||
// (undocumented)
|
||||
get createdBy(): string | undefined;
|
||||
// (undocumented)
|
||||
get done(): boolean;
|
||||
// (undocumented)
|
||||
emitLog(message: string, logMetadata?: JsonObject): Promise<void>;
|
||||
@@ -609,6 +616,7 @@ export interface TaskStore {
|
||||
// @public
|
||||
export type TaskStoreCreateTaskOptions = {
|
||||
spec: TaskSpec;
|
||||
createdBy?: string;
|
||||
secrets?: TaskSecrets;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* Copyright 2020 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.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
await knex.schema.alterTable('tasks', table => {
|
||||
table
|
||||
.text('created_by')
|
||||
.nullable()
|
||||
.comment('An entityRef of the user who created the task');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import('knex').Knex} knex
|
||||
*/
|
||||
exports.down = async function down(knex) {
|
||||
await knex.schema.alterTable('tasks', table => {
|
||||
table.dropColumn('created_by');
|
||||
});
|
||||
};
|
||||
@@ -43,6 +43,7 @@ export type RawDbTaskRow = {
|
||||
status: TaskStatus;
|
||||
last_heartbeat_at?: string;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
secrets?: string | null;
|
||||
};
|
||||
|
||||
@@ -100,6 +101,7 @@ export class DatabaseTaskStore implements TaskStore {
|
||||
status: result.status,
|
||||
lastHeartbeatAt: result.last_heartbeat_at,
|
||||
createdAt: result.created_at,
|
||||
createdBy: result.created_by ?? undefined,
|
||||
secrets,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -115,6 +117,7 @@ export class DatabaseTaskStore implements TaskStore {
|
||||
id: taskId,
|
||||
spec: JSON.stringify(options.spec),
|
||||
secrets: options.secrets ? JSON.stringify(options.secrets) : undefined,
|
||||
created_by: options.createdBy ?? null,
|
||||
status: 'open',
|
||||
});
|
||||
return { taskId };
|
||||
@@ -155,6 +158,7 @@ export class DatabaseTaskStore implements TaskStore {
|
||||
status: 'processing',
|
||||
lastHeartbeatAt: task.last_heartbeat_at,
|
||||
createdAt: task.created_at,
|
||||
createdBy: task.created_by ?? undefined,
|
||||
secrets,
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { ScmIntegrations } from '@backstage/integration';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { TaskContext, TaskSecrets } from './types';
|
||||
import { TaskSpec } from '@backstage/plugin-scaffolder-common';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
|
||||
// The Stream module is lazy loaded, so make sure it's in the module cache before mocking fs
|
||||
void winston.transports.Stream;
|
||||
@@ -558,6 +559,36 @@ describe('DefaultWorkflowRunner', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('user', () => {
|
||||
it('allows access to the user entity at the templating level', async () => {
|
||||
const task = createMockTaskWithSpec({
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
steps: [
|
||||
{
|
||||
id: 'test',
|
||||
name: 'name',
|
||||
action: 'output-action',
|
||||
input: {},
|
||||
},
|
||||
],
|
||||
user: {
|
||||
entity: { metadata: { name: 'bob' } } as UserEntity,
|
||||
ref: 'user:default/guest',
|
||||
},
|
||||
output: {
|
||||
foo: '${{ user.entity.metadata.name }} ${{ user.ref }}',
|
||||
},
|
||||
parameters: {
|
||||
repoUrl: 'github.com?repo=repo&owner=owner',
|
||||
},
|
||||
});
|
||||
|
||||
const { output } = await runner.execute(task);
|
||||
|
||||
expect(output.foo).toEqual('bob user:default/guest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
it('provides the parseRepoUrl filter', async () => {
|
||||
const task = createMockTaskWithSpec({
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
TaskSpecV1beta3,
|
||||
TaskStep,
|
||||
} from '@backstage/plugin-scaffolder-common';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
|
||||
type NunjucksWorkflowRunnerOptions = {
|
||||
workingDirectory: string;
|
||||
@@ -52,6 +53,10 @@ type TemplateContext = {
|
||||
[stepName: string]: { output: { [outputName: string]: JsonValue } };
|
||||
};
|
||||
secrets?: Record<string, string>;
|
||||
user?: {
|
||||
entity?: UserEntity;
|
||||
ref?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const isValidTaskSpec = (taskSpec: TaskSpec): taskSpec is TaskSpecV1beta3 => {
|
||||
@@ -203,6 +208,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
const context: TemplateContext = {
|
||||
parameters: task.spec.parameters,
|
||||
steps: {},
|
||||
user: task.spec.user,
|
||||
};
|
||||
|
||||
for (const step of task.spec.steps) {
|
||||
|
||||
@@ -59,6 +59,10 @@ export class TaskManager implements TaskContext {
|
||||
return this.task.secrets;
|
||||
}
|
||||
|
||||
get createdBy() {
|
||||
return this.task.createdBy;
|
||||
}
|
||||
|
||||
async getWorkspaceName() {
|
||||
return this.task.taskId;
|
||||
}
|
||||
@@ -127,6 +131,10 @@ export interface CurrentClaimedTask {
|
||||
* The secrets that are stored with the task.
|
||||
*/
|
||||
secrets?: TaskSecrets;
|
||||
/**
|
||||
* The creator of the task.
|
||||
*/
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
function defer() {
|
||||
@@ -156,6 +164,7 @@ export class StorageTaskBroker implements TaskBroker {
|
||||
taskId: pendingTask.id,
|
||||
spec: pendingTask.spec,
|
||||
secrets: pendingTask.secrets,
|
||||
createdBy: pendingTask.createdBy,
|
||||
},
|
||||
this.storage,
|
||||
this.logger,
|
||||
|
||||
@@ -47,6 +47,7 @@ export type SerializedTask = {
|
||||
status: TaskStatus;
|
||||
createdAt: string;
|
||||
lastHeartbeatAt?: string;
|
||||
createdBy?: string;
|
||||
secrets?: TaskSecrets;
|
||||
};
|
||||
|
||||
@@ -97,6 +98,7 @@ export type TaskBrokerDispatchResult = {
|
||||
export type TaskBrokerDispatchOptions = {
|
||||
spec: TaskSpec;
|
||||
secrets?: TaskSecrets;
|
||||
createdBy?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -107,6 +109,7 @@ export type TaskBrokerDispatchOptions = {
|
||||
export interface TaskContext {
|
||||
spec: TaskSpec;
|
||||
secrets?: TaskSecrets;
|
||||
createdBy?: string;
|
||||
done: boolean;
|
||||
emitLog(message: string, logMetadata?: JsonObject): Promise<void>;
|
||||
complete(result: TaskCompletionState, metadata?: JsonObject): Promise<void>;
|
||||
@@ -157,6 +160,7 @@ export type TaskStoreListEventsOptions = {
|
||||
*/
|
||||
export type TaskStoreCreateTaskOptions = {
|
||||
spec: TaskSpec;
|
||||
createdBy?: string;
|
||||
secrets?: TaskSecrets;
|
||||
};
|
||||
|
||||
|
||||
@@ -48,12 +48,11 @@ import request from 'supertest';
|
||||
*/
|
||||
import { createRouter, DatabaseTaskStore, TaskBroker } from '../index';
|
||||
import { StorageTaskBroker } from '../scaffolder/tasks/StorageTaskBroker';
|
||||
import { stringifyEntityRef } from '@backstage/catalog-model';
|
||||
|
||||
const createCatalogClient = (template: any) =>
|
||||
({
|
||||
getEntityByRef: async () => template,
|
||||
} as unknown as CatalogApi);
|
||||
import {
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
|
||||
function createDatabase(): PluginDatabaseManager {
|
||||
return DatabaseManager.fromConfig(
|
||||
@@ -76,7 +75,9 @@ const mockUrlReader = UrlReaders.default({
|
||||
describe('createRouter', () => {
|
||||
let app: express.Express;
|
||||
let taskBroker: TaskBroker;
|
||||
const template: TemplateEntityV1beta3 = {
|
||||
const catalogClient = { getEntityByRef: jest.fn() } as unknown as CatalogApi;
|
||||
|
||||
const mockTemplate: TemplateEntityV1beta3 = {
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
@@ -105,6 +106,22 @@ describe('createRouter', () => {
|
||||
},
|
||||
};
|
||||
|
||||
const mockUser: UserEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'User',
|
||||
metadata: {
|
||||
name: 'guest',
|
||||
annotations: {
|
||||
'google.com/email': 'bobby@tables.com',
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
profile: {
|
||||
displayName: 'Robert Tables of the North',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const logger = getVoidLogger();
|
||||
const databaseTaskStore = await DatabaseTaskStore.create({
|
||||
@@ -120,11 +137,26 @@ describe('createRouter', () => {
|
||||
logger: getVoidLogger(),
|
||||
config: new ConfigReader({}),
|
||||
database: createDatabase(),
|
||||
catalogClient: createCatalogClient(template),
|
||||
catalogClient,
|
||||
reader: mockUrlReader,
|
||||
taskBroker,
|
||||
});
|
||||
app = express().use(router);
|
||||
|
||||
jest
|
||||
.spyOn(catalogClient, 'getEntityByRef')
|
||||
.mockImplementation(async ref => {
|
||||
const { kind } = parseEntityRef(ref);
|
||||
|
||||
if (kind === 'template') {
|
||||
return mockTemplate;
|
||||
}
|
||||
|
||||
if (kind === 'user') {
|
||||
return mockUser;
|
||||
}
|
||||
throw new Error(`no mock found for kind: ${kind}`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -158,9 +190,8 @@ describe('createRouter', () => {
|
||||
});
|
||||
|
||||
it('return the template id', async () => {
|
||||
(
|
||||
taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch']
|
||||
).mockResolvedValue({
|
||||
const broker = taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
broker.mockResolvedValue({
|
||||
taskId: 'a-random-id',
|
||||
});
|
||||
|
||||
@@ -179,6 +210,88 @@ describe('createRouter', () => {
|
||||
expect(response.body.id).toBe('a-random-id');
|
||||
expect(response.status).toEqual(201);
|
||||
});
|
||||
|
||||
it('should call the broker with a correct spec', async () => {
|
||||
const broker = taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
const mockToken =
|
||||
'blob.eyJzdWIiOiJ1c2VyOmRlZmF1bHQvZ3Vlc3QiLCJuYW1lIjoiSm9obiBEb2UifQ.blob';
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
await request(app)
|
||||
.post('/v2/tasks')
|
||||
.set('Authorization', `Bearer ${mockToken}`)
|
||||
.send({
|
||||
templateRef: stringifyEntityRef({
|
||||
kind: 'template',
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(broker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdBy: 'user:default/guest',
|
||||
secrets: {
|
||||
backstageToken: mockToken,
|
||||
},
|
||||
|
||||
spec: {
|
||||
apiVersion: mockTemplate.apiVersion,
|
||||
steps: mockTemplate.spec.steps.map((step, index) => ({
|
||||
...step,
|
||||
id: step.id ?? `step-${index + 1}`,
|
||||
name: step.name ?? step.action,
|
||||
})),
|
||||
output: mockTemplate.spec.output ?? {},
|
||||
parameters: {
|
||||
required: 'required-value',
|
||||
},
|
||||
user: {
|
||||
entity: mockUser,
|
||||
ref: 'user:default/guest',
|
||||
},
|
||||
templateInfo: {
|
||||
entityRef: stringifyEntityRef({
|
||||
kind: 'Template',
|
||||
namespace: 'Default',
|
||||
name: mockTemplate.metadata?.name,
|
||||
}),
|
||||
baseUrl: 'https://dev.azure.com',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not decorate a user when no backstage auth is passed', async () => {
|
||||
const broker = taskBroker.dispatch as jest.Mocked<TaskBroker>['dispatch'];
|
||||
|
||||
await request(app)
|
||||
.post('/v2/tasks')
|
||||
.send({
|
||||
templateRef: stringifyEntityRef({
|
||||
kind: 'template',
|
||||
name: 'create-react-app-template',
|
||||
}),
|
||||
values: {
|
||||
required: 'required-value',
|
||||
},
|
||||
});
|
||||
|
||||
expect(broker).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
createdBy: undefined,
|
||||
spec: expect.objectContaining({
|
||||
user: { entity: undefined, ref: undefined },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /v2/tasks/:taskId', () => {
|
||||
|
||||
@@ -16,7 +16,11 @@
|
||||
|
||||
import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
UserEntity,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Config } from '@backstage/config';
|
||||
import { InputError, NotFoundError } from '@backstage/errors';
|
||||
@@ -130,10 +134,11 @@ export async function createRouter(
|
||||
'/v2/templates/:namespace/:kind/:name/parameter-schema',
|
||||
async (req, res) => {
|
||||
const { namespace, kind, name } = req.params;
|
||||
const { token } = parseBearerToken(req.headers.authorization);
|
||||
const template = await findTemplate({
|
||||
catalogApi: catalogClient,
|
||||
entityRef: { kind, namespace, name },
|
||||
token: getBearerToken(req.headers.authorization),
|
||||
token,
|
||||
});
|
||||
if (isSupportedTemplate(template)) {
|
||||
const parameters = [template.spec.parameters ?? []].flat();
|
||||
@@ -168,12 +173,20 @@ export async function createRouter(
|
||||
const { kind, namespace, name } = parseEntityRef(templateRef, {
|
||||
defaultKind: 'template',
|
||||
});
|
||||
const { token, entityRef: userEntityRef } = parseBearerToken(
|
||||
req.headers.authorization,
|
||||
);
|
||||
|
||||
const userEntity = userEntityRef
|
||||
? await catalogClient.getEntityByRef(userEntityRef, { token })
|
||||
: undefined;
|
||||
|
||||
const values = req.body.values;
|
||||
const token = getBearerToken(req.headers.authorization);
|
||||
|
||||
const template = await findTemplate({
|
||||
catalogApi: catalogClient,
|
||||
entityRef: { kind, namespace, name },
|
||||
token: getBearerToken(req.headers.authorization),
|
||||
token,
|
||||
});
|
||||
|
||||
if (!isSupportedTemplate(template)) {
|
||||
@@ -203,6 +216,10 @@ export async function createRouter(
|
||||
})),
|
||||
output: template.spec.output ?? {},
|
||||
parameters: values,
|
||||
user: {
|
||||
entity: userEntity as UserEntity,
|
||||
ref: userEntityRef,
|
||||
},
|
||||
templateInfo: {
|
||||
entityRef: stringifyEntityRef({
|
||||
kind,
|
||||
@@ -215,6 +232,7 @@ export async function createRouter(
|
||||
|
||||
const result = await taskBroker.dispatch({
|
||||
spec: taskSpec,
|
||||
createdBy: userEntityRef,
|
||||
secrets: {
|
||||
...req.body.secrets,
|
||||
backstageToken: token,
|
||||
@@ -315,6 +333,21 @@ export async function createRouter(
|
||||
return app;
|
||||
}
|
||||
|
||||
function getBearerToken(header?: string): string | undefined {
|
||||
return header?.match(/Bearer\s+(\S+)/i)?.[1];
|
||||
function parseBearerToken(header?: string): {
|
||||
token?: string;
|
||||
entityRef?: string;
|
||||
} {
|
||||
const token = header?.match(/Bearer\s+(\S+)/i)?.[1];
|
||||
|
||||
if (!token) return {};
|
||||
|
||||
const [_header, rawPayload, _signature] = token.split('.');
|
||||
const payload: { sub: string } = JSON.parse(
|
||||
Buffer.from(rawPayload, 'base64').toString(),
|
||||
);
|
||||
|
||||
return {
|
||||
entityRef: payload.sub,
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Entity } from '@backstage/catalog-model';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { KindValidator } from '@backstage/catalog-model';
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
|
||||
// @public
|
||||
export type TaskSpec = TaskSpecV1beta3;
|
||||
@@ -20,6 +21,10 @@ export interface TaskSpecV1beta3 {
|
||||
parameters: JsonObject;
|
||||
steps: TaskStep[];
|
||||
templateInfo?: TemplateInfo;
|
||||
user?: {
|
||||
entity?: UserEntity;
|
||||
ref?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// @public
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { UserEntity } from '@backstage/catalog-model';
|
||||
import { JsonValue, JsonObject } from '@backstage/types';
|
||||
|
||||
/**
|
||||
@@ -91,6 +92,19 @@ export interface TaskSpecV1beta3 {
|
||||
* Some information about the template that is stored on the task spec.
|
||||
*/
|
||||
templateInfo?: TemplateInfo;
|
||||
/**
|
||||
* Some decoration of the author of the task that should be available in the context
|
||||
*/
|
||||
user?: {
|
||||
/**
|
||||
* The decorated entity from the Catalog
|
||||
*/
|
||||
entity?: UserEntity;
|
||||
/**
|
||||
* An entity ref for the author of the task
|
||||
*/
|
||||
ref?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user