Merge branch 'master' of github.com:spotify/backstage into shmidt-i/scaffolder-flow-frontend

This commit is contained in:
Ivan Shmidt
2020-07-06 13:53:45 +02:00
151 changed files with 3135 additions and 683 deletions
-19
View File
@@ -43,23 +43,4 @@ jobs:
- run: yarn tsc
- run: yarn build
- name: verify app and plugin creation
working-directory: ${{ runner.temp }}
run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: lint newly created app and plugin
run: yarn lint:all
working-directory: ${{ runner.temp }}/test-app
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: test newly created app and plugin
run: yarn test:all
working-directory: ${{ runner.temp }}/test-app
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: e2e test newly created app
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
BACKSTAGE_E2E_CLI_TEST: true
-19
View File
@@ -44,25 +44,6 @@ jobs:
- run: yarn tsc
- run: yarn build
- name: verify app and plugin creation
working-directory: ${{ runner.temp }}
run: |
sudo sysctl fs.inotify.max_user_watches=524288
node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: lint newly created app and plugin
run: yarn lint:all
working-directory: ${{ runner.temp }}/test-app
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: test newly created app and plugin
run: yarn test:all
working-directory: ${{ runner.temp }}/test-app
env:
BACKSTAGE_E2E_CLI_TEST: true
- name: e2e test newly created app
run: yarn test:e2e:ci
working-directory: ${{ runner.temp }}/test-app/packages/app
env:
APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
BACKSTAGE_E2E_CLI_TEST: true
+13 -11
View File
@@ -15,18 +15,20 @@
For more information go to [backstage.io](https://backstage.io) or join our [Discord chatroom](https://discord.gg/EBHEGzX).
### Features
* Create and manage all of your organizations software and microservices in one place
* Services catalog keeps track of all software and its ownership
* Visualizations provide information about your backend services and tooling, and help you monitor them
* A unified method for managing microservices offers both visibility and control
* Preset templates allow engineers to quickly create microservices in a standardized way ([coming soon](https://github.com/spotify/backstage/milestone/11))
* Centralized, full-featured technical documentation with integrated tooling that makes it easy for developers to set up, publish, and maintain alongside their code ([coming soon](https://github.com/spotify/backstage/milestone/15))
- Create and manage all of your organizations software and microservices in one place
- Services catalog keeps track of all software and its ownership
- Visualizations provide information about your backend services and tooling, and help you monitor them
- A unified method for managing microservices offers both visibility and control
- Preset templates allow engineers to quickly create microservices in a standardized way ([coming soon](https://github.com/spotify/backstage/milestone/11))
- Centralized, full-featured technical documentation with integrated tooling that makes it easy for developers to set up, publish, and maintain alongside their code ([coming soon](https://github.com/spotify/backstage/milestone/15))
### Benefits
* For engineering managers, it allows you to maintain standards and best practices across the organization, and can help you manage your whole tech ecosystem, from migrations to test certification.
* For end users (developers), it makes it fast and simple to build software components in a standardized way, and it provides a central place to manage all projects and documentation.
* For platform engineers, it enables extensibility and scalability by letting you easily integrate new tools and services (via plugins), as well as extending the functionality of existing ones.
* For everyone, its a single, consistent experience that ties all your infrastructure tooling, resources, standards, owners, contributors, and administrators together in one place.
- For engineering managers, it allows you to maintain standards and best practices across the organization, and can help you manage your whole tech ecosystem, from migrations to test certification.
- For end users (developers), it makes it fast and simple to build software components in a standardized way, and it provides a central place to manage all projects and documentation.
- For platform engineers, it enables extensibility and scalability by letting you easily integrate new tools and services (via plugins), as well as extending the functionality of existing ones.
- For everyone, its a single, consistent experience that ties all your infrastructure tooling, resources, standards, owners, contributors, and administrators together in one place.
## Backstage Service Catalog (alpha)
@@ -54,7 +56,7 @@ Our vision for Backstage is for it to become the trusted standard toolbox (read:
The Backstage platform consists of a number of different components:
- **app** - Main web application that users interact with. It's built up by a number of different _Plugins_. This repo contains an example implementation of an app (located in `packages/example-app`) and you can easily get started with your own app by [creating one](docs/create-an-app.md).
- **app** - Main web application that users interact with. It's built up by a number of different _Plugins_. This repo contains an example implementation of an app (located in `packages/app`) and you can easily get started with your own app by [creating one](docs/create-an-app.md).
- [**plugins**](https://github.com/spotify/backstage/tree/master/plugins) - Each plugin is treated as a self-contained web app and can include almost any type of content. Plugins all use a common set of platform API's and reusable UI components. Plugins can fetch data either from the _backend_ or through any RESTful API exposed through the _proxy_.
- [**service catalog**](https://github.com/spotify/backstage/tree/master/packages/backend) - Service that holds the model of your software ecosystem, including organisational information and what team owns what software. The backend also has a Plugin model for extending its graph.
- **proxy** \* - Terminates HTTPS and exposes any RESTful API to Plugins.
@@ -0,0 +1,47 @@
# ADR006: Avoid React.FC and React.SFC
## Context
Facebook has removed `React.FC` from their base template for a Typescript
project. The reason for this was that it was found to be an unnecessary feature
with next to no benefits in combination with a few downsides.
The main reasons were:
- **children props** were implicitly added
- **Generic Type** were not supported on children
Read more about the removal in
[this PR](https://github.com/facebook/create-react-app/pull/8177).
## Decision
To keep our codebase up to date, we have decided that `React.FC` and `React.SFC`
should be avoided in our codebase when adding new code.
Here is an example:
```ts
/* Avoid this: */
type BadProps = { text: string };
const BadComponent: FC<BadProps> = ({ text, children }) => (
<div>
<div>{text}</div>
{children}
</div>
);
/* Do this instead: */
type GoodProps = { text: string; children?: React.ReactNode };
const GoodComponent = ({ text, children }: GoodProps) => (
<div>
<div>{text}</div>
{children}
</div>
);
```
## Consequences
We will gradually remove the current usage of `React.FC` and `React.SFC` from
our codebase.
+27
View File
@@ -0,0 +1,27 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backstage
labels:
app: backstage
component: frontend
spec:
replicas: 1
selector:
matchLabels:
app: backstage
component: frontend
template:
metadata:
labels:
app: backstage
component: frontend
spec:
containers:
- name: app
image: spotify/backstage:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: app
protocol: TCP
+27
View File
@@ -0,0 +1,27 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: backstage-backend
labels:
app: backstage
component: backend
spec:
replicas: 1
selector:
matchLabels:
app: backstage
component: backend
template:
metadata:
labels:
app: backstage
component: backend
spec:
containers:
- name: backend
image: spotify/backstage-backend:latest
imagePullPolicy: Always
ports:
- containerPort: 7000
name: backend
protocol: TCP
+22
View File
@@ -0,0 +1,22 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+5
View File
@@ -0,0 +1,5 @@
apiVersion: v1
appVersion: "1.0"
description: A Helm chart for Spotify Backstage
name: backstage
version: 0.1.1-alpha.12
+51
View File
@@ -0,0 +1,51 @@
# Backstage Helm Chart
## App/Frontend Values
| Parameter | Description | Default |
| --------------------------- | --------------------------------------------------------- | ------------------- |
| app.enabled | Whether to render the frontend app config or not | `true` |
| app.nameOverride | Override the name given to the app/frontend | `""` |
| app.fullnameOverride | Override the full name of the app/frontend | `""` |
| app.replicaCount | The number of replicas for the app/frontend | `1` |
| app.image.repository | The image repository of the app/frontend container | `spotify/backstage` |
| app.image.tag | The app/frontend tag to pull | `latest` |
| app.image.pullPolicy | Image pull policy | `Always` |
| app.service.type | The service type for the app/frontend service | `ClusterIP` |
| app.service.port | The port for the app/frontend | `80` |
| app.ingress.enabled | Whether to create ingress or not | `false` |
| app.ingress.annotations | Annotations for the app/frontend ingress | `{}` |
| app.ingress.hosts[].host | Hostname for the app/frontend | `backstage.local` |
| app.ingress.hosts[].paths[] | Path name to serve the app/frontend on | `["/"]` |
| app.imagePullSecrets[] | Any image secrets you need to pull `app.image.repository` | `[]` |
| app.podSecurityContext | Security context for the app/frontend pods | `{}` |
| app.securityContext | Security context settings for the deployment | `{}` |
| app.resources | Kubernetes Pod resource requests/limits | `{}` |
| app.nodeSelector | Node selectors for scheduling app/frontend pods | `{}` |
| app.tolerations | Tolerations for scheduling app/frontend pods | `{}` |
| app.affinity | Affinity setttings for scheduling app/frontend pods | `{}` |
## Backend Values
| Parameter | Description | Default |
| ------------------------------- | ------------------------------------------------------------- | ------------------- |
| backend.enabled | Whether to render the backend config or not | `true` |
| backend.nameOverride | Override the name given to the backend | `""` |
| backend.fullnameOverride | Override the full name of the backend | `""` |
| backend.replicaCount | The number of replicas for the backend | `1` |
| backend.image.repository | The image repository of the backend container | `spotify/backstage` |
| backend.image.tag | The backend tag to pull | `latest` |
| backend.image.pullPolicy | Image pull policy | `Always` |
| backend.service.type | The service type for the backend service | `ClusterIP` |
| backend.service.port | The port for the backend | `80` |
| backend.ingress.enabled | Whether to create ingress or not | `false` |
| backend.ingress.annotations | Annotations for the backend ingress | `{}` |
| backend.ingress.hosts[].host | Hostname for the backend | `backstage.local` |
| backend.ingress.hosts[].paths[] | Path name to serve the backend on | `["/"]` |
| backend.imagePullSecrets[] | Any image secrets you need to pull `backend.image.repository` | `[]` |
| backend.podSecurityContext | Security context for the backend pods | `{}` |
| backend.securityContext | Security context settings for the deployment | `{}` |
| backend.resources | Kubernetes Pod resource requests/limits | `{}` |
| backend.nodeSelector | Node selectors for scheduling backend pods | `{}` |
| backend.tolerations | Tolerations for scheduling backend pods | `{}` |
| backend.affinity | Affinity setttings for scheduling backend pods | `{}` |
@@ -0,0 +1,80 @@
{{/* vim: set filetype=mustache: */}}
{{/*
Expand the name of the chart.
*/}}
{{- define "backstage.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "backstage.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "backstage.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Common App labels
*/}}
{{- define "backstage.app.labels" -}}
app.kubernetes.io/name: {{ include "backstage.name" . }}-app
helm.sh/chart: {{ include "backstage.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{/*
Common Backend labels
*/}}
{{- define "backstage.backend.labels" -}}
app.kubernetes.io/name: {{ include "backstage.name" . }}-backend
helm.sh/chart: {{ include "backstage.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{/*
Create the name of the service account to use for the app
*/}}
{{- define "backstage.app.serviceAccountName" -}}
{{- if .Values.app.serviceAccount.create -}}
{{ default "default" .Values.app.serviceAccount.name }}
{{- else -}}
{{ default "default" .Values.app.serviceAccount.name }}
{{- end -}}
{{- end -}}
{{/*
Create the name of the service account to use for the backend
*/}}
{{- define "backstage.backend.serviceAccountName" -}}
{{- if .Values.backend.serviceAccount.create -}}
{{ default default .Values.backend.serviceAccount.name }}
{{- else -}}
{{ default "default" .Values.backend.serviceAccount.name }}
{{- end -}}
{{- end -}}
@@ -0,0 +1,119 @@
{{- if .Values.app.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "backstage.fullname" . }}-app
labels:
{{ include "backstage.app.labels" . | indent 4 }}
spec:
replicas: {{ .Values.app.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "backstage.name" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "backstage.name" . }}-app
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
{{- with .Values.app.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ template "backstage.app.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.app.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}-app
securityContext:
{{- toYaml .Values.app.securityContext | nindent 12 }}
image: "{{ .Values.app.image.repository }}:{{ .Values.app.image.tag }}"
imagePullPolicy: {{ .Values.app.image.pullPolicy }}
ports:
- name: app
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.app.resources | nindent 12 }}
{{- with .Values.app.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.app.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.app.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
{{- if .Values.backend.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "backstage.fullname" . }}-backend
labels:
{{ include "backstage.backend.labels" . | indent 4 }}
spec:
replicas: {{ .Values.backend.replicaCount }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "backstage.name" . }}-backend
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "backstage.name" . }}-backend
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
{{- with .Values.backend.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ template "backstage.backend.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.backend.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}-backend
securityContext:
{{- toYaml .Values.backend.securityContext | nindent 12 }}
image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}"
imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
ports:
- name: backend
containerPort: 80
protocol: TCP
livenessProbe:
httpGet:
path: /
port: backend
readinessProbe:
httpGet:
path: /
port: backend
resources:
{{- toYaml .Values.backend.resources | nindent 12 }}
{{- with .Values.backend.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.backend.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.backend.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- end }}
@@ -0,0 +1,83 @@
{{- if .Values.app.ingress.enabled -}}
{{- $fullName := include "backstage.fullname" . -}}
{{- $svcPort := .Values.app.service.port -}}
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-app
labels:
{{ include "backstage.app.labels" . | indent 4 }}
{{- with .Values.app.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.app.ingress.tls }}
tls:
{{- range .Values.app.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.app.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ . }}
backend:
serviceName: {{ $fullName }}-app
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{ end }}
{{- if .Values.backend.ingress.enabled }}
---
{{- $fullName := include "backstage.fullname" . -}}
{{- $svcPort := .Values.backend.service.port -}}
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion }}
apiVersion: networking.k8s.io/v1beta1
{{- else }}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}-backend
labels:
{{ include "backstage.backend.labels" . | indent 4 }}
{{- with .Values.backend.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.backend.ingress.tls }}
tls:
{{- range .Values.backend.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.backend.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ . }}
backend:
serviceName: {{ $fullName }}-backend
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{ end }}
@@ -0,0 +1,37 @@
{{- if .Values.app.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "backstage.fullname" . }}-app
labels:
{{ include "backstage.app.labels" . | indent 4 }}
spec:
type: {{ .Values.app.service.type }}
ports:
- port: {{ .Values.app.service.port }}
targetPort: app
protocol: TCP
name: app
selector:
app.kubernetes.io/name: {{ include "backstage.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- if .Values.app.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "backstage.fullname" . }}-backend
labels:
{{ include "backstage.backend.labels" . | indent 4 }}
spec:
type: {{ .Values.backend.service.type }}
ports:
- port: {{ .Values.backend.service.port }}
targetPort: backend
protocol: TCP
name: backend
selector:
app.kubernetes.io/name: {{ include "backstage.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
+95
View File
@@ -0,0 +1,95 @@
app:
enabled: true
nameOverride: ""
fullnameOverride: ""
replicaCount: 1
serviceAccount:
create: false
Name: ""
image:
repository: spotify/backstage
tag: latest
pullPolicy: Always
service:
type: ClusterIP
port: 80
ingress:
enabled: false
annotations: {}
# kubernetes.io/ingress.class: "nginx"
hosts:
- host: backstage.local
paths:
- /
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
imagePullSecrets: []
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
resources: {}
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
nodeSelector: {}
tolerations: []
affinity: {}
backend:
enabled: false
nameOverride: ""
fullnameOverride: ""
replicaCount: 1
serviceAccount:
create: false
Name: ""
image:
repository: spotify/backstage-backend
tag: latest
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 7000
ingress:
enabled: false
annotations: {}
# kubernetes.io/ingress.class: "nginx"
hosts:
- host: backstage.local
paths:
- /backend
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
imagePullSecrets: []
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
resources: {}
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
nodeSelector: {}
tolerations: []
affinity: {}
+20
View File
@@ -0,0 +1,20 @@
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: backstage
labels:
app: backstage
component: ingress
spec:
rules:
- host: <HOSTNAME>
http:
paths:
- backend:
serviceName: backstage
servicePort: frontend
path: /
- backend:
serviceName: backstage-backend
servicePort: backend
path: /backend
+35
View File
@@ -0,0 +1,35 @@
apiVersion: v1
kind: Service
metadata:
name: backstage
labels:
app: backstage
component: frontend
spec:
type: ClusterIP
selector:
app: backstage
component: frontend
ports:
- name: frontend
port: 80
protocol: TCP
targetPort: app
---
apiVersion: v1
kind: Service
metadata:
name: backstage-backend
labels:
app: backstage
component: backend
spec:
type: ClusterIP
selector:
app: backstage
component: backend
ports:
- name: backend
port: 7000
protocol: TCP
targetPort: backend
+4 -2
View File
@@ -8,6 +8,7 @@
"@backstage/plugin-catalog": "^0.1.1-alpha.12",
"@backstage/plugin-circleci": "^0.1.1-alpha.12",
"@backstage/plugin-explore": "^0.1.1-alpha.12",
"@backstage/plugin-github-actions": "^0.1.1-alpha.12",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.12",
"@backstage/plugin-graphiql": "^0.1.1-alpha.12",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.12",
@@ -21,12 +22,13 @@
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"history": "^5.0.0",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 890 B

After

Width:  |  Height:  |  Size: 883 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg id="favicon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 337.46 428.5">
<title>
Backstage favicon
</title>
<style>
#favicon path {
fill: #121212;
}
@media (prefers-color-scheme: dark) {
#favicon path {
fill: #7df3e1;
}
}
</style>
<path d="M303,166.05a80.69,80.69,0,0,0,13.45-10.37c.79-.77,1.55-1.53,2.3-2.3a83.12,83.12,0,0,0,7.93-9.38A63.69,63.69,0,0,0,333,133.23a48.58,48.58,0,0,0,4.35-16.4c1.49-19.39-10-38.67-35.62-54.22L198.56,0,78.3,115.23,0,190.25l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.69,19.16-18.36,25.52-42.12,13.7-61.87a49.22,49.22,0,0,0-6.8-8.87A89.17,89.17,0,0,0,259,178.29h.15a85.08,85.08,0,0,0,31-5.79A80.88,80.88,0,0,0,303,166.05ZM202.45,225.86c-19.32,18.51-50.4,21.23-75.7,5.9L51.61,186.15l67.45-64.64,76.41,46.38C223,184.58,221.49,207.61,202.45,225.86Zm8.93-82.22-70.65-42.89L205.14,39,274.51,81.1c25.94,15.72,29.31,37,10.55,55A60.69,60.69,0,0,1,211.38,143.64Zm29.86,190c-19.57,18.75-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,282.52v24.67L108.6,373.1a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A87.27,87.27,0,0,1,241.24,333.68Zm0-39c-19.57,18.75-46.17,29.08-74.88,29.08a123.81,123.81,0,0,1-64.1-18.19L0,243.53v24.68l108.6,65.91a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.5v-1.78A87.27,87.27,0,0,1,241.24,294.7Zm0-39c-19.57,18.76-46.17,29.09-74.88,29.09a123.81,123.81,0,0,1-64.1-18.19L0,204.55v24.68l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.68,12.88-12.35,20-27.13,19.68-41.5v-1.82A86.09,86.09,0,0,1,241.24,255.71Zm83.7,25.74a94.15,94.15,0,0,1-60.2,25.86h0V334a81.6,81.6,0,0,0,51.74-22.37c14-13.38,21.14-28.11,21-42.64v-2.19A94.92,94.92,0,0,1,324.94,281.45Zm-83.7,91.21c-19.57,18.76-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,321.5v24.68l108.6,65.9a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.8,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A86.29,86.29,0,0,1,241.24,372.66ZM327,162.45c-.68.69-1.35,1.38-2.05,2.06a94.37,94.37,0,0,1-10.64,8.65,91.35,91.35,0,0,1-11.6,7,94.53,94.53,0,0,1-26.24,8.71,97.69,97.69,0,0,1-14.16,1.57c.5,1.61.9,3.25,1.25,4.9a53.27,53.27,0,0,1,1.14,12V217h.05a84.41,84.41,0,0,0,25.35-5.55,81,81,0,0,0,26.39-16.82c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,172.17a48.55,48.55,0,0,0,4.32-16.45c.09-1.23.2-2.47.19-3.7V150q-1.08,1.54-2.25,3.09A96.73,96.73,0,0,1,327,162.45Zm0,77.92c-.69.7-1.31,1.41-2,2.1a94.2,94.2,0,0,1-60.2,25.86h0l0,26.67h0a81.6,81.6,0,0,0,51.74-22.37A73.51,73.51,0,0,0,333,250.13a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.47.19-3.71v-2.19c-.74,1.07-1.46,2.15-2.27,3.21A95.68,95.68,0,0,1,327,240.37Zm0-39c-.69.7-1.31,1.41-2,2.1a93.18,93.18,0,0,1-10.63,8.65,91.63,91.63,0,0,1-11.63,7,95.47,95.47,0,0,1-37.94,10.18h0V256h0a81.65,81.65,0,0,0,51.74-22.37c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,211.15a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.48.19-3.71v-2.2c-.74,1.08-1.46,2.16-2.27,3.22A95.68,95.68,0,0,1,327,201.39Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+12 -1
View File
@@ -26,12 +26,14 @@ import {
FeatureFlags,
GoogleAuth,
GithubAuth,
OAuth2,
OktaAuth,
GitlabAuth,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
oauth2ApiRef,
oktaAuthApiRef,
gitlabAuthApiRef,
storageApiRef,
@@ -110,7 +112,16 @@ export const apis = (config: ConfigApi) => {
builder.add(
gitlabAuthApiRef,
GitlabAuth.create({
apiOrigin: 'http://localhost:7000',
apiOrigin: backendUrl,
basePath: '/auth/',
oauthRequestApi,
}),
);
builder.add(
oauth2ApiRef,
OAuth2.create({
apiOrigin: backendUrl,
basePath: '/auth/',
oauthRequestApi,
}),
+1
View File
@@ -25,3 +25,4 @@ export { plugin as Sentry } from '@backstage/plugin-sentry';
export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles';
export { plugin as TechDocs } from '@backstage/plugin-techdocs';
export { plugin as GraphiQL } from '@backstage/plugin-graphiql';
export { plugin as GithubActions } from '@backstage/plugin-github-actions';
@@ -15,4 +15,5 @@
*/
require('jest-fetch-mock').enableMocks();
export {};
+7 -1
View File
@@ -25,7 +25,13 @@ You should only need to do this once.
After that, go to the `packages/backend` directory and run
```bash
AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x SENTRY_TOKEN=x LOG_LEVEL=debug yarn start
AUTH_GOOGLE_CLIENT_ID=x AUTH_GOOGLE_CLIENT_SECRET=x \
AUTH_GITHUB_CLIENT_ID=x AUTH_GITHUB_CLIENT_SECRET=x \
AUTH_OAUTH2_CLIENT_ID=x AUTH_OAUTH2_CLIENT_SECRET=x \
AUTH_OAUTH2_AUTH_URL=x AUTH_OAUTH2_TOKEN_URL=x \
SENTRY_TOKEN=x \
LOG_LEVEL=debug \
yarn start
```
Substitute `x` for actual values, or leave them as
+1
View File
@@ -36,6 +36,7 @@ module.exports = {
rules: {
'no-console': 0, // Permitted in console programs
'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
'import/newline-after-import': 'error',
'import/no-duplicates': 'warn',
'import/no-extraneous-dependencies': [
'error',
+1
View File
@@ -41,6 +41,7 @@ module.exports = {
},
ignorePatterns: ['.eslintrc.js', '**/dist/**'],
rules: {
'import/newline-after-import': 'error',
'import/no-duplicates': 'warn',
'import/no-extraneous-dependencies': [
'error',
+204 -31
View File
@@ -16,65 +16,238 @@
const os = require('os');
const fs = require('fs-extra');
const { resolve: resolvePath } = require('path');
const killTree = require('tree-kill');
const { resolve: resolvePath, join: joinPath } = require('path');
const Browser = require('zombie');
const {
spawnPiped,
runPlain,
handleError,
waitForPageWithText,
waitFor,
waitForExit,
print,
} = require('./helpers');
const createTestApp = require('./createTestApp');
const createTestPlugin = require('./createTestPlugin');
Browser.localhost('localhost', 3000);
async function createTempDir() {
return fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
}
async function main() {
process.env.BACKSTAGE_E2E_CLI_TEST = 'true';
const rootDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-'));
print(`CLI E2E test root: ${rootDir}\n`);
const workDir = process.env.CI ? process.cwd() : await createTempDir();
print('Building dist workspace');
const workspaceDir = await buildDistWorkspace('workspace', rootDir);
process.stdout.write(`Initial directory: ${process.cwd()}\n`);
process.chdir(workDir);
process.stdout.write(`Working directory: ${process.cwd()}\n`);
print('Creating a Backstage App');
const appDir = await createApp('test-app', workspaceDir, rootDir);
await createTestApp();
const appDir = resolvePath(workDir, 'test-app');
process.chdir(appDir);
process.stdout.write(`App directory: ${appDir}\n`);
await createTestPlugin();
print('Creating a Backstage Plugin');
const pluginName = await createPlugin('test-plugin', appDir);
print('Starting the app');
const startApp = spawnPiped(['yarn', 'start']);
await testAppServe(pluginName, appDir);
print('All tests successful, removing test dir');
await fs.remove(rootDir);
}
/**
* Builds a dist workspace that contains the cli and core packages
*/
async function buildDistWorkspace(workspaceName, rootDir) {
const workspaceDir = resolvePath(rootDir, workspaceName);
await fs.ensureDir(workspaceDir);
print(`Preparing workspace`);
await runPlain([
'yarn',
'backstage-cli',
'build-workspace',
workspaceDir,
'@backstage/cli',
'@backstage/core',
'@backstage/dev-utils',
'@backstage/test-utils',
]);
print('Pinning yarn version in workspace');
await pinYarnVersion(workspaceDir);
print('Installing workspace dependencies');
await runPlain(['yarn', 'install', '--production', '--frozen-lockfile'], {
cwd: workspaceDir,
});
return workspaceDir;
}
/**
* Pin the yarn version in a directory to the one we're using in the Backstage repo
*/
async function pinYarnVersion(dir) {
const repoRoot = resolvePath(__dirname, '../../..');
const yarnRc = await fs.readFile(resolvePath(repoRoot, '.yarnrc'), 'utf8');
const yarnRcLines = yarnRc.split('\n');
const yarnPathLine = yarnRcLines.find(line => line.startsWith('yarn-path'));
const [, localYarnPath] = yarnPathLine.match(/"(.*)"/);
const yarnPath = resolvePath(repoRoot, localYarnPath);
await fs.writeFile(resolvePath(dir, '.yarnrc'), `yarn-path "${yarnPath}"\n`);
}
/**
* Creates a new app inside rootDir called test-app, using packages from the workspaceDir
*/
async function createApp(appName, workspaceDir, rootDir) {
const child = spawnPiped(
[
'node',
resolvePath(workspaceDir, 'packages/cli/bin/backstage-cli'),
'create-app',
'--skip-install',
],
{
cwd: rootDir,
},
);
try {
let stdout = '';
child.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter a name for the app'));
child.stdin.write(`${appName}\n`);
print('Waiting for app create script to be done');
await waitForExit(child);
const appDir = resolvePath(rootDir, appName);
print('Rewriting module resolutions of app to use workspace packages');
await overrideModuleResolutions(appDir, workspaceDir);
print('Pinning yarn version and registry in app');
await pinYarnVersion(appDir);
await fs.writeFile(
resolvePath(appDir, '.npmrc'),
'registry=https://registry.npmjs.org/\n',
);
print('Test app created');
for (const cmd of ['install', 'tsc', 'build', 'lint:all', 'test:all']) {
print(`Running 'yarn ${cmd}' in newly created app`);
await runPlain(['yarn', cmd], { cwd: appDir });
}
print(`Running 'yarn test:e2e:ci' in newly created app`);
await runPlain(['yarn', 'test:e2e:ci'], {
cwd: resolvePath(appDir, 'packages', 'app'),
env: {
...process.env,
APP_CONFIG_app_baseUrl: '"http://localhost:3001"',
},
});
return appDir;
} finally {
child.kill();
}
}
/**
* This points dependency resolutions into the workspace for each package that is present there
*/
async function overrideModuleResolutions(appDir, workspaceDir) {
const pkgJsonPath = resolvePath(appDir, 'package.json');
const pkgJson = await fs.readJson(pkgJsonPath);
pkgJson.resolutions = pkgJson.resolutions || {};
pkgJson.dependencies = pkgJson.dependencies || {};
const packageNames = await fs.readdir(resolvePath(workspaceDir, 'packages'));
for (const name of packageNames) {
const pkgPath = joinPath('..', 'workspace', 'packages', name);
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
delete pkgJson.devDependencies[`@backstage/${name}`];
}
fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
}
/**
* Uses create-plugin command to create a new plugin in the app
*/
async function createPlugin(pluginName, appDir) {
const child = spawnPiped(['yarn', 'create-plugin'], {
cwd: appDir,
});
try {
let stdout = '';
child.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter an ID for the plugin'));
child.stdin.write(`${pluginName}\n`);
// await waitFor(() => stdout.includes('Enter the owner(s) of the plugin'));
// child.stdin.write('@someuser\n');
print('Waiting for plugin create script to be done');
await waitForExit(child);
const pluginDir = resolvePath(appDir, 'plugins', pluginName);
for (const cmd of [['lint'], ['test', '--no-watch']]) {
print(`Running 'yarn ${cmd.join(' ')}' in newly created plugin`);
await runPlain(['yarn', ...cmd], { cwd: pluginDir });
}
return pluginName;
} finally {
child.kill();
}
}
/**
* Start serving the newly created app and make sure that the create plugin is rendering correctly
*/
async function testAppServe(pluginName, appDir) {
const startApp = spawnPiped(['yarn', 'start'], {
cwd: appDir,
});
Browser.localhost('localhost', 3000);
let successful = false;
try {
const browser = new Browser();
await waitForPageWithText(browser, '/', 'Welcome to Backstage');
await waitForPageWithText(
browser,
'/test-plugin',
'Welcome to test-plugin!',
`/${pluginName}`,
`Welcome to ${pluginName}!`,
);
print('Both App and Plugin loaded correctly');
successful = true;
} catch (error) {
throw new Error(`App serve test failed, ${error}`);
} finally {
startApp.kill();
// Kill entire process group, otherwise we'll end up with hanging serve processes
killTree(startApp.pid);
}
await waitForExit(startApp);
print('All tests done');
process.exit(0);
try {
await waitForExit(startApp);
} catch (error) {
if (!successful) {
throw error;
}
}
}
process.on('unhandledRejection', handleError);
-44
View File
@@ -1,44 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
const { resolve: resolvePath } = require('path');
const { spawnPiped, waitFor, waitForExit, print } = require('./helpers');
async function createTestApp() {
const cliPath = resolvePath(__dirname, '../bin/backstage-cli');
print('Creating a Backstage App');
const createApp = spawnPiped(['node', cliPath, 'create-app']);
try {
let stdout = '';
createApp.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter a name for the app'));
createApp.stdin.write('test-app\n');
print('Waiting for app create script to be done');
await waitForExit(createApp);
print('Test app created');
} finally {
createApp.kill();
}
}
module.exports = createTestApp;
-44
View File
@@ -1,44 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
const { spawnPiped, waitFor, waitForExit, print } = require('./helpers');
async function createTestPlugin() {
print('Creating a Backstage Plugin');
const createPlugin = spawnPiped(['yarn', 'create-plugin']);
try {
let stdout = '';
createPlugin.stdout.on('data', data => {
stdout = stdout + data.toString('utf8');
});
await waitFor(() => stdout.includes('Enter an ID for the plugin'));
createPlugin.stdin.write('test-plugin\n');
// await waitFor(() => stdout.includes('Enter the owner(s) of the plugin'));
// createPlugin.stdin.write('@someuser\n');
print('Waiting for plugin create script to be done');
await waitForExit(createPlugin);
print('Test plugin created');
} finally {
createPlugin.kill();
}
}
module.exports = createTestPlugin;
+25 -11
View File
@@ -14,8 +14,10 @@
* limitations under the License.
*/
const childProcess = require('child_process');
const { spawn } = childProcess;
const { spawn, execFile: execFileCb } = require('child_process');
const { promisify } = require('util');
const execFile = promisify(execFileCb);
const EXPECTED_LOAD_ERRORS = /ECONNREFUSED|ECONNRESET|did not get to load all resources/;
@@ -36,24 +38,35 @@ function spawnPiped(cmd, options) {
...options,
});
child.on('error', handleError);
child.on('exit', code => {
if (code) {
print(`Child '${cmd.join(' ')}' exited with code ${code}`);
process.exit(code);
}
});
const logPrefix = cmd.map(s => s.replace(/.+\//, '')).join(' ');
child.stdout.on(
'data',
pipeWithPrefix(process.stdout, `[${cmd.join(' ')}].out: `),
pipeWithPrefix(process.stdout, `[${logPrefix}].out: `),
);
child.stderr.on(
'data',
pipeWithPrefix(process.stderr, `[${cmd.join(' ')}].err: `),
pipeWithPrefix(process.stderr, `[${logPrefix}].err: `),
);
return child;
}
async function runPlain(cmd, options) {
try {
const { stdout } = await execFile(cmd[0], cmd.slice(1), {
...options,
shell: true,
});
return stdout.trim();
} catch (error) {
if (error.stderr) {
process.stderr.write(error.stderr);
}
throw error;
}
}
function handleError(err) {
process.stdout.write(`${err.name}: ${err.stack || err.message}\n`);
if (typeof err.code === 'number') {
@@ -133,7 +146,7 @@ async function waitForPageWithText(
if (findTextAttempts <= maxFindTextAttempts) {
await browser.visit(path);
await new Promise(resolve => setTimeout(resolve, intervalMs));
continue
continue;
} else {
throw error;
}
@@ -147,6 +160,7 @@ function print(msg) {
module.exports = {
spawnPiped,
runPlain,
handleError,
waitFor,
waitForExit,
+1
View File
@@ -112,6 +112,7 @@
"@types/webpack-dev-server": "^3.10.0",
"del": "^5.1.0",
"nodemon": "^2.0.2",
"tree-kill": "^1.2.2",
"ts-node": "^8.6.2",
"zombie": "^6.1.4"
},
@@ -14,18 +14,16 @@
* limitations under the License.
*/
const normalizeBaseURL = (baseURL: string): string => {
const url = new URL(baseURL);
url.pathname = url.pathname.replace(/([^/])$/, '$1/');
return url.toString();
import fs from 'fs-extra';
import { Command } from 'commander';
import { createDistWorkspace } from '../lib/packager';
export default async (dir: string, _cmd: Command, packages: string[]) => {
if (!(await fs.pathExists(dir))) {
throw new Error(`Target workspace directory doesn't exist, '${dir}'`);
}
await createDistWorkspace(packages, {
targetDir: dir,
});
};
export default class URLParser {
constructor(public baseURL: string, public pathname: string) {
this.baseURL = normalizeBaseURL(baseURL);
}
parse(): string {
return new URL(this.pathname, this.baseURL).toString();
}
}
@@ -17,13 +17,15 @@
import fs from 'fs-extra';
import { promisify } from 'util';
import chalk from 'chalk';
import { Command } from 'commander';
import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath } from 'path';
import os from 'os';
import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
import { Task, templatingTask } from '../../lib/tasks';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
const exec = promisify(execCb);
async function checkExists(rootDir: string, name: string) {
@@ -39,7 +41,7 @@ async function checkExists(rootDir: string, name: string) {
});
}
export async function createTemporaryAppFolder(tempDir: string) {
async function createTemporaryAppFolder(tempDir: string) {
await Task.forItem('creating', 'temporary directory', async () => {
try {
await fs.mkdir(tempDir);
@@ -70,16 +72,12 @@ async function buildApp(appDir: string) {
});
};
await installWithLocalDeps(appDir);
await runCmd('yarn install');
await runCmd('yarn tsc');
await runCmd('yarn build');
}
export async function moveApp(
tempDir: string,
destination: string,
id: string,
) {
async function moveApp(tempDir: string, destination: string, id: string) {
await Task.forItem('moving', id, async () => {
await fs.move(tempDir, destination).catch(error => {
throw new Error(
@@ -89,7 +87,7 @@ export async function moveApp(
});
}
export default async () => {
export default async (cmd: Command): Promise<void> => {
const questions: Question[] = [
{
type: 'input',
@@ -129,8 +127,10 @@ export default async () => {
Task.section('Moving to final location');
await moveApp(tempDir, appDir, answers.name);
Task.section('Building the app');
await buildApp(appDir);
if (!cmd.skipInstall) {
Task.section('Building the app');
await buildApp(appDir);
}
Task.log();
Task.log(
@@ -28,7 +28,8 @@ import {
} from '../../lib/codeowners';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
import { Task, templatingTask } from '../../lib/tasks';
const exec = promisify(execCb);
async function checkExists(rootDir: string, id: string) {
@@ -141,9 +142,7 @@ async function cleanUp(tempDir: string) {
}
async function buildPlugin(pluginFolder: string) {
await installWithLocalDeps(paths.targetRoot);
const commands = ['yarn tsc', 'yarn build'];
const commands = ['yarn install', 'yarn tsc', 'yarn build'];
for (const command of commands) {
await Task.forItem('executing', command, async () => {
process.chdir(pluginFolder);
+9
View File
@@ -25,6 +25,10 @@ const main = (argv: string[]) => {
program
.command('create-app')
.description('Creates a new app in a new directory')
.option(
'--skip-install',
'Skip the install and builds steps after creating the app',
)
.action(
lazyAction(() => import('./commands/create-app/createApp'), 'default'),
);
@@ -140,6 +144,11 @@ const main = (argv: string[]) => {
.description('Delete cache directories')
.action(lazyAction(() => import('./commands/clean/clean'), 'default'));
program
.command('build-workspace <workspace-dir> ...<packages>')
.description('Builds a temporary dist workspace from the provided packages')
.action(lazyAction(() => import('./commands/buildWorkspace'), 'default'));
program.on('command:*', () => {
console.log();
console.log(
+1 -1
View File
@@ -59,7 +59,7 @@ type Options = {
*/
export async function createDistWorkspace(
packageNames: string[],
options: Options,
options: Options = {},
) {
const targetDir =
options.targetDir ??
+1
View File
@@ -23,6 +23,7 @@ import {
import { ExitCodeError } from './errors';
import { promisify } from 'util';
import { LogFunc } from './logging';
const execFile = promisify(execFileCb);
type SpawnOptionsPartialEnv = Omit<SpawnOptions, 'env'> & {
+1 -106
View File
@@ -18,12 +18,8 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
import { resolve as resolvePath, basename, dirname } from 'path';
import { basename, dirname } from 'path';
import recursive from 'recursive-readdir';
import { promisify } from 'util';
import { exec as execCb } from 'child_process';
import { paths } from './paths';
const exec = promisify(execCb);
const TASK_NAME_MAX_LENGTH = 14;
@@ -107,104 +103,3 @@ export async function templatingTask(
}
}
}
// List of local packages that we need to modify as a part of an E2E test
const PATCH_PACKAGES = [
'cli',
'config',
'config-loader',
'core',
'core-api',
'dev-utils',
'test-utils',
'test-utils-core',
'theme',
];
// This runs a `yarn install` task, but with special treatment for e2e tests
export async function installWithLocalDeps(dir: string) {
// This makes us install any package inside this repo as a local file dependency.
// For example, instead of trying to fetch @backstage/core from npm, we point it
// to <repo-root>/packages/core. This makes yarn use a simple file copy to install it instead.
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Linking packages locally for e2e tests');
const pkgJsonPath = resolvePath(dir, 'package.json');
const pkgJson = await fs.readJson(pkgJsonPath);
pkgJson.resolutions = pkgJson.resolutions || {};
pkgJson.dependencies = pkgJson.dependencies || {};
if (!pkgJson.resolutions[`@backstage/${PATCH_PACKAGES[0]}`]) {
for (const name of PATCH_PACKAGES) {
await Task.forItem(
'adding',
`@backstage/${name} link to package.json`,
async () => {
const pkgPath = paths.resolveOwnRoot('packages', name);
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
delete pkgJson.devDependencies[`@backstage/${name}`];
await fs
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
.catch(error => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
},
);
}
}
}
await Task.forItem('executing', 'yarn install', async () => {
await exec('yarn install', { cwd: dir }).catch(error => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
throw new Error(
`Could not execute command ${chalk.cyan('yarn install')}`,
);
});
});
// This takes care of pointing all the installed packages from this repo to
// dist instead of the local src, using the field overrides in publishConfig.
// Without this we get type checking errors in the e2e test
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
Task.section('Patching local dependencies for e2e tests');
for (const name of PATCH_PACKAGES) {
await Task.forItem(
'patching',
`node_modules/@backstage/${name} package.json`,
async () => {
const depJsonPath = resolvePath(
dir,
'node_modules/@backstage',
name,
'package.json',
);
const depJson = await fs.readJson(depJsonPath);
// We want dist to be used for e2e tests
for (const key of Object.keys(depJson.publishConfig)) {
if (key !== 'access') {
depJson[key] = depJson.publishConfig[key];
}
}
await fs
.writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 })
.catch(error => {
throw new Error(
`Failed to add resolutions to package.json: ${error.message}`,
);
});
},
);
}
}
}
@@ -10,11 +10,12 @@
"@backstage/core": "^{{version}}",
"@backstage/test-utils": "^{{version}}",
"@backstage/theme": "^{{version}}",
"history": "^5.0.0",
"plugin-welcome": "0.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
@@ -26,7 +26,7 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
"react-router-dom": "6.0.0-alpha.5"
"react-router-dom": "6.0.0-beta.0"
},
"devDependencies": {
"@backstage/cli": "^{{version}}",
@@ -5,7 +5,7 @@
* 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
* 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,
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+1 -1
View File
@@ -36,7 +36,7 @@
"@types/react": "^16.9",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-router-dom": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
@@ -263,3 +263,13 @@ export const gitlabAuthApiRef = createApiRef<
id: 'core.auth.gitlab',
description: 'Provides authentication towards Gitlab APIs',
});
/**
* Provides authentication for custom identity providers.
*/
export const oauth2ApiRef = createApiRef<
OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi
>({
id: 'core.auth.oauth2',
description: 'Example of how to use oauth2 custom provider',
});
@@ -14,7 +14,8 @@
* limitations under the License.
*/
export * from './google';
export * from './github';
export * from './gitlab';
export * from './google';
export * from './oauth2';
export * from './okta';
@@ -0,0 +1,164 @@
/*
* Copyright 2020 Spotify AB
*
* 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 OAuth2Icon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { Observable } from '../../../../types';
import { AuthProvider, OAuthRequestApi } from '../../../definitions';
import {
AuthRequestOptions,
BackstageIdentity,
OAuthApi,
OpenIdConnectApi,
ProfileInfo,
ProfileInfoApi,
SessionState,
SessionStateApi,
} from '../../../definitions/auth';
import { OAuth2Session } from './types';
type CreateOptions = {
apiOrigin: string;
basePath: string;
oauthRequestApi: OAuthRequestApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
export type OAuth2Response = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'oauth2',
title: 'Your Identity Provider',
icon: OAuth2Icon,
};
const SCOPE_PREFIX = '';
class OAuth2
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
static create({
apiOrigin,
basePath,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
apiOrigin,
basePath,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: OAuth2Response): OAuth2Session {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: OAuth2.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set([
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
]),
sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes,
sessionShouldRefresh: (session: OAuth2Session) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
return new OAuth2(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<OAuth2Session>) {}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const normalizedScopes = OAuth2.normalizeScopes(scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s]/).filter(Boolean);
return new Set(scopeList);
}
}
export default OAuth2;
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { default as OAuth2 } from './OAuth2';
export * from './types';
@@ -0,0 +1,28 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type OAuth2Session = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
+1
View File
@@ -16,4 +16,5 @@
export * from './public';
import * as privateExports from './private';
export default privateExports;
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+2 -2
View File
@@ -47,8 +47,8 @@
"react-dom": "^16.12.0",
"react-helmet": "6.1.0",
"react-hook-form": "^5.7.2",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-sparklines": "^1.7.0",
"react-syntax-highlighter": "^12.2.1",
"react-use": "^14.2.0"
@@ -25,6 +25,7 @@ import {
WithStyles,
Theme,
} from '@material-ui/core';
const tableTitleCellStyles = (theme: Theme) =>
createStyles({
root: {
@@ -0,0 +1,66 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React from 'react';
import {
render,
act,
RenderResult,
waitFor,
fireEvent,
} from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { SupportButton } from './SupportButton';
const SUPPORT_BUTTON_ID = 'support-button';
const POPOVER_ID = 'support-button-popover';
describe('<SupportButton />', () => {
it('renders without exploding', async () => {
let renderResult: RenderResult;
await act(async () => {
renderResult = render(wrapInTestApp(<SupportButton />));
});
await waitFor(() =>
expect(renderResult.getByTestId(SUPPORT_BUTTON_ID)).toBeInTheDocument(),
);
});
it('shows popover on click', async () => {
let renderResult: RenderResult;
await act(async () => {
renderResult = render(wrapInTestApp(<SupportButton />));
});
let button: HTMLElement;
await waitFor(() => {
expect(renderResult.getByTestId(SUPPORT_BUTTON_ID)).toBeInTheDocument();
button = renderResult.getByTestId(SUPPORT_BUTTON_ID);
});
await act(async () => {
fireEvent.click(button);
});
await waitFor(() => {
expect(renderResult.getByTestId(POPOVER_ID)).toBeInTheDocument();
});
});
});
@@ -87,6 +87,7 @@ export const SupportButton: FC<Props> = ({
Support
</Button>
<Popover
data-testid="support-button-popover"
open={popoverOpen}
anchorEl={anchorEl}
anchorOrigin={{
@@ -120,7 +121,8 @@ export const SupportButton: FC<Props> = ({
<GroupIcon />
</ListItemIcon>
<ListItemText
primary="Support"
disableTypography
primary={<Typography>Support</Typography>}
secondary={
<div>
{slackChannels.map((channel, i) => (
@@ -137,7 +139,8 @@ export const SupportButton: FC<Props> = ({
<GroupIcon />
</ListItemIcon>
<ListItemText
primary="Contact"
disableTypography
primary={<Typography>Contact</Typography>}
secondary={
<div>
{contactEmails.map((em, index) => (
+3
View File
@@ -44,6 +44,9 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.shortest,
}),
'& > *': {
flexShrink: 0,
},
},
drawerOpen: {
width: sidebarConfig.drawerWidthOpen,
@@ -14,25 +14,26 @@
* limitations under the License.
*/
import React, { useContext, useEffect } from 'react';
import Collapse from '@material-ui/core/Collapse';
import Star from '@material-ui/icons/Star';
import SignOutIcon from '@material-ui/icons/MeetingRoom';
import { SidebarContext } from './config';
import {
googleAuthApiRef,
githubAuthApiRef,
gitlabAuthApiRef,
googleAuthApiRef,
identityApiRef,
oauth2ApiRef,
oktaAuthApiRef,
useApi,
} from '@backstage/core-api';
import Collapse from '@material-ui/core/Collapse';
import SignOutIcon from '@material-ui/icons/MeetingRoom';
import Star from '@material-ui/icons/Star';
import React, { useContext, useEffect } from 'react';
import { SidebarContext } from './config';
import { SidebarItem } from './Items';
import {
OAuthProviderSettings,
OIDCProviderSettings,
UserProfile as SidebarUserProfile,
} from './Settings';
import { SidebarItem } from './Items';
export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
@@ -68,6 +69,11 @@ export function SidebarUserSettings() {
apiRef={oktaAuthApiRef}
icon={Star}
/>
<OIDCProviderSettings
title="YourOrg"
apiRef={oauth2ApiRef}
icon={Star}
/>
<SidebarItem
icon={SignOutIcon}
text="Sign Out"
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+2 -2
View File
@@ -42,8 +42,8 @@
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5"
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0"
},
"devDependencies": {
"@types/jest": "^25.2.2",
+23 -12
View File
@@ -1,21 +1,23 @@
import {
ApiRegistry,
AlertApiForwarder,
alertApiRef,
ApiRegistry,
ErrorAlerter,
ErrorApiForwarder,
errorApiRef,
GithubAuth,
githubAuthApiRef,
GitlabAuth,
gitlabAuthApiRef,
GoogleAuth,
googleAuthApiRef,
identityApiRef,
OAuth2,
oauth2ApiRef,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
gitlabAuthApiRef,
oktaAuthApiRef,
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
GoogleAuth,
GithubAuth,
GitlabAuth,
OktaAuth,
identityApiRef,
oktaAuthApiRef,
} from '@backstage/core';
const builder = ApiRegistry.builder();
@@ -72,4 +74,13 @@ builder.add(
}),
);
builder.add(
oauth2ApiRef,
OAuth2.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
export const apis = builder.build();
+2 -2
View File
@@ -40,8 +40,8 @@
"@types/react": "^16.9",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
@@ -25,6 +25,7 @@ import privateExports, {
import { RenderResult } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils-core';
import { createMockApiRegistry } from './mockApiRegistry';
const { PrivateAppImpl } = privateExports;
const NotFoundErrorPage = () => {
@@ -15,14 +15,15 @@
*/
import Router from 'express-promise-router';
import { createGithubProvider } from './github';
import { createGoogleProvider } from './google';
import { createGitlabProvider } from './gitlab';
import { createSamlProvider } from './saml';
import { createOktaProvider } from './okta';
import { AuthProviderFactory, AuthProviderConfig } from './types';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
import { createGithubProvider } from './github';
import { createGitlabProvider } from './gitlab';
import { createGoogleProvider } from './google';
import { createOAuth2Provider } from './oauth2';
import { createOktaProvider } from './okta';
import { createSamlProvider } from './saml';
import { AuthProviderConfig, AuthProviderFactory } from './types';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -30,6 +31,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
gitlab: createGitlabProvider,
saml: createSamlProvider,
okta: createOktaProvider,
oauth2: createOAuth2Provider,
};
export const createAuthProviderRouter = (
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { createOAuth2Provider } from './provider';
@@ -0,0 +1,198 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import passport from 'passport';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import {
EnvironmentHandler,
EnvironmentHandlers,
} from '../../lib/EnvironmentHandler';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
EnvironmentProviderConfig,
GenericOAuth2ProviderConfig,
GenericOAuth2ProviderOptions,
OAuthProviderHandlers,
OAuthResponse,
PassportDoneCallback,
RedirectInfo,
} from '../types';
type PrivateInfo = {
refreshToken: string;
};
export class OAuth2AuthProvider implements OAuthProviderHandlers {
private readonly _strategy: OAuth2Strategy;
constructor(options: GenericOAuth2ProviderOptions) {
this._strategy = new OAuth2Strategy(
{ ...options, passReqToCallback: false as true },
(
accessToken: any,
refreshToken: any,
params: any,
rawProfile: passport.Profile,
done: PassportDoneCallback<OAuthResponse, PrivateInfo>,
) => {
const profile = makeProfileInfo(rawProfile, params.id_token);
done(
undefined,
{
providerInfo: {
idToken: params.id_token,
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
},
profile,
},
{
refreshToken,
},
);
},
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
const providerOptions = {
...options,
accessType: 'offline',
prompt: 'consent',
};
return await executeRedirectStrategy(req, this._strategy, providerOptions);
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
const { response, privateInfo } = await executeFrameHandlerStrategy<
OAuthResponse,
PrivateInfo
>(req, this._strategy);
return {
response: await this.populateIdentity(response),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(refreshToken: string, scope: string): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
refreshToken,
scope,
);
const profile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
params.id_token,
);
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
});
}
// Use this function to grab the user profile info from the token
// Then populate the profile with it
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
if (!profile.email) {
throw new Error('Profile does not contain a profile');
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id } };
}
}
export function createOAuth2Provider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as GenericOAuth2ProviderConfig;
const { secure, appOrigin } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/oauth2/handler/frame${callbackURLParam}`,
authorizationURL: config.authorizationURL,
tokenURL: config.tokenURL,
};
if (
!opts.clientID ||
!opts.clientSecret ||
!opts.authorizationURL ||
!opts.tokenURL
) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize OAuth2 auth provider, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars',
);
}
logger.warn(
'OAuth2 auth provider disabled, set AUTH_OAUTH2_CLIENT_ID, AUTH_OAUTH2_CLIENT_SECRET, AUTH_OAUTH2_AUTH_URL, and AUTH_OAUTH2_TOKEN_URL env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new OAuth2AuthProvider(opts), {
disableRefresh: false,
providerId: 'oauth2',
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
}
@@ -33,6 +33,11 @@ export type OAuthProviderOptions = {
callbackURL: string;
};
export type GenericOAuth2ProviderOptions = OAuthProviderOptions & {
authorizationURL: string;
tokenURL: string;
};
export type OAuthProviderConfig = {
/**
* Cookies can be marked with a secure flag to send cookies only when the request
@@ -61,6 +66,11 @@ export type OAuthProviderConfig = {
audience?: string;
};
export type GenericOAuth2ProviderConfig = OAuthProviderConfig & {
authorizationURL: string;
tokenURL: string;
};
export type EnvironmentProviderConfig = {
/**
* key, values are environment names and OAuthProviderConfigs
@@ -100,6 +100,16 @@ export async function createRouter(
audience: process.env.AUTH_OKTA_AUDIENCE,
},
},
oauth2: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
clientId: process.env.AUTH_OAUTH2_CLIENT_ID!,
clientSecret: process.env.AUTH_OAUTH2_CLIENT_SECRET!,
authorizationURL: process.env.AUTH_OAUTH2_AUTH_URL!,
tokenURL: process.env.AUTH_OAUTH2_TOKEN_URL!,
},
},
},
},
};
+1
View File
@@ -15,4 +15,5 @@
*/
require('jest-fetch-mock').enableMocks();
export {};
@@ -15,4 +15,5 @@
*/
require('jest-fetch-mock').enableMocks();
export {};
+2 -2
View File
@@ -32,8 +32,8 @@
"moment": "^2.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0",
"swr": "^0.2.2"
},
@@ -18,6 +18,7 @@ import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
import { Entity } from '@backstage/catalog-model';
const server = setupServer();
describe('CatalogClient', () => {
@@ -19,6 +19,7 @@ import {
ContentHeader,
identityApiRef,
SupportButton,
configApiRef,
useApi,
} from '@backstage/core';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
@@ -51,6 +52,8 @@ const CatalogPageContents = () => {
const userId = useApi(identityApiRef).getUserId();
const [selectedTab, setSelectedTab] = useState<string>();
const [selectedSidebarItem, setSelectedSidebarItem] = useState<string>();
const orgName =
useApi(configApiRef).getOptionalString('organization.name') ?? 'Company';
const tabs = useMemo<LabeledComponentType[]>(
() => [
@@ -98,7 +101,7 @@ const CatalogPageContents = () => {
],
},
{
name: 'Company', // TODO: Replace with Company name, read from app config.
name: orgName,
items: [
{
id: 'all',
@@ -108,7 +111,7 @@ const CatalogPageContents = () => {
],
},
],
[isStarredEntity, userId],
[isStarredEntity, userId, orgName],
);
return (
@@ -18,14 +18,16 @@ import { Table, TableColumn, TableProps } from '@backstage/core';
import { Link } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import GitHub from '@material-ui/icons/GitHub';
import Star from '@material-ui/icons/Star';
import StarOutline from '@material-ui/icons/StarBorder';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { entityRoute } from '../../routes';
import {
favouriteEntityIcon,
favouriteEntityTooltip,
} from '../FavouriteEntity/FavouriteEntity';
const columns: TableColumn<Entity>[] = [
{
@@ -125,13 +127,8 @@ export const CatalogTable = ({
const isStarred = isStarredEntity(rowData);
return {
cellStyle: { paddingLeft: '1em' },
icon: () =>
isStarred ? (
<Star htmlColor="#f3ba37" fontSize="small" />
) : (
<StarOutline fontSize="small" />
),
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
icon: () => favouriteEntityIcon(isStarred),
tooltip: favouriteEntityTooltip(isStarred),
onClick: () => toggleStarredEntity(rowData),
};
},
@@ -28,7 +28,7 @@ import {
useApi,
} from '@backstage/core';
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
import { Grid } from '@material-ui/core';
import { Grid, Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
@@ -37,6 +37,7 @@ import { catalogApiRef } from '../..';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
const REDIRECT_DELAY = 1000;
function headerProps(
@@ -63,6 +64,16 @@ export const getPageTheme = (entity?: Entity): PageTheme => {
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
entity,
title,
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
</Box>
);
export const EntityPage: FC<{}> = () => {
const { optionalNamespaceAndName, kind } = useParams() as {
optionalNamespaceAndName: string;
@@ -138,7 +149,11 @@ export const EntityPage: FC<{}> = () => {
return (
<Page theme={getPageTheme(entity)}>
<Header title={headerTitle} type={headerType}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
<>
<HeaderLabel
@@ -0,0 +1,56 @@
/*
* Copyright 2020 Spotify AB
*
* 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 React, { ComponentProps } from 'react';
import { IconButton, Tooltip, withStyles } from '@material-ui/core';
import StarBorder from '@material-ui/icons/StarBorder';
import Star from '@material-ui/icons/Star';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { Entity } from '@backstage/catalog-model';
type Props = ComponentProps<typeof IconButton> & { entity: Entity };
const YellowStar = withStyles({
root: {
color: '#f3ba37',
},
})(Star);
export const favouriteEntityTooltip = (isStarred: boolean) =>
isStarred ? 'Remove from favorites' : 'Add to favorites';
export const favouriteEntityIcon = (isStarred: boolean) =>
isStarred ? <YellowStar /> : <StarBorder />;
/**
* IconButton for showing if a current entity is starred and adding/removing it from the favourite entities
* @param props MaterialUI IconButton props extended by required `entity` prop
*/
export const FavouriteEntity: React.FC<Props> = props => {
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const isStarred = isStarredEntity(props.entity);
return (
<IconButton
color="inherit"
{...props}
onClick={() => toggleStarredEntity(props.entity)}
>
<Tooltip title={favouriteEntityTooltip(isStarred)}>
{favouriteEntityIcon(isStarred)}
</Tooltip>
</IconButton>
);
};
+2 -2
View File
@@ -40,8 +40,8 @@
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-lazylog": "^4.5.2",
"react-router": "^6.0.0-alpha.5",
"react-router-dom": "^6.0.0-alpha.5",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom/extend-expect';
require('jest-fetch-mock').enableMocks();
+1
View File
@@ -16,6 +16,7 @@
import React, { FC, useReducer, Dispatch, Reducer } from 'react';
import { circleCIApiRef } from '../api';
import type { State, Action, SettingsState } from './types';
export type { SettingsState };
export const AppContext = React.createContext<[State, Dispatch<Action>]>(
+1
View File
@@ -15,4 +15,5 @@
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+13
View File
@@ -0,0 +1,13 @@
# github-actions
Welcome to the github-actions plugin!
_This plugin was created through the Backstage CLI_
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/github-actions](http://localhost:3000/github-actions).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
createDevApp().registerPlugin(plugin).render();
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@backstage/plugin-github-actions",
"version": "0.1.1-alpha.12",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.12",
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.12",
"@backstage/dev-utils": "^0.1.1-alpha.12",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
]
}
@@ -0,0 +1,44 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Build, BuildDetails, BuildStatus } from './types';
export class BuildsClient {
static create(): BuildsClient {
return new BuildsClient();
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async listBuilds(_entityUri: string): Promise<Build[]> {
return [];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getBuild(_buildUri: string): Promise<BuildDetails> {
return {
build: {
commitId: 'TODO',
branch: 'TODO',
uri: 'TODO',
status: BuildStatus.Running,
message: 'TODO',
},
author: 'TODO',
logUrl: 'TODO',
overviewUrl: 'TODO',
};
}
}
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { BuildsClient } from './BuildsClient';
export * from './types';
@@ -0,0 +1,38 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export enum BuildStatus {
Null,
Success,
Failure,
Pending,
Running,
}
export type Build = {
commitId: string;
message: string;
branch: string;
status: BuildStatus;
uri: string;
};
export type BuildDetails = {
build: Build;
author: string;
logUrl: string;
overviewUrl: string;
};
@@ -0,0 +1,143 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Link } from '@backstage/core';
import {
Button,
ButtonGroup,
LinearProgress,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Theme,
Typography,
} from '@material-ui/core';
import React from 'react';
import { useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { BuildsClient } from '../../apis/builds';
import { BuildStatusIndicator } from '../BuildStatusIndicator';
const useStyles = makeStyles<Theme>(theme => ({
root: {
maxWidth: 720,
margin: theme.spacing(2),
},
title: {
padding: theme.spacing(1, 0, 2, 0),
},
table: {
padding: theme.spacing(1),
},
}));
const client = BuildsClient.create();
export const BuildDetailsPage = () => {
const classes = useStyles();
const { buildUri } = useParams();
const status = useAsync(() => client.getBuild(buildUri), [buildUri]);
if (status.loading) {
return <LinearProgress />;
} else if (status.error) {
return (
<Typography variant="h6" color="error">
Failed to load build, {status.error.message}
</Typography>
);
}
const details = status.value;
return (
<div className={classes.root}>
<Typography className={classes.title} variant="h3">
<Link to="/builds">
<Typography component="span" variant="h3" color="primary">
&lt;
</Typography>
</Link>
Build Details
</Typography>
<TableContainer component={Paper} className={classes.table}>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Typography noWrap>Branch</Typography>
</TableCell>
<TableCell>{details?.build.branch}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Message</Typography>
</TableCell>
<TableCell>{details?.build.message}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Commit ID</Typography>
</TableCell>
<TableCell>{details?.build.commitId}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Status</Typography>
</TableCell>
<TableCell>
<BuildStatusIndicator status={details?.build.status} />
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Author</Typography>
</TableCell>
<TableCell>{details?.author}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Links</Typography>
</TableCell>
<TableCell>
<ButtonGroup
variant="text"
color="primary"
aria-label="text primary button group"
>
{details?.overviewUrl && (
<Button>
<Link to={details.overviewUrl}>GitHub</Link>
</Button>
)}
{details?.logUrl && (
<Button>
<Link to={details.logUrl}>Logs</Link>
</Button>
)}
</ButtonGroup>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</div>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { BuildDetailsPage } from './BuildDetailsPage';
@@ -0,0 +1,102 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Link } from '@backstage/core';
import {
LinearProgress,
makeStyles,
Table,
TableBody,
TableCell,
TableRow,
Theme,
Typography,
} from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { BuildsClient } from '../../apis/builds';
import { BuildStatusIndicator } from '../BuildStatusIndicator';
const client = BuildsClient.create();
const useStyles = makeStyles<Theme>(theme => ({
root: {
// height: 400,
},
title: {
paddingBottom: theme.spacing(1),
},
}));
export const BuildInfoCard = () => {
const classes = useStyles();
const status = useAsync(() => client.listBuilds('entity:spotify:backstage'));
let content: JSX.Element;
if (status.loading) {
content = <LinearProgress />;
} else if (status.error) {
content = (
<Typography variant="h2" color="error">
Failed to load builds, {status.error.message}
</Typography>
);
} else {
const [build] =
status.value?.filter(({ branch }) => branch === 'master') ?? [];
content = (
<Table>
<TableBody>
<TableRow>
<TableCell>
<Typography noWrap>Message</Typography>
</TableCell>
<TableCell>
<Link to={`builds/${encodeURIComponent(build?.uri || '')}`}>
<Typography color="primary">{build?.message}</Typography>
</Link>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Commit ID</Typography>
</TableCell>
<TableCell>{build?.commitId}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Status</Typography>
</TableCell>
<TableCell>
<BuildStatusIndicator status={build?.status} />
</TableCell>
</TableRow>
</TableBody>
</Table>
);
}
return (
<div className={classes.root}>
<Typography variant="h2" className={classes.title}>
Master Build
</Typography>
{content}
</div>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { BuildInfoCard } from './BuildInfoCard';
@@ -0,0 +1,128 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Link } from '@backstage/core';
import {
LinearProgress,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Theme,
Tooltip,
Typography,
} from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { BuildsClient } from '../../apis/builds';
import { BuildStatusIndicator } from '../BuildStatusIndicator';
const client = BuildsClient.create();
const LongText = ({ text, max }: { text: string; max: number }) => {
if (text.length < max) {
return <span>{text}</span>;
}
return (
<Tooltip title={text}>
<span>{text.slice(0, max)}...</span>
</Tooltip>
);
};
const useStyles = makeStyles<Theme>(theme => ({
root: {
padding: theme.spacing(2),
},
title: {
padding: theme.spacing(1, 0, 2, 0),
},
}));
const PageContents = () => {
const { loading, error, value } = useAsync(() =>
client.listBuilds('entity:spotify:backstage'),
);
if (loading) {
return <LinearProgress />;
}
if (error) {
return (
<Typography variant="h2" color="error">
Failed to load builds, {error.message}{' '}
</Typography>
);
}
return (
<TableContainer component={Paper}>
<Table aria-label="CI/CD builds table">
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Branch</TableCell>
<TableCell>Message</TableCell>
<TableCell>Commit</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value!.map(build => (
<TableRow key={build.uri}>
<TableCell>
<BuildStatusIndicator status={build.status} />
</TableCell>
<TableCell>
<Typography>
<LongText text={build.branch} max={30} />
</Typography>
</TableCell>
<TableCell>
<Link to={`builds/${encodeURIComponent(build.uri)}`}>
<Typography color="primary">
<LongText text={build.message} max={60} />
</Typography>
</Link>
</TableCell>
<TableCell>
<Tooltip title={build.commitId}>
<Typography noWrap>{build.commitId.slice(0, 10)}</Typography>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
export const BuildListPage = () => {
const classes = useStyles();
return (
<div className={classes.root}>
<Typography variant="h3" className={classes.title}>
CI/CD Builds
</Typography>
<PageContents />
</div>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { BuildListPage } from './BuildListPage';
@@ -0,0 +1,74 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { IconComponent } from '@backstage/core';
import { makeStyles, Theme } from '@material-ui/core';
import ProgressIcon from '@material-ui/icons/Autorenew';
import SuccessIcon from '@material-ui/icons/CheckCircle';
import FailureIcon from '@material-ui/icons/Error';
import UnknownIcon from '@material-ui/icons/Help';
import React from 'react';
import { BuildStatus } from '../../apis/builds';
type Props = {
status?: BuildStatus;
};
type StatusStyle = {
icon: IconComponent;
color: string;
};
const styles: { [key in BuildStatus]: StatusStyle } = {
[BuildStatus.Null]: {
icon: UnknownIcon,
color: '#f49b20',
},
[BuildStatus.Success]: {
icon: SuccessIcon,
color: '#1db855',
},
[BuildStatus.Failure]: {
icon: FailureIcon,
color: '#CA001B',
},
[BuildStatus.Pending]: {
icon: UnknownIcon,
color: '#5BC0DE',
},
[BuildStatus.Running]: {
icon: ProgressIcon,
color: '#BEBEBE',
},
};
const useStyles = makeStyles<Theme, StatusStyle>({
icon: style => ({
color: style.color,
}),
});
export const BuildStatusIndicator = ({ status }: Props) => {
const style = (status && styles[status]) || styles[BuildStatus.Null];
const classes = useStyles(style);
const Icon = style.icon;
return (
<div className={classes.icon}>
<Icon />
</div>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { BuildStatusIndicator } from './BuildStatusIndicator';
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { plugin } from './plugin';
+23
View File
@@ -0,0 +1,23 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { plugin } from './plugin';
describe('github-actions', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+37
View File
@@ -0,0 +1,37 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createPlugin, createRouteRef } from '@backstage/core';
import { BuildDetailsPage } from './components/BuildDetailsPage';
import { BuildListPage } from './components/BuildListPage';
// TODO(freben): This is just a demo route for now
export const rootRouteRef = createRouteRef({
path: '/github-actions',
title: 'GitHub Actions',
});
export const buildRouteRef = createRouteRef({
path: '/github-actions/builds/:buildUri',
title: 'GitHub Actions Build',
});
export const plugin = createPlugin({
id: 'github-actions',
register({ router }) {
router.addRoute(rootRouteRef, BuildListPage);
router.addRoute(buildRouteRef, BuildDetailsPage);
},
});

Some files were not shown because too many files have changed in this diff Show More