diff --git a/.changeset/eight-plums-hide.md b/.changeset/eight-plums-hide.md new file mode 100644 index 0000000000..d1f8bcecfe --- /dev/null +++ b/.changeset/eight-plums-hide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Forward user token to scaffolder task for subsequent api requests diff --git a/.changeset/flat-paws-rule.md b/.changeset/flat-paws-rule.md new file mode 100644 index 0000000000..af7725793e --- /dev/null +++ b/.changeset/flat-paws-rule.md @@ -0,0 +1,6 @@ +--- +'@backstage/core': patch +'@backstage/plugin-scaffolder': patch +--- + +Adding Headings for Accessibility on the Scaffolder Plugin diff --git a/.changeset/healthy-stingrays-shout.md b/.changeset/healthy-stingrays-shout.md new file mode 100644 index 0000000000..c3ce59ce1f --- /dev/null +++ b/.changeset/healthy-stingrays-shout.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +SystemDiagramCard UI improvements diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 8222fbb400..5dbad9cf4a 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -78,6 +78,7 @@ cluster. Valid values are: | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `serviceAccount` | This will use a Kubernetes [service account](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to access the Kubernetes API. When this is used the `serviceAccountToken` field should also be set. | | `google` | This will use a user's Google auth token from the [Google auth plugin](https://backstage.io/docs/auth/) to access the Kubernetes API. | +| `aws` | This will use AWS credentials to access resources in EKS clusters | ##### `clusters.\*.skipTLSVerify` diff --git a/docs/features/techdocs/using-cloud-storage.md b/docs/features/techdocs/using-cloud-storage.md index d5a3a68425..23015935da 100644 --- a/docs/features/techdocs/using-cloud-storage.md +++ b/docs/features/techdocs/using-cloud-storage.md @@ -135,7 +135,21 @@ techdocs: bucketName: 'name-of-techdocs-storage-bucket' ``` -**3a. (Recommended) Setup authentication the AWS way, using environment +**3. Create minimal AWS IAM policies to manage TechDocs** + +To _write_ TechDocs into the S3 bucket the IAM policy needs to have at a minimum +permissions to: + +- `s3:ListBucket` to retrieve bucket metadata +- `s3:PutObject` to upload files to the bucket + +To _read_ TechDocs from the S3 bucket the IAM policy needs to have at a minimum +permissions to: + +- `s3:ListBucket` - To retrieve bucket metadata +- `s3:GetObject` - To retrieve files from the bucket + +**4a. (Recommended) Setup authentication the AWS way, using environment variables** You should follow the @@ -166,7 +180,7 @@ more in The AWS Region of the bucket is optional since TechDocs uses AWS SDK V2 and not V3. -**3b. Authentication using app-config.yaml** +**4b. Authentication using app-config.yaml** AWS credentials and region can be provided to the AWS SDK via `app-config.yaml`. If the configs below are present, they will be used over existing `AWS_*` @@ -187,7 +201,7 @@ techdocs: Refer to the [official AWS documentation for obtaining the credentials](https://docs.aws.amazon.com/sdk-for-javascript/v2/developer-guide/getting-your-credentials.html). -**3c. Authentication using an assumed role** Users with multiple AWS accounts +**4c. Authentication using an assumed role** Users with multiple AWS accounts may want to use a role for S3 storage that is in a different AWS account. Using the `roleArn` parameter as seen below, you can instruct the TechDocs publisher to assume a role before accessing S3. @@ -207,7 +221,7 @@ Note: Assuming a role requires that primary credentials are already configured at `AWS.config.credentials`. Read more about [assuming roles in AWS](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html). -**4. That's it!** +**5. That's it!** Your Backstage app is now ready to use AWS S3 for TechDocs, to store and read the static generated documentation files. When you start the backend of the app, diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 86e94d9fbd..1ab242ba2d 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -203,6 +203,7 @@ ], "Deployment": [ "deployment/index", + "deployment/docker", "deployment/k8s", "deployment/helm", "deployment/heroku" diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index fdbee075fc..0557f4159c 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -306,9 +306,6 @@ export class PrivateAppImpl implements BackstageApp { this.verifyPlugins(this.plugins); // Initialize APIs once all plugins are available - if (this.apiHolder) { - throw new Error('Plugin holder was initialized too soon'); - } this.getApiHolder(); return result; diff --git a/packages/core/src/components/Lifecycle/Lifecycle.tsx b/packages/core/src/components/Lifecycle/Lifecycle.tsx index d388452cdc..585f7a5c18 100644 --- a/packages/core/src/components/Lifecycle/Lifecycle.tsx +++ b/packages/core/src/components/Lifecycle/Lifecycle.tsx @@ -25,7 +25,7 @@ type Props = CSS.Properties & { const useStyles = makeStyles({ alpha: { - color: '#d00150', + color: '#ffffff', fontFamily: 'serif', fontWeight: 'normal', fontStyle: 'italic', diff --git a/packages/core/src/layout/ContentHeader/ContentHeader.tsx b/packages/core/src/layout/ContentHeader/ContentHeader.tsx index e9fba56358..3ccf8f894c 100644 --- a/packages/core/src/layout/ContentHeader/ContentHeader.tsx +++ b/packages/core/src/layout/ContentHeader/ContentHeader.tsx @@ -66,7 +66,12 @@ const DefaultTitle = ({ title = 'Unknown page', className, }: DefaultTitleProps) => ( - + {title} ); diff --git a/packages/core/src/layout/Header/Header.tsx b/packages/core/src/layout/Header/Header.tsx index efa7502723..1ad7b91687 100644 --- a/packages/core/src/layout/Header/Header.tsx +++ b/packages/core/src/layout/Header/Header.tsx @@ -140,7 +140,7 @@ const TypeFragment = ({ const TitleFragment = ({ pageTitle, classes, tooltip }: TitleFragmentProps) => { const FinalTitle = ( - + {pageTitle} ); @@ -166,7 +166,11 @@ const SubtitleFragment = ({ classes, subtitle }: SubtitleFragmentProps) => { } return ( - + {subtitle} ); diff --git a/packages/core/src/layout/InfoCard/InfoCard.tsx b/packages/core/src/layout/InfoCard/InfoCard.tsx index cf06a5f0a1..5bb3e19362 100644 --- a/packages/core/src/layout/InfoCard/InfoCard.tsx +++ b/packages/core/src/layout/InfoCard/InfoCard.tsx @@ -125,6 +125,7 @@ type Props = { actionsTopRight?: ReactNode; className?: string; noPadding?: boolean; + titleTypographyProps?: object; }; export const InfoCard = ({ @@ -143,6 +144,7 @@ export const InfoCard = ({ actionsTopRight, className, noPadding, + titleTypographyProps, }: Props): JSX.Element => { const classes = useStyles(); /** @@ -183,6 +185,7 @@ export const InfoCard = ({ title={title} subheader={subheader} style={{ ...headerStyle }} + titleTypographyProps={titleTypographyProps} {...headerProps} /> )} diff --git a/packages/core/src/layout/ItemCard/ItemCardHeader.tsx b/packages/core/src/layout/ItemCard/ItemCardHeader.tsx index 17c4a19e58..ebf419cba0 100644 --- a/packages/core/src/layout/ItemCard/ItemCardHeader.tsx +++ b/packages/core/src/layout/ItemCard/ItemCardHeader.tsx @@ -77,8 +77,16 @@ export const ItemCardHeader = (props: ItemCardHeaderProps) => { const classes = useStyles(props); return (
- {subtitle && {subtitle}} - {title && {title}} + {subtitle && ( + + {subtitle} + + )} + {title && ( + + {title} + + )} {children}
); diff --git a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx index baa8c667d2..337e9d12b6 100644 --- a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx +++ b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx @@ -19,6 +19,7 @@ import { catalogApiRef, CatalogApi, EntityProvider, + entityRouteRef, } from '@backstage/plugin-catalog-react'; import { Entity, RELATION_PART_OF } from '@backstage/catalog-model'; import { renderInTestApp } from '@backstage/test-utils'; @@ -59,13 +60,16 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); expect(queryByText(/System Diagram/)).toBeInTheDocument(); - expect(queryByText(/system:my-namespace2\/my-system2/)).toBeInTheDocument(); - expect( - queryByText(/component:my-namespace\/my-entity/), - ).not.toBeInTheDocument(); + expect(queryByText(/my-namespace2\/my-system2/)).toBeInTheDocument(); + expect(queryByText(/my-namespace\/my-entity/)).not.toBeInTheDocument(); }); it('shows related systems', async () => { @@ -115,10 +119,15 @@ describe('', () => { , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ); expect(getByText('System Diagram')).toBeInTheDocument(); - expect(getByText('system:my-namespace/my-system')).toBeInTheDocument(); - expect(getByText('component:my-namespace/my-entity')).toBeInTheDocument(); + expect(getByText('my-namespace/my-system')).toBeInTheDocument(); + expect(getByText('my-namespace/my-entity')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx index 78288c46e9..5fd32a6298 100644 --- a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx +++ b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx @@ -19,10 +19,13 @@ import { RELATION_DEPENDS_ON, RELATION_PROVIDES_API, RELATION_PART_OF, - serializeEntityRef, + stringifyEntityRef, + ENTITY_DEFAULT_NAMESPACE, + parseEntityRef, } from '@backstage/catalog-model'; import { catalogApiRef, + entityRouteRef, getEntityRelations, useEntity, } from '@backstage/plugin-catalog-react'; @@ -33,26 +36,107 @@ import { Progress, useApi, ResponseErrorPanel, + Link, + useRouteRef, } from '@backstage/core'; -import { Box, Typography } from '@material-ui/core'; +import { Box, makeStyles, Typography } from '@material-ui/core'; import ZoomOutMap from '@material-ui/icons/ZoomOutMap'; import React from 'react'; import { useAsync } from 'react-use'; +import { BackstageTheme } from '@backstage/theme'; -function simplifiedEntityName( +const useStyles = makeStyles((theme: BackstageTheme) => ({ + domainNode: { + fill: theme.palette.primary.main, + stroke: theme.palette.border, + }, + systemNode: { + fill: 'coral', + stroke: theme.palette.border, + }, + componentNode: { + fill: 'yellowgreen', + stroke: theme.palette.border, + }, + apiNode: { + fill: theme.palette.gold, + stroke: theme.palette.border, + }, + resourceNode: { + fill: 'grey', + stroke: theme.palette.border, + }, +})); + +// Simplifies the diagram output by hiding the default namespace and kind +function readableEntityName( ref: | Entity | { - kind?: string; + kind: string; namespace?: string; name: string; }, ): string { - // Simplify the diagram output by hiding only the default namespace - return serializeEntityRef(ref) - .toString() + return stringifyEntityRef(ref) .toLocaleLowerCase('en-US') - .replace(':default/', ':'); + .replace(`:${ENTITY_DEFAULT_NAMESPACE}/`, ':') + .split(':')[1]; +} + +function RenderNode(props: DependencyGraphTypes.RenderNodeProps) { + const classes = useStyles(); + const catalogEntityRoute = useRouteRef(entityRouteRef); + const kind = props.node.kind || 'Component'; + const ref = parseEntityRef(props.node.id); + let nodeClass = classes.componentNode; + + switch (kind) { + case 'Domain': + nodeClass = classes.domainNode; + break; + case 'System': + nodeClass = classes.systemNode; + break; + case 'Component': + nodeClass = classes.componentNode; + break; + case 'API': + nodeClass = classes.apiNode; + break; + case 'Resource': + nodeClass = classes.resourceNode; + break; + default: + nodeClass = classes.componentNode; + } + + return ( + + + + + {props.node.name} + + + + + {props.node.kind} + + + ); } /** @@ -62,8 +146,8 @@ function simplifiedEntityName( export function SystemDiagramCard() { const { entity } = useEntity(); const currentSystemName = entity.metadata.name; - const currentSystemNode = simplifiedEntityName(entity); - const systemNodes = new Array<{ id: string }>(); + const currentSystemNode = stringifyEntityRef(entity); + const systemNodes = new Array<{ id: string; kind: string; name: string }>(); const systemEdges = new Array<{ from: string; to: string; label: string }>(); const catalogApi = useApi(catalogApiRef); @@ -71,7 +155,10 @@ export function SystemDiagramCard() { return catalogApi.getEntities({ filter: { kind: ['Component', 'API', 'Resource', 'System', 'Domain'], - 'spec.system': currentSystemName, + 'spec.system': [ + currentSystemName, + `${ENTITY_DEFAULT_NAMESPACE}/${currentSystemName}`, + ], }, }); }, [catalogApi, currentSystemName]); @@ -79,22 +166,26 @@ export function SystemDiagramCard() { // pick out the system itself systemNodes.push({ id: currentSystemNode, + kind: 'System', + name: readableEntityName(entity), }); // check if the system has an assigned domain // even if the domain object doesn't exist in the catalog, display it in the map const catalogItemDomain = getEntityRelations(entity, RELATION_PART_OF, { - kind: 'domain', + kind: 'Domain', }); catalogItemDomain.forEach(foundDomain => systemNodes.push({ - id: simplifiedEntityName(foundDomain), + id: stringifyEntityRef(foundDomain), + kind: foundDomain.kind, + name: readableEntityName(foundDomain), }), ); catalogItemDomain.forEach(foundDomain => systemEdges.push({ from: currentSystemNode, - to: simplifiedEntityName(foundDomain), + to: stringifyEntityRef(foundDomain), label: 'part of', }), ); @@ -102,7 +193,9 @@ export function SystemDiagramCard() { if (catalogResponse && catalogResponse.items) { for (const catalogItem of catalogResponse.items) { systemNodes.push({ - id: simplifiedEntityName(catalogItem), + id: stringifyEntityRef(catalogItem), + kind: catalogItem.kind, + name: readableEntityName(catalogItem), }); // Check relations of the entity assigned to this system to see @@ -115,8 +208,8 @@ export function SystemDiagramCard() { ); catalogItemRelations_partOf.forEach(foundRelation => systemEdges.push({ - from: simplifiedEntityName(catalogItem), - to: simplifiedEntityName(foundRelation), + from: stringifyEntityRef(catalogItem), + to: stringifyEntityRef(foundRelation), label: 'part of', }), ); @@ -127,9 +220,9 @@ export function SystemDiagramCard() { ); catalogItemRelations_providesApi.forEach(foundRelation => systemEdges.push({ - from: simplifiedEntityName(catalogItem), - to: simplifiedEntityName(foundRelation), - label: 'provides API', + from: stringifyEntityRef(catalogItem), + to: stringifyEntityRef(foundRelation), + label: 'provides', }), ); @@ -139,8 +232,8 @@ export function SystemDiagramCard() { ); catalogItemRelations_dependsOn.forEach(foundRelation => systemEdges.push({ - from: simplifiedEntityName(catalogItem), - to: simplifiedEntityName(foundRelation), + from: stringifyEntityRef(catalogItem), + to: stringifyEntityRef(foundRelation), label: 'depends on', }), ); @@ -160,6 +253,7 @@ export function SystemDiagramCard() { edges={systemEdges} nodeMargin={10} direction={DependencyGraphTypes.Direction.BOTTOM_TOP} + renderNode={RenderNode} /> { + table + .text('secrets') + .nullable() + .comment('JSON encoded secrets to authenticate tasks with'); + }); +}; + +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { + await knex.schema.alterTable('tasks', table => { + table.dropColumn('secrets'); + }); +}; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts index 070ad15ab7..16a918e207 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.test.ts @@ -84,10 +84,13 @@ describe('catalog:register', () => { catalogInfoUrl: 'http://foo/var', }, }); - expect(addLocation).toBeCalledWith({ - type: 'url', - target: 'http://foo/var', - }); + expect(addLocation).toBeCalledWith( + { + type: 'url', + target: 'http://foo/var', + }, + {}, + ); expect(mockContext.output).toBeCalledWith( 'entityRef', diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts index 8ee98f5d20..e9710f7da4 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/catalog/register.ts @@ -95,10 +95,13 @@ export function createCatalogRegisterAction(options: { ctx.logger.info(`Registering ${catalogInfoUrl} in the catalog`); - const result = await catalogClient.addLocation({ - type: 'url', - target: catalogInfoUrl, - }); + const result = await catalogClient.addLocation( + { + type: 'url', + target: catalogInfoUrl, + }, + ctx.token ? { token: ctx.token } : {}, + ); if (result.entities.length >= 1) { const { kind, name, namespace } = getEntityName(result.entities[0]); ctx.output('entityRef', `${kind}:${namespace}/${name}`); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts index 955cb72de9..fbe18b848e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/types.ts @@ -32,6 +32,10 @@ export type ActionContext = { logger: Logger; logStream: Writable; + /** + * User token forwarded from initial request, for use in subsequent api requests + */ + token?: string | undefined; workspacePath: string; input: Input; output(name: string, value: JsonValue): void; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts index 91264bd3e2..1f1228baf8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/DatabaseTaskStore.ts @@ -24,6 +24,7 @@ import { DbTaskRow, Status, TaskEventType, + TaskSecrets, TaskSpec, TaskStore, TaskStoreEmitOptions, @@ -42,6 +43,7 @@ export type RawDbTaskRow = { status: Status; last_heartbeat_at?: string; created_at: string; + secrets?: string; }; export type RawDbTaskEventRow = { @@ -71,23 +73,29 @@ export class DatabaseTaskStore implements TaskStore { } try { const spec = JSON.parse(result.spec); + const secrets = result.secrets ? JSON.parse(result.secrets) : undefined; return { id: result.id, spec, status: result.status, lastHeartbeatAt: result.last_heartbeat_at, createdAt: result.created_at, + secrets, }; } catch (error) { throw new Error(`Failed to parse spec of task '${taskId}', ${error}`); } } - async createTask(spec: TaskSpec): Promise<{ taskId: string }> { + async createTask( + spec: TaskSpec, + secrets?: TaskSecrets, + ): Promise<{ taskId: string }> { const taskId = uuid(); await this.db('tasks').insert({ id: taskId, spec: JSON.stringify(spec), + secrets: secrets ? JSON.stringify(secrets) : undefined, status: 'open', }); return { taskId }; @@ -119,12 +127,14 @@ export class DatabaseTaskStore implements TaskStore { try { const spec = JSON.parse(task.spec); + const secrets = task.secrets ? JSON.parse(task.secrets) : undefined; return { id: task.id, spec, status: 'processing', lastHeartbeatAt: task.last_heartbeat_at, createdAt: task.created_at, + secrets, }; } catch (error) { throw new Error(`Failed to parse spec of task '${task.id}', ${error}`); @@ -209,6 +219,7 @@ export class DatabaseTaskStore implements TaskStore { }) .update({ status, + secrets: null as any, }); if (updateCount !== 1) { throw new ConflictError( diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts index fd2de30332..686dfc052f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.test.ts @@ -21,7 +21,7 @@ import { import { ConfigReader } from '@backstage/config'; import { DatabaseTaskStore } from './DatabaseTaskStore'; import { StorageTaskBroker, TaskAgent } from './StorageTaskBroker'; -import { TaskSpec, DbTaskEventRow } from './types'; +import { TaskSecrets, TaskSpec, DbTaskEventRow } from './types'; async function createStore(): Promise { const manager = SingleConnectionDatabaseManager.fromConfig( @@ -39,6 +39,7 @@ async function createStore(): Promise { describe('StorageTaskBroker', () => { let storage: DatabaseTaskStore; + const fakeSecrets = { token: 'secret' } as TaskSecrets; beforeAll(async () => { storage = await createStore(); @@ -78,6 +79,13 @@ describe('StorageTaskBroker', () => { await expect(taskC.spec.steps[0].id).toBe('c'); }); + it('should store secrets', async () => { + const broker = new StorageTaskBroker(storage, logger); + await broker.dispatch({} as TaskSpec, fakeSecrets); + const task = await broker.claim(); + expect(task.secrets).toEqual(fakeSecrets); + }, 10000); + it('should complete a task', async () => { const broker = new StorageTaskBroker(storage, logger); const dispatchResult = await broker.dispatch({} as TaskSpec); @@ -87,6 +95,16 @@ describe('StorageTaskBroker', () => { expect(taskRow.status).toBe('completed'); }, 10000); + it('should remove secrets after completing a task', async () => { + const broker = new StorageTaskBroker(storage, logger); + const dispatchResult = await broker.dispatch({} as TaskSpec, fakeSecrets); + const task = await broker.claim(); + await task.complete('completed'); + const taskRow = await storage.getTask(dispatchResult.taskId); + expect(taskRow.status).toBe('completed'); + expect(taskRow.secrets).toBeUndefined(); + }, 10000); + it('should fail a task', async () => { const broker = new StorageTaskBroker(storage, logger); const dispatchResult = await broker.dispatch({} as TaskSpec); @@ -96,6 +114,16 @@ describe('StorageTaskBroker', () => { expect(taskRow.status).toBe('failed'); }); + it('should remove secrets after failing a task', async () => { + const broker = new StorageTaskBroker(storage, logger); + const dispatchResult = await broker.dispatch({} as TaskSpec, fakeSecrets); + const task = await broker.claim(); + await task.complete('failed'); + const taskRow = await storage.getTask(dispatchResult.taskId); + expect(taskRow.status).toBe('failed'); + expect(taskRow.secrets).toBeUndefined(); + }); + it('multiple brokers should be able to observe a single task', async () => { const broker1 = new StorageTaskBroker(storage, logger); const broker2 = new StorageTaskBroker(storage, logger); diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts index fb1f4ab422..6a82fa53ca 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/StorageTaskBroker.ts @@ -18,6 +18,7 @@ import { Logger } from 'winston'; import { CompletedTaskState, Task, + TaskSecrets, TaskSpec, TaskStore, TaskBroker, @@ -48,6 +49,10 @@ export class TaskAgent implements Task { return this.state.spec; } + get secrets() { + return this.state.secrets; + } + async getWorkspaceName() { return this.state.taskId; } @@ -101,6 +106,7 @@ export class TaskAgent implements Task { interface TaskState { spec: TaskSpec; taskId: string; + secrets?: TaskSecrets; } function defer() { @@ -126,6 +132,7 @@ export class StorageTaskBroker implements TaskBroker { { taskId: pendingTask.id, spec: pendingTask.spec, + secrets: pendingTask.secrets, }, this.storage, this.logger, @@ -136,8 +143,11 @@ export class StorageTaskBroker implements TaskBroker { } } - async dispatch(spec: TaskSpec): Promise { - const taskRow = await this.storage.createTask(spec); + async dispatch( + spec: TaskSpec, + secrets?: TaskSecrets, + ): Promise { + const taskRow = await this.storage.createTask(spec, secrets); this.signalDispatch(); return { taskId: taskRow.taskId, diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts index 14752e1988..9e4deab31a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/TaskWorker.ts @@ -164,6 +164,7 @@ export class TaskWorker { logger: taskLogger, logStream: stream, input, + token: task.secrets?.token, workspacePath, async createTemporaryDirectory() { const tmpDir = await fs.mkdtemp( diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts index 6963817a1e..d7d57a55ff 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/types.ts @@ -31,6 +31,7 @@ export type DbTaskRow = { status: Status; createdAt: string; lastHeartbeatAt?: string; + secrets?: TaskSecrets; }; export type TaskEventType = 'completion' | 'log'; @@ -54,12 +55,17 @@ export type TaskSpec = { output: { [name: string]: string }; }; +export type TaskSecrets = { + token: string | undefined; +}; + export type DispatchResult = { taskId: string; }; export interface Task { spec: TaskSpec; + secrets?: TaskSecrets; done: boolean; emitLog(message: string, metadata?: JsonValue): Promise; complete(result: CompletedTaskState, metadata?: JsonValue): Promise; @@ -68,7 +74,7 @@ export interface Task { export interface TaskBroker { claim(): Promise; - dispatch(spec: TaskSpec): Promise; + dispatch(spec: TaskSpec, secrets?: TaskSecrets): Promise; vacuumTasks(timeoutS: { timeoutS: number }): Promise; observe( options: { @@ -93,7 +99,10 @@ export type TaskStoreGetEventsOptions = { }; export interface TaskStore { - createTask(task: TaskSpec): Promise<{ taskId: string }>; + createTask( + task: TaskSpec, + secrets?: TaskSecrets, + ): Promise<{ taskId: string }>; getTask(taskId: string): Promise; claimTask(): Promise; completeTask(options: { diff --git a/plugins/scaffolder-backend/src/service/router.test.ts b/plugins/scaffolder-backend/src/service/router.test.ts index f01b4f8c95..bb52edaaea 100644 --- a/plugins/scaffolder-backend/src/service/router.test.ts +++ b/plugins/scaffolder-backend/src/service/router.test.ts @@ -301,4 +301,27 @@ describe('createRouter', () => { expect(response.status).toEqual(201); }); }); + + describe('GET /v2/tasks/:taskId', () => { + it('does not divulge secrets', async () => { + const postResponse = await request(app) + .post('/v2/tasks') + .set('Authorization', 'Bearer secret') + .send({ + templateName: 'create-react-app-template', + values: { + storePath: 'https://github.com/backstage/backstage', + component_id: '123', + name: 'test', + use_typescript: false, + }, + }); + + const response = await request(app) + .get(`/v2/tasks/${postResponse.body.id}`) + .send(); + expect(response.status).toEqual(200); + expect(response.body.secrets).toBeUndefined(); + }); + }); }); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index 1e2891cfb8..35a57da6a2 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -358,8 +358,9 @@ export async function createRouter( .post('/v2/tasks', async (req, res) => { const templateName: string = req.body.templateName; const values: TemplaterValues = req.body.values; + const token = getBearerToken(req.headers.authorization); const template = await entityClient.findTemplate(templateName, { - token: getBearerToken(req.headers.authorization), + token, }); let taskSpec; @@ -402,7 +403,9 @@ export async function createRouter( ); } - const result = await taskBroker.dispatch(taskSpec); + const result = await taskBroker.dispatch(taskSpec, { + token: token, + }); res.status(201).json({ id: result.taskId }); }) @@ -412,6 +415,8 @@ export async function createRouter( if (!task) { throw new NotFoundError(`Task with id ${taskId} does not exist`); } + // Do not disclose secrets + delete task.secrets; res.status(200).json(task); }) .get('/v2/tasks/:taskId/eventstream', async (req, res) => { diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index 876d6dfa66..58f5f5cd11 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -71,11 +71,17 @@ export const MultistepJsonForm = ({ return ( <> - {steps.map(({ title, schema, ...formProps }) => { + {steps.map(({ title, schema, ...formProps }, index) => { return ( - - {title} + + + {title} +
{description} - diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 15b6c9935c..ba71b38d75 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -187,7 +187,11 @@ export const TemplatePage = () => { {loading && } {schema && ( - +