Merge branch 'backstage:master' into update-adopters

This commit is contained in:
Marios Antonoudiou
2023-06-13 11:04:39 +03:00
committed by GitHub
85 changed files with 3157 additions and 992 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search-react': patch
---
Fix accessibility issue in `SearchCheckbox` component, making it possible to use the field via keyboard.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-import': patch
---
Swap `ImportStepper` and `InfoCard` order to fix tab order in `catalog-import`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Add `TargetBranchName` variable and output for the `publish:gitlab:merge-request` and `publish:github:pull-request` s'cascaffolder actions.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes-backend': patch
---
K8s proxy HEADER_KUBERNETES_CLUSTER is now optional in single-cluster setups.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Enhance the sidebar item accessibility by using appropriate header semantics.
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-events-backend-module-aws-sqs': patch
'@backstage/plugin-catalog-backend-module-aws': patch
'@backstage/integration-aws-node': patch
'@backstage/plugin-kubernetes-backend': patch
'@backstage/backend-common': patch
'@backstage/plugin-techdocs-node': patch
---
Fix SNYK-JS-FASTXMLPARSER-5668858 (`fast-xml-parser`) by upgrading aws-sdk to at least the current latest version.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Add a scaffolder action that pull-requests for bitbucket server
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/core-components': patch
'@backstage/plugin-techdocs': patch
---
Make the documentation pages printable (also handy for exporting to PDF)
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Added `github:deployKey:create` and `github:environment:create` scaffolder actions. You will need to add `read/write` permissions to your GITHUB_TOKEN and/or Github Backstage App for Repository `Administration` (for deploy key functionality) and `Environments` (for Environment functionality)
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
Adds an optional `EventBroker` that is used for sending an event when there are conflicts, with details of the conflict so that it can be handled elsewhere.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Adding in type and kind entity details to catalog table title for user clarity
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-explore': patch
---
Enhance the tools card accessibility by using appropriate header semantics.
+2
View File
@@ -434,3 +434,5 @@ zod
Zolotusky
zoomable
zsh
Pulumi
Lightsail
@@ -29,7 +29,7 @@ jobs:
ref: 'refs/pull/${{ github.event.pull_request.number }}/merge'
- name: fetch base
run: git fetch --depth 1 origin ${{ github.base_ref }}
- uses: backstage/actions/changeset-feedback@v0.6.3
- uses: backstage/actions/changeset-feedback@v0.6.4
name: Generate feedback
with:
diff-ref: 'origin/master'
+3 -3
View File
@@ -35,7 +35,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -66,7 +66,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -188,7 +188,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
cron:
runs-on: ubuntu-latest
steps:
- uses: backstage/actions/cron@v0.6.3
- uses: backstage/actions/cron@v0.6.4
with:
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }}
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
node-version: 18.x
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v18.x
+2 -2
View File
@@ -68,7 +68,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -145,7 +145,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -10,4 +10,4 @@ jobs:
if: github.repository == 'backstage/backstage'
steps:
- name: Issue sync
uses: backstage/actions/issue-sync@v0.6.3
uses: backstage/actions/issue-sync@v0.6.4
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
const prNumber = artifact.name.slice('pr_number-'.length)
core.setOutput('pr-number', prNumber);
- uses: backstage/actions/re-review@v0.6.3
- uses: backstage/actions/re-review@v0.6.4
with:
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }}
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
if: github.repository == 'backstage/backstage' && ( github.event.pull_request || github.event.issue.pull_request )
steps:
- name: PR sync
uses: backstage/actions/pr-sync@v0.6.3
uses: backstage/actions/pr-sync@v0.6.4
with:
github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }}
app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }}
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
@@ -19,7 +19,7 @@ jobs:
node-version: 18.x
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v18.x
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
registry-url: https://registry.npmjs.org/
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: linux-v16
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
with:
node-version: 16.x
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v16.x
- name: run Lighthouse CI
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -52,7 +52,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
+1 -1
View File
@@ -37,7 +37,7 @@ jobs:
node-version: ${{ matrix.node-version }}
registry-url: https://registry.npmjs.org/ # Needed for auth
- name: yarn install
uses: backstage/actions/yarn-install@v0.6.3
uses: backstage/actions/yarn-install@v0.6.4
with:
cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }}
- name: storybook yarn install
+180
View File
@@ -0,0 +1,180 @@
---
id: aws-lightsail
title: Deploying Backstage on AWS Lightsail
sidebar_label: AWS Lightsail
description: How to deploy Backstage on AWS Lightsail
---
> **DISCLAIMER: The `deploy` command is in alpha and still experimental. Do not use the `deploy` command for production deployments.**
Getting started with Backstage often involves setting up an instance on a cloud provider and sharing it with your team so they can experiment. To make this cloud deployment easier, we've built a `deploy` command to stand up a proof-of-concept instance on AWS (using Lightsail).
## What is AWS Lightsail
:::tip
AWS offers a free tier for up to three months on $10 USD/month Container service (Micro -1 node). By default we use the `nano` node, so if you are a new user this approach shouldn't cost you anything. For more information, refer to the [pricing](https://aws.amazon.com/lightsail/pricing/) documentation.
:::
AWS Lightsail offers a simple way to run containers in the cloud. To learn more about AWS Lightsail, please refer to the [official documentation](https://lightsail.aws.amazon.com/ls/docs/en_us/articles/amazon-lightsail-container-services-deployments).
## Creating user in AWS
- Open the AWS console and navigate to the IAM section
- In the left side menu click on `Users` and then click on `Add users`
- Specify a username and then click on `Next`
- Afterwards you can assign permissions, select `Attach policies directly` and then click on `Create policy`.
This should take you to a new window in which you can create a new policy based on `JSON`.
Copy over the following:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VisualEditor0",
"Effect": "Allow",
"Action": [
"ecr:DescribeImageReplicationStatus",
"ecr:ListTagsForResource",
"ecr:UploadLayerPart",
"ecr:BatchGetRepositoryScanningConfiguration",
"ecr:DeleteRepository",
"ecr:GetRegistryScanningConfiguration",
"ecr:CompleteLayerUpload",
"ecr:TagResource",
"ecr:DescribeRepositories",
"ecr:DeleteRepositoryPolicy",
"ecr:BatchCheckLayerAvailability",
"ecr:GetLifecyclePolicy",
"ecr:GetRegistryPolicy",
"ecr:PutLifecyclePolicy",
"ecr:DescribeImageScanFindings",
"ecr:GetLifecyclePolicyPreview",
"ecr:CreateRepository",
"ecr:DescribeRegistry",
"ecr:GetDownloadUrlForLayer",
"ecr:GetAuthorizationToken",
"ecr:DeleteLifecyclePolicy",
"ecr:PutImage",
"ecr:UntagResource",
"ecr:SetRepositoryPolicy",
"ecr:BatchGetImage",
"ecr:InitiateLayerUpload",
"ecr:GetRepositoryPolicy",
"lightsail:CreateContainerService",
"lightsail:GetKeyPair",
"lightsail:GetContainerServiceDeployments",
"lightsail:CreateContainerServiceRegistryLogin",
"lightsail:GetContainerImages",
"lightsail:UntagResource",
"lightsail:RegisterContainerImage",
"lightsail:GetContainerServices",
"lightsail:GetContainerServicePowers",
"lightsail:GetKeyPairs",
"lightsail:CreateContainerServiceDeployment",
"lightsail:GetContainerServiceMetricData",
"lightsail:GetContainerAPIMetadata",
"lightsail:DeleteContainerService",
"lightsail:GetContainerLog",
"lightsail:TagResource"
],
"Resource": "*"
},
{
"Sid": "Statement1",
"Effect": "Allow",
"Action": [],
"Resource": []
}
]
}
```
Then click on `Next` and give the policy a name and a description of your liking. Afterwards, click on `Create policy`.
- Navigate back to the user creation window and press on the refresh button and search for the policy you just created. Now, create the user.
- Now you will be redirected to all users, click on the user you just created and click on `Security credentials`
- Scroll below and click on `Create access key`
- Choose `Command Line Interface (CLI)`
- Now export the following values
```bash
$ export AWS_ACCESS_KEY_ID=... (first value)
$ export AWS_SECRET_ACCESS_KEY=.... (second secret value)
```
## Configuring the Pulumi CLI
Second, install the [Pulumi CLI](https://www.pulumi.com/docs/get-started/install/).
Then we need to execute the following commands, to set Pulumi up:
:::tip
Make sure to store your passphrase somewhere safe as it is used to encrypt/decrypt your Pulumi config.
:::
```bash
$ pulumi login --local
$ export PULUMI_CONFIG_PASSPHRASE="<your-secret>"
```
By using `pulumi login --local` we are making sure that Pulumi stores our state on the local file disk. The environment variable `PULUMI_CONFIG_PASSPHRASE` is used by Pulumi to generate a unique key for your stack
## Deploying your instance on Lightsail
:::warning
Make sure that [Docker](https://docs.docker.com/) is running before you start this section.
:::
After you have made your changes to your local instance, it's time to deploy it on Lightsail.
First, we need to configure a new `app-config` file and update the `baseUrl`.
```bash
$ touch app-config.deployment.yaml
```
And then update the file with the following yaml:
```yaml
app:
baseUrl: ${BACKSTAGE_HOST}
backend:
baseUrl: ${BACKSTAGE_HOST}
```
The environment variable `BACKSTAGE_HOST` will be set to the endpoint that AWS Lightsail creates.
Now we can deploy our instance!
```bash
$ npx backstage-deploy aws --stack backstage-poc --create-dockerfile
```
In the first part of the command, we are specifying that we want to deploy our instance on AWS. With the [`--stack`](https://www.pulumi.com/docs/reference/cli/pulumi_stack/) option, we are providing Pulumi a reference to our container deployment. Furthermore, with the `--create-dockerfile` option, there will be a `Dockerfile` and `.dockerignore` created in the root of the project.
After running the command, Pulumi will start creating the following resources for you in AWS:
- ECR Repository
- Lightsail Container Service
- Lightsail Container Service Deployment
- Policy that allows Lightsail to pull from ECR
If it's the first time building the Docker image, it might take a while for everything to be fully provisioned. After the command is finished running, your Backstage instance should be up and running on AWS Lightsail! 🎉
### Cleaning up resources
Cleaning up the resources is also done with the deploy command.
```bash
$ npx backstage-deploy aws --stack backstage-poc --destroy
```
This will delete everything that was originally created by the `deploy` command.
+1 -1
View File
@@ -94,7 +94,7 @@ export default async function createPlugin(
scheduler: env.scheduler,
});
env.eventBroker.subscribe(githubProvider);
builder.addEntityProvider(demoProvider);
builder.addEntityProvider(githubProvider);
/* highlight-add-end */
const { processingEngine, router } = await builder.build();
await processingEngine.start();
+1
View File
@@ -138,3 +138,4 @@ integration:
- `Commit statuses`: `Read-only`
- `Variables`: `Read & write` (if templates include GitHub Action Repository Variables)
- `Secrets`: `Read & write` (if templates include GitHub Action Repository Secrets)
- `Environments`: `Read & write` (if templates include GitHub Environments)
+47 -38
View File
@@ -10,55 +10,64 @@ and where to use them.
## help
This command displays a help summary or detailed help screens for each command.
Below is a cleaned up output of `yarn backstage-cli --help`
Below is a cleaned up output of `yarn backstage-cli --help`:
```text
repo [command] Command that run across an entire Backstage project
package [command] Lifecycle scripts for individual packages
migrate [command] Migration utilities
create Open up an interactive guide to creating new things in your app
config:docs Browse the configuration reference documentation
config:print Print the app configuration for the current package
config:check Validate that the given configuration loads and matches schema
config:schema Dump the app configuration schema
versions:bump Bump Backstage packages to the latest versions
versions:check Check Backstage package versioning
build-workspace Builds a temporary dist workspace from the provided packages
create-github-app Create new GitHub App in your organization (experimental)
info Show helpful information for debugging and reporting bugs
help [command] display help for command
new [options] Open up an interactive guide to creating new things in
your app
test Run tests, forwarding args to Jest, defaulting to watch
mode [DEPRECATED]
config:docs [options] Browse the configuration reference documentation
config:print [options] Print the app configuration for the current package
config:check [options] Validate that the given configuration loads and matches
schema
config:schema [options] Print configuration schema
repo [command] Command that run across an entire Backstage project
package [command] Lifecycle scripts for individual packages
migrate [command] Migration utilities
versions:bump [options] Bump Backstage packages to the latest versions
versions:check [options] Check Backstage package versioning
clean Delete cache directories [DEPRECATED]
build-workspace <workspace-dir> [packages...] Builds a temporary dist workspace from the provided
packages
create-github-app <github-org> Create new GitHub App in your organization.
info Show helpful information for debugging and reporting bugs
help [command] display help for command
```
The `package` command category, `yarn backstage-cli package --help`
The `package` command category, `yarn backstage-cli package --help`:
```text
start [options] Start a package for local development
build [options] Build a package for production deployment or publishing
lint [options] Lint a package
test Run tests, forwarding args to Jest, defaulting to watch mode
clean Delete cache directories
prepack Prepares a package for packaging before publishing
postpack Restores the changes made by the prepack command
start [options] Start a package for local development
build [options] Build a package for production deployment or publishing
lint [options] [directories...] Lint a package
test Run tests, forwarding args to Jest, defaulting to watch mode
clean Delete cache directories
prepack Prepares a package for packaging before publishing
postpack Restores the changes made by the prepack command
help [command] display help for command
```
The `repo` command category, `yarn backstage-cli repo --help`
The `repo` command category, `yarn backstage-cli repo --help`:
```text
build [options] Build packages in the project, excluding bundled app and backend packages.
lint [options] Lint all packages in the project
build [options] Build packages in the project, excluding bundled app and backend packages.
lint [options] Lint all packages in the project
clean Delete cache and output directories
list-deprecations [options] List deprecations
test [options] Run tests, forwarding args to Jest, defaulting to watch mode
help [command] display help for command
```
The `migrate` command category, `yarn backstage-cli migrate --help`
The `migrate` command category, `yarn backstage-cli migrate --help`:
```text
package-roles Add package role field to packages that don't have it
package-scripts Set package scripts according to each package role
package-exports Synchronize package subpath export definitions
package-lint-configs Migrates all packages to use @backstage/cli/config/eslint-factory
react-router-deps Migrates the react-router dependencies for all packages to be peer dependencies
help [command] display help for command
```
## repo build
@@ -235,7 +244,7 @@ Options:
-h, --help display help for command
```
## config:docs
## config\:docs
This commands opens up the reference documentation of your apps local
configuration schema in the browser. This is useful to get an overview of what
@@ -252,7 +261,7 @@ Options:
-h, --help display help for command
```
## config:print
## config\:print
Print the static configuration, defaulting to reading `app-config.yaml` in the
repo root, using schema collected from all local packages in the repo.
@@ -277,7 +286,7 @@ Options:
-h, --help display help for command
```
## config:check
## config\:check
Validate that static configuration loads and matches schema, defaulting to
reading `app-config.yaml` in the repo root and using schema collected from all
@@ -296,7 +305,7 @@ Options:
-h, --help display help for command
```
## config:schema
## config\:schema
Dump the configuration schema that was collected from all local packages in the
repo.
@@ -315,7 +324,7 @@ Options:
-h, --help display help for command
```
## versions:bump
## versions\:bump
Bump all `@backstage` packages to the latest versions. This checks for updates
in the package registry, and will update entries both in `yarn.lock` and
@@ -330,7 +339,7 @@ Options:
--release <version|next|main> Bump to a specific Backstage release line or version (default: "main")
```
## versions:check
## versions\:check
Validate `@backstage` dependencies within the repo, making sure that there are
no duplicates of packages that might lead to breakages.
@@ -38,7 +38,7 @@ It's now time to put the Deploy CLI into action and deploy a Backstage POC. The
Deploy CLI makes use of [Pulumi](https://www.pulumi.com/docs/) which is an infrastructure-as-code tool that helps provision resources in the cloud. Before you get started, ensure you have the [Pulumi CLI](https://www.pulumi.com/docs/cli/) and [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) installed on your local machine.
You can find the step-by-step installation documentation for Pulumi under the [Deployment section](https://backstage.io/docs/deployment/pulumi/aws).
You can find the step-by-step installation documentation for Pulumi under the [Deployment section](https://backstage.io/docs/deployment/backstage-deploy/aws-lightsail).
But we summarize the steps to deploy your POC here. You can invoke the the CLI you can use the following command:
@@ -74,5 +74,6 @@ So, please try this new CLI out and let us know what you think on [Discord](http
Useful links:
- [Backstage Deploy](https://github.com/backstage/backstage-deploy)
- [Documentation](https://backstage.io/docs/deployment/backstage-deploy/aws-lightsail)
- [Pulumi docs](https://www.pulumi.com/docs/)
- [#deployment](https://discord.com/channels/687207715902193673/995973463208644678) channel in Discord
+6 -1
View File
@@ -325,7 +325,12 @@
"deployment/scaling",
"deployment/docker",
"deployment/k8s",
"deployment/heroku"
"deployment/heroku",
{
"type": "category",
"label": "Using Backstage Deploy (alpha)",
"items": ["deployment/backstage-deploy/aws-lightsail"]
}
],
"Designing for Backstage": [
"dls/design",
+5 -5
View File
@@ -46,10 +46,10 @@
"test:kubernetes": "backstage-cli package test -t KubernetesContainerRunner --no-watch"
},
"dependencies": {
"@aws-sdk/abort-controller": "^3.310.0",
"@aws-sdk/client-s3": "^3.310.0",
"@aws-sdk/credential-providers": "^3.310.0",
"@aws-sdk/types": "^3.310.0",
"@aws-sdk/abort-controller": "^3.347.0",
"@aws-sdk/client-s3": "^3.350.0",
"@aws-sdk/credential-providers": "^3.350.0",
"@aws-sdk/types": "^3.347.0",
"@backstage/backend-app-api": "workspace:^",
"@backstage/backend-dev-utils": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
@@ -114,7 +114,7 @@
}
},
"devDependencies": {
"@aws-sdk/util-stream-node": "^3.310.0",
"@aws-sdk/util-stream-node": "^3.350.0",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/archiver": "^5.1.0",
@@ -92,6 +92,10 @@ const VARIANT_STYLES = {
flexDirection: 'column',
height: 'calc(100% - 10px)', // for pages without content header
marginBottom: '10px',
breakInside: 'avoid-page',
'@media print': {
height: 'auto',
},
},
},
cardContent: {
@@ -33,6 +33,11 @@ const useStyles = makeStyles<BackstageTheme>(
[theme.breakpoints.down('xs')]: {
height: '100%',
},
'@media print': {
display: 'block',
height: 'auto',
overflowY: 'inherit',
},
},
}),
{ name: 'BackstagePage' },
@@ -63,6 +63,9 @@ const useStyles = makeStyles<BackstageTheme, { sidebarConfig: SidebarConfig }>(
'&::-webkit-scrollbar': {
display: 'none',
},
'@media print': {
display: 'none',
},
},
drawerWidth: props => ({
width: props.sidebarConfig.drawerWidthClosed,
@@ -411,7 +411,11 @@ const SidebarItemBase = forwardRef<any, SidebarItemProps>((props, ref) => {
{itemIcon}
</Box>
{text && (
<Typography variant="subtitle2" className={classes.label}>
<Typography
variant="subtitle2"
component="span"
className={classes.label}
>
{text}
</Typography>
)}
@@ -72,6 +72,9 @@ const useStyles = makeStyles<BackstageTheme, { sidebarConfig: SidebarConfig }>(
zIndex: theme.zIndex.snackbar,
// SidebarDivider color
borderTop: '1px solid #383838',
'@media print': {
display: 'none',
},
},
overlay: props => ({
@@ -51,6 +51,9 @@ const useStyles = makeStyles<
[theme.breakpoints.down('xs')]: {
paddingBottom: props => props.sidebarConfig.mobileSidebarHeight,
},
'@media print': {
padding: '0px !important',
},
},
content: {
zIndex: 0,
@@ -127,7 +127,7 @@ export const SidebarSubmenu = (props: SidebarSubmenuProps) => {
[classes.drawerOpen]: isSubmenuOpen,
})}
>
<Typography variant="h5" className={classes.title}>
<Typography variant="h5" component="span" className={classes.title}>
{props.title}
</Typography>
{props.children}
@@ -172,11 +172,19 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
)}
>
{Icon && <Icon fontSize="small" />}
<Typography variant="subtitle1" className={classes.label}>
<Typography
variant="subtitle1"
component="span"
className={classes.label}
>
{title}
<br />
{subtitle && (
<Typography variant="caption" className={classes.subtitle}>
<Typography
variant="caption"
component="span"
className={classes.subtitle}
>
{subtitle}
</Typography>
)}
@@ -204,7 +212,7 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
onClick={closeSubmenu}
onTouchStart={e => e.stopPropagation()}
>
<Typography className={classes.textContent}>
<Typography component="span" className={classes.textContent}>
{object.title}
</Typography>
</Link>
@@ -230,11 +238,19 @@ export const SidebarSubmenuItem = (props: SidebarSubmenuItemProps) => {
onTouchStart={e => e.stopPropagation()}
>
{Icon && <Icon fontSize="small" />}
<Typography variant="subtitle1" className={classes.label}>
<Typography
variant="subtitle1"
component="span"
className={classes.label}
>
{title}
<br />
{subtitle && (
<Typography variant="caption" className={classes.subtitle}>
<Typography
variant="caption"
component="span"
className={classes.subtitle}
>
{subtitle}
</Typography>
)}
+4 -4
View File
@@ -32,10 +32,10 @@
"clean": "backstage-cli package clean"
},
"dependencies": {
"@aws-sdk/client-sts": "^3.310.0",
"@aws-sdk/credential-provider-node": "^3.310.0",
"@aws-sdk/credential-providers": "^3.310.0",
"@aws-sdk/types": "^3.310.0",
"@aws-sdk/client-sts": "^3.350.0",
"@aws-sdk/credential-provider-node": "^3.350.0",
"@aws-sdk/credential-providers": "^3.350.0",
"@aws-sdk/types": "^3.347.0",
"@aws-sdk/util-arn-parser": "^3.310.0",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^"
@@ -168,7 +168,7 @@ describe('DefaultApiExplorerPage', () => {
const { findByTitle, findByText } = await renderWrapped(
<DefaultApiExplorerPage />,
);
expect(await findByText(/All \(1\)/)).toBeInTheDocument();
expect(await findByText(/All apis \(1\)/)).toBeInTheDocument();
expect(await findByTitle(/View/)).toBeInTheDocument();
expect(await findByTitle(/View/)).toBeInTheDocument();
expect(await findByTitle(/Edit/)).toBeInTheDocument();
@@ -198,7 +198,7 @@ describe('DefaultApiExplorerPage', () => {
const { findByTitle, findByText } = await renderWrapped(
<DefaultApiExplorerPage actions={actions} />,
);
expect(await findByText(/All \(1\)/)).toBeInTheDocument();
expect(await findByText(/All apis \(1\)/)).toBeInTheDocument();
expect(await findByTitle(/Foo Action/)).toBeInTheDocument();
expect(await findByTitle(/Bar Action/)).toBeInTheDocument();
expect((await findByTitle(/Bar Action/)).firstChild).toBeDisabled();
@@ -45,12 +45,12 @@
"clean": "backstage-cli package clean"
},
"dependencies": {
"@aws-sdk/client-eks": "^3.310.0",
"@aws-sdk/client-organizations": "^3.310.0",
"@aws-sdk/client-s3": "^3.310.0",
"@aws-sdk/credential-providers": "^3.310.0",
"@aws-sdk/middleware-endpoint": "^3.310.0",
"@aws-sdk/types": "^3.310.0",
"@aws-sdk/client-eks": "^3.350.0",
"@aws-sdk/client-organizations": "^3.350.0",
"@aws-sdk/client-s3": "^3.350.0",
"@aws-sdk/credential-providers": "^3.350.0",
"@aws-sdk/middleware-endpoint": "^3.347.0",
"@aws-sdk/types": "^3.347.0",
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
@@ -69,7 +69,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@aws-sdk/util-stream-node": "^3.310.0",
"@aws-sdk/util-stream-node": "^3.350.0",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/lodash": "^4.14.151",
+5
View File
@@ -33,6 +33,7 @@ import { EntityProvider as EntityProvider_2 } from '@backstage/plugin-catalog-no
import { EntityProviderConnection as EntityProviderConnection_2 } from '@backstage/plugin-catalog-node';
import { EntityProviderMutation as EntityProviderMutation_2 } from '@backstage/plugin-catalog-node';
import { EntityRelationSpec as EntityRelationSpec_2 } from '@backstage/plugin-catalog-node';
import { EventBroker } from '@backstage/plugin-events-node';
import { GetEntitiesRequest } from '@backstage/catalog-client';
import { JsonValue } from '@backstage/types';
import { LocationSpec as LocationSpec_2 } from '@backstage/plugin-catalog-common';
@@ -113,6 +114,9 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor_2 {
validateEntityKind(entity: Entity): Promise<boolean>;
}
// @public (undocumented)
export const CATALOG_CONFLICTS_TOPIC = 'experimental.catalog.conflict';
// @public
export class CatalogBuilder {
addEntityPolicy(
@@ -142,6 +146,7 @@ export class CatalogBuilder {
replaceProcessors(processors: CatalogProcessor_2[]): CatalogBuilder;
setAllowedLocationTypes(allowedLocationTypes: string[]): CatalogBuilder;
setEntityDataParser(parser: CatalogProcessorParser_2): CatalogBuilder;
setEventBroker(broker: EventBroker): CatalogBuilder;
setFieldFormatValidators(validators: Partial<Validators>): CatalogBuilder;
setLocationAnalyzer(locationAnalyzer: LocationAnalyzer): CatalogBuilder;
setPlaceholderResolver(
+1
View File
@@ -56,6 +56,7 @@
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-node": "workspace:^",
"@backstage/plugin-events-node": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/plugin-scaffolder-common": "workspace:^",
@@ -311,3 +311,29 @@ export type Cursor = {
*/
totalItems?: number;
};
/**
* The details of the conflicting entity.
*/
export type CatalogConflictEventPayload = {
/**
* The new conflicting entity in raw, unvalidated form
*/
unprocessedEntity: Entity;
/**
* The new conflicting entity's ref
*/
entityRef: string;
/**
* The location key of this new conflicting entity
*/
newLocationKey: string;
/**
* The location key of the entity already in the catalog that shares the same name and namespace as this entity
*/
existingLocationKey: string;
/**
* The datetime that the conflict was processed at
*/
lastConflictAt: string;
};
+18
View File
@@ -0,0 +1,18 @@
/*
* 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.
*/
/** @public */
export const CATALOG_CONFLICTS_TOPIC = 'experimental.catalog.conflict';
@@ -44,6 +44,10 @@ import { insertUnprocessedEntity } from './operations/refreshState/insertUnproce
import { updateUnprocessedEntity } from './operations/refreshState/updateUnprocessedEntity';
import { deleteOrphanedEntities } from './operations/util/deleteOrphanedEntities';
import { generateStableHash } from './util';
import { EventBroker, EventParams } from '@backstage/plugin-events-node';
import { DateTime } from 'luxon';
import { CATALOG_CONFLICTS_TOPIC } from '../constants';
import { CatalogConflictEventPayload } from '../catalog/types';
// The number of items that are sent per batch to the database layer, when
// doing .batchInsert calls to knex. This needs to be low enough to not cause
@@ -57,6 +61,7 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
database: Knex;
logger: Logger;
refreshInterval: ProcessingIntervalFunction;
eventBroker?: EventBroker;
},
) {
initDatabaseMetrics(options.database);
@@ -361,6 +366,19 @@ export class DefaultProcessingDatabase implements ProcessingDatabase {
this.options.logger.warn(
`Detected conflicting entityRef ${entityRef} already referenced by ${conflictingKey} and now also ${locationKey}`,
);
if (this.options.eventBroker && locationKey) {
const eventParams: EventParams<CatalogConflictEventPayload> = {
topic: CATALOG_CONFLICTS_TOPIC,
eventPayload: {
unprocessedEntity: entity,
entityRef,
newLocationKey: locationKey,
existingLocationKey: conflictingKey,
lastConflictAt: DateTime.now().toISO(),
},
};
await this.options.eventBroker?.publish(eventParams);
}
}
}
+1
View File
@@ -27,6 +27,7 @@ export * from './processing';
export * from './search';
export * from './service';
export * from './deprecated';
export * from './constants';
import {
DefaultCatalogCollatorFactory as _DefaultCatalogCollatorFactory,
@@ -99,6 +99,7 @@ import {
import { AuthorizedLocationService } from './AuthorizedLocationService';
import { DefaultProviderDatabase } from '../database/DefaultProviderDatabase';
import { DefaultCatalogDatabase } from '../database/DefaultCatalogDatabase';
import { EventBroker } from '@backstage/plugin-events-node';
/**
* This is a duplicate of the alpha `CatalogPermissionRule` type, for use in the stable API.
@@ -168,6 +169,7 @@ export class CatalogBuilder {
private readonly permissionRules: CatalogPermissionRuleInput[];
private allowedLocationType: string[];
private legacySingleProcessorValidation = false;
private eventBroker?: EventBroker;
/**
* Creates a catalog builder.
@@ -419,6 +421,14 @@ export class CatalogBuilder {
return this;
}
/**
* Enables the publishing of events for cloflicts in the DefaultProcessingDatabase
*/
setEventBroker(broker: EventBroker): CatalogBuilder {
this.eventBroker = broker;
return this;
}
/**
* Wires up and returns all of the component parts of the catalog
*/
@@ -442,6 +452,7 @@ export class CatalogBuilder {
database: dbClient,
logger,
refreshInterval: this.processingInterval,
eventBroker: this.eventBroker,
});
const providerDatabase = new DefaultProviderDatabase({
database: dbClient,
@@ -22,7 +22,7 @@ import {
SupportButton,
} from '@backstage/core-components';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import { Grid } from '@material-ui/core';
import { Grid, useMediaQuery, useTheme } from '@material-ui/core';
import React from 'react';
import { ImportInfoCard } from '../ImportInfoCard';
import { ImportStepper } from '../ImportStepper';
@@ -33,9 +33,21 @@ import { ImportStepper } from '../ImportStepper';
* @public
*/
export const DefaultImportPage = () => {
const theme = useTheme();
const configApi = useApi(configApiRef);
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const appTitle = configApi.getOptional('app.title') || 'Backstage';
const contentItems = [
<Grid item xs={12} md={4} lg={6} xl={8}>
<ImportInfoCard />
</Grid>,
<Grid item xs={12} md={8} lg={6} xl={4}>
<ImportStepper />
</Grid>,
];
return (
<Page themeId="home">
<Header title="Register an existing component" />
@@ -47,14 +59,8 @@ export const DefaultImportPage = () => {
</SupportButton>
</ContentHeader>
<Grid container spacing={2} direction="row-reverse">
<Grid item xs={12} md={4} lg={6} xl={8}>
<ImportInfoCard />
</Grid>
<Grid item xs={12} md={8} lg={6} xl={4}>
<ImportStepper />
</Grid>
<Grid container spacing={2}>
{isMobile ? contentItems : contentItems.reverse()}
</Grid>
</Content>
</Page>
@@ -205,7 +205,9 @@ describe('DefaultCatalogPage', () => {
it('should render the default actions of an item in the grid', async () => {
await renderWrapped(<DefaultCatalogPage />);
fireEvent.click(screen.getByTestId('user-picker-owned'));
await expect(screen.findByText(/Owned \(1\)/)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Owned components \(1\)/),
).resolves.toBeInTheDocument();
await expect(screen.findByTitle(/View/)).resolves.toBeInTheDocument();
await expect(screen.findByTitle(/Edit/)).resolves.toBeInTheDocument();
await expect(
@@ -235,7 +237,9 @@ describe('DefaultCatalogPage', () => {
await renderWrapped(<DefaultCatalogPage actions={actions} />);
fireEvent.click(screen.getByTestId('user-picker-owned'));
await expect(screen.findByText(/Owned \(1\)/)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Owned components \(1\)/),
).resolves.toBeInTheDocument();
await expect(screen.findByTitle(/Foo Action/)).resolves.toBeInTheDocument();
await expect(screen.findByTitle(/Bar Action/)).resolves.toBeInTheDocument();
await expect(
@@ -249,14 +253,20 @@ describe('DefaultCatalogPage', () => {
it('should render', async () => {
await renderWrapped(<DefaultCatalogPage />);
fireEvent.click(screen.getByTestId('user-picker-owned'));
await expect(screen.findByText(/Owned \(1\)/)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Owned components \(1\)/),
).resolves.toBeInTheDocument();
fireEvent.click(screen.getByTestId('user-picker-all'));
await expect(screen.findByText(/All \(2\)/)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/All components \(2\)/),
).resolves.toBeInTheDocument();
}, 20_000);
it('should set initial filter correctly', async () => {
await renderWrapped(<DefaultCatalogPage initiallySelectedFilter="all" />);
await expect(screen.findByText(/All \(2\)/)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/All components \(2\)/),
).resolves.toBeInTheDocument();
}, 20_000);
// this test is for fixing the bug after favoriting an entity, the matching
@@ -264,7 +274,9 @@ describe('DefaultCatalogPage', () => {
it('should render the correct entities filtered on the selected filter', async () => {
await renderWrapped(<DefaultCatalogPage />);
fireEvent.click(screen.getByTestId('user-picker-owned'));
await expect(screen.findByText(/Owned \(1\)/)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/Owned components \(1\)/),
).resolves.toBeInTheDocument();
// The "Starred" menu option should initially be disabled, since there
// aren't any starred entities.
expect(screen.getByTestId('user-picker-starred')).toHaveAttribute(
@@ -272,11 +284,15 @@ describe('DefaultCatalogPage', () => {
'true',
);
fireEvent.click(screen.getByTestId('user-picker-all'));
await expect(screen.findByText(/All \(2\)/)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/All components \(2\)/),
).resolves.toBeInTheDocument();
const starredIcons = await screen.findAllByTitle('Add to favorites');
fireEvent.click(starredIcons[0]);
await expect(screen.findByText(/All \(2\)/)).resolves.toBeInTheDocument();
await expect(
screen.findByText(/All components \(2\)/),
).resolves.toBeInTheDocument();
// Now that we've starred an entity, the "Starred" menu option should be
// enabled.
@@ -286,7 +302,7 @@ describe('DefaultCatalogPage', () => {
);
fireEvent.click(screen.getByTestId('user-picker-starred'));
await expect(
screen.findByText(/Starred \(1\)/),
screen.findByText(/Starred components \(1\)/),
).resolves.toBeInTheDocument();
}, 20_000);
@@ -95,6 +95,11 @@ describe('CatalogTable component', () => {
() => false,
() => false,
),
kind: {
value: 'component',
getCatalogFilters: () => ({ kind: 'component' }),
toQueryValue: () => 'component',
},
},
}}
>
@@ -107,7 +112,7 @@ describe('CatalogTable component', () => {
},
},
);
expect(screen.getByText(/Owned \(3\)/)).toBeInTheDocument();
expect(screen.getByText(/Owned components \(3\)/)).toBeInTheDocument();
expect(screen.getByText(/component1/)).toBeInTheDocument();
expect(screen.getByText(/component2/)).toBeInTheDocument();
expect(screen.getByText(/component3/)).toBeInTheDocument();
@@ -43,6 +43,7 @@ import { capitalize } from 'lodash';
import React, { ReactNode, useMemo } from 'react';
import { columnFactories } from './columns';
import { CatalogTableRow } from './types';
import pluralize from 'pluralize';
/**
* Props for {@link CatalogTable}.
@@ -226,6 +227,11 @@ export const CatalogTable = (props: CatalogTableProps) => {
typeColumn.hidden = !showTypeColumn;
}
const showPagination = rows.length > 20;
const currentKind = filters.kind?.value || '';
const currentType = filters.type?.value || '';
const titleDisplay = [titlePreamble, currentType, pluralize(currentKind)]
.filter(s => s)
.join(' ');
return (
<Table<CatalogTableRow>
@@ -241,7 +247,7 @@ export const CatalogTable = (props: CatalogTableProps) => {
pageSizeOptions: [20, 50, 100],
...tableOptions,
}}
title={`${titlePreamble} (${entities.length})`}
title={`${titleDisplay} (${entities.length})`}
data={rows}
actions={actions || defaultActions}
subtitle={subtitle}
@@ -35,7 +35,7 @@
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@aws-sdk/client-sqs": "^3.310.0",
"@aws-sdk/client-sqs": "^3.350.0",
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
"@backstage/backend-tasks": "workspace:^",
@@ -46,7 +46,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@aws-sdk/types": "^3.310.0",
"@aws-sdk/types": "^3.347.0",
"@backstage/backend-common": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
@@ -36,8 +36,12 @@ describe('<ToolCard />', () => {
});
it('renders props correctly', () => {
const { getByText } = render(wrapInTestApp(<ToolCard {...minProps} />));
expect(getByText(minProps.card.title)).toBeInTheDocument();
const { getByRole, getByText } = render(
wrapInTestApp(<ToolCard {...minProps} />),
);
expect(
getByRole('heading', { name: minProps.card.title }),
).toBeInTheDocument();
expect(getByText(minProps.card.description)).toBeInTheDocument();
});
@@ -74,7 +74,7 @@ export const ToolCard = ({ card, objectFit }: Props) => {
})}
/>
<CardContent>
<Typography paragraph variant="h5">
<Typography variant="h5">
{title}{' '}
{lifecycle && lifecycle.toLocaleLowerCase('en-US') !== 'ga' && (
<Chip
+2 -2
View File
@@ -50,8 +50,8 @@
},
"dependencies": {
"@aws-crypto/sha256-js": "^3.0.0",
"@aws-sdk/credential-providers": "^3.310.0",
"@aws-sdk/signature-v4": "^3.310.0",
"@aws-sdk/credential-providers": "^3.350.0",
"@aws-sdk/signature-v4": "^3.347.0",
"@azure/identity": "^2.0.4",
"@backstage/backend-common": "workspace:^",
"@backstage/backend-plugin-api": "workspace:^",
@@ -104,6 +104,56 @@ describe('KubernetesProxy', () => {
).rejects.toThrow(NotFoundError);
});
it('should return a ERROR_NOT_FOUND if multi-cluster & no cluster selected', async () => {
clusterSupplier.getClusters.mockResolvedValue([
{
name: 'local',
url: 'http:/localhost:8001',
authProvider: 'localKubectlProxy',
skipMetricsLookup: true,
} as ClusterDetails,
{
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'tokenA',
authProvider: 'googleServiceAccount',
} as ClusterDetails,
]);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
const req = buildMockRequest(undefined, 'api');
const { res, next } = getMockRes();
await expect(
proxy.createRequestHandler({ permissionApi })(req, res, next),
).rejects.toThrow(NotFoundError);
});
it('should return a ERROR_NOT_FOUND if selected cluster not in config', async () => {
clusterSupplier.getClusters.mockResolvedValue([
{
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'tokenA',
authProvider: 'googleServiceAccount',
} as ClusterDetails,
]);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
const req = buildMockRequest('test', 'api');
const { res, next } = getMockRes();
await expect(
proxy.createRequestHandler({ permissionApi })(req, res, next),
).rejects.toThrow(NotFoundError);
});
it('should pass the exact response from Kubernetes', async () => {
const apiResponse = {
kind: 'APIVersions',
@@ -155,6 +205,55 @@ describe('KubernetesProxy', () => {
expect(response.body).toStrictEqual(apiResponse);
});
it('should pass the exact response from Kubernetes default cluster & no cluster selected in single cluster setup', async () => {
const apiResponse = {
kind: 'APIVersions',
versions: ['v1'],
serverAddressByClientCIDRs: [
{
clientCIDR: '0.0.0.0/0',
serverAddress: '192.168.0.1:3333',
},
],
};
clusterSupplier.getClusters.mockResolvedValue([
{
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
},
] as ClusterDetails[]);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: '',
authProvider: 'serviceAccount',
} as ClusterDetails);
const router = Router();
router.use('/mountpath', proxy.createRequestHandler({ permissionApi }));
const app = express().use(router);
const requestPromise = request(app).get('/mountpath/api');
worker.use(
rest.get('https://localhost:9999/api', (_: any, res: any, ctx: any) =>
res(ctx.status(299), ctx.json(apiResponse)),
),
rest.all(requestPromise.url, (req: any) => req.passthrough()),
);
const response = await requestPromise;
expect(response.status).toEqual(299);
expect(response.body).toStrictEqual(apiResponse);
});
it('sets host header to support clusters behind name-based virtual hosts', async () => {
worker.use(
rest.get(
@@ -481,55 +580,6 @@ describe('KubernetesProxy', () => {
});
});
it('should return a 400 error if Backstage-Kubernetes-Cluster field isnt provided in request', async () => {
worker.use(
rest.get('https://localhost:9999/api/v1/namespaces', (_req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
kind: 'NamespaceList',
apiVersion: 'v1',
items: [],
}),
);
}),
);
permissionApi.authorize.mockReturnValue(
Promise.resolve([{ result: AuthorizeResult.ALLOW }]),
);
clusterSupplier.getClusters.mockResolvedValue([
{
name: 'cluster1',
url: 'https://localhost:9999',
authProvider: 'googleServiceAccount',
},
] as ClusterDetails[]);
authTranslator.decorateClusterDetailsWithAuth.mockResolvedValue({
name: 'cluster1',
url: 'https://localhost:9999',
serviceAccountToken: 'tokenA',
authProvider: 'googleServiceAccount',
} as ClusterDetails);
const router = Router();
router.use('/mountpath', proxy.createRequestHandler({ permissionApi }));
router.use(errorHandler());
const app = express().use(router);
const requestPromise = request(app)
.get('/mountpath/api/v1/namespaces')
.set(HEADER_KUBERNETES_AUTH, 'tokenB');
worker.use(rest.all(requestPromise.url, (req: any) => req.passthrough()));
const response = await requestPromise;
expect(response.status).toEqual(400);
});
it('returns a 500 error if authTranslator errors out and Backstage-Kubernetes-Authorization field is not provided', async () => {
worker.use(
rest.get('https://localhost:9999/api/v1/namespaces', (req, res, ctx) => {
@@ -17,7 +17,6 @@
import {
ErrorResponseBody,
ForwardedError,
InputError,
NotAllowedError,
NotFoundError,
serializeError,
@@ -175,13 +174,23 @@ export class KubernetesProxy {
private async getClusterForRequest(req: Request): Promise<ClusterDetails> {
const clusterName = req.header(HEADER_KUBERNETES_CLUSTER);
if (!clusterName) {
throw new InputError(`Missing '${HEADER_KUBERNETES_CLUSTER}' header.`);
const clusters = await this.clusterSupplier.getClusters();
if (!clusters || clusters.length <= 0) {
throw new NotFoundError(`No Clusters configured`);
}
const hasClusterNameHeader =
typeof clusterName === 'string' && clusterName.length > 0;
let cluster: ClusterDetails | undefined;
if (hasClusterNameHeader) {
cluster = clusters.find(c => c.name === clusterName);
} else if (clusters.length === 1) {
cluster = clusters.at(0);
}
const cluster = await this.clusterSupplier
.getClusters()
.then(clusters => clusters.find(c => c.name === clusterName));
if (!cluster) {
throw new NotFoundError(`Cluster '${clusterName}' not found`);
}
+65
View File
@@ -211,6 +211,50 @@ export function createGithubActionsDispatchAction(options: {
JsonObject
>;
// @public
export function createGithubDeployKeyAction(options: {
integrations: ScmIntegrationRegistry;
}): TemplateAction_2<
{
repoUrl: string;
publicKey: string;
privateKey: string;
deployKeyName: string;
privateKeySecretName?: string | undefined;
token?: string | undefined;
},
JsonObject
>;
// @public
export function createGithubEnvironmentAction(options: {
integrations: ScmIntegrationRegistry;
}): TemplateAction_2<
{
repoUrl: string;
name: string;
deploymentBranchPolicy?:
| {
protected_branches: boolean;
custom_branch_policies: boolean;
}
| undefined;
customBranchPolicyNames?: string[] | undefined;
environmentVariables?:
| {
[key: string]: string;
}
| undefined;
secrets?:
| {
[key: string]: string;
}
| undefined;
token?: string | undefined;
},
JsonObject
>;
// @public
export function createGithubIssuesLabelAction(options: {
integrations: ScmIntegrationRegistry;
@@ -458,6 +502,22 @@ export function createPublishBitbucketServerAction(options: {
JsonObject
>;
// @public
export function createPublishBitbucketServerPullRequestAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}): TemplateAction_2<
{
repoUrl: string;
title: string;
description?: string | undefined;
targetBranch?: string | undefined;
sourceBranch: string;
token?: string | undefined;
},
JsonObject
>;
// @public
export function createPublishGerritAction(options: {
integrations: ScmIntegrationRegistry;
@@ -584,6 +644,7 @@ export const createPublishGithubPullRequestAction: (
{
title: string;
branchName: string;
targetBranchName?: string | undefined;
description: string;
repoUrl: string;
draft?: boolean | undefined;
@@ -626,6 +687,7 @@ export const createPublishGitlabMergeRequestAction: (options: {
title: string;
description: string;
branchName: string;
targetBranchName?: string | undefined;
sourcePath?: string | undefined;
targetPath?: string | undefined;
token?: string | undefined;
@@ -772,6 +834,9 @@ export type OctokitWithPullRequestPluginClient = Octokit & {
data: {
html_url: string;
number: number;
base: {
ref: string;
};
};
} | null>;
};
@@ -42,6 +42,8 @@ import {
} from './filesystem';
import {
createGithubActionsDispatchAction,
createGithubDeployKeyAction,
createGithubEnvironmentAction,
createGithubIssuesLabelAction,
createGithubRepoCreateAction,
createGithubRepoPushAction,
@@ -52,6 +54,7 @@ import {
createPublishBitbucketAction,
createPublishBitbucketCloudAction,
createPublishBitbucketServerAction,
createPublishBitbucketServerPullRequestAction,
createPublishGerritAction,
createPublishGerritReviewAction,
createPublishGithubAction,
@@ -162,6 +165,10 @@ export const createBuiltinActions = (
integrations,
config,
}),
createPublishBitbucketServerPullRequestAction({
integrations,
config,
}),
createPublishAzureAction({
integrations,
config,
@@ -194,6 +201,12 @@ export const createBuiltinActions = (
config,
githubCredentialsProvider,
}),
createGithubEnvironmentAction({
integrations,
}),
createGithubDeployKeyAction({
integrations,
}),
];
return actions as TemplateAction[];
@@ -0,0 +1,112 @@
/*
* 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 { createGithubDeployKeyAction } from './githubDeployKey';
import { getVoidLogger } from '@backstage/backend-common';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
const mockOctokit = {
rest: {
actions: {
getRepoPublicKey: jest.fn(),
createOrUpdateRepoSecret: jest.fn(),
},
repos: {
createDeployKey: jest.fn(),
},
},
};
jest.mock('octokit', () => ({
Octokit: class {
constructor() {
return mockOctokit;
}
},
}));
const publicKey = '2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU=';
describe('github:deployKey:create', () => {
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
let action: TemplateAction<any>;
const mockContext = {
input: {
repoUrl: 'github.com?repo=repository&owner=owner',
publicKey: 'pubkey',
privateKey: 'privkey',
deployKeyName: 'Push Tags',
},
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
action = createGithubDeployKeyAction({
integrations,
});
});
it('should work happy path', async () => {
mockOctokit.rest.actions.getRepoPublicKey.mockResolvedValue({
data: {
key: publicKey,
key_id: 'keyid',
},
});
await action.handler(mockContext);
expect(mockOctokit.rest.repos.createDeployKey).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
title: 'Push Tags',
key: 'pubkey',
});
expect(
mockOctokit.rest.actions.createOrUpdateRepoSecret,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
secret_name: 'PUSH_TAGS_PRIVATE_KEY',
key_id: 'keyid',
encrypted_value: expect.any(String),
});
expect(mockContext.output).toHaveBeenCalledWith(
'privateKeySecretName',
'PUSH_TAGS_PRIVATE_KEY',
);
});
});
@@ -0,0 +1,157 @@
/*
* 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 { InputError } from '@backstage/errors';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { parseRepoUrl } from '../publish/util';
import { getOctokitOptions } from './helpers';
import { Octokit } from 'octokit';
import Sodium from 'libsodium-wrappers';
/**
* Creates an `github:deployKey:create` Scaffolder action that creates a Deploy Key
*
* @public
*/
export function createGithubDeployKeyAction(options: {
integrations: ScmIntegrationRegistry;
}) {
const { integrations } = options;
// For more information on how to define custom actions, see
// https://backstage.io/docs/features/software-templates/writing-custom-actions
return createTemplateAction<{
repoUrl: string;
publicKey: string;
privateKey: string;
deployKeyName: string;
privateKeySecretName?: string;
token?: string;
}>({
id: 'github:deployKey:create',
description: 'Creates and stores Deploy Keys',
schema: {
input: {
type: 'object',
required: ['repoUrl', 'publicKey', 'privateKey', 'deployKeyName'],
properties: {
repoUrl: {
title: 'Repository Location',
description: `Accepts the format 'github.com?repo=reponame&owner=owner' where 'reponame' is the new repository name and 'owner' is an organization or username`,
type: 'string',
},
publicKey: {
title: 'SSH Public Key',
description: `Generated from ssh-keygen. Begins with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'.`,
type: 'string',
},
privateKey: {
title: 'SSH Private Key',
description: `SSH Private Key generated from ssh-keygen`,
type: 'string',
},
deployKeyName: {
title: 'Deploy Key Name',
description: `Name of the Deploy Key`,
type: 'string',
},
privateKeySecretName: {
title: 'Private Key GitHub Secret Name',
description: `Name of the GitHub Secret to store the private key related to the Deploy Key. Defaults to: 'KEY_NAME_PRIVATE_KEY' where 'KEY_NAME' is the name of the Deploy Key`,
type: 'string',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitHub',
},
},
},
output: {
type: 'object',
properties: {
privateKeySecretName: {
title: 'The GitHub Action Repo Secret Name for the Private Key',
type: 'string',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
publicKey,
privateKey,
deployKeyName,
privateKeySecretName = `${deployKeyName
.split(' ')
.join('_')
.toLocaleUpperCase('en-US')}_PRIVATE_KEY`,
token: providedToken,
} = ctx.input;
const octokitOptions = await getOctokitOptions({
integrations,
token: providedToken,
repoUrl: repoUrl,
});
const { owner, repo } = parseRepoUrl(repoUrl, integrations);
if (!owner) {
throw new InputError(`No owner provided for repo ${repoUrl}`);
}
const client = new Octokit(octokitOptions);
await client.rest.repos.createDeployKey({
owner: owner,
repo: repo,
title: deployKeyName,
key: publicKey,
});
const publicKeyResponse = await client.rest.actions.getRepoPublicKey({
owner: owner,
repo: repo,
});
await Sodium.ready;
const binaryKey = Sodium.from_base64(
publicKeyResponse.data.key,
Sodium.base64_variants.ORIGINAL,
);
const binarySecret = Sodium.from_string(privateKey);
const encryptedBinarySecret = Sodium.crypto_box_seal(
binarySecret,
binaryKey,
);
const encryptedBase64Secret = Sodium.to_base64(
encryptedBinarySecret,
Sodium.base64_variants.ORIGINAL,
);
await client.rest.actions.createOrUpdateRepoSecret({
owner: owner,
repo: repo,
secret_name: privateKeySecretName,
encrypted_value: encryptedBase64Secret,
key_id: publicKeyResponse.data.key_id,
});
ctx.output('privateKeySecretName', privateKeySecretName);
},
});
}
@@ -0,0 +1,258 @@
/*
* 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 { createGithubEnvironmentAction } from './githubEnvironment';
import { getVoidLogger } from '@backstage/backend-common';
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
const mockOctokit = {
rest: {
actions: {
getRepoPublicKey: jest.fn(),
createEnvironmentVariable: jest.fn(),
createOrUpdateEnvironmentSecret: jest.fn(),
},
repos: {
createDeploymentBranchPolicy: jest.fn(),
createOrUpdateEnvironment: jest.fn(),
get: jest.fn(),
},
},
};
jest.mock('octokit', () => ({
Octokit: class {
constructor() {
return mockOctokit;
}
},
}));
const publicKey = '2Sg8iYjAxxmI2LvUXpJjkYrMxURPc8r+dB7TJyvvcCU=';
describe('github:environment:create', () => {
const config = new ConfigReader({
integrations: {
github: [
{ host: 'github.com', token: 'tokenlols' },
{ host: 'ghe.github.com' },
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
let action: TemplateAction<any>;
const mockContext = {
input: {
repoUrl: 'github.com?repo=repository&owner=owner',
name: 'envname',
},
workspacePath: 'lol',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
beforeEach(() => {
mockOctokit.rest.actions.getRepoPublicKey.mockResolvedValue({
data: {
key: publicKey,
key_id: 'keyid',
},
});
mockOctokit.rest.repos.get.mockResolvedValue({
data: {
id: 'repoid',
},
});
action = createGithubEnvironmentAction({
integrations,
});
});
afterEach(jest.resetAllMocks);
it('should work happy path', async () => {
await action.handler(mockContext);
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
});
});
it('should work specify deploymentBranchPolicy protected', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
deploymentBranchPolicy: {
protected_branches: true,
custom_branch_policies: false,
},
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: {
protected_branches: true,
custom_branch_policies: false,
},
});
});
it('should work specify deploymentBranchPolicy custom', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
deploymentBranchPolicy: {
protected_branches: false,
custom_branch_policies: true,
},
customBranchPolicyNames: ['main', '*.*.*'],
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: {
protected_branches: false,
custom_branch_policies: true,
},
});
expect(
mockOctokit.rest.repos.createDeploymentBranchPolicy,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
name: 'main',
});
expect(
mockOctokit.rest.repos.createDeploymentBranchPolicy,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
name: '*.*.*',
});
});
it('should work specify environment variables', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
environmentVariables: {
key1: 'val1',
key2: 'val2',
},
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
});
expect(
mockOctokit.rest.actions.createEnvironmentVariable,
).toHaveBeenCalledTimes(2);
expect(
mockOctokit.rest.actions.createEnvironmentVariable,
).toHaveBeenCalledWith({
repository_id: 'repoid',
environment_name: 'envname',
name: 'key1',
value: 'val1',
});
expect(
mockOctokit.rest.actions.createEnvironmentVariable,
).toHaveBeenCalledWith({
repository_id: 'repoid',
environment_name: 'envname',
name: 'key2',
value: 'val2',
});
});
it('should work specify secrets', async () => {
await action.handler({
...mockContext,
input: {
...mockContext.input,
secrets: {
key1: 'val1',
key2: 'val2',
},
},
});
expect(
mockOctokit.rest.repos.createOrUpdateEnvironment,
).toHaveBeenCalledWith({
owner: 'owner',
repo: 'repository',
environment_name: 'envname',
deployment_branch_policy: null,
});
expect(
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
).toHaveBeenCalledTimes(2);
expect(
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
).toHaveBeenCalledWith({
repository_id: 'repoid',
environment_name: 'envname',
secret_name: 'key1',
key_id: 'keyid',
encrypted_value: expect.any(String),
});
expect(
mockOctokit.rest.actions.createOrUpdateEnvironmentSecret,
).toHaveBeenCalledWith({
repository_id: 'repoid',
environment_name: 'envname',
secret_name: 'key2',
key_id: 'keyid',
encrypted_value: expect.any(String),
});
});
});
@@ -0,0 +1,200 @@
/*
* 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 { InputError } from '@backstage/errors';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { parseRepoUrl } from '../publish/util';
import { getOctokitOptions } from './helpers';
import { Octokit } from 'octokit';
import Sodium from 'libsodium-wrappers';
/**
* Creates an `github:environment:create` Scaffolder action that creates a Github Environment.
*
* @public
*/
export function createGithubEnvironmentAction(options: {
integrations: ScmIntegrationRegistry;
}) {
const { integrations } = options;
// For more information on how to define custom actions, see
// https://backstage.io/docs/features/software-templates/writing-custom-actions
return createTemplateAction<{
repoUrl: string;
name: string;
deploymentBranchPolicy?: {
protected_branches: boolean;
custom_branch_policies: boolean;
};
customBranchPolicyNames?: string[];
environmentVariables?: { [key: string]: string };
secrets?: { [key: string]: string };
token?: string;
}>({
id: 'github:environment:create',
description: 'Creates Deployment Environments',
schema: {
input: {
type: 'object',
required: ['repoUrl', 'name'],
properties: {
repoUrl: {
title: 'Repository Location',
description: `Accepts the format 'github.com?repo=reponame&owner=owner' where 'reponame' is the new repository name and 'owner' is an organization or username`,
type: 'string',
},
name: {
title: 'Environment Name',
description: `Name of the deployment environment to create`,
type: 'string',
},
deploymentBranchPolicy: {
title: 'Deployment Branch Policy',
description: `The type of deployment branch policy for this environment. To allow all branches to deploy, set to null.`,
type: 'object',
required: ['protected_branches', 'custom_branch_policies'],
properties: {
protected_branches: {
title: 'Protected Branches',
description: `Whether only branches with branch protection rules can deploy to this environment. If protected_branches is true, custom_branch_policies must be false; if protected_branches is false, custom_branch_policies must be true.`,
type: 'boolean',
},
custom_branch_policies: {
title: 'Custom Branch Policies',
description: `Whether only branches that match the specified name patterns can deploy to this environment. If custom_branch_policies is true, protected_branches must be false; if custom_branch_policies is false, protected_branches must be true.`,
type: 'boolean',
},
},
},
customBranchPolicyNames: {
title: 'Custom Branch Policy Name',
description: `The name pattern that branches must match in order to deploy to the environment.
Wildcard characters will not match /. For example, to match branches that begin with release/ and contain an additional single slash, use release/*/*. For more information about pattern matching syntax, see the Ruby File.fnmatch documentation.`,
type: 'array',
items: {
type: 'string',
},
},
environmentVariables: {
title: 'Environment Variables',
description: `Environment variables attached to the deployment environment`,
type: 'object',
},
secrets: {
title: 'Deployment Secrets',
description: `Secrets attached to the deployment environment`,
type: 'object',
},
token: {
title: 'Authentication Token',
type: 'string',
description: 'The token to use for authorization to GitHub',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
name,
deploymentBranchPolicy,
customBranchPolicyNames,
environmentVariables,
secrets,
token: providedToken,
} = ctx.input;
const octokitOptions = await getOctokitOptions({
integrations,
token: providedToken,
repoUrl: repoUrl,
});
const { owner, repo } = parseRepoUrl(repoUrl, integrations);
if (!owner) {
throw new InputError(`No owner provided for repo ${repoUrl}`);
}
const client = new Octokit(octokitOptions);
const repository = await client.rest.repos.get({
owner: owner,
repo: repo,
});
await client.rest.repos.createOrUpdateEnvironment({
owner: owner,
repo: repo,
environment_name: name,
deployment_branch_policy: deploymentBranchPolicy ?? null,
});
if (customBranchPolicyNames) {
for (const item of customBranchPolicyNames) {
await client.rest.repos.createDeploymentBranchPolicy({
owner: owner,
repo: repo,
environment_name: name,
name: item,
});
}
}
for (const [key, value] of Object.entries(environmentVariables ?? {})) {
await client.rest.actions.createEnvironmentVariable({
repository_id: repository.data.id,
environment_name: name,
name: key,
value,
});
}
if (secrets) {
const publicKeyResponse = await client.rest.actions.getRepoPublicKey({
owner: owner,
repo: repo,
});
await Sodium.ready;
const binaryKey = Sodium.from_base64(
publicKeyResponse.data.key,
Sodium.base64_variants.ORIGINAL,
);
for (const [key, value] of Object.entries(secrets)) {
const binarySecret = Sodium.from_string(value);
const encryptedBinarySecret = Sodium.crypto_box_seal(
binarySecret,
binaryKey,
);
const encryptedBase64Secret = Sodium.to_base64(
encryptedBinarySecret,
Sodium.base64_variants.ORIGINAL,
);
await client.rest.actions.createOrUpdateEnvironmentSecret({
repository_id: repository.data.id,
environment_name: name,
secret_name: key,
encrypted_value: encryptedBase64Secret,
key_id: publicKeyResponse.data.key_id,
});
}
}
},
});
}
@@ -98,7 +98,7 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
client: Octokit,
repo: string,
owner: string,
repoVisibility: 'private' | 'internal' | 'public',
repoVisibility: 'private' | 'internal' | 'public' | undefined,
description: string | undefined,
homepage: string | undefined,
deleteBranchOnMerge: boolean,
@@ -149,7 +149,7 @@ export async function createGithubRepoWithCollaboratorsAndTopics(
name: repo,
org: owner,
private: repoVisibility === 'private',
// @ts-ignore
// @ts-ignore https://github.com/octokit/types.ts/issues/522
visibility: repoVisibility,
description: description,
delete_branch_on_merge: deleteBranchOnMerge,
@@ -19,3 +19,5 @@ export { createGithubIssuesLabelAction } from './githubIssuesLabel';
export { createGithubRepoCreateAction } from './githubRepoCreate';
export { createGithubRepoPushAction } from './githubRepoPush';
export { createGithubWebhookAction } from './githubWebhook';
export { createGithubDeployKeyAction } from './githubDeployKey';
export { createGithubEnvironmentAction } from './githubEnvironment';
@@ -0,0 +1,389 @@
/*
* 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.
*/
jest.mock('../helpers', () => {
return {
initRepoAndPush: jest.fn().mockResolvedValue({
commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6',
}),
commitAndPushRepo: jest.fn().mockResolvedValue({
commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6',
}),
};
});
import { createPublishBitbucketServerPullRequestAction } from './bitbucketServerPullRequest';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { ScmIntegrations } from '@backstage/integration';
import { ConfigReader } from '@backstage/config';
import { getVoidLogger } from '@backstage/backend-common';
import { PassThrough } from 'stream';
describe('publish:bitbucketServer:pull-request', () => {
const config = new ConfigReader({
integrations: {
bitbucketServer: [
{
host: 'hosted.bitbucket.com',
token: 'thing',
apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0',
},
{
host: 'basic-auth.bitbucket.com',
username: 'test-user',
password: 'test-password',
apiBaseUrl: 'https://basic-auth.bitbucket.com/rest/api/1.0',
},
{
host: 'no-credentials.bitbucket.com',
},
],
},
});
const integrations = ScmIntegrations.fromConfig(config);
const action = createPublishBitbucketServerPullRequestAction({
integrations,
config,
});
const mockContext = {
input: {
repoUrl: 'hosted.bitbucket.com?project=project&repo=repo',
title: 'Add Scaffolder actions for Bitbucket Server',
description:
'I just made a Pull Request that Add Scaffolder actions for Bitbucket Server',
targetBranch: 'master',
sourceBranch: 'develop',
},
workspacePath: 'wsp',
logger: getVoidLogger(),
logStream: new PassThrough(),
output: jest.fn(),
createTemporaryDirectory: jest.fn(),
};
const responseOfBranches = {
size: 3,
limit: 25,
isLastPage: true,
values: [
{
id: 'refs/heads/master',
displayId: 'master',
type: 'BRANCH',
latestCommit: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1',
latestChangeset: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1',
isDefault: true,
},
{
id: 'refs/heads/develop',
displayId: 'develop',
type: 'BRANCH',
latestCommit: '98e21148205367aeb11c25a52eaca3c2945253fa',
latestChangeset: '98e21148205367aeb11c25a52eaca3c2945253fa',
isDefault: false,
},
{
id: 'refs/heads/develop-2',
displayId: 'develop-2',
type: 'BRANCH',
latestCommit: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1',
latestChangeset: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1',
isDefault: false,
},
],
start: 0,
};
const responseOfPullRequests = {
id: 19,
version: 0,
title: 'Test for bitbucket server pull-requests',
description: 'Test for bitbucket server pull-requests',
state: 'OPEN',
open: true,
closed: false,
createdDate: 1684200289521,
updatedDate: 1684200289521,
fromRef: {
id: 'refs/heads/develop',
displayId: 'develop',
latestCommit: '98e21148205367aeb11c25a52eaca3c2945253fa',
type: 'BRANCH',
repository: {
slug: 'repo',
id: 1812,
name: 'repo',
description: 'This is a test repo',
hierarchyId: '1da8822903a9b11a27b8',
scmId: 'git',
state: 'AVAILABLE',
statusMessage: 'Available',
forkable: true,
project: {},
public: false,
links: {},
},
},
toRef: {
id: 'refs/heads/master',
displayId: 'master',
latestCommit: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1',
type: 'BRANCH',
repository: {
slug: 'repo',
id: 1812,
name: 'repo',
description: 'This is a test repo',
hierarchyId: '1da8822903a9b11a27b8',
scmId: 'git',
state: 'AVAILABLE',
statusMessage: 'Available',
forkable: true,
project: {},
public: false,
links: {},
},
},
locked: false,
author: {
user: {
name: 'test-user',
emailAddress: 'test-user@sample.com',
id: 2944,
displayName: 'test-user',
active: true,
slug: 'test-user',
type: 'NORMAL',
links: {},
},
role: 'AUTHOR',
approved: false,
status: 'UNAPPROVED',
},
reviewers: [],
participants: [],
links: {
self: [
{
href: 'https://hosted.bitbucket.com/projects/project/repos/repo/pull-requests/1',
},
],
},
};
const handlers = [
rest.get(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches',
(_, res, ctx) => {
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(responseOfBranches),
);
},
),
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests',
(_, res, ctx) => {
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json(responseOfPullRequests),
);
},
),
];
const server = setupServer();
setupRequestMockHandlers(server);
beforeEach(() => {
jest.resetAllMocks();
});
it('should throw an error when the repoUrl is not well formed', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'hosted.bitbucket.com?repo=repo',
},
}),
).rejects.toThrow(/missing project/);
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'hosted.bitbucket.com?project=project',
},
}),
).rejects.toThrow(/missing repo/);
});
it('should throw if there is no integration config provided', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'missing.com?project=project&repo=repo',
},
}),
).rejects.toThrow(/No matching integration configuration/);
});
it('should throw if there no credentials in the integration config that is returned', async () => {
await expect(
action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'no-credentials.bitbucket.com?project=project&repo=repo',
},
}),
).rejects.toThrow(
/Authorization has not been provided for no-credentials.bitbucket.com/,
);
});
it('should call the correct APIs with token', async () => {
expect.assertions(3);
server.use(
rest.get(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(responseOfBranches),
);
},
),
rest.post(
'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe('Bearer thing');
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json(responseOfPullRequests),
);
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'hosted.bitbucket.com?project=project&repo=repo',
},
});
});
it('should call the correct APIs with basic auth', async () => {
expect.assertions(3);
server.use(
rest.get(
'https://basic-auth.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe(
'Basic dGVzdC11c2VyOnRlc3QtcGFzc3dvcmQ=',
);
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(responseOfBranches),
);
},
),
rest.post(
'https://basic-auth.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe(
'Basic dGVzdC11c2VyOnRlc3QtcGFzc3dvcmQ=',
);
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json(responseOfPullRequests),
);
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'basic-auth.bitbucket.com?project=project&repo=repo',
},
});
});
it('should work if the token is provided through ctx.input', async () => {
expect.assertions(3);
const token = 'user-token';
server.use(
rest.get(
'https://no-credentials.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`);
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(responseOfBranches),
);
},
),
rest.post(
'https://no-credentials.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests',
(req, res, ctx) => {
expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`);
return res(
ctx.status(201),
ctx.set('Content-Type', 'application/json'),
ctx.json(responseOfPullRequests),
);
},
),
);
await action.handler({
...mockContext,
input: {
...mockContext.input,
repoUrl: 'no-credentials.bitbucket.com?project=project&repo=repo',
token: token,
},
});
});
it('should call outputs with the correct urls', async () => {
server.use(...handlers);
await action.handler(mockContext);
expect(mockContext.output).toHaveBeenCalledWith(
'pullRequestUrl',
'https://hosted.bitbucket.com/projects/project/repos/repo/pull-requests/1',
);
});
});
@@ -0,0 +1,291 @@
/*
* 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 { InputError } from '@backstage/errors';
import {
getBitbucketServerRequestOptions,
ScmIntegrationRegistry,
} from '@backstage/integration';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import fetch, { RequestInit, Response } from 'node-fetch';
import { parseRepoUrl } from './util';
import { Config } from '@backstage/config';
const createPullRequest = async (opts: {
project: string;
repo: string;
title: string;
description?: string;
toRef: {
id: string;
displayId: string;
type: string;
latestCommit: string;
latestChangeset: string;
isDefault: boolean;
};
fromRef: {
id: string;
displayId: string;
type: string;
latestCommit: string;
latestChangeset: string;
isDefault: boolean;
};
authorization: string;
apiBaseUrl: string;
}) => {
const {
project,
repo,
title,
description,
toRef,
fromRef,
authorization,
apiBaseUrl,
} = opts;
let response: Response;
const data: RequestInit = {
method: 'POST',
body: JSON.stringify({
title: title,
description: description,
state: 'OPEN',
open: true,
closed: false,
locked: true,
toRef: toRef,
fromRef: fromRef,
}),
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
},
};
try {
response = await fetch(
`${apiBaseUrl}/projects/${encodeURIComponent(
project,
)}/repos/${encodeURIComponent(repo)}/pull-requests`,
data,
);
} catch (e) {
throw new Error(`Unable to create pull-reqeusts, ${e}`);
}
if (response.status !== 201) {
throw new Error(
`Unable to create pull requests, ${response.status} ${
response.statusText
}, ${await response.text()}`,
);
}
const r = await response.json();
return `${r.links.self[0].href}`;
};
const findBranches = async (opts: {
project: string;
repo: string;
branchName: string;
authorization: string;
apiBaseUrl: string;
}) => {
const { project, repo, branchName, authorization, apiBaseUrl } = opts;
let response: Response;
const options: RequestInit = {
method: 'GET',
headers: {
Authorization: authorization,
'Content-Type': 'application/json',
},
};
try {
response = await fetch(
`${apiBaseUrl}/projects/${encodeURIComponent(
project,
)}/repos/${encodeURIComponent(
repo,
)}/branches?boostMatches=true&filterText=${encodeURIComponent(
branchName,
)}`,
options,
);
} catch (e) {
throw new Error(`Unable to get branches, ${e}`);
}
if (response.status !== 200) {
throw new Error(
`Unable to get branches, ${response.status} ${
response.statusText
}, ${await response.text()}`,
);
}
const r = await response.json();
for (const object of r.values) {
if (object.displayId === branchName) {
return object;
}
}
return undefined;
};
/**
* Creates a BitbucketServer Pull Request action.
* @public
*/
export function createPublishBitbucketServerPullRequestAction(options: {
integrations: ScmIntegrationRegistry;
config: Config;
}) {
const { integrations } = options;
return createTemplateAction<{
repoUrl: string;
title: string;
description?: string;
targetBranch?: string;
sourceBranch: string;
token?: string;
}>({
id: 'publish:bitbucketServer:pull-request',
schema: {
input: {
type: 'object',
required: ['repoUrl', 'title', 'sourceBranch'],
properties: {
repoUrl: {
title: 'Repository Location',
type: 'string',
},
title: {
title: 'Pull Request title',
type: 'string',
description: 'The title for the pull request',
},
description: {
title: 'Pull Request Description',
type: 'string',
description: 'The description of the pull request',
},
targetBranch: {
title: 'Target Branch',
type: 'string',
description: `Branch of repository to apply changes to. The default value is 'master'`,
},
sourceBranch: {
title: 'Source Branch',
type: 'string',
description: 'Branch of repository to copy changes from',
},
token: {
title: 'Authorization Token',
type: 'string',
description:
'The token to use for authorization to BitBucket Server',
},
},
},
output: {
type: 'object',
properties: {
pullRequestUrl: {
title: 'A URL to the pull request with the provider',
type: 'string',
},
},
},
},
async handler(ctx) {
const {
repoUrl,
title,
description,
targetBranch = 'master',
sourceBranch,
} = ctx.input;
const { project, repo, host } = parseRepoUrl(repoUrl, integrations);
if (!project) {
throw new InputError(
`Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing project`,
);
}
const integrationConfig = integrations.bitbucketServer.byHost(host);
if (!integrationConfig) {
throw new InputError(
`No matching integration configuration for host ${host}, please check your integrations config`,
);
}
const token = ctx.input.token ?? integrationConfig.config.token;
const authConfig = {
...integrationConfig.config,
...{ token },
};
const reqOpts = getBitbucketServerRequestOptions(authConfig);
const authorization = reqOpts.headers.Authorization;
if (!authorization) {
throw new Error(
`Authorization has not been provided for ${integrationConfig.config.host}. Please add either (a) a user login auth token, or (b) a token input from the template or (c) username + password to the integration config.`,
);
}
const apiBaseUrl = integrationConfig.config.apiBaseUrl;
const toRef = await findBranches({
project,
repo,
branchName: targetBranch,
authorization,
apiBaseUrl,
});
const fromRef = await findBranches({
project,
repo,
branchName: sourceBranch,
authorization,
apiBaseUrl,
});
const pullRequestUrl = await createPullRequest({
project,
repo,
title,
description,
toRef,
fromRef,
authorization,
apiBaseUrl,
});
ctx.output('pullRequestUrl', pullRequestUrl);
},
});
}
@@ -62,6 +62,9 @@ describe('createPublishGithubPullRequestAction', () => {
data: {
html_url: 'https://github.com/myorg/myrepo/pull/123',
number: 123,
base: {
ref: 'main',
},
},
};
}),
@@ -90,6 +93,94 @@ describe('createPublishGithubPullRequestAction', () => {
jest.resetAllMocks();
});
describe('with targetBranchName', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
beforeEach(() => {
fakeClient = {
createPullRequest: jest.fn(async (_: any) => {
return {
url: 'https://api.github.com/myorg/myrepo/pull/123',
headers: {},
status: 201,
data: {
html_url: 'https://github.com/myorg/myrepo/pull/123',
number: 123,
base: {
ref: 'test',
},
},
};
}),
rest: {
pulls: {
requestReviewers: jest.fn(async (_: any) => ({ data: {} })),
},
},
};
input = {
repoUrl: 'github.com?owner=myorg&repo=myrepo',
title: 'Create my new app',
branchName: 'new-app',
targetBranchName: 'test',
description: 'This PR is really good',
draft: true,
};
mockFs({
[workspacePath]: { 'file.txt': 'Hello there!' },
});
ctx = {
createTemporaryDirectory: jest.fn(),
output: jest.fn(),
logger: getRootLogger(),
logStream: new Writable(),
input,
workspacePath,
};
});
it('creates a pull request', async () => {
await instance.handler(ctx);
expect(fakeClient.createPullRequest).toHaveBeenCalledWith({
owner: 'myorg',
repo: 'myrepo',
title: 'Create my new app',
head: 'new-app',
base: 'test',
body: 'This PR is really good',
draft: true,
changes: [
{
commit: 'Create my new app',
files: {
'file.txt': {
content: Buffer.from('Hello there!').toString('base64'),
encoding: 'base64',
mode: '100644',
},
},
},
],
});
});
it('creates outputs for the pull request url and number', async () => {
await instance.handler(ctx);
expect(ctx.output).toHaveBeenCalledWith('targetBranchName', 'test');
expect(ctx.output).toHaveBeenCalledWith(
'remoteUrl',
'https://github.com/myorg/myrepo/pull/123',
);
expect(ctx.output).toHaveBeenCalledWith('pullRequestNumber', 123);
});
});
describe('with no sourcePath', () => {
let input: GithubPullRequestActionInput;
let ctx: ActionContext<GithubPullRequestActionInput>;
@@ -145,6 +236,7 @@ describe('createPublishGithubPullRequestAction', () => {
it('creates outputs for the pull request url and number', async () => {
await instance.handler(ctx);
expect(ctx.output).toHaveBeenCalledWith('targetBranchName', 'main');
expect(ctx.output).toHaveBeenCalledWith(
'remoteUrl',
'https://github.com/myorg/myrepo/pull/123',
@@ -42,6 +42,9 @@ export type OctokitWithPullRequestPluginClient = Octokit & {
data: {
html_url: string;
number: number;
base: {
ref: string;
};
};
} | null>;
};
@@ -128,6 +131,7 @@ export const createPublishGithubPullRequestAction = (
return createTemplateAction<{
title: string;
branchName: string;
targetBranchName?: string;
description: string;
repoUrl: string;
draft?: boolean;
@@ -154,6 +158,11 @@ export const createPublishGithubPullRequestAction = (
title: 'Branch Name',
description: 'The name for the branch',
},
targetBranchName: {
type: 'string',
title: 'Target Branch Name',
description: 'The target branch name of the merge request',
},
title: {
type: 'string',
title: 'Pull Request Name',
@@ -214,6 +223,10 @@ export const createPublishGithubPullRequestAction = (
required: ['remoteUrl'],
type: 'object',
properties: {
targetBranchName: {
title: 'Target branch name of the merge request',
type: 'string',
},
remoteUrl: {
type: 'string',
title: 'Pull Request URL',
@@ -231,6 +244,7 @@ export const createPublishGithubPullRequestAction = (
const {
repoUrl,
branchName,
targetBranchName,
title,
description,
draft,
@@ -298,7 +312,7 @@ export const createPublishGithubPullRequestAction = (
);
try {
const response = await client.createPullRequest({
const createOptions: createPullRequest.Options = {
owner,
repo,
title,
@@ -311,7 +325,11 @@ export const createPublishGithubPullRequestAction = (
body: description,
head: branchName,
draft,
});
};
if (targetBranchName) {
createOptions.base = targetBranchName;
}
const response = await client.createPullRequest(createOptions);
if (!response) {
throw new GithubResponseError('null response from Github');
@@ -329,6 +347,8 @@ export const createPublishGithubPullRequestAction = (
);
}
const targetBranch = response.data.base.ref;
ctx.output('targetBranchName', targetBranch);
ctx.output('remoteUrl', response.data.html_url);
ctx.output('pullRequestNumber', pullRequestNumber);
} catch (e) {
@@ -101,6 +101,49 @@ describe('createGitLabMergeRequest', () => {
mockFs.restore();
});
describe('createGitLabMergeRequestWithSpecifiedTargetBranch', () => {
it('removeSourceBranch is false by default when not passed in options', async () => {
const input = {
repoUrl: 'gitlab.com?repo=repo&owner=owner',
title: 'Create my new MR',
branchName: 'new-mr',
targetBranchName: 'test',
description: 'This MR is really good',
targetPath: 'Subdirectory',
};
mockFs({
[workspacePath]: {
source: { 'foo.txt': 'Hello there!' },
irrelevant: { 'bar.txt': 'Nothing to see here' },
},
});
const ctx = {
createTemporaryDirectory: jest.fn(),
output: jest.fn(),
logger: getRootLogger(),
logStream: new Writable(),
input,
workspacePath,
};
await instance.handler(ctx);
expect(mockGitlabClient.Projects.show).not.toHaveBeenCalled();
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'test',
);
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'test',
'Create my new MR',
{ description: 'This MR is really good', removeSourceBranch: false },
);
expect(ctx.output).toHaveBeenCalledWith('targetBranchName', 'test');
});
});
describe('createGitLabMergeRequestWithoutRemoveBranch', () => {
it('removeSourceBranch is false by default when not passed in options', async () => {
const input = {
@@ -126,6 +169,12 @@ describe('createGitLabMergeRequest', () => {
};
await instance.handler(ctx);
expect(mockGitlabClient.Projects.show).toHaveBeenCalledWith('owner/repo');
expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
'main',
);
expect(mockGitlabClient.MergeRequests.create).toHaveBeenCalledWith(
'owner/repo',
'new-mr',
@@ -133,6 +182,8 @@ describe('createGitLabMergeRequest', () => {
'Create my new MR',
{ description: 'This MR is really good', removeSourceBranch: false },
);
expect(ctx.output).toHaveBeenCalledWith('targetBranchName', 'main');
});
});
@@ -39,6 +39,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
title: string;
description: string;
branchName: string;
targetBranchName?: string;
sourcePath?: string;
targetPath?: string;
token?: string;
@@ -77,8 +78,13 @@ export const createPublishGitlabMergeRequestAction = (options: {
},
branchName: {
type: 'string',
title: 'Destination Branch name',
description: 'The description of the merge request',
title: 'Source Branch Name',
description: 'The source branch name of the merge request',
},
targetBranchName: {
type: 'string',
title: 'Target Branch Name',
description: 'The target branch name of the merge request',
},
sourcePath: {
type: 'string',
@@ -119,6 +125,10 @@ export const createPublishGitlabMergeRequestAction = (options: {
output: {
type: 'object',
properties: {
targetBranchName: {
title: 'Target branch name of the merge request',
type: 'string',
},
projectid: {
title: 'Gitlab Project id/Name(slug)',
type: 'string',
@@ -139,6 +149,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
const {
assignee,
branchName,
targetBranchName,
description,
repoUrl,
removeSourceBranch,
@@ -211,12 +222,16 @@ export const createPublishGitlabMergeRequestAction = (options: {
execute_filemode: file.executable,
}));
const projects = await api.Projects.show(repoID);
let targetBranch = targetBranchName;
if (!targetBranch) {
const projects = await api.Projects.show(repoID);
const { default_branch: defaultBranch } = projects;
const { default_branch: defaultBranch } = projects;
targetBranch = defaultBranch!;
}
try {
await api.Branches.create(repoID, branchName, String(defaultBranch));
await api.Branches.create(repoID, branchName, String(targetBranch));
} catch (e) {
throw new InputError(
`The branch creation failed. Please check that your repo does not already contain a branch named '${branchName}'. ${e}`,
@@ -235,7 +250,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
const mergeRequestUrl = await api.MergeRequests.create(
repoID,
branchName,
String(defaultBranch),
String(targetBranch),
title,
{
description,
@@ -246,6 +261,7 @@ export const createPublishGitlabMergeRequestAction = (options: {
return mergeRequest.web_url;
});
ctx.output('projectid', repoID);
ctx.output('targetBranchName', targetBranch);
ctx.output('projectPath', repoID);
ctx.output('mergeRequestUrl', mergeRequestUrl);
} catch (e) {
@@ -18,6 +18,7 @@ export { createPublishAzureAction } from './azure';
export { createPublishBitbucketAction } from './bitbucket';
export { createPublishBitbucketCloudAction } from './bitbucketCloud';
export { createPublishBitbucketServerAction } from './bitbucketServer';
export { createPublishBitbucketServerPullRequestAction } from './bitbucketServerPullRequest';
export { createPublishGerritAction } from './gerrit';
export { createPublishGerritReviewAction } from './gerritReview';
export { createPublishGithubAction } from './github';
@@ -138,7 +138,6 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => {
control={
<Checkbox
color="primary"
tabIndex={-1}
inputProps={{ 'aria-labelledby': value }}
value={value}
name={value}
+5 -5
View File
@@ -39,11 +39,11 @@
"url": "https://github.com/backstage/backstage/issues"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.310.0",
"@aws-sdk/credential-providers": "^3.310.0",
"@aws-sdk/lib-storage": "^3.310.0",
"@aws-sdk/node-http-handler": "^3.310.0",
"@aws-sdk/types": "^3.310.0",
"@aws-sdk/client-s3": "^3.350.0",
"@aws-sdk/credential-providers": "^3.350.0",
"@aws-sdk/lib-storage": "^3.350.0",
"@aws-sdk/node-http-handler": "^3.350.0",
"@aws-sdk/types": "^3.347.0",
"@azure/identity": "^2.1.0",
"@azure/storage-blob": "^12.5.0",
"@backstage/backend-common": "workspace:^",
@@ -39,6 +39,9 @@ const useStyles = makeStyles({
width: 'calc(100% - 34.4rem)',
margin: '0 auto',
},
'@media print': {
display: 'none',
},
},
});
@@ -39,6 +39,9 @@ const useStyles = makeStyles(theme => ({
flexDirection: 'column',
minHeight: 'auto',
padding: theme.spacing(3, 3, 0),
'@media print': {
display: 'none',
},
},
}));
@@ -227,4 +227,18 @@ export default ({ theme, sidebar }: RuleOptions) => `
width: 12.1rem;
}
}
@media print {
.md-sidebar,
#toggle-sidebar {
display: none;
}
.md-content {
margin: 0;
width: 100%;
max-width: 100%;
}
}
`;
+810 -809
View File
File diff suppressed because it is too large Load Diff