Merge branch 'backstage:master' into update-adopters
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-react': patch
|
||||
---
|
||||
|
||||
Fix accessibility issue in `SearchCheckbox` component, making it possible to use the field via keyboard.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-import': patch
|
||||
---
|
||||
|
||||
Swap `ImportStepper` and `InfoCard` order to fix tab order in `catalog-import`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-kubernetes-backend': patch
|
||||
---
|
||||
|
||||
K8s proxy HEADER_KUBERNETES_CLUSTER is now optional in single-cluster setups.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Enhance the sidebar item accessibility by using appropriate header semantics.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': minor
|
||||
---
|
||||
|
||||
Add a scaffolder action that pull-requests for bitbucket server
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
'@backstage/plugin-techdocs': patch
|
||||
---
|
||||
|
||||
Make the documentation pages printable (also handy for exporting to PDF)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Adding in type and kind entity details to catalog table title for user clarity
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-explore': patch
|
||||
---
|
||||
|
||||
Enhance the tools card accessibility by using appropriate header semantics.
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
+112
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
+258
@@ -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),
|
||||
});
|
||||
});
|
||||
});
|
||||
+200
@@ -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';
|
||||
|
||||
+389
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+291
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
+92
@@ -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',
|
||||
|
||||
+22
-2
@@ -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) {
|
||||
|
||||
+51
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+22
-6
@@ -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}
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
+3
@@ -39,6 +39,9 @@ const useStyles = makeStyles({
|
||||
width: 'calc(100% - 34.4rem)',
|
||||
margin: '0 auto',
|
||||
},
|
||||
'@media print': {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+3
@@ -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%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user