plugin(redhat-argocd): migrate of Janus argocd plugins to backstage/community-plugins repository (#856)

* initial redhat-argocd workspace creation
* add missing dependency react-router-dom
* change skipLibCheck to true
* remove --tsc form api reports
* override api report
* add missing devDependencies, react-dom and react
* Run prettier
* update names
* update Changeset/cuddly-mice-jam.md file with a message
* regenerate API reports
* update pluginPackage name
* update yarn.lock to match package.json
* update links

---------

Signed-off-by: Fortune-Ndlovu <fndlovu@redhat.com>
Signed-off-by: Bethany Griggs <bethanyngriggs@gmail.com>
Co-authored-by: Beth Griggs <bethanyngriggs@gmail.com>
This commit is contained in:
Fortune Ndlovu 2024-08-19 11:47:12 +01:00 committed by GitHub
parent c81314e4c2
commit 3b45ff62e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
82 changed files with 32421 additions and 0 deletions

View File

@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

View File

@ -0,0 +1,10 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch"
}

View File

@ -0,0 +1,6 @@
---
'@backstage-community/plugin-redhat-argocd-common': patch
'@backstage-community/plugin-redhat-argocd': patch
---
The `argocd` and `argocd-common` plugins from the [janus-idp/backstage-plugins](https://github.com/janus-idp/backstage-plugins) repository were migrated to the community plugins, based on commit [c3232099](https://github.com/janus-idp/backstage-plugins/commit/c3232099). The migration was performed by following the manual migration steps outlined in the [Community Plugins CONTRIBUTING guide](https://github.com/backstage/community-plugins/blob/main/CONTRIBUTING.md#migrating-a-plugin)

View File

@ -0,0 +1,8 @@
.git
.yarn/cache
.yarn/install-state.gz
node_modules
packages/*/src
packages/*/node_modules
plugins
*.local.yaml

View File

@ -0,0 +1 @@
playwright.config.ts

View File

@ -0,0 +1,3 @@
module.exports = {
root: true,
};

54
workspaces/redhat-argocd/.gitignore vendored Normal file
View File

@ -0,0 +1,54 @@
# macOS
.DS_Store
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Coverage directory generated when running tests with coverage
coverage
# Dependencies
node_modules/
# Yarn 3 files
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Node version directives
.nvmrc
# dotenv environment variables file
.env
.env.test
# Build output
dist
dist-types
# Temporary change files created by Vim
*.swp
# MkDocs build output
site
# Local configuration files
*.local.yaml
# Sensitive credentials
*-credentials.yaml
# vscode database functionality support files
*.session.sql
# E2E test reports
e2e-test-report/

View File

@ -0,0 +1,5 @@
dist
dist-types
coverage
.vscode
.eslintrc.js

View File

@ -0,0 +1,10 @@
# [Backstage](https://backstage.io)
This is your newly scaffolded Backstage App, Good Luck!
To start the app, run:
```sh
yarn install
yarn dev
```

View File

@ -0,0 +1,3 @@
{
"version": "1.29.0"
}

View File

@ -0,0 +1,13 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: redhat-argocd
description: An example of a Backstage application.
# Example for optional annotations
# annotations:
# github.com/project-slug: backstage/backstage
# backstage.io/techdocs-ref: dir:.
spec:
type: website
owner: john@example.com
lifecycle: experimental

View File

@ -0,0 +1,59 @@
{
"name": "@internal/redhat-argocd",
"version": "1.0.0",
"private": true,
"engines": {
"node": "18 || 20"
},
"scripts": {
"tsc": "tsc",
"tsc:full": "tsc --skipLibCheck true --incremental false",
"build:all": "backstage-cli repo build --all",
"build:api-reports": "yarn build:api-reports:only",
"build:api-reports:only": "backstage-repo-tools api-reports --allow-all-warnings -o ae-wrong-input-file-type --validate-release-tags",
"clean": "backstage-cli repo clean",
"test": "backstage-cli repo test",
"test:all": "backstage-cli repo test --coverage",
"fix": "backstage-cli repo fix",
"lint": "backstage-cli repo lint --since origin/main",
"lint:all": "backstage-cli repo lint",
"prettier:check": "prettier --check .",
"new": "backstage-cli new --scope @backstage-community"
},
"workspaces": {
"packages": [
"packages/*",
"plugins/*"
]
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/community-plugins",
"directory": "workspaces/redhat-argocd"
},
"devDependencies": {
"@backstage/cli": "^0.26.11",
"@backstage/e2e-test-utils": "^0.1.1",
"@backstage/repo-tools": "^0.8.0",
"@changesets/cli": "^2.27.1",
"@spotify/prettier-config": "^12.0.0",
"node-gyp": "^9.0.0",
"prettier": "^2.3.2",
"typescript": "~5.3.0"
},
"resolutions": {
"@types/react": "^18",
"@types/react-dom": "^18",
"@microsoft/api-extractor": "7.36.4"
},
"prettier": "@spotify/prettier-config",
"lint-staged": {
"*.{js,jsx,ts,tsx,mjs,cjs}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md}": [
"prettier --write"
]
}
}

View File

@ -0,0 +1,9 @@
# The Plugins Folder
This is where your own plugins and their associated modules live, each in a
separate folder of its own.
If you want to create a new plugin here, go to your project root directory, run
the command `yarn new`, and follow the on-screen instructions.
You can also check out existing plugins on [the plugin marketplace](https://backstage.io/plugins)!

View File

@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);

View File

@ -0,0 +1,11 @@
# argocd-common
Welcome to the argocd-common plugin!
This plugin contains common utilities for the argocd plugin.
# Argo CD plugin for Backstage
The Argocd plugin displays the information about your argocd applications in your Backstage application.
For more information about Argocd plugin, see the [Argocd plugin documentation](https://github.com/backstage/community-plugins/tree/main/workspaces/redhat-argocd) on GitHub.

View File

@ -0,0 +1,17 @@
## API Report File for "@backstage-community/plugin-redhat-argocd-common"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BasicPermission } from '@backstage/plugin-permission-common';
// Warning: (ae-missing-release-tag) "argocdPermissions" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export const argocdPermissions: BasicPermission[];
// Warning: (ae-missing-release-tag) "argocdViewPermission" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const argocdViewPermission: BasicPermission;
```

View File

@ -0,0 +1,60 @@
{
"name": "@backstage-community/plugin-redhat-argocd-common",
"version": "1.0.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "common-library",
"supported-versions": "1.28.4",
"pluginId": "redhat-argocd-common",
"pluginPackage": "@backstage-community/plugin-redhat-argocd-common",
"pluginPackages": [
"@backstage-community/plugin-redhat-argocd-common"
]
},
"sideEffects": false,
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"postpack": "backstage-cli package postpack",
"prepack": "backstage-cli package prepack",
"test": "backstage-cli package test --passWithNoTests --coverage",
"tsc": "tsc"
},
"dependencies": {
"@backstage/plugin-permission-common": "^0.8.0"
},
"devDependencies": {
"@backstage/cli": "0.26.11"
},
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "git+https://github.com/backstage/community-plugins.git",
"directory": "workspaces/redhat-argocd/plugins/argocd-common"
},
"keywords": [
"support:production",
"lifecycle:active",
"backstage",
"plugin"
],
"homepage": "https://red.ht/rhdh",
"bugs": "https://github.com/backstage/community-plugins/issues",
"maintainers": [
"karthikjeeyar",
"rohitkrai03",
"Eswaraiahsapram"
],
"author": "Red Hat"
}

View File

@ -0,0 +1,7 @@
/**
* Common functionalities for the argocd plugin.
*
* @packageDocumentation
*/
export * from './permissions';

View File

@ -0,0 +1,13 @@
import { createPermission } from '@backstage/plugin-permission-common';
export const argocdViewPermission = createPermission({
name: 'argocd.view.read',
attributes: {
action: 'read',
},
});
/**
* List of all permissions on permission polices.
*/
export const argocdPermissions = [argocdViewPermission];

View File

@ -0,0 +1,9 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
"include": ["src", "dev"],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "../../dist-types/plugins/argocd-common",
"rootDir": "."
}
}

View File

@ -0,0 +1,9 @@
{
"extends": ["//"],
"pipeline": {
"tsc": {
"outputs": ["../../dist-types/plugins/argocd-common/**"],
"dependsOn": ["^tsc"]
}
}
}

View File

@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);

View File

@ -0,0 +1,216 @@
## @janus-idp/backstage-plugin-argocd [1.5.7](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.5.6...@janus-idp/backstage-plugin-argocd@1.5.7) (2024-08-02)
### Dependencies
- **@janus-idp/backstage-plugin-argocd-common:** upgraded to 1.0.0
## @janus-idp/backstage-plugin-argocd [1.5.6](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.5.5...@janus-idp/backstage-plugin-argocd@1.5.6) (2024-08-02)
### Dependencies
- **@janus-idp/backstage-plugin-argocd-common:** upgraded to 1.0.0
## @janus-idp/backstage-plugin-argocd [1.5.5](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.5.4...@janus-idp/backstage-plugin-argocd@1.5.5) (2024-08-01)
### Dependencies
- **@janus-idp/backstage-plugin-argocd-common:** upgraded to 1.0.0
## @janus-idp/backstage-plugin-argocd [1.5.4](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.5.3...@janus-idp/backstage-plugin-argocd@1.5.4) (2024-07-31)
### Bug Fixes
- argocd dependency package version ([#1992](https://github.com/janus-idp/backstage-plugins/issues/1992)) ([e3c4419](https://github.com/janus-idp/backstage-plugins/commit/e3c4419318ea3a24f8e6369decfadd26be10ae00))
### Dependencies
- **@janus-idp/backstage-plugin-argocd-common:** upgraded to 1.0.0
## @janus-idp/backstage-plugin-argocd [1.5.3](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.5.2...@janus-idp/backstage-plugin-argocd@1.5.3) (2024-07-31)
### Bug Fixes
- **argocd:** fix argocd naming ([#1990](https://github.com/janus-idp/backstage-plugins/issues/1990)) ([6b764a8](https://github.com/janus-idp/backstage-plugins/commit/6b764a8105811475c1d71ea2f78077d1b6b6e6d8))
### Dependencies
- **@janus-idp/backstage-plugin-argocd-common:** upgraded to 1.0.0
## @janus-idp/backstage-plugin-argocd [1.5.2](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.5.1...@janus-idp/backstage-plugin-argocd@1.5.2) (2024-07-31)
### Bug Fixes
- **argocd:** fix argocd-common plugin version ([#1987](https://github.com/janus-idp/backstage-plugins/issues/1987)) ([fb441fe](https://github.com/janus-idp/backstage-plugins/commit/fb441fe637137da08dc7388dbd75e58c775e01ea))
### Dependencies
- **@janus-idp/backstage-plugin-argocd-common:** upgraded to 1.0.0
## @janus-idp/backstage-plugin-argocd [1.5.1](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.5.0...@janus-idp/backstage-plugin-argocd@1.5.1) (2024-07-31)
### Dependencies
- **@janus-idp/backstage-plugin-argocd-common:** upgraded to 1.0.0
## @janus-idp/backstage-plugin-argocd [1.5.0](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.4.1...@janus-idp/backstage-plugin-argocd@1.5.0) (2024-07-30)
### Features
- **argocd:** add permission support for argocd ([#1855](https://github.com/janus-idp/backstage-plugins/issues/1855)) ([3b78237](https://github.com/janus-idp/backstage-plugins/commit/3b782377683605ea4d584c43bea14be2f435003d))
## @janus-idp/backstage-plugin-argocd [1.4.1](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.4.0...@janus-idp/backstage-plugin-argocd@1.4.1) (2024-07-26)
### Documentation
- **argocd:** update argocd configuration documentation ([#1875](https://github.com/janus-idp/backstage-plugins/issues/1875)) ([054ceec](https://github.com/janus-idp/backstage-plugins/commit/054ceec2bdca47e1ee251b2d882671b2c95915c6))
## @janus-idp/backstage-plugin-argocd [1.4.0](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.3.0...@janus-idp/backstage-plugin-argocd@1.4.0) (2024-07-25)
### Features
- **deps:** update to backstage 1.29 ([#1900](https://github.com/janus-idp/backstage-plugins/issues/1900)) ([f53677f](https://github.com/janus-idp/backstage-plugins/commit/f53677fb02d6df43a9de98c43a9f101a6db76802))
### Bug Fixes
- **deps:** update dependency react-use to v17.5.1 ([#1943](https://github.com/janus-idp/backstage-plugins/issues/1943)) ([0c05ad5](https://github.com/janus-idp/backstage-plugins/commit/0c05ad5cc1aef3df1d14f1ffa59933850a04ebbc))
### Dependencies
- **@janus-idp/cli:** upgraded to 1.13.0
## @janus-idp/backstage-plugin-argocd [1.3.0](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.2.4...@janus-idp/backstage-plugin-argocd@1.3.0) (2024-07-23)
### Features
- **deps:** update to backstage 1.28 ([#1891](https://github.com/janus-idp/backstage-plugins/issues/1891)) ([1ba1108](https://github.com/janus-idp/backstage-plugins/commit/1ba11088e0de60e90d138944267b83600dc446e5))
### Documentation
- fix argocd naming ([#1904](https://github.com/janus-idp/backstage-plugins/issues/1904)) ([3173d79](https://github.com/janus-idp/backstage-plugins/commit/3173d79b9b226fd994d48f27b392df7d167d4e64))
## @janus-idp/backstage-plugin-argocd [1.2.4](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.2.3...@janus-idp/backstage-plugin-argocd@1.2.4) (2024-07-17)
### Bug Fixes
- **argocd:** fix argocd commit message visibility ([#1874](https://github.com/janus-idp/backstage-plugins/issues/1874)) ([e558d75](https://github.com/janus-idp/backstage-plugins/commit/e558d7549c49a1821eafc9424f174c6d457ce414))
## @janus-idp/backstage-plugin-argocd [1.2.3](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.2.2...@janus-idp/backstage-plugin-argocd@1.2.3) (2024-06-21)
### Bug Fixes
- **argocd:** hide commit section for helm based applications ([#1834](https://github.com/janus-idp/backstage-plugins/issues/1834)) ([a86ce1e](https://github.com/janus-idp/backstage-plugins/commit/a86ce1e5237ac419eb93a9766cb8e2736ba9b8d7))
## @janus-idp/backstage-plugin-argocd [1.2.2](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.2.1...@janus-idp/backstage-plugin-argocd@1.2.2) (2024-06-19)
### Dependencies
- **@janus-idp/cli:** upgraded to 1.11.1
## @janus-idp/backstage-plugin-argocd [1.2.1](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.2.0...@janus-idp/backstage-plugin-argocd@1.2.1) (2024-06-14)
### Bug Fixes
- **deps:** update dependency react-use to v17.5.0 ([#1780](https://github.com/janus-idp/backstage-plugins/issues/1780)) ([a25bf15](https://github.com/janus-idp/backstage-plugins/commit/a25bf15e14e9fa5a946551c5626c92f6b1f83d2f))
## @janus-idp/backstage-plugin-argocd [1.2.0](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.10...@janus-idp/backstage-plugin-argocd@1.2.0) (2024-06-13)
### Features
- **deps:** update to backstage 1.27 ([#1683](https://github.com/janus-idp/backstage-plugins/issues/1683)) ([a14869c](https://github.com/janus-idp/backstage-plugins/commit/a14869c3f4177049cb8d6552b36c3ffd17e7997d))
### Dependencies
- **@janus-idp/cli:** upgraded to 1.11.0
## @janus-idp/backstage-plugin-argocd [1.1.10](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.9...@janus-idp/backstage-plugin-argocd@1.1.10) (2024-06-13)
### Dependencies
- **@janus-idp/cli:** upgraded to 1.10.1
## @janus-idp/backstage-plugin-argocd [1.1.9](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.8...@janus-idp/backstage-plugin-argocd@1.1.9) (2024-06-10)
## @janus-idp/backstage-plugin-argocd [1.1.8](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.7...@janus-idp/backstage-plugin-argocd@1.1.8) (2024-06-05)
### Dependencies
- **@janus-idp/cli:** upgraded to 1.10.0
## @janus-idp/backstage-plugin-argocd [1.1.7](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.6...@janus-idp/backstage-plugin-argocd@1.1.7) (2024-06-04)
## @janus-idp/backstage-plugin-argocd [1.1.6](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.5...@janus-idp/backstage-plugin-argocd@1.1.6) (2024-06-03)
### Dependencies
- **@janus-idp/cli:** upgraded to 1.9.0
## @janus-idp/backstage-plugin-argocd [1.1.5](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.4...@janus-idp/backstage-plugin-argocd@1.1.5) (2024-05-31)
## @janus-idp/backstage-plugin-argocd [1.1.4](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.3...@janus-idp/backstage-plugin-argocd@1.1.4) (2024-05-29)
### Dependencies
- **@janus-idp/cli:** upgraded to 1.8.10
## @janus-idp/backstage-plugin-argocd [1.1.3](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.2...@janus-idp/backstage-plugin-argocd@1.1.3) (2024-05-29)
### Bug Fixes
- **release:** change problematic plugins to private ([#1738](https://github.com/janus-idp/backstage-plugins/issues/1738)) ([69176bd](https://github.com/janus-idp/backstage-plugins/commit/69176bd75ccd842a313445e096223ecc339b655b))
### Dependencies
- **@janus-idp/cli:** upgraded to 1.8.9
## @janus-idp/backstage-plugin-argocd [1.1.2](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.1...@janus-idp/backstage-plugin-argocd@1.1.2) (2024-05-28)
### Dependencies
- **@janus-idp/cli:** upgraded to 1.8.8
## @janus-idp/backstage-plugin-argocd [1.1.1](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.1.0...@janus-idp/backstage-plugin-argocd@1.1.1) (2024-05-16)
### Dependencies
- **@janus-idp/cli:** upgraded to 1.8.7
## @janus-idp/backstage-plugin-argocd [1.1.0](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.0.4...@janus-idp/backstage-plugin-argocd@1.1.0) (2024-05-14)
### Features
- **deps:** use RHDH themes in the backstage app and dev pages ([#1480](https://github.com/janus-idp/backstage-plugins/issues/1480)) ([8263bf0](https://github.com/janus-idp/backstage-plugins/commit/8263bf099736cbb0d0f2316082d338ba81fa6927))
## @janus-idp/backstage-plugin-argocd [1.0.4](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.0.3...@janus-idp/backstage-plugin-argocd@1.0.4) (2024-05-14)
### Bug Fixes
- **argocd:** make refreshInterval configuration as optional ([#1647](https://github.com/janus-idp/backstage-plugins/issues/1647)) ([2c24d35](https://github.com/janus-idp/backstage-plugins/commit/2c24d35f050801801c597967e890b6d2e647fb06))
## @janus-idp/backstage-plugin-argocd [1.0.3](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.0.2...@janus-idp/backstage-plugin-argocd@1.0.3) (2024-05-09)
### Dependencies
- **@janus-idp/cli:** upgraded to 1.8.6
## @janus-idp/backstage-plugin-argocd [1.0.2](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.0.1...@janus-idp/backstage-plugin-argocd@1.0.2) (2024-05-08)
### Bug Fixes
- **argocd:** fix sonarcloud lint warnings in argocd plugin ([#1620](https://github.com/janus-idp/backstage-plugins/issues/1620)) ([66d3763](https://github.com/janus-idp/backstage-plugins/commit/66d3763324d83875fa30d568cd3fd1d69c72a7e7))
## @janus-idp/backstage-plugin-argocd [1.0.1](https://github.com/janus-idp/backstage-plugins/compare/@janus-idp/backstage-plugin-argocd@1.0.0...@janus-idp/backstage-plugin-argocd@1.0.1) (2024-05-07)
### Bug Fixes
- **argocd:** fix argocd configurations visibility ([#1618](https://github.com/janus-idp/backstage-plugins/issues/1618)) ([ade677f](https://github.com/janus-idp/backstage-plugins/commit/ade677f1bccff30b16091c76112c3b6aaf7fa421))
### Other changes
- **argocd:** add playwright dev mode tests ([#1616](https://github.com/janus-idp/backstage-plugins/issues/1616)) ([07c1452](https://github.com/janus-idp/backstage-plugins/commit/07c1452b3098f2a4a59477845a9ab678d1766fba))
## @janus-idp/backstage-plugin-argocd 1.0.0 (2024-05-07)
### Features
- **argocd:** add argocd deployment lifecycle and summary component ([#1540](https://github.com/janus-idp/backstage-plugins/issues/1540)) ([4c7c533](https://github.com/janus-idp/backstage-plugins/commit/4c7c533cae664efc5deff15f7411ed4d74c287a7))
- **argocd:** create a new plugin for argocd ([#1360](https://github.com/janus-idp/backstage-plugins/issues/1360)) ([a3b6916](https://github.com/janus-idp/backstage-plugins/commit/a3b691688942c53892717f8f05e0e06bdaba6454))

View File

@ -0,0 +1,218 @@
# Argo CD plugin for Backstage
## 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 [/argocd/deployment-lifecycle](http://localhost:3000/argocd/deployment-lifecycle).
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.
## For Administrators
### Installation and configuration
#### Prerequisites
- Install `@roadiehq/backstage-plugin-argo-cd-backend` plugin using the following command from the root directory
<!-- configure it by following [Argo CD Backend Plugin docs](https://www.npmjs.com/package/@roadiehq/backstage-plugin-argo-cd-backend) -->
```bash
yarn workspace app add @roadiehq/backstage-plugin-argo-cd-backend
```
- Create plugin file for Argo CD backend in your `packages/backend/src/plugins/` directory.
```ts
// packages/backend/src/plugins/argocd.ts
import { createRouter } from '@roadiehq/backstage-plugin-argo-cd-backend';
import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config });
}
```
- Modify your backend router to expose the APIs for Argo CD backend
```ts
// packages/backend/src/index.ts
import {legacyPlugin} from '@backstage/backend-common';
...
backend.add(legacyPlugin('argocd', import('./plugins/argocd')));
```
- add argocd instance information in app.config.yaml
```ts
argocd:
appLocatorMethods:
- type: 'config'
instances:
- name: argoInstance1
url: https://argoInstance1.com
username: ${ARGOCD_USERNAME}
password: ${ARGOCD_PASSWORD}
- name: argoInstance2
url: https://argoInstance2.com
username: ${ARGOCD_USERNAME}
password: ${ARGOCD_PASSWORD}
```
#### How to add argocd frontend plugin to Backstage app
1. Install the Argocd plugin using the following command:
```bash
yarn workspace app add @janus-idp/backstage-plugin-argocd
```
2. Add deployment summary and deployment lifecycle compoennt to the `entityPage.tsx` source file:
```ts
// packages/app/src/components/catalog/EntityPage.tsx
import {
ArgocdDeploymentSummary,
ArgocdDeploymentLifecycle,
isArgocdConfigured,
} from '@janus-idp/backstage-plugin-argocd';
const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
...
<EntitySwitch>
<EntitySwitch.Case if={e => Boolean(isArgocdConfigured(e))}>
<Grid item sm={12}>
<ArgocdDeploymentSummary />
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
...
</Grid>
);
const cicdcontent = (
<EntitySwitch>
{/* ... */}
{/* highlight-add-start */}
...
<EntitySwitch.Case if={e => Boolean(isArgocdConfigured(e))}>
<Grid item sm={12}>
<ArgocdDeploymentLifecycle />
</Grid>
</EntitySwitch.Case>
{/* highlight-add-end */}
</EntitySwitch>
);
```
- Add one of the following annotations to the entity's `catalog-info.yaml` file to enable Argo CD features in the backstage instance:
- To get all the applications matching the metadata labels.
```yaml
annotations:
...
argocd/app-selector: 'rht-gitops.com/janus-argocd=quarkus-app' # format: `label.key=label.value`
```
**or**
- To fetch a single application, use the following annotation in `catalog-info.yaml` file:
```yaml
annotations:
...
argocd/app-name: 'quarkus-app'
```
> [!Note] > **You should not add both the annotations in the same catalog, adding both annotations will result in error in the plugin.**
- To switch between argocd instances, you can use the following annotation
```yaml
annotations:
...
argocd/instance-name: 'argoInstance2'
```
> [!Note] > **If this annotation is not set, the plugin will default to the first Argo CD instance configured in the `app.config.yaml`**
## Loading as Dynamic Plugin
To install this plugin into Red Hat Developer Hub or Janus IDP via Helm use this configuration:
```yaml
global:
dynamic:
includes:
- dynamic-plugins.default.yaml
plugins:
- package: ./dynamic-plugins/dist/roadiehq-backstage-plugin-argo-cd-backend-dynamic
disabled: false
- package: ./dynamic-plugins/dist/janus-idp-backstage-plugin-argocd
disabled: false
```
This plugin can be loaded in backstage showcase application as a dynamic plugin.
Follow the below steps -
- Export dynamic plugin assets. This will build and create the static assets for the plugin and put it inside dist-scalprum folder.
`yarn install`
`yarn tsc`
`yarn build`
`yarn export-dynamic`
- Package and copy dist-scalprum folder assets to dynamic-plugins-root folder in showcase application.
```sh
pkg=../plugins/argocd
archive=$(npm pack $pkg)
tar -xzf "$archive" && rm "$archive"
mv package $(echo $archive | sed -e 's:\.tgz$::')
```
- Add the extension point inside the `app-config.yaml` or `app-config.local.yaml` file.
```yaml
dynamicPlugins:
frontend:
janus-idp.backstage-plugin-argocd:
mountPoints:
- mountPoint: entity.page.overview/cards
importName: ArgocdDeploymentSummary
config:
layout:
gridColumnEnd:
lg: 'span 8'
xs: 'span 12'
if:
allOf:
- isArgocdAvailable
- mountPoint: entity.page.cd/cards
importName: ArgocdDeploymentLifecycle
config:
layout:
gridColumn: '1 / -1'
if:
allOf:
- isArgocdConfigured
```
For more detailed explanation on dynamic plugins follow this [doc](https://github.com/janus-idp/backstage-showcase/blob/main/showcase-docs/dynamic-plugins.md).

View File

@ -0,0 +1,40 @@
## API Report File for "@backstage-community/plugin-redhat-argocd"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { JSX as JSX_2 } from 'react';
import { RouteRef } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "ArgocdDeploymentLifecycle" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const ArgocdDeploymentLifecycle: () => JSX_2.Element | null;
// Warning: (ae-missing-release-tag) "ArgocdDeploymentSummary" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const ArgocdDeploymentSummary: () => JSX_2.Element | null;
// Warning: (ae-missing-release-tag) "argocdPlugin" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const argocdPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{},
{}
>;
// Warning: (ae-missing-release-tag) "isArgocdConfigured" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const isArgocdConfigured: (entity: Entity) => boolean;
// (No @packageDocumentation comment for this package)
```

View File

@ -0,0 +1,13 @@
dynamicPlugins:
frontend:
janus-idp.backstage-plugin-argocd:
mountPoints:
- mountPoint: entity.page.cd/cards
importName: ArgocdPage
config:
layout:
gridColumn: '1 / -1'
if:
anyOf:
- hasAnnotation: backstage.io/kubernetes-id
- hasAnnotation: backstage.io/kubernetes-namespace

View File

@ -0,0 +1,57 @@
export interface Config {
/** Optional configurations for the ArgoCD plugin */
argocd?: {
/**
* The base url of the ArgoCD instance.
* @visibility frontend
*/
baseUrl?: string;
/**
* Support for the ArgoCD beta feature "Applications in any namespace"
* @visibility frontend
*/
namespacedApps?: boolean;
/**
* The number of revisions to load per application in the history table.
* @visibility frontend
*/
revisionsToLoad?: number;
/**
* Polling interval timeout
* @visibility frontend
*/
refreshInterval?: number;
/**
* The base url of the ArgoCD instance.
* @visibility frontend
*/
appLocatorMethods?: Array</**
* @visibility frontend
*/
{
/**
* The base url of the ArgoCD instance.
* @visibility frontend
*/
type: string;
instances: Array<{
/**
* @visibility frontend
*/
name: string;
/**
* @visibility frontend
*/
url: string;
/**
* @visibility secret
*/
username: string;
/**
* @visibility secret
*/
password: string;
}>;
}>;
};
}

View File

@ -0,0 +1,296 @@
import { Application } from '../../src/types';
const commonMetadata = {
creationTimestamp: new Date('2024-04-22T05:39:23Z'),
labels: {
'rht-gitops.com/janus-argocd': 'quarkus-app-bootstrap',
},
instance: { name: 'main' },
name: 'quarkus-app-dev',
};
const commonSpec = {
project: 'janus',
destination: {
namespace: 'quarkus-app-dev',
server: 'https://kubernetes.default.svc',
},
source: {
helm: {
parameters: [
{
name: 'namespace.name',
value: 'quarkus-app-dev',
},
{
name: 'environment',
value: 'dev',
},
{
name: 'image.tag',
value: 'latest',
},
],
},
path: './helm/app',
repoURL:
'https://gitlab-gitlab.apps.cluster.test.com/development/quarkus-app-gitops.git',
targetRevision: 'HEAD',
},
};
const commonStatus = {
history: [
{
revision: '90f9758b7033a4bbb7c33a35ee474d61091644bc',
deployedAt: '2024-04-22T05:39:24Z',
id: 0,
source: {
repoURL:
'https://gitlab-gitlab.apps.cluster.test.com/development/quarkus-app-gitops.git',
path: './helm/app',
targetRevision: 'HEAD',
helm: {
parameters: [
{
name: 'namespace.name',
value: 'quarkus-app-dev',
},
{
name: 'environment',
value: 'dev',
},
{
name: 'image.tag',
value: 'latest',
},
],
},
},
deployStartedAt: '2024-04-22T05:39:23Z',
},
{
revision: '90f9758b7033a4bbb7c33a35ee474d61091644bc',
deployedAt: '2024-04-22T17:57:40Z',
id: 1,
source: {
repoURL:
'https://gitlab-gitlab.apps.cluster.test.com/development/quarkus-app-gitops.git',
path: './helm/app',
targetRevision: 'HEAD',
helm: {
parameters: [
{
name: 'namespace.name',
value: 'quarkus-app-dev',
},
{
name: 'environment',
value: 'dev',
},
{
name: 'image.tag',
value: 'latest',
},
],
},
},
deployStartedAt: '2024-04-22T17:57:40Z',
},
],
health: {
status: 'Healthy',
},
operationState: {
operation: {
sync: {
prune: true,
revision: '90f9758b7033a4bbb7c33a35ee474d61091644bc',
syncOptions: [
'RespectIgnoreDifferences=true',
'ApplyOutOfSyncOnly=true',
],
},
},
phase: 'Succeeded',
},
summary: {
images: ['quay-hw6fw.apps.cluster.test.com/quayadmin/quarkus-app:latest'],
},
sync: {
status: 'Synced',
},
};
export const mockApplication: Application = {
metadata: {
...commonMetadata,
creationTimestamp: new Date('2024-04-22T05:39:23Z'),
name: 'quarkus-app-dev',
},
spec: commonSpec,
status: commonStatus,
};
const preProdHelmParameters = {
parameters: [
{
name: 'namespace.name',
value: 'quarkus-app-preprod',
},
{
name: 'environment',
value: 'preprod',
},
{
name: 'image.tag',
value: 'latest',
},
],
};
export const preProdApplication = {
metadata: {
...commonMetadata,
creationTimestamp: new Date('2024-04-22T05:39:23Z'),
name: 'quarkus-app-preprod',
},
spec: {
...commonSpec,
destination: {
...commonSpec.destination,
namespace: 'quarkus-app-preprod',
},
source: {
...commonSpec.source,
helm: {
parameters: preProdHelmParameters,
},
},
},
status: {
...commonStatus,
history: [
{
...commonStatus.history[0],
revision: '80f9758b7033a4bbb7c33a35ee474d61091644bc',
deployedAt: '2024-04-22T05:39:24Z',
source: {
...commonStatus.history[0].source,
helm: preProdHelmParameters,
},
},
{
...commonStatus.history[1],
revision: '80f9758b7033a4bbb7c33a35ee474d61091644bc',
source: {
...commonStatus.history[0].source,
helm: preProdHelmParameters,
},
},
],
operationState: {
...commonStatus.operationState,
operation: {
...commonStatus.operationState.operation,
sync: {
...commonStatus.operationState.operation.sync,
revision: '80f9758b7033a4bbb7c33a35ee474d61091644bc',
},
},
},
health: {
status: 'Degraded',
},
sync: {
status: 'Synced',
},
},
};
const prodHelmParameters = {
parameters: [
{
name: 'namespace.name',
value: 'quarkus-app-pre-prod',
},
{
name: 'environment',
value: 'prod',
},
{
name: 'image.tag',
value: 'prod',
},
],
};
export const prodApplication = {
metadata: {
creationTimestamp: new Date('2024-04-22T05:39:23Z'),
labels: {
'rht-gitops.com/janus-argocd': 'quarkus-app-bootstrap',
},
instance: { name: 'main' },
name: 'quarkus-app-prod',
},
spec: {
destination: {
namespace: 'quarkus-app-prod',
server: 'https://kubernetes.default.svc',
},
project: 'janus',
source: {
helm: prodHelmParameters,
path: './helm/app',
repoURL:
'https://gitlab-gitlab.apps.cluster.test.com/prod/quarkus-app-gitops.git',
targetRevision: 'HEAD',
},
},
status: {
history: [
{
...commonStatus.history[0],
revision: '70f9758b7033a4bbb7c33a35ee474d61091644bc',
deployedAt: '2024-04-19T05:39:24Z',
source: {
...commonStatus.history[0].source,
helm: preProdHelmParameters,
repoURL:
'https://gitlab-gitlab.apps.cluster.test.com/prod/quarkus-app-gitops.git',
},
},
{
...commonStatus.history[1],
revision: '70f9758b7033a4bbb7c33a35ee474d61091644bc',
deployedAt: '2024-04-19T05:39:24Z',
id: 1,
source: {
...commonStatus.history[1].source,
helm: preProdHelmParameters,
repoURL:
'https://gitlab-gitlab.apps.cluster.test.com/prod/quarkus-app-gitops.git',
},
},
],
operationState: {
...commonStatus.operationState,
operation: {
...commonStatus.operationState.operation,
sync: {
...commonStatus.operationState.operation.sync,
revision: '80f9758b7033a4bbb7c33a35ee474d61091644bc',
},
},
},
summary: {
images: ['quay-hw6fw.apps.cluster.test.com/quayadmin/quarkus-app:latest'],
},
health: {
status: 'Missing',
},
sync: {
status: 'OutOfSync',
},
},
};

View File

@ -0,0 +1,22 @@
export const mockArgocdConfig = {
argocd: {
baseUrl: 'https://localhost:8080',
appLocatorMethods: [
{
type: 'config',
instances: [
{
name: 'argoInstance1',
url: 'https://test-openshift-gitops.apps.test.devcluster.openshift.com/',
token: 'fake-jwt-token1',
},
{
name: 'argoInstance2',
url: 'https://test-openshift-gitops.apps.test.devcluster.openshift.com/',
token: 'fake-jwt-token2',
},
],
},
],
},
};

View File

@ -0,0 +1,21 @@
import { Entity } from '@backstage/catalog-model';
export const mockEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'backstage',
description: 'backstage.io',
annotations: {
'argocd/app-selector':
'rht-gitops.com/janus-argocd=quarkus-app-bootstrap',
'argocd/project-name': 'project-name',
'argocd/instance-name': 'instance-1',
},
},
spec: {
lifecycle: 'production',
type: 'service',
owner: 'user:guest',
},
};

View File

@ -0,0 +1,4 @@
export * from './applications';
export * from './config';
export * from './entity';
export * from './revision';

View File

@ -0,0 +1,19 @@
export const mockRevision = {
author: 'Karthik',
date: '2023-10-10T05:28:38Z',
message: 'First release',
};
export const mockRevisionTwo = {
author: 'Karthik',
date: '2023-10-11T05:28:38Z',
message: 'Commit v1.0.0 tag release',
};
export const mockRevisionThree = {
author: 'Karthik',
date: '2023-10-13T05:28:38Z',
message: 'Initial commit',
};
export const mockRevisions = [mockRevision, mockRevisionTwo, mockRevisionThree];

View File

@ -0,0 +1,111 @@
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { configApiRef } from '@backstage/core-plugin-api';
import { createDevApp } from '@backstage/dev-utils';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import { MockPermissionApi, TestApiProvider } from '@backstage/test-utils';
import { Box } from '@material-ui/core';
import { createDevAppThemes } from '@redhat-developer/red-hat-developer-hub-theme';
import {
ArgoCDApi,
argoCDApiRef,
GetApplicationOptions,
listAppsOptions,
RevisionDetailsListOptions,
RevisionDetailsOptions,
} from '../src/api';
import {
ArgocdDeploymentLifecycle,
ArgocdDeploymentSummary,
argocdPlugin,
} from '../src/plugin';
import { Application } from '../src/types';
import {
mockApplication,
mockArgocdConfig,
mockRevision,
mockRevisions,
preProdApplication,
prodApplication,
} from './__data__';
const mockEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'backstage-argocd',
description: 'rhtap argocd plugin',
annotations: {
'argocd/app-selector': 'rht.gitops.com/quarks-app-bootstrap',
},
},
spec: {
lifecycle: 'production',
type: 'service',
owner: 'user:guest',
},
};
const mockPermissionApi = new MockPermissionApi();
export class MockArgoCDApiClient implements ArgoCDApi {
async listApps(_options: listAppsOptions): Promise<any> {
return { items: [mockApplication, preProdApplication, prodApplication] };
}
async getRevisionDetails(_options: RevisionDetailsOptions): Promise<any> {
return mockRevision;
}
async getRevisionDetailsList(
_options: RevisionDetailsListOptions,
): Promise<any> {
return mockRevisions;
}
async getApplication(_options: GetApplicationOptions): Promise<Application> {
return mockApplication;
}
}
createDevApp()
.registerPlugin(argocdPlugin)
.addThemes(createDevAppThemes())
.addPage({
element: (
<TestApiProvider
apis={[
[configApiRef, new ConfigReader(mockArgocdConfig)],
[argoCDApiRef, new MockArgoCDApiClient()],
[permissionApiRef, mockPermissionApi],
]}
>
<EntityProvider entity={mockEntity}>
<Box margin={2}>
<ArgocdDeploymentLifecycle />
</Box>
</EntityProvider>
</TestApiProvider>
),
title: 'Lifecycle',
path: '/argocd/deployment-lifecycle',
})
.addPage({
element: (
<TestApiProvider
apis={[
[configApiRef, new ConfigReader(mockArgocdConfig)],
[argoCDApiRef, new MockArgoCDApiClient()],
]}
>
<EntityProvider entity={mockEntity}>
<ArgocdDeploymentSummary />
</EntityProvider>
</TestApiProvider>
),
title: 'Summary',
path: 'argocd/deployment-summary',
})
.render();

View File

@ -0,0 +1,108 @@
{
"name": "@backstage-community/plugin-redhat-argocd",
"version": "1.6.0",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"backstage": {
"role": "frontend-plugin",
"supported-versions": "1.28.4",
"pluginId": "redhat-argocd",
"pluginPackage": "@backstage-community/plugin-redhat-argocd",
"pluginPackages": [
"@backstage-community/plugin-redhat-argocd"
]
},
"sideEffects": false,
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"export-dynamic": "janus-cli package export-dynamic-plugin --in-place",
"lint": "backstage-cli package lint",
"postpack": "backstage-cli package postpack",
"postversion": "yarn run export-dynamic",
"prepack": "backstage-cli package prepack",
"start": "backstage-cli package start",
"test": "backstage-cli package test --passWithNoTests --coverage",
"tsc": "tsc",
"ui-test": "yarn playwright test"
},
"dependencies": {
"@backstage/catalog-model": "^1.5.0",
"@backstage/core-components": "^0.14.9",
"@backstage/core-plugin-api": "^1.9.3",
"@backstage/plugin-catalog-react": "^1.12.2",
"@backstage/plugin-permission-react": "^0.4.24",
"@backstage/theme": "^0.5.6",
"@janus-idp/backstage-plugin-argocd-common": "1.0.1",
"@kubernetes/client-node": "^0.20.0",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.61",
"@patternfly/patternfly": "^5.1.0",
"@patternfly/react-charts": "^7.1.1",
"@patternfly/react-core": "^5.1.2",
"@patternfly/react-icons": "^5.1.1",
"@patternfly/react-tokens": "^5.1.2",
"moment": "^2.30.1",
"react-use": "17.5.1"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-router-dom": "^6.3.0"
},
"devDependencies": {
"@backstage/cli": "0.26.11",
"@backstage/config": "1.2.0",
"@backstage/core-app-api": "1.14.1",
"@backstage/dev-utils": "1.0.36",
"@backstage/test-utils": "1.5.9",
"@janus-idp/cli": "1.13.0",
"@playwright/test": "1.45.3",
"@redhat-developer/red-hat-developer-hub-theme": "0.0.54",
"@testing-library/jest-dom": "6.4.8",
"@testing-library/react": "14.3.1",
"@testing-library/user-event": "14.5.2",
"msw": "1.3.3",
"react": "^16.13.1 || ^17.0.0 || ^18.0.0",
"react-dom": "^18",
"react-router-dom": "^6.3.0"
},
"files": [
"dist",
"config.d.ts",
"dist-scalprum",
"app-config.janus-idp.yaml"
],
"scalprum": {
"name": "janus-idp.backstage-plugin-argocd",
"exposedModules": {
"PluginRoot": "./src/index.ts"
}
},
"configSchema": "config.d.ts",
"repository": {
"type": "git",
"url": "https://github.com/backstage/community-plugins",
"directory": "workspaces/redhat-argocd/plugins/argocd"
},
"keywords": [
"support:production",
"lifecycle:active",
"backstage",
"plugin"
],
"homepage": "https://red.ht/rhdh",
"bugs": "https://github.com/backstage/community-plugins/issues",
"maintainers": [
"karthikjeeyar",
"rohitkrai03",
"Eswaraiahsapram"
],
"author": "Red Hat"
}

View File

@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test';
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Run tests in sequence. */
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
baseURL: process.env.PLUGIN_BASE_URL || 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@ -0,0 +1,281 @@
import { IdentityApi } from '@backstage/core-plugin-api';
import { ArgoCDApiClient } from '..';
import { mockApplication } from '../../../dev/__data__';
const getIdentityApiStub: IdentityApi = {
getProfileInfo: jest.fn(),
getBackstageIdentity: jest.fn(),
async getCredentials() {
return { token: 'fake-jwt-token' };
},
signOut: jest.fn(),
};
describe('API calls', () => {
let fetchSpy: jest.SpyInstance;
beforeEach(() => {
fetchSpy = jest.spyOn(global, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
describe('listApps', () => {
beforeEach(() => {
fetchSpy.mockImplementation(() =>
Promise.resolve<Response>({
ok: true,
json: () => Promise.resolve({ items: [] }),
} as Response),
);
});
test('fetches app based on provided projectName', async () => {
const client = new ArgoCDApiClient({
backendBaseUrl: 'https://test.com',
useNamespacedApps: false,
identityApi: getIdentityApiStub,
});
await client.listApps({
url: '',
appSelector: 'janus.io%253Dquarkus-app',
projectName: 'test',
appNamespace: 'my-test-ns',
});
expect(global.fetch).toHaveBeenCalledWith(
'https://test.com/api/argocd/applications/selector/janus.io%253Dquarkus-app?selector=janus.io%25253Dquarkus-app&project=test',
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer fake-jwt-token`,
},
}),
);
});
test('fetches app based on provided appSelector', async () => {
const client = new ArgoCDApiClient({
backendBaseUrl: 'https://test.com',
useNamespacedApps: false,
identityApi: getIdentityApiStub,
});
await client.listApps({
url: '',
appSelector: 'my-test-app-selector',
});
expect(global.fetch).toHaveBeenCalledWith(
'https://test.com/api/argocd/applications/selector/my-test-app-selector?selector=my-test-app-selector',
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer fake-jwt-token`,
},
}),
);
});
test('Should throw error incase of any internal API failure', async () => {
fetchSpy.mockImplementation(() =>
Promise.resolve<Response>({
ok: false,
status: 'Internal server error',
statusText: 'Something went wrong',
json: () => Promise.reject({ status: 'Internal server error' }),
} as unknown as Response),
);
const client = new ArgoCDApiClient({
backendBaseUrl: 'https://test.com',
useNamespacedApps: false,
identityApi: getIdentityApiStub,
});
let error;
try {
await client.listApps({
url: '',
appSelector: 'my-test-app-selector',
});
} catch (e: any) {
error = e;
} finally {
expect(error.message).toBe(
'failed to fetch data, status Internal server error: Something went wrong',
);
}
});
test('should not pass the token for the guest user', async () => {
const client = new ArgoCDApiClient({
backendBaseUrl: 'https://test.com',
useNamespacedApps: false,
identityApi: {
...getIdentityApiStub,
getCredentials: async () => {
return {};
},
},
});
await client.listApps({
url: '',
projectName: 'test',
appSelector: 'janus.io%253Dquarkus-app',
appNamespace: 'my-test-ns',
});
expect(global.fetch).toHaveBeenCalledWith(
'https://test.com/api/argocd/applications/selector/janus.io%253Dquarkus-app?selector=janus.io%25253Dquarkus-app&project=test',
expect.objectContaining({
headers: undefined,
}),
);
});
});
describe('getApplication', () => {
beforeEach(() => {
fetchSpy.mockImplementation(() =>
Promise.resolve<Response>({
ok: true,
json: () => Promise.resolve({}),
} as Response),
);
});
test('should return empty object as response', async () => {
const client = new ArgoCDApiClient({
backendBaseUrl: 'https://test.com',
useNamespacedApps: false,
identityApi: getIdentityApiStub,
});
const data = await client.getApplication({
url: '',
appName: 'quarkus-app',
appNamespace: '',
});
expect(Object.keys(data)).toHaveLength(0);
});
test('should return application object', async () => {
fetchSpy.mockImplementation(() =>
Promise.resolve<Response>({
ok: true,
json: () => Promise.resolve(mockApplication),
} as Response),
);
const client = new ArgoCDApiClient({
backendBaseUrl: 'https://test.com',
useNamespacedApps: false,
identityApi: getIdentityApiStub,
});
const data = await client.getApplication({
url: '',
appName: 'quarkus-app',
appNamespace: '',
});
expect(data).toEqual(
expect.objectContaining({
metadata: expect.objectContaining({
name: 'quarkus-app-dev',
}),
}),
);
});
});
describe('getRevisionDetails', () => {
beforeEach(() => {
fetchSpy.mockImplementation(() =>
Promise.resolve<Response>({
ok: true,
json: () => Promise.resolve({ items: [] }),
} as Response),
);
});
test('should return the revision details', async () => {
const client = new ArgoCDApiClient({
backendBaseUrl: 'https://test.com',
useNamespacedApps: false,
identityApi: getIdentityApiStub,
});
await client.getRevisionDetails({
instanceName: 'main',
app: 'my-test-app',
revisionID: '12345',
});
expect(global.fetch).toHaveBeenCalledWith(
'https://test.com/api/argocd/argoInstance/main/applications/name/my-test-app/revisions/12345/metadata',
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer fake-jwt-token`,
},
}),
);
});
});
describe('getRevisionDetailsList', () => {
beforeEach(() => {
fetchSpy.mockImplementation(() =>
Promise.resolve<Response>({
ok: true,
json: () => Promise.resolve({ items: [] }),
} as Response),
);
});
test('should return empty list if the revisionIds are not passed', async () => {
const client = new ArgoCDApiClient({
backendBaseUrl: 'https://test.com',
useNamespacedApps: false,
identityApi: getIdentityApiStub,
});
const data = await client.getRevisionDetailsList({
instanceName: 'main',
apps: [mockApplication],
revisionIDs: [],
appNamespace: '',
});
expect(data).toHaveLength(0);
});
test('should return the list of revision details', async () => {
const client = new ArgoCDApiClient({
backendBaseUrl: 'https://test.com',
useNamespacedApps: false,
identityApi: getIdentityApiStub,
});
await client.getRevisionDetailsList({
instanceName: 'main',
apps: [mockApplication],
revisionIDs: ['90f9758b7033a4bbb7c33a35ee474d61091644bc'],
appNamespace: '',
});
expect(global.fetch).toHaveBeenCalledWith(
'https://test.com/api/argocd/argoInstance/main/applications/name/quarkus-app-dev/revisions/90f9758b7033a4bbb7c33a35ee474d61091644bc/metadata',
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer fake-jwt-token`,
},
}),
);
});
});
});

View File

@ -0,0 +1,182 @@
import { createApiRef, IdentityApi } from '@backstage/core-plugin-api';
import { Application, Revision } from '../types';
export type ArgoCDAppDeployRevisionDetails = Revision;
export type listAppsOptions = {
url: string;
appSelector?: string;
appNamespace?: string;
projectName?: string;
};
export type RevisionDetailsOptions = {
app: string;
appNamespace?: string;
revisionID: string;
instanceName?: string;
};
export type RevisionDetailsListOptions = {
appNamespace?: string;
revisionIDs: string[];
instanceName?: string;
apps: Application[];
};
export type GetApplicationOptions = {
url: string;
appName: string;
appNamespace?: string;
instance?: string;
};
export interface ArgoCDApi {
listApps(options: listAppsOptions): Promise<{ items: Application[] }>;
getRevisionDetails(
options: RevisionDetailsOptions,
): Promise<ArgoCDAppDeployRevisionDetails>;
getRevisionDetailsList(
options: RevisionDetailsListOptions,
): Promise<ArgoCDAppDeployRevisionDetails[]>;
getApplication(options: GetApplicationOptions): Promise<Application>;
}
export const argoCDApiRef = createApiRef<ArgoCDApi>({
id: 'plugin.argo.cd.service',
});
export type Options = {
backendBaseUrl: string;
identityApi: IdentityApi;
proxyPath?: string;
useNamespacedApps: boolean;
};
const APP_NAMESPACE_QUERY_PARAM = 'appNamespace';
export class ArgoCDApiClient implements ArgoCDApi {
private readonly backendBaseUrl: string;
private readonly identityApi: IdentityApi;
private readonly useNamespacedApps: boolean;
constructor(options: Options) {
this.backendBaseUrl = options.backendBaseUrl;
this.identityApi = options.identityApi;
this.useNamespacedApps = options.useNamespacedApps;
}
async getBaseUrl() {
return `${this.backendBaseUrl}/api/argocd`;
}
getQueryParams(params: { [p: string]: string | undefined }) {
const result = Object.keys(params)
.filter(key => params[key] !== undefined)
.filter(
key => key !== APP_NAMESPACE_QUERY_PARAM || this.useNamespacedApps,
)
.map(
k =>
`${encodeURIComponent(k)}=${encodeURIComponent(params[k] as string)}`,
)
.join('&');
return result ? `?${result}` : '';
}
async fetcher(url: string) {
const { token } = await this.identityApi.getCredentials();
const response = await fetch(url, {
headers: token
? {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
}
: undefined,
});
if (!response.ok) {
throw new Error(
`failed to fetch data, status ${response.status}: ${response.statusText}`,
);
}
const json = await response.json();
return json;
}
async listApps(options: {
url: string;
appSelector?: string;
appNamespace?: string;
projectName?: string;
}) {
const proxyUrl = await this.getBaseUrl();
const query = this.getQueryParams({
selector: options.appSelector,
project: options.projectName,
appNamespace: options.appNamespace,
});
return this.fetcher(
`${proxyUrl}${options.url}/applications/selector/${options.appSelector}${query}`,
);
}
async getApplication(options: GetApplicationOptions) {
const proxyUrl = await this.getBaseUrl();
const query = this.getQueryParams({
appNamespace: options.appNamespace,
});
return this.fetcher(
`${proxyUrl}${options.url}/applications/${encodeURIComponent(
options.appName,
)}${query}`,
);
}
async getRevisionDetails(options: {
app: string;
appNamespace?: string;
revisionID: string;
instanceName: string;
}) {
const proxyUrl = await this.getBaseUrl();
const query = this.getQueryParams({
appNamespace: options.appNamespace,
});
return this.fetcher(
`${proxyUrl}/argoInstance/${
options.instanceName
}/applications/name/${encodeURIComponent(
options.app,
)}/revisions/${encodeURIComponent(options.revisionID)}/metadata${query}`,
);
}
async getRevisionDetailsList(options: {
appNamespace: string;
revisionIDs: string[];
instanceName: string;
apps: Application[];
}): Promise<Revision[]> {
if (!options.revisionIDs || options.revisionIDs.length < 1) {
return Promise.resolve([]);
}
const promises: any = [];
options.revisionIDs.forEach((revisionID: string) => {
const application = options.apps.find(app =>
app?.status?.history?.find(h => h.revision === revisionID),
);
if (application) {
promises.push(
this.getRevisionDetails({
app: application.metadata.name as string,
appNamespace: options.appNamespace,
instanceName: options.instanceName,
revisionID,
}),
);
}
});
return Promise.all(promises);
}
}

View File

@ -0,0 +1,28 @@
import React from 'react';
import { Chip } from '@material-ui/core';
import { Application, HealthStatus } from '../../types';
import { AppHealthIcon } from './StatusIcons';
const AppHealthStatus: React.FC<{ app: Application; isChip?: boolean }> = ({
app,
isChip = false,
}) => {
return isChip ? (
<Chip
data-testid="app-health-status-chip"
size="small"
variant="outlined"
icon={<AppHealthIcon status={app.status.health.status as HealthStatus} />}
label={app.status.health.status}
/>
) : (
<>
<AppHealthIcon status={app.status.health.status as HealthStatus} />{' '}
{app.status.health.status}
</>
);
};
export default AppHealthStatus;

View File

@ -0,0 +1,35 @@
import React from 'react';
import { Chip, Typography } from '@material-ui/core';
import { Flex, FlexItem } from '@patternfly/react-core';
import { Application } from '../../types';
const AppNamespace: React.FC<{ app: Application }> = ({ app }) => {
if (!app) {
return null;
}
return (
<Flex
gap={{ default: 'gapNone' }}
alignItems={{ default: 'alignItemsFlexStart' }}
>
<FlexItem>
<Chip
size="small"
variant="default"
color="primary"
label="NS"
style={{ background: 'green' }}
/>
</FlexItem>
<FlexItem>
<Typography variant="body2" color="textSecondary">
{app.spec.destination.namespace}{' '}
</Typography>
</FlexItem>
</Flex>
);
};
export default AppNamespace;

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Chip } from '@material-ui/core';
import { Application, SyncStatusCode } from '../../types';
import { SyncIcon } from './StatusIcons';
const AppSyncStatus: React.FC<{
app: Application;
isChip?: boolean;
}> = ({ app, isChip = false }) => {
return isChip ? (
<Chip
data-testid="app-sync-status-chip"
size="small"
variant="outlined"
icon={<SyncIcon status={app?.status?.sync?.status as SyncStatusCode} />}
label={app?.status?.sync?.status}
/>
) : (
<>
<SyncIcon status={app?.status?.sync?.status as SyncStatusCode} />{' '}
{app?.status?.sync?.status}
</>
);
};
export default AppSyncStatus;

View File

@ -0,0 +1,19 @@
import React from 'react';
import { Application } from '../../types';
import AppHealthStatus from './AppHealthStatus';
import AppSyncStatus from './AppSyncStatus';
const StatusHeading: React.FC<{ app: Application }> = ({ app }) => {
if (!app) {
return null;
}
return (
<>
<AppSyncStatus app={app} isChip />
<AppHealthStatus app={app} isChip />
</>
);
};
export default StatusHeading;

View File

@ -0,0 +1,123 @@
import React from 'react';
import { createStyles, makeStyles, Theme } from '@material-ui/core';
import ArrowCircleUpIcon from '@patternfly/react-icons/dist/esm/icons/arrow-alt-circle-up-icon';
import CheckCircleIcon from '@patternfly/react-icons/dist/esm/icons/check-circle-icon';
import CircleNotchIcon from '@patternfly/react-icons/dist/esm/icons/circle-notch-icon';
import GhostIcon from '@patternfly/react-icons/dist/esm/icons/ghost-icon';
import HeartBrokenIcon from '@patternfly/react-icons/dist/esm/icons/heart-broken-icon';
import HeartIcon from '@patternfly/react-icons/dist/esm/icons/heart-icon';
import PauseCircleIcon from '@patternfly/react-icons/dist/esm/icons/pause-circle-icon';
import QuestionCircleIcon from '@patternfly/react-icons/dist/esm/icons/question-circle-icon';
import { HealthStatus, SyncStatusCode, SyncStatuses } from '../../types';
const useIconStyles = makeStyles<Theme>(theme =>
createStyles({
icon: {
marginLeft: theme.spacing(0.6),
},
'icon-spin': {
animation: '$spin-animation 0.5s infinite',
display: 'inline-block',
},
'@keyframes spin-animation': {
'0%': {
transform: 'rotate(0deg)',
},
'100%': {
transform: 'rotate(359deg)',
},
},
}),
);
export const SyncIcon: React.FC<{ status: SyncStatusCode }> = ({
status,
}): React.ReactNode => {
const classes = useIconStyles();
switch (status) {
case SyncStatuses.Synced:
return (
<CheckCircleIcon
data-testid="synced-icon"
className={`${classes.icon}`}
style={{ height: '1em', color: 'green' }}
/>
);
case SyncStatuses.OutOfSync:
return (
<ArrowCircleUpIcon
data-testid="outofsync-icon"
className={`${classes.icon}`}
style={{ height: '1em', color: '#f4c030' }}
/>
);
case SyncStatuses.Unknown:
return (
<CircleNotchIcon
data-testid="unknown-icon"
className={`${classes.icon} ${classes['icon-spin']}`}
style={{ height: '1em', color: '#0DADEA' }}
/>
);
default:
return null;
}
};
export const AppHealthIcon: React.FC<{ status: HealthStatus }> = ({
status,
}): React.ReactNode => {
const classes = useIconStyles();
switch (status) {
case HealthStatus.Healthy:
return (
<HeartIcon
data-testid="healthy-icon"
className={`${classes.icon}`}
style={{ height: '1em', color: 'green' }}
/>
);
case HealthStatus.Suspended:
return (
<PauseCircleIcon
data-testid="suspended-icon"
className={`${classes.icon}`}
style={{ height: '1em', color: '#766f94' }}
/>
);
case HealthStatus.Degraded:
return (
<HeartBrokenIcon
data-testid="degraded-icon"
className={`${classes.icon}`}
style={{ height: '1em', color: '#E96D76' }}
/>
);
case HealthStatus.Progressing:
return (
<CircleNotchIcon
data-testid="progressing-icon"
className={`${classes.icon} ${classes['icon-spin']}`}
style={{ height: '1em', color: '#0DADEA' }}
/>
);
case HealthStatus.Missing:
return (
<GhostIcon
data-testid="missing-icon"
className={`${classes.icon}`}
style={{ height: '1em', color: '#f4c030' }}
/>
);
default:
return (
<QuestionCircleIcon
data-testid="unknown-icon"
style={{ height: '1em', color: 'green' }}
/>
);
}
};

View File

@ -0,0 +1,21 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { mockApplication } from '../../../../dev/__data__';
import AppHealthStatus from '../AppHealthStatus';
describe('AppHealthStatus', () => {
test('should return default component', () => {
render(<AppHealthStatus app={mockApplication} />);
expect(screen.queryByTestId('healthy-icon')).toBeInTheDocument();
expect(screen.queryByText('Healthy')).toBeInTheDocument();
});
test('should return application health chip component', () => {
render(<AppHealthStatus app={mockApplication} isChip />);
expect(screen.getByTestId('app-health-status-chip')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,24 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { mockApplication } from '../../../../dev/__data__';
import { Application } from '../../../types';
import AppNamespace from '../AppNamespace';
describe('AppNamespace', () => {
test('should not render if the application is not available', () => {
render(<AppNamespace app={null as unknown as Application} />);
expect(
screen.queryByText(mockApplication.spec.destination.namespace),
).not.toBeInTheDocument();
});
test('should render the namespace application is available', () => {
render(<AppNamespace app={mockApplication} />);
expect(
screen.queryByText(mockApplication.spec.destination.namespace),
).toBeInTheDocument();
});
});

View File

@ -0,0 +1,21 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { mockApplication } from '../../../../dev/__data__';
import AppSyncStatus from '../AppSyncStatus';
describe('AppSyncStatus', () => {
test('should return default component', () => {
render(<AppSyncStatus app={mockApplication} />);
expect(screen.getByTestId('synced-icon')).toBeInTheDocument();
expect(screen.getByText('Synced')).toBeInTheDocument();
});
test('should return application health chip component', () => {
render(<AppSyncStatus app={mockApplication} isChip />);
expect(screen.getByTestId('app-sync-status-chip')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,21 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { mockApplication } from '../../../../dev/__data__';
import { Application } from '../../../types';
import StatusHeading from '../StatusHeading';
describe('StatusHeading', () => {
test('should not render if the application is not available', () => {
render(<StatusHeading app={null as unknown as Application} />);
expect(screen.queryByText('app-sync-status-chip')).not.toBeInTheDocument();
});
test('should render if the application is available', () => {
render(<StatusHeading app={mockApplication} />);
expect(screen.queryByTestId('app-health-status-chip')).toBeInTheDocument();
expect(screen.queryByTestId('app-sync-status-chip')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,46 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { HealthStatus, SyncStatusCode } from '../../../types';
import { AppHealthIcon, SyncIcon } from '../StatusIcons';
describe('StatusIcons', () => {
describe('Sync Status', () => {
test('should return application sync icon', () => {
render(<SyncIcon status="Synced" />);
expect(screen.getByTestId('synced-icon')).toBeInTheDocument();
render(<SyncIcon status="OutOfSync" />);
expect(screen.getByTestId('outofsync-icon')).toBeInTheDocument();
render(<SyncIcon status="Unknown" />);
expect(screen.getByTestId('unknown-icon')).toBeInTheDocument();
render(<SyncIcon status={'invalid' as SyncStatusCode} />);
expect(screen.getByTestId('unknown-icon')).toBeInTheDocument();
});
});
describe('Health Status', () => {
test('should return application health status icon', () => {
render(<AppHealthIcon status={HealthStatus.Healthy} />);
expect(screen.getByTestId('healthy-icon')).toBeInTheDocument();
render(<AppHealthIcon status={HealthStatus.Suspended} />);
expect(screen.getByTestId('suspended-icon')).toBeInTheDocument();
render(<AppHealthIcon status={HealthStatus.Progressing} />);
expect(screen.getByTestId('progressing-icon')).toBeInTheDocument();
render(<AppHealthIcon status={HealthStatus.Missing} />);
expect(screen.getByTestId('missing-icon')).toBeInTheDocument();
render(<AppHealthIcon status={HealthStatus.Degraded} />);
expect(screen.getByTestId('degraded-icon')).toBeInTheDocument();
render(<AppHealthIcon status={HealthStatus.Unknown} />);
expect(screen.getByTestId('unknown-icon')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,14 @@
import React from 'react';
import { Alert, AlertTitle } from '@material-ui/lab';
const PermissionAlert = () => {
return (
<Alert severity="warning" data-testid="no-permission-alert">
<AlertTitle>Permission required</AlertTitle>
To view argocd plugin, contact your administrator to give you the
argocd.view.read permission.
</Alert>
);
};
export default PermissionAlert;

View File

@ -0,0 +1,152 @@
import * as React from 'react';
import { Progress, ResponseErrorPanel } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import { createStyles, makeStyles, Theme, Typography } from '@material-ui/core';
import { argoCDApiRef } from '../../api';
import { useApplications } from '../../hooks/useApplications';
import { useArgocdConfig } from '../../hooks/useArgocdConfig';
import { useArgocdViewPermission } from '../../hooks/useArgocdViewPermission';
import { Application, Revision } from '../../types';
import {
getArgoCdAppConfig,
getInstanceName,
getUniqueRevisions,
} from '../../utils/utils';
import PermissionAlert from '../Common/PermissionAlert';
import DeploymentLifecycleCard from './DeploymentLifecycleCard';
import DeploymentLifecycleDrawer from './DeploymentLifecycleDrawer';
const useDrawerStyles = makeStyles<Theme>(theme =>
createStyles({
lifecycle: {
display: 'flex',
flexWrap: 'nowrap',
overflowX: 'auto',
background:
theme.palette.type === 'dark'
? theme.palette.grey[700]
: theme.palette.grey[200],
color: 'black',
margin: '1px solid red',
padding: '20px',
borderRadius: '10px',
},
}),
);
const DeploymentLifecycle = () => {
const { entity } = useEntity();
const classes = useDrawerStyles();
const api = useApi(argoCDApiRef);
const { instances, intervalMs } = useArgocdConfig();
const instanceName = getInstanceName(entity) || instances?.[0]?.name;
const { appSelector, appName, projectName, appNamespace } =
getArgoCdAppConfig({ entity });
const { apps, loading, error } = useApplications({
instanceName,
intervalMs,
appSelector,
appName,
appNamespace,
projectName,
});
const hasArgocdViewAccess = useArgocdViewPermission();
const [open, setOpen] = React.useState(false);
const [activeItem, setActiveItem] = React.useState<string>();
const [, setRevisions] = React.useState<{
[key: string]: Revision;
}>();
const revisionCache = React.useRef<{ [key: string]: Revision }>({});
const uniqRevisions: string[] = React.useMemo(
() => getUniqueRevisions(apps),
[apps],
);
React.useEffect(() => {
if (uniqRevisions.length !== Object.keys(revisionCache.current).length) {
api
.getRevisionDetailsList({
apps: apps,
instanceName,
revisionIDs: uniqRevisions,
})
.then(data => {
uniqRevisions.forEach((rev, i) => {
revisionCache.current[rev] = data[i];
});
setRevisions(revisionCache.current);
})
.catch(e => {
// eslint-disable-next-line no-console
console.warn(e);
});
}
}, [api, apps, entity, instanceName, uniqRevisions]);
const toggleDrawer = () => setOpen(e => !e);
const activeApp = apps.find(a => a.metadata.name === activeItem);
if (!hasArgocdViewAccess) {
return <PermissionAlert />;
}
if (error) {
return <ResponseErrorPanel data-testid="error-panel" error={error} />;
}
if (loading) {
return (
<div data-testid="argocd-loader">
<Progress />
</div>
);
} else if (apps?.length === 0) {
return null;
}
return (
<>
<Typography variant="h2" gutterBottom>
Deployment lifecycle
</Typography>
<Typography variant="subtitle1" gutterBottom>
Review deployed components/systems in the namespace using ArgoCD plugin
</Typography>
<div className={classes.lifecycle}>
{apps.map((app: Application, idx: number) => (
<DeploymentLifecycleCard
app={app}
key={app.metadata.uid ?? idx}
revisionsMap={revisionCache.current}
onclick={() => {
toggleDrawer();
setActiveItem(app.metadata.name);
}}
/>
))}
</div>
<DeploymentLifecycleDrawer
app={activeApp}
isOpen={open}
onClose={() => setOpen(false)}
revisionsMap={revisionCache.current}
/>
</>
);
};
export default DeploymentLifecycle;

View File

@ -0,0 +1,208 @@
import * as React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
Card,
CardContent,
CardHeader,
Chip,
createStyles,
Divider,
Grid,
Link,
makeStyles,
Theme,
Tooltip,
Typography,
} from '@material-ui/core';
import { Skeleton } from '@material-ui/lab';
import GitLabIcon from '@patternfly/react-icons/dist/esm/icons/gitlab-icon';
import moment from 'moment';
import { Application, Revision } from '../../types';
import { getCommitUrl, isAppHelmChartType } from '../../utils/utils';
import AppNamespace from '../AppStatus/AppNamespace';
import StatusHeading from '../AppStatus/StatusHeading';
import DeploymentLifecycleHeader from './DeploymentLifecycleHeader';
const useCardStyles = makeStyles<Theme>(theme =>
createStyles({
card: {
flex: '0 0 auto',
marginRight: theme.spacing(2.5),
maxWidth: '300px',
},
commitMessage: {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
},
}),
);
interface DeploymentLifecycleCardProps {
app: Application;
revisionsMap: { [key: string]: Revision };
onclick?: () => void;
}
const DeploymentLifecycleCard: React.FC<DeploymentLifecycleCardProps> = ({
app,
onclick,
revisionsMap,
}) => {
const appHistory = app?.status?.history ?? [];
const latestRevision = appHistory[appHistory.length - 1];
const appDeployedAt = latestRevision?.deployedAt;
const classes = useCardStyles();
const { entity } = useEntity();
if (!app) {
return null;
}
return (
<Card
data-testid={`${app?.metadata?.name}-card`}
key={app?.metadata?.uid}
className={classes.card}
style={{ justifyContent: 'space-between' }}
onClick={onclick}
>
<CardHeader
title={<DeploymentLifecycleHeader app={app} />}
titleTypographyProps={{
variant: 'subtitle2',
}}
subheader={<StatusHeading app={app} />}
/>
<Divider />
<CardContent>
<Grid container spacing={1} alignItems="flex-start">
<Grid item xs={12}>
<Typography variant="body1">Instance</Typography>
<Typography variant="body2" color="textSecondary" gutterBottom>
{app?.metadata?.instance?.name}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="body1">Server</Typography>
<Typography
style={{ overflowWrap: 'anywhere' }}
variant="body2"
color="textSecondary"
gutterBottom
>
{app?.spec?.destination?.server}{' '}
{app?.spec?.destination?.server ===
'https://kubernetes.default.svc' ? (
<Tooltip
data-testid="local-cluster-tooltip"
title="This is the local cluster where Argo CD is installed."
>
<span>(in-cluster) </span>
</Tooltip>
) : (
''
)}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="body1" color="textPrimary">
Namespace
</Typography>
<AppNamespace app={app} />
</Grid>
{!isAppHelmChartType(app) && (
<Grid item xs={12}>
<Typography color="textPrimary">Commit</Typography>
{revisionsMap && latestRevision ? (
<>
<Chip
data-testid={`${latestRevision?.revision?.slice(
0,
5,
)}-commit-link`}
size="small"
variant="outlined"
onClick={e => {
e.stopPropagation();
const repoUrl = app?.spec?.source?.repoURL ?? '';
if (repoUrl.length) {
window.open(
getCommitUrl(
repoUrl,
latestRevision?.revision,
entity?.metadata?.annotations ?? {},
),
'_blank',
);
}
}}
icon={<GitLabIcon />}
color="primary"
label={latestRevision?.revision.slice(0, 7)}
/>
<Typography
variant="body2"
color="textSecondary"
className={classes.commitMessage}
>
{revisionsMap?.[latestRevision?.revision] ? (
<Tooltip
data-testid={`${latestRevision?.revision?.slice(
0,
5,
)}-commit-message`}
title={
revisionsMap?.[latestRevision?.revision]?.message
}
>
<span>
{revisionsMap?.[latestRevision?.revision]?.message}
</span>
</Tooltip>
) : (
<Skeleton />
)}
</Typography>
</>
) : (
<>-</>
)}
</Grid>
)}
<Grid item xs={12}>
<Typography variant="body1" color="textPrimary">
Deployment
</Typography>
{appHistory.length >= 1 ? (
<Typography variant="body2" color="textSecondary">
Image{' '}
<Link
href={`https://${app?.status?.summary?.images?.[0]}`}
target="_blank"
rel="noopener"
>
{app?.status?.summary?.images?.[0].split('/').pop()}
</Link>{' '}
deployed {moment(appDeployedAt).local().fromNow()}
</Typography>
) : (
<>-</>
)}
</Grid>
</Grid>
</CardContent>
</Card>
);
};
export default DeploymentLifecycleCard;

View File

@ -0,0 +1,302 @@
import * as React from 'react';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
Box,
Card,
CardContent,
Chip,
createStyles,
Drawer,
Grid,
IconButton,
Link,
makeStyles,
Theme,
Tooltip,
Typography,
} from '@material-ui/core';
import Close from '@material-ui/icons/Close';
import { Skeleton } from '@material-ui/lab';
import GitLabIcon from '@patternfly/react-icons/dist/esm/icons/gitlab-icon';
import moment from 'moment';
import { Application, Revision } from '../../types';
import { getCommitUrl, isAppHelmChartType } from '../../utils/utils';
import AppNamespace from '../AppStatus/AppNamespace';
import StatusHeading from '../AppStatus/StatusHeading';
import DeploymentLifecycledHeader from './DeploymentLifecycleHeader';
interface DeploymentLifecycleDrawerProps {
app: Application | undefined;
isOpen: boolean;
onClose: () => void;
revisionsMap: { [key: string]: Revision };
}
const useDrawerStyles = makeStyles<Theme>(theme =>
createStyles({
icon: {
fontSize: 20,
},
paper: {
width: '40%',
padding: theme.spacing(2.5),
gap: '3%',
},
header: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'baseline',
},
commitMessage: {
wordBreak: 'break-word',
},
}),
);
const DeploymentLifecycleDrawer: React.FC<DeploymentLifecycleDrawerProps> = ({
app,
isOpen,
onClose,
revisionsMap,
}) => {
const appHistory = app?.status?.history ?? [];
const latestRevision = appHistory[appHistory.length - 1];
const appDeployedAt = latestRevision?.deployedAt;
const { entity } = useEntity();
const classes = useDrawerStyles();
if (!app) {
return null;
}
return (
<Drawer
data-testid={`${app?.metadata?.name}-drawer`}
anchor="right"
open={isOpen}
onClose={onClose}
classes={{
paper: classes.paper,
}}
>
<CardContent>
<Grid container alignItems="stretch">
<Grid item xs={12}>
<div className={classes.header}>
<Typography variant="h4">
<DeploymentLifecycledHeader app={app} />
</Typography>
<IconButton
key="dismiss"
title="Close the drawer"
onClick={onClose}
color="inherit"
>
<Close className={classes.icon} />
</IconButton>
</div>
<div style={{ display: 'flex' }}>
<StatusHeading app={app} />
</div>
</Grid>
<Grid item xs={12}>
<Typography color="textPrimary">Instance</Typography>
<Typography color="textSecondary" gutterBottom>
{app?.metadata?.instance?.name}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="body1">Server</Typography>
<Typography
style={{ overflowWrap: 'anywhere' }}
variant="body2"
color="textSecondary"
gutterBottom
>
{app?.spec?.destination?.server}{' '}
{app?.spec?.destination?.server ===
'https://kubernetes.default.svc' ? (
<Tooltip title="This is the local cluster where Argo CD is installed.">
<span>(in-cluster) </span>
</Tooltip>
) : (
''
)}
</Typography>
</Grid>
<Grid item xs={12}>
<Typography color="textPrimary">Namespace</Typography>
<AppNamespace app={app} />
</Grid>
{!isAppHelmChartType(app) && (
<Grid item xs={12}>
<Typography color="textPrimary">Commit</Typography>
{latestRevision ? (
<>
<Chip
data-testid={`${latestRevision?.revision?.slice(
0,
5,
)}-commit-link`}
size="small"
variant="outlined"
icon={<GitLabIcon />}
color="primary"
onClick={e => {
e.stopPropagation();
const repoUrl = app?.spec?.source?.repoURL ?? '';
if (repoUrl) {
window.open(
isAppHelmChartType(app)
? repoUrl
: getCommitUrl(
repoUrl,
latestRevision?.revision,
entity?.metadata?.annotations ?? {},
),
'_blank',
);
}
}}
label={latestRevision?.revision.slice(0, 7)}
/>
<Typography
color="textSecondary"
className={classes.commitMessage}
>
{revisionsMap?.[latestRevision?.revision] ? (
<>
{revisionsMap?.[latestRevision?.revision]?.message} by{' '}
{revisionsMap?.[latestRevision?.revision]?.author}
</>
) : (
<Skeleton />
)}
</Typography>
</>
) : (
<>-</>
)}
</Grid>
)}
{appHistory.length >= 1 && (
<Grid item xs={12}>
<Typography color="textPrimary">Latest deployment</Typography>
<Card elevation={2} style={{ margin: '10px' }}>
<CardContent>
<Typography color="textPrimary" gutterBottom>
Deployment
</Typography>
<Typography
variant="body2"
color="textSecondary"
className={classes.commitMessage}
>
Image{' '}
<Link
href={`https://${app?.status?.summary?.images?.[0]}`}
target="_blank"
rel="noopener"
>
{app?.status?.summary?.images?.[0].split('/').pop()}
</Link>
<br />
{revisionsMap[latestRevision?.revision]?.message}{' '}
<Link
href={
isAppHelmChartType(app)
? app?.spec?.source?.repoURL
: getCommitUrl(
app?.spec?.source?.repoURL ?? '',
latestRevision?.revision,
entity?.metadata?.annotations ?? {},
)
}
target="_blank"
rel="noopener"
>
{latestRevision?.revision.slice(0, 7)}
</Link>{' '}
deployed {moment(appDeployedAt).local().fromNow()}
</Typography>
</CardContent>
</Card>
</Grid>
)}
{appHistory.length > 1 && (
<Grid item xs={12}>
<Typography color="textPrimary">Deployment history</Typography>
<Box
style={{
width: '100%',
margin: 0,
padding: '0',
height: '35vh',
overflowY: 'auto',
}}
>
<br />
{app?.status?.history
?.slice()
?.reverse()
.slice(1)
?.map(dep => {
const commitUrl = app?.spec?.source?.repoURL
? getCommitUrl(
app.spec.source.repoURL,
dep?.revision,
entity?.metadata?.annotations ?? {},
)
: null;
return (
<Card
elevation={2}
key={dep.id}
style={{ margin: '10px' }}
>
<CardContent>
<Typography color="textPrimary" gutterBottom>
Deployment
</Typography>
<Typography
variant="body2"
color="textSecondary"
className={classes.commitMessage}
>
{revisionsMap[dep.revision]?.message}{' '}
<Link
aria-disabled={!!commitUrl}
href={commitUrl ?? ''}
target="_blank"
rel="noopener"
>
{dep.revision.slice(0, 7)}
</Link>{' '}
deployed {moment(dep.deployedAt).local().fromNow()}
</Typography>
</CardContent>
</Card>
);
})}
</Box>
</Grid>
)}
</Grid>
</CardContent>
</Drawer>
);
};
export default DeploymentLifecycleDrawer;

View File

@ -0,0 +1,38 @@
import React from 'react';
import { IconButton } from '@material-ui/core';
import ExternalLinkIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon';
import { useArgocdConfig } from '../../hooks/useArgocdConfig';
import { Application } from '../../types';
const DeploymentLifecycleHeader: React.FC<{ app: Application }> = ({ app }) => {
const { instances, baseUrl } = useArgocdConfig();
const supportsMultipleArgoInstances = !!instances.length;
const getBaseUrl = (row: Application): string | undefined => {
if (supportsMultipleArgoInstances && !baseUrl) {
return instances?.find(
value => value?.name === row.metadata?.instance?.name,
)?.url;
}
return baseUrl;
};
return (
<>
{app.metadata.name}{' '}
<IconButton
data-testid={`${app.metadata.name}-link`}
color="primary"
size="small"
target="_blank"
href={`${getBaseUrl(app)}/applications/${app.metadata.name}`}
onClick={e => e.stopPropagation()}
>
<ExternalLinkIcon />
</IconButton>
</>
);
};
export default DeploymentLifecycleHeader;

View File

@ -0,0 +1,204 @@
import React, { PropsWithChildren } from 'react';
import { useApi } from '@backstage/core-plugin-api';
import { usePermission } from '@backstage/plugin-permission-react';
import { createTheme, ThemeProvider } from '@material-ui/core';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { mockApplication, mockEntity } from '../../../../dev/__data__';
import { useArgocdConfig } from '../../../hooks/useArgocdConfig';
import DeploymentLifecycle from '../DeploymentLifecycle';
jest.mock('../../../hooks/useArgocdConfig', () => ({
useArgocdConfig: jest.fn(),
}));
jest.mock('@backstage/plugin-permission-react', () => ({
usePermission: jest.fn(),
}));
const mockUsePermission = usePermission as jest.MockedFunction<
typeof usePermission
>;
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useApi: jest.fn(),
}));
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntity: () => ({
entity: mockEntity,
}),
}));
jest.mock('@backstage/core-components', () => ({
...jest.requireActual('@backstage/core-components'),
ResponseErrorPanel: ({ error }: { error: Error }) => (
<div data-testid="error-panel">{JSON.stringify(error)}</div>
),
}));
describe('DeploymentLifecycle', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUsePermission.mockReturnValue({ loading: false, allowed: true });
(useArgocdConfig as any).mockReturnValue({
baseUrl: 'https://baseurl.com',
instances: [{ name: 'main', url: 'https://main-instance-url.com' }],
intervalMs: 10000,
instanceName: 'main',
});
(useApi as any).mockReturnValue({
listApps: async () => {
return Promise.resolve({ items: [mockApplication] });
},
getRevisionDetailsList: async () => {
return Promise.resolve({
commit: 'commit message',
author: 'test-user',
date: new Date(),
});
},
});
jest.spyOn(console, 'warn').mockImplementation(() => {});
});
it('should render Permission alert if the user does not have view permission', () => {
mockUsePermission.mockReturnValue({ loading: false, allowed: false });
const { getByTestId } = render(<DeploymentLifecycle />);
expect(getByTestId('no-permission-alert')).toBeInTheDocument();
});
test('should render the loader component', async () => {
render(<DeploymentLifecycle />);
await waitFor(() => {
expect(screen.getByTestId('argocd-loader')).toBeInTheDocument();
});
});
test('should render deployment lifecycle component', async () => {
render(<DeploymentLifecycle />);
await waitFor(() => {
expect(screen.queryByTestId('argocd-loader')).not.toBeInTheDocument();
screen.getByText('Deployment lifecycle');
screen.getByTestId('quarkus-app-dev-card');
});
});
const theme = createTheme({
palette: {
type: 'dark',
},
});
const Providers = ({ children }: PropsWithChildren) => (
<ThemeProvider theme={theme}>{children}</ThemeProvider>
);
test('should render components in dark theme', async () => {
render(
<Providers>
<DeploymentLifecycle />
</Providers>,
);
await waitFor(() => {
expect(screen.queryByTestId('argocd-loader')).not.toBeInTheDocument();
screen.getByText('Deployment lifecycle');
screen.getByTestId('quarkus-app-dev-card');
});
});
test('should catch the error while fetching revision details', async () => {
(useApi as any).mockReturnValue({
listApps: async () => {
return Promise.resolve({ items: [mockApplication] });
},
getRevisionDetailsList: async () => {
return Promise.reject(new Error('500: Internal server error'));
},
});
render(<DeploymentLifecycle />);
await waitFor(() => {
expect(screen.queryByTestId('argocd-loader')).not.toBeInTheDocument();
screen.getByText('Deployment lifecycle');
screen.getByTestId('quarkus-app-dev-card');
});
});
test('should catch the error while fetching applications', async () => {
(useApi as any).mockReturnValue({
listApps: async () => {
return Promise.reject(new Error('500: Internal server error'));
},
getRevisionDetailsList: async () => {
return Promise.resolve({
commit: 'commit message',
author: 'test-user',
date: new Date(),
});
},
});
render(<DeploymentLifecycle />);
await waitFor(() => {
expect(screen.queryByTestId('argocd-loader')).not.toBeInTheDocument();
screen.getByTestId('error-panel');
});
});
test('should not render the component if there are no applications matching the selector', async () => {
(useApi as any).mockReturnValue({
listApps: async () => {
return Promise.resolve({ items: [] });
},
getRevisionDetailsList: async () => {
return Promise.resolve({});
},
});
render(<DeploymentLifecycle />);
await waitFor(() => {
expect(screen.queryByTestId('argocd-loader')).not.toBeInTheDocument();
expect(
screen.queryByText('Deployment lifecycle'),
).not.toBeInTheDocument();
});
});
test('should open and close the sidebar', async () => {
render(<DeploymentLifecycle />);
await waitFor(() => {
expect(screen.queryByTestId('argocd-loader')).not.toBeInTheDocument();
screen.getByText('Deployment lifecycle');
screen.getByTestId('quarkus-app-dev-card');
});
fireEvent.click(screen.getByTestId('quarkus-app-dev-card'));
await waitFor(() => {
screen.getByTestId('quarkus-app-dev-drawer');
});
fireEvent.click(screen.getByRole('button', { name: 'Close the drawer' }));
await waitFor(() => {
expect(
screen.queryByTestId('quarkus-app-dev-drawer'),
).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,172 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { mockApplication, mockEntity } from '../../../../dev/__data__';
import { useArgocdConfig } from '../../../hooks/useArgocdConfig';
import { Application, Source } from '../../../types';
import DeploymentLifecycleCard from '../DeploymentLifecycleCard';
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntity: () => ({
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {
...mockEntity.metadata.annotations,
'gitlab.com/source-url': 'https://gitlab.com/testingrepo',
},
},
}),
}));
jest.mock('../../../hooks/useArgocdConfig', () => ({
useArgocdConfig: jest.fn(),
}));
describe('DeploymentLifecylceCard', () => {
beforeEach(() => {
jest.clearAllMocks();
(useArgocdConfig as any).mockReturnValue({
baseUrl: '',
instances: [{ name: 'main', url: 'https://main-instance-url.com' }],
intervalMs: 10000,
instanceName: 'main',
});
});
test('should not render if the application is not passed', () => {
render(
<DeploymentLifecycleCard
app={null as unknown as Application}
revisionsMap={{}}
/>,
);
expect(
screen.queryByTestId('quarkus-app-dev-card'),
).not.toBeInTheDocument();
});
test('should render if the application card', () => {
render(<DeploymentLifecycleCard app={mockApplication} revisionsMap={{}} />);
expect(screen.getByTestId('quarkus-app-dev-card')).toBeInTheDocument();
});
test('application header should link to external link', () => {
render(<DeploymentLifecycleCard app={mockApplication} revisionsMap={{}} />);
const link = screen.getByTestId('quarkus-app-dev-link');
fireEvent.click(link);
expect(link).toHaveAttribute(
'href',
'https://main-instance-url.com/applications/quarkus-app-dev',
);
});
test('should render incluster tooltip', () => {
render(<DeploymentLifecycleCard app={mockApplication} revisionsMap={{}} />);
fireEvent.mouseDown(screen.getByText('(in-cluster)'));
expect(screen.getByTestId('local-cluster-tooltip')).toBeInTheDocument();
});
test('should render remote cluster url', () => {
const remoteApplication: Application = {
...mockApplication,
spec: {
...mockApplication.spec,
destination: {
server: 'https://remote-url.com',
namespace: 'remote-ns',
},
},
};
render(
<DeploymentLifecycleCard app={remoteApplication} revisionsMap={{}} />,
);
expect(screen.queryByText('(in-cluster)')).not.toBeInTheDocument();
expect(
screen.queryByTestId('local-cluster-tooltip'),
).not.toBeInTheDocument();
screen.getByText('https://remote-url.com');
});
test('should not open a new windown if the missing git url', () => {
const remoteApplication: Application = {
...mockApplication,
spec: {
...mockApplication.spec,
source: undefined as unknown as Source,
},
};
global.open = jest.fn();
render(
<DeploymentLifecycleCard app={remoteApplication} revisionsMap={{}} />,
);
const commitLink = screen.getByTestId('90f97-commit-link');
fireEvent.click(commitLink);
expect(global.open).toHaveBeenCalledTimes(0);
});
test('application card should contain commit link', () => {
(useArgocdConfig as any).mockReturnValue({
baseUrl: 'https://baseUrl.com',
instances: [{ name: 'main', url: 'https://main-instance-url.com' }],
intervalMs: 10000,
instanceName: 'main',
});
global.open = jest.fn();
render(
<DeploymentLifecycleCard
app={mockApplication}
revisionsMap={{
'90f9758b7033a4bbb7c33a35ee474d61091644bc': {
author: 'test user',
message: 'commit message',
date: new Date(),
},
}}
/>,
);
const commitLink = screen.getByTestId('90f97-commit-link');
fireEvent.click(commitLink);
expect(global.open).toHaveBeenCalled();
expect(global.open).toHaveBeenCalledWith(
'https://gitlab-gitlab.apps.cluster.test.com/development/quarkus-app-gitops',
'_blank',
);
screen.getByText('commit message');
});
});
test('application card should not contain commit section for helm based applications', () => {
(useArgocdConfig as any).mockReturnValue({
baseUrl: 'https://baseUrl.com',
instances: [{ name: 'main', url: 'https://main-instance-url.com' }],
intervalMs: 10000,
instanceName: 'main',
});
const helmApplication = {
...mockApplication,
spec: {
...mockApplication.spec,
source: { ...mockApplication.spec.source, chart: 'redhat-charts' },
},
};
render(<DeploymentLifecycleCard app={helmApplication} revisionsMap={{}} />);
const commitLink = screen.queryByText('Commit');
expect(commitLink).not.toBeInTheDocument();
});

View File

@ -0,0 +1,186 @@
import React from 'react';
import { configApiRef } from '@backstage/core-plugin-api';
import { MockConfigApi, TestApiProvider } from '@backstage/test-utils';
import { fireEvent, render, screen } from '@testing-library/react';
import { mockApplication, mockEntity } from '../../../../dev/__data__';
import { Application, Source } from '../../../types';
import DeploymentLifecycleDrawer from '../DeploymentLifecycleDrawer';
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntity: () => ({
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {
...mockEntity.metadata.annotations,
'gitlab.com/source-url': 'https://gitlab.com/testingrepo',
},
},
}),
}));
describe('DeploymentLifecycleDrawer', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<TestApiProvider
apis={[
[
configApiRef,
new MockConfigApi({
argocd: {
appLocatorMethods: [
{
instances: [
{
name: 'main',
url: 'https://test.com',
},
],
type: 'config',
},
],
},
}),
],
]}
>
{children}
</TestApiProvider>
);
};
test('should not render the application drawer component', () => {
render(
<DeploymentLifecycleDrawer
app={null as unknown as Application}
isOpen
onClose={() => jest.fn()}
revisionsMap={{}}
/>,
);
expect(
screen.queryByTestId('quarkus-app-dev-drawer'),
).not.toBeInTheDocument();
});
test('should render the application drawer component', () => {
render(
<DeploymentLifecycleDrawer
app={mockApplication}
isOpen
onClose={() => jest.fn()}
revisionsMap={{}}
/>,
{ wrapper },
);
expect(screen.getByTestId('quarkus-app-dev-drawer')).toBeInTheDocument();
});
test('should not render the commit section for helm based applications in drawer component', () => {
const helmApplication = {
...mockApplication,
spec: {
...mockApplication.spec,
source: { ...mockApplication.spec.source, chart: 'redhat-charts' },
},
};
render(
<DeploymentLifecycleDrawer
app={helmApplication}
isOpen
onClose={() => jest.fn()}
revisionsMap={{}}
/>,
{ wrapper },
);
const commitLink = screen.queryByText('Commit');
expect(commitLink).not.toBeInTheDocument();
});
test('should render the commit link in drawer component', () => {
global.open = jest.fn();
render(
<DeploymentLifecycleDrawer
app={mockApplication}
isOpen
onClose={() => jest.fn()}
revisionsMap={{
'90f9758b7033a4bbb7c33a35ee474d61091644bc': {
author: 'test user',
message: 'commit message',
date: new Date(),
},
}}
/>,
{ wrapper },
);
const commitLink = screen.getByTestId('90f97-commit-link');
fireEvent.click(commitLink);
expect(global.open).toHaveBeenCalled();
expect(global.open).toHaveBeenCalledWith(
'https://gitlab-gitlab.apps.cluster.test.com/development/quarkus-app-gitops',
'_blank',
);
});
test('should not open a new windown if the missing git url', () => {
const remoteApplication: Application = {
...mockApplication,
spec: {
...mockApplication.spec,
source: undefined as unknown as Source,
},
};
global.open = jest.fn();
render(
<DeploymentLifecycleDrawer
isOpen
onClose={jest.fn()}
app={remoteApplication}
revisionsMap={{}}
/>,
{ wrapper },
);
const commitLink = screen.getByTestId('90f97-commit-link');
fireEvent.click(commitLink);
expect(global.open).toHaveBeenCalledTimes(0);
});
test('should render remote cluster url', () => {
const remoteApplication: Application = {
...mockApplication,
spec: {
...mockApplication.spec,
destination: {
server: 'https://remote-url.com',
namespace: 'remote-ns',
},
},
};
render(
<DeploymentLifecycleDrawer
isOpen
onClose={jest.fn()}
app={remoteApplication}
revisionsMap={{}}
/>,
{ wrapper },
);
expect(screen.queryByText('(in-cluster)')).not.toBeInTheDocument();
expect(
screen.queryByTestId('local-cluster-tooltip'),
).not.toBeInTheDocument();
screen.getByText('https://remote-url.com');
});
});

View File

@ -0,0 +1,99 @@
import React from 'react';
import { configApiRef } from '@backstage/core-plugin-api';
import { MockConfigApi, TestApiProvider } from '@backstage/test-utils';
import { fireEvent, render, screen } from '@testing-library/react';
import { mockApplication } from '../../../../dev/__data__';
import DeploymentLifecycleHeader from '../DeploymentLifecycleHeader';
describe('DeploymentLifecycleCardHeader', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<TestApiProvider
apis={[
[
configApiRef,
new MockConfigApi({
argocd: {
appLocatorMethods: [
{
instances: [
{
name: 'main',
url: 'https://test.com',
},
],
type: 'config',
},
],
},
}),
],
]}
>
{children}
</TestApiProvider>
);
};
test('should render the deployment lifecylce appliction header', () => {
render(<DeploymentLifecycleHeader app={mockApplication} />, { wrapper });
expect(screen.queryByText('quarkus-app-dev')).toBeInTheDocument();
expect(screen.queryByTestId('quarkus-app-dev-link')).toBeInTheDocument();
});
test('should render the deployment lifecylce appliction header link with instance url', () => {
render(<DeploymentLifecycleHeader app={mockApplication} />, { wrapper });
const link = screen.queryByTestId(
'quarkus-app-dev-link',
) as HTMLLinkElement;
fireEvent.click(link);
expect(link).toHaveAttribute(
'href',
'https://test.com/applications/quarkus-app-dev',
);
});
test('should render the deployment lifecylce appliction header with base url', () => {
const apiProviderWrapper = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<TestApiProvider
apis={[
[
configApiRef,
new MockConfigApi({
argocd: {
baseUrl: 'https://baseurl.com',
appLocatorMethods: [],
},
}),
],
]}
>
{children}
</TestApiProvider>
);
};
render(<DeploymentLifecycleHeader app={mockApplication} />, {
wrapper: apiProviderWrapper,
});
const link = screen.queryByTestId(
'quarkus-app-dev-link',
) as HTMLLinkElement;
expect(link).toHaveAttribute(
'href',
'https://baseurl.com/applications/quarkus-app-dev',
);
});
});

View File

@ -0,0 +1 @@
export { default as DeploymentLifecycle } from './DeploymentLifecycle';

View File

@ -0,0 +1,189 @@
import React from 'react';
import { Table, TableColumn } from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import { IconButton, Link } from '@material-ui/core';
import ExternalLinkIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon';
import moment from 'moment';
import { useApplications } from '../../hooks/useApplications';
import { useArgocdConfig } from '../../hooks/useArgocdConfig';
import { useArgocdViewPermission } from '../../hooks/useArgocdViewPermission';
import { Application, HealthStatus, SyncStatuses } from '../../types';
import {
getArgoCdAppConfig,
getCommitUrl,
getInstanceName,
isAppHelmChartType,
} from '../../utils/utils';
import AppSyncStatus from '../AppStatus/AppSyncStatus';
import { AppHealthIcon } from '../AppStatus/StatusIcons';
const DeploymentSummary = () => {
const { entity } = useEntity();
const { baseUrl, instances, intervalMs } = useArgocdConfig();
const instanceName = getInstanceName(entity) || instances?.[0]?.name;
const { appSelector, appName, projectName, appNamespace } =
getArgoCdAppConfig({ entity });
const { apps, loading, error } = useApplications({
instanceName,
intervalMs,
appSelector,
projectName,
appName,
appNamespace,
});
const hasArgocdViewAccess = useArgocdViewPermission();
const supportsMultipleArgoInstances = !!instances.length;
const getBaseUrl = (row: any): string | undefined => {
if (supportsMultipleArgoInstances && !baseUrl) {
return instances?.find(
value => value?.name === row.metadata?.instance?.name,
)?.url;
}
return baseUrl;
};
const columns: TableColumn<Application>[] = [
{
title: 'ArgoCD App',
field: 'name',
render: (row: Application): React.ReactNode =>
getBaseUrl(row) ? (
<Link
href={`${getBaseUrl(row)}/applications/${row?.metadata?.name}`}
target="_blank"
rel="noopener"
>
{row.metadata.name}{' '}
<IconButton color="primary" size="small">
<ExternalLinkIcon />
</IconButton>
</Link>
) : (
row.metadata.name
),
},
{
title: 'Namespace',
field: 'namespace',
render: (row: Application): React.ReactNode => {
return <>{row.spec.destination.namespace}</>;
},
},
{
title: 'Instance',
field: 'instance',
render: (row: Application): React.ReactNode => {
return <>{row.metadata?.instance?.name || instanceName}</>;
},
},
{
title: 'Server',
field: 'server',
render: (row: Application): React.ReactNode => {
return <>{row.spec.destination.server}</>;
},
},
{
title: 'Revision',
field: 'revision',
render: (row: Application): React.ReactNode => {
const historyList = row.status?.history ?? [];
const latestRev = historyList[historyList.length - 1];
const repoUrl = row?.spec?.source?.repoURL;
const commitUrl = isAppHelmChartType(row)
? repoUrl
: getCommitUrl(
repoUrl,
latestRev?.revision,
entity?.metadata?.annotations || {},
);
return (
<Link href={commitUrl} target="_blank" rel="noopener">
{latestRev?.revision?.substring(0, 7) ?? '-'}
</Link>
);
},
},
{
id: 'test',
title: 'Last deployed',
field: 'lastdeployed',
customSort: (a: Application, b: Application) => {
const bHistory = b?.status?.history ?? [];
const bDeployedAt = bHistory?.[bHistory.length - 1]?.deployedAt;
const aHistory = a?.status?.history ?? [];
const aDeployedAt = aHistory?.[aHistory.length - 1]?.deployedAt;
return moment(aDeployedAt).diff(moment(bDeployedAt));
},
render: (row: Application): React.ReactNode => {
const history = row?.status?.history ?? [];
const finishedAt = history[history.length - 1]?.deployedAt;
return (
<>
{finishedAt
? moment(finishedAt).local().format('D/MM/YYYY, H:mm:ss')
: null}
</>
);
},
},
{
title: 'Sync status',
field: 'syncstatus',
customSort: (a: Application, b: Application): number => {
const syncStatusOrder: string[] = Object.values(SyncStatuses);
return (
syncStatusOrder.indexOf(a?.status?.sync?.status) -
syncStatusOrder.indexOf(b?.status?.sync?.status)
);
},
render: (row: Application): React.ReactNode => (
<AppSyncStatus app={row} />
),
},
{
title: 'Health status',
field: 'healthstatus',
customSort: (a: Application, b: Application): number => {
const healthStatusOrder: string[] = Object.values(HealthStatus);
return (
healthStatusOrder.indexOf(a?.status?.health?.status) -
healthStatusOrder.indexOf(b?.status?.health?.status)
);
},
render: (row: Application): React.ReactNode => (
<>
<AppHealthIcon status={row.status.health.status as HealthStatus} />{' '}
{row?.status?.health?.status}
</>
),
},
];
return !error && hasArgocdViewAccess ? (
<Table
title="Deployment summary"
options={{
paging: true,
search: false,
draggable: false,
padding: 'dense',
}}
isLoading={loading}
data={apps}
columns={columns}
/>
) : null;
};
export default DeploymentSummary;

View File

@ -0,0 +1,344 @@
import React from 'react';
import { usePermission } from '@backstage/plugin-permission-react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { mockApplication, mockEntity } from '../../../../dev/__data__';
import { useApplications } from '../../../hooks/useApplications';
import { useArgocdConfig } from '../../../hooks/useArgocdConfig';
import { Application, History } from '../../../types';
import DeploymentSummary from '../DeploymentSummary';
jest.mock('../../../hooks/useArgocdConfig', () => ({
useArgocdConfig: jest.fn(),
}));
jest.mock('../../../hooks/useApplications', () => ({
useApplications: jest.fn(),
}));
jest.mock('@backstage/plugin-permission-react', () => ({
usePermission: jest.fn(),
}));
const mockUsePermission = usePermission as jest.MockedFunction<
typeof usePermission
>;
jest.mock('@backstage/plugin-catalog-react', () => ({
useEntity: () => ({
entity: mockEntity,
}),
}));
describe('DeploymentSummary', () => {
beforeEach(() => {
(useApplications as any).mockReturnValue({
apps: [mockApplication],
loading: false,
error: undefined,
});
mockUsePermission.mockReturnValue({ loading: false, allowed: true });
(useArgocdConfig as any).mockReturnValue({
baseUrl: '',
instances: [{ name: 'main', url: 'https://main-instance-url.com' }],
intervalMs: 10000,
instanceName: 'main',
});
});
test('should render loading indicator', async () => {
(useApplications as any).mockReturnValue({
apps: [],
loading: true,
error: undefined,
});
render(<DeploymentSummary />);
await waitFor(() => {
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
});
test('should render empty table incase of no applications', async () => {
(useApplications as any).mockReturnValue({
apps: [],
loading: false,
error: undefined,
});
(useArgocdConfig as any).mockReturnValue({
baseUrl: 'https://baseurl.com',
instances: [{ name: 'test', url: 'https://main-instance-url.com' }],
intervalMs: 10000,
instanceName: 'main',
});
render(<DeploymentSummary />);
await waitFor(() => {
expect(screen.queryByText('No records to display')).toBeInTheDocument();
});
});
test('should not render deployment summary table when the user does not have view permission', async () => {
mockUsePermission.mockReturnValue({ loading: false, allowed: false });
render(<DeploymentSummary />);
await waitFor(() => {
expect(screen.queryByText('Deployment summary')).not.toBeInTheDocument();
});
});
test('should not render deployment summary table incase of error', async () => {
(useApplications as any).mockReturnValue({
apps: [],
loading: false,
error: new Error('500: Internal server error'),
});
render(<DeploymentSummary />);
await waitFor(() => {
expect(screen.queryByText('Deployment summary')).not.toBeInTheDocument();
});
});
test('should render deployment summary', async () => {
(useArgocdConfig as any).mockReturnValue({
instances: [{ name: 'test', url: 'https://main-instance-url.com' }],
intervalMs: 10000,
instanceName: 'test',
});
render(<DeploymentSummary />);
await waitFor(() => {
expect(screen.queryAllByText('quarkus-app-dev')).toBeDefined();
expect(screen.queryByText('Healthy')).toBeInTheDocument();
expect(screen.queryByText('Synced')).toBeInTheDocument();
});
});
test('should link the application to instance url', async () => {
render(<DeploymentSummary />);
await waitFor(() => {
expect(
screen.getByRole('link', { name: 'quarkus-app-dev' }),
).toHaveAttribute(
'href',
'https://main-instance-url.com/applications/quarkus-app-dev',
);
});
});
test('should link the application to the base argocd url', async () => {
(useArgocdConfig as any).mockReturnValue({
baseUrl: 'https://baseurl.com',
instances: [],
intervalMs: 10000,
instanceName: 'main',
});
render(<DeploymentSummary />);
await waitFor(() => {
screen.getByText('Healthy');
screen.getByText('Synced');
expect(
screen.getByRole('link', { name: 'quarkus-app-dev' }),
).toHaveAttribute(
'href',
'https://baseurl.com/applications/quarkus-app-dev',
);
});
});
test('should sort by last deployment time', async () => {
const mockApplicationTwo: Application = {
...mockApplication,
status: {
...mockApplication.status,
history: [
{
...(mockApplication?.status?.history?.[0] as History),
revision: '12345',
},
{
...(mockApplication?.status?.history?.[1] as History),
revision: 'abcde',
deployedAt: new Date().toISOString(),
},
],
},
};
(useApplications as any).mockReturnValue({
apps: [mockApplication, mockApplicationTwo],
loading: false,
error: undefined,
});
render(<DeploymentSummary />);
const lastDeployedHeader = screen.getByRole('button', {
name: 'Last deployed',
});
await waitFor(() => {
const tableBody = screen.queryAllByRole('rowgroup')[1];
const firstRow = within(tableBody).queryAllByRole('row')[0];
within(firstRow).getByText('90f9758');
});
await fireEvent.click(lastDeployedHeader);
// miui table requires two clicks to start sorting
await fireEvent.click(lastDeployedHeader);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
const tableBody = screen.queryAllByRole('rowgroup')[1];
const firstRow = within(tableBody).queryAllByRole('row')[0];
within(firstRow).getByText('abcde');
});
});
test('should sort by last deployment time even if the history is missing for some applications', async () => {
const mockApplicationTwo: Application = {
...mockApplication,
status: {
...mockApplication.status,
history: undefined,
},
};
(useApplications as any).mockReturnValue({
apps: [mockApplication, mockApplicationTwo],
loading: false,
error: undefined,
});
render(<DeploymentSummary />);
const lastDeployedHeader = screen.getByRole('button', {
name: 'Last deployed',
});
await waitFor(() => {
const tableBody = screen.queryAllByRole('rowgroup')[1];
const firstRow = within(tableBody).queryAllByRole('row')[0];
expect(within(firstRow).queryByText('90f9758')).toBeInTheDocument();
});
await fireEvent.click(lastDeployedHeader);
// miui table requires two clicks to start sorting
await fireEvent.click(lastDeployedHeader);
await waitFor(() => {
const tableBody = screen.queryAllByRole('rowgroup')[1];
const firstRow = within(tableBody).queryAllByRole('row')[0];
expect(within(firstRow).getByText('-')).toBeInTheDocument();
});
});
test('should sort by application sync status', async () => {
const mockApplicationTwo: Application = {
...mockApplication,
status: {
...mockApplication.status,
sync: {
status: 'OutOfSync',
},
},
};
(useApplications as any).mockReturnValue({
apps: [mockApplication, mockApplicationTwo],
loading: false,
error: undefined,
});
render(<DeploymentSummary />);
const syncStatusHeader = screen.getByRole('button', {
name: 'Sync status',
});
await waitFor(() => {
const tableBody = screen.queryAllByRole('rowgroup')[1];
const firstRow = within(tableBody).queryAllByRole('row')[0];
within(firstRow).getByText('Synced');
});
await fireEvent.click(syncStatusHeader);
// miui table requires two clicks to start sorting
await fireEvent.click(syncStatusHeader);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
const tableBody = screen.queryAllByRole('rowgroup')[1];
const firstRow = within(tableBody).queryAllByRole('row')[0];
expect(within(firstRow).getByText('OutOfSync')).toBeInTheDocument();
});
});
test('should sort by application health status', async () => {
const mockApplicationTwo: Application = {
...mockApplication,
status: {
...mockApplication.status,
health: {
status: 'Degraded',
},
},
};
(useApplications as any).mockReturnValue({
apps: [mockApplication, mockApplicationTwo],
loading: false,
error: undefined,
});
render(<DeploymentSummary />);
const healthStatusHeader = screen.getByRole('button', {
name: 'Health status',
});
await waitFor(() => {
const tableBody = screen.queryAllByRole('rowgroup')[1];
const firstRow = within(tableBody).queryAllByRole('row')[0];
expect(within(firstRow).queryByText('Healthy')).toBeInTheDocument();
});
await fireEvent.click(healthStatusHeader);
// miui table requires two clicks to start sorting
await fireEvent.click(healthStatusHeader);
await waitFor(() => {
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument();
const tableBody = screen.queryAllByRole('rowgroup')[1];
const firstRow = within(tableBody).queryAllByRole('row')[0];
expect(within(firstRow).getByText('Degraded')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1 @@
export { default as DeploymentSummary } from '../DeploymentSummary/DeploymentSummary';

View File

@ -0,0 +1,228 @@
import { useApi } from '@backstage/core-plugin-api';
import { renderHook, waitFor } from '@testing-library/react';
import { mockApplication } from '../../../dev/__data__';
import { useApplications } from '../useApplications';
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useApi: jest.fn(),
}));
describe('useApplications', () => {
beforeEach(() => {
(useApi as any).mockReturnValue({
getApplication: async () => {
return Promise.resolve({ items: [mockApplication] });
},
listApps: async () => {
return Promise.resolve({ items: [mockApplication] });
},
});
});
test('should return empty if appselector is not passed', async () => {
(useApi as any).mockReturnValue({
listApps: async () => {
return Promise.resolve({});
},
});
const { result } = renderHook(() =>
useApplications({
instanceName: 'main',
appSelector: null as unknown as string,
}),
);
await waitFor(() => {
expect(result.current.loading).toBe(true);
expect(result.current.apps).toHaveLength(0);
});
});
test('should return empty if no applicaitons are available', async () => {
(useApi as any).mockReturnValue({
listApps: async () => {
return Promise.resolve({});
},
});
const { result } = renderHook(() =>
useApplications({
instanceName: 'main',
intervalMs: 10000,
appSelector: 'rht.gitops.com/quarkus-app-bootstrap',
}),
);
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.apps).toHaveLength(0);
});
});
test('should return the applications and loading state', async () => {
const { result } = renderHook(() =>
useApplications({
instanceName: 'main',
intervalMs: 10000,
appSelector: 'rht.gitops.com/quarkus-app-bootstrap',
}),
);
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.apps).toHaveLength(1);
});
});
test('should return the applications and loading state when the app selector updates', async () => {
(useApi as any).mockReturnValue({
listApps: async () => {
return Promise.resolve({ items: [mockApplication] });
},
});
const { result, rerender } = renderHook(prop => useApplications(prop), {
initialProps: {
instanceName: 'main',
intervalMs: 10000,
appSelector: 'rht.gitops.com/quarkus-app-test',
},
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.apps).toHaveLength(1);
});
rerender({
instanceName: 'main',
intervalMs: 10000,
appSelector: 'rht.gitops.com/quarkus-app-bootstrap',
});
await waitFor(() => {
expect(result.current.loading).toBe(true);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.apps).toHaveLength(1);
});
});
test('should invoke listApps method when appSelector is passed', async () => {
const mockListApps = jest.fn();
const mockgetApplication = jest.fn();
(useApi as any).mockReturnValue({
getApplication: mockgetApplication,
listApps: mockListApps,
});
renderHook(() =>
useApplications({
instanceName: 'main',
intervalMs: 10000,
appSelector: 'rht.gitops.com/quarkus-app-bootstrap',
}),
);
await waitFor(() => {
expect(mockListApps).toHaveBeenCalled();
expect(mockgetApplication).not.toHaveBeenCalled();
});
});
test('should invoke getApplication method if appName is passed', async () => {
const mockListApps = jest.fn();
const mockgetApplication = jest.fn();
(useApi as any).mockReturnValue({
getApplication: mockgetApplication,
listApps: mockListApps,
});
renderHook(() =>
useApplications({
instanceName: 'main',
intervalMs: 10000,
appSelector: null as unknown as string,
appName: 'quarkus-app-bootstrap',
}),
);
await waitFor(() => {
expect(mockgetApplication).toHaveBeenCalled();
expect(mockListApps).not.toHaveBeenCalled();
});
});
test('should return single application and loading state when the appName is passed', async () => {
(useApi as any).mockReturnValue({
getApplication: async () => {
return Promise.resolve({ items: [mockApplication] });
},
});
const { result } = renderHook(prop => useApplications(prop), {
initialProps: {
instanceName: 'main',
intervalMs: 10000,
appSelector: null as unknown as string,
appName: 'quarkus-app-test',
},
});
await waitFor(() => {
expect(result.current.loading).toBe(true);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.apps).toHaveLength(1);
});
});
test('should return the applications and loading state when the app name updates', async () => {
(useApi as any).mockReturnValue({
getApplication: async () => {
return Promise.resolve({ items: [mockApplication] });
},
});
const { result, rerender } = renderHook(prop => useApplications(prop), {
initialProps: {
instanceName: 'main',
intervalMs: 10000,
appSelector: null as unknown as string,
appName: 'quarkus-app',
},
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.apps).toHaveLength(1);
});
rerender({
instanceName: 'main',
intervalMs: 10000,
appSelector: null as unknown as string,
appName: 'quarkus-test-app',
});
await waitFor(() => {
expect(result.current.loading).toBe(true);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.apps).toHaveLength(1);
});
});
});

View File

@ -0,0 +1,133 @@
import React from 'react';
import { configApiRef } from '@backstage/core-plugin-api';
import { MockConfigApi, TestApiProvider } from '@backstage/test-utils';
import { renderHook } from '@testing-library/react';
import { useArgocdConfig } from '../useArgocdConfig';
describe('useArgocdConfig', () => {
it('should return default instance and refreshInterval', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<TestApiProvider
apis={[
[
configApiRef,
new MockConfigApi({
argocd: {
appLocatorMethods: [],
},
}),
],
]}
>
{children}
</TestApiProvider>
);
};
const { result } = renderHook(() => useArgocdConfig(), { wrapper });
expect(result.current.instances).toHaveLength(0);
expect(result.current.intervalMs).toBe(10000);
expect(result.current.baseUrl).toBeUndefined();
});
it('should return default name and url', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<TestApiProvider
apis={[
[
configApiRef,
new MockConfigApi({
argocd: {
appLocatorMethods: [
{
instances: [{}],
type: 'config',
},
],
},
}),
],
]}
>
{children}
</TestApiProvider>
);
};
const { result } = renderHook(() => useArgocdConfig(), { wrapper });
expect(result.current.instances).toHaveLength(1);
expect(result.current.instances[0].name).toBe('');
expect(result.current.instances[0].url).toBe('');
});
it('should return base url', () => {
const wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<TestApiProvider
apis={[
[
configApiRef,
new MockConfigApi({
argocd: {
baseUrl: 'https://argo-base-url.com',
appLocatorMethods: [
{
instances: [{}],
type: 'config',
},
],
},
}),
],
]}
>
{children}
</TestApiProvider>
);
};
const { result } = renderHook(() => useArgocdConfig(), { wrapper });
expect(result.current.baseUrl).toBe('https://argo-base-url.com');
});
it('should return configured instance and refreshInterval', () => {
const mockConfig = new MockConfigApi({
argocd: {
refreshInterval: 50000,
appLocatorMethods: [
{
instances: [
{
name: 'test',
url: 'https://test.com',
},
],
type: 'config',
},
],
},
});
const wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<TestApiProvider apis={[[configApiRef, mockConfig]]}>
{children}
</TestApiProvider>
);
};
const { result } = renderHook(() => useArgocdConfig(), { wrapper });
expect(result.current.instances).toHaveLength(1);
expect(result.current.instances[0].url).toBe('https://test.com');
expect(result.current.intervalMs).toBe(50000);
});
});

View File

@ -0,0 +1,97 @@
import * as React from 'react';
import { useAsyncRetry, useInterval } from 'react-use';
import { useApi } from '@backstage/core-plugin-api';
import { argoCDApiRef } from '../api';
import { Application } from '../types';
interface AppOptions {
instanceName: string;
appSelector: string;
intervalMs?: number;
projectName?: string;
appName?: string;
appNamespace?: string;
}
export const useApplications = ({
appName,
appNamespace,
instanceName,
appSelector,
projectName,
intervalMs = 10000,
}: AppOptions): {
apps: Application[];
error: Error | undefined;
loading: boolean;
} => {
const [loadingData, setLoadingData] = React.useState<boolean>(true);
const [, setAppSelector] = React.useState<string>(appSelector ?? '');
const [, setAppName] = React.useState<string | undefined>(appName ?? '');
const [apps, setApps] = React.useState<Application[]>([]);
const api = useApi(argoCDApiRef);
const getApplications = React.useCallback(async () => {
return await api
.listApps({
url: `/argoInstance/${instanceName}`,
appSelector,
projectName,
})
.then(applications => setApps(applications?.items ?? []));
}, [api, appSelector, instanceName, projectName]);
const getApplication = React.useCallback(async () => {
return await api
.getApplication({
url: `/argoInstance/${instanceName}`,
appName: appName as string,
appNamespace,
})
.then(application => setApps([application]));
}, [api, appName, appNamespace, instanceName]);
const { error, loading, retry } = useAsyncRetry(async () => {
if (appName) {
return await getApplication();
}
return await getApplications();
}, [getApplications, getApplication]);
useInterval(() => retry(), intervalMs);
React.useEffect(() => {
let mounted = true;
if (!loading && mounted) {
if (appSelector) {
setAppSelector(prevState => {
if (prevState === appSelector) {
setLoadingData(false);
return appSelector;
}
setLoadingData(true);
return appSelector;
});
} else {
setAppName(oldName => {
if (oldName === appName) {
setLoadingData(false);
return appName;
}
setLoadingData(true);
return appName;
});
}
}
return () => {
mounted = false;
};
}, [loading, appSelector, appName]);
return { apps, error, loading: loadingData };
};

View File

@ -0,0 +1,29 @@
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import { Instances } from '../types';
export const useArgocdConfig = (): {
instances: Instances;
intervalMs: number;
baseUrl: string | undefined;
} => {
const configApi = useApi(configApiRef);
const instances = (
configApi
.getConfigArray('argocd.appLocatorMethods')
.find(value => value.getOptionalString('type') === 'config')
?.getOptionalConfigArray('instances') ?? []
).map(config => ({
name: config.getOptionalString('name') ?? '',
url: config.getOptionalString('url') ?? '',
}));
const intervalMs =
configApi.getOptionalNumber('argocd.refreshInterval') ?? 10000;
const baseUrl = configApi.getOptionalString('argocd.baseUrl');
return {
baseUrl,
instances,
intervalMs,
};
};

View File

@ -0,0 +1,11 @@
import { usePermission } from '@backstage/plugin-permission-react';
import { argocdViewPermission } from '@janus-idp/backstage-plugin-argocd-common';
export const useArgocdViewPermission = () => {
const argocdViewPermissionResult = usePermission({
permission: argocdViewPermission,
});
return argocdViewPermissionResult.allowed;
};

View File

@ -0,0 +1,6 @@
export {
argocdPlugin,
ArgocdDeploymentLifecycle,
ArgocdDeploymentSummary,
} from './plugin';
export { isArgocdConfigured } from './utils/isArgocdConfigured';

View File

@ -0,0 +1,116 @@
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import { configApiRef } from '@backstage/core-plugin-api';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import {
MockConfigApi,
MockPermissionApi,
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import { screen, waitFor } from '@testing-library/react';
import { mockApplication, mockEntity, mockRevision } from '../dev/__data__';
import { argoCDApiRef } from './api';
import {
ArgocdDeploymentLifecycle,
ArgocdDeploymentSummary,
argocdPlugin,
} from './plugin';
import { rootRouteRef } from './routes';
describe('argocd', () => {
const mockedApi: any = {
listApps: async () => {
return { items: [mockApplication] };
},
getRevisionDetails: async () => {
return mockRevision;
},
getRevisionDetailsList: async () => {
return [mockRevision];
},
};
const mockPermissionApi = new MockPermissionApi();
const mockConfiguration = new MockConfigApi({
backend: {
baseUrl: 'http://localhost:7007',
listen: {
port: 7007,
},
},
argocd: {
appLocatorMethods: [],
},
});
const Wrapper = ({ children }: { children: React.ReactNode }) => {
return (
<TestApiProvider
apis={[
[argoCDApiRef, mockedApi],
[configApiRef, mockConfiguration],
[permissionApiRef, mockPermissionApi],
]}
>
<EntityProvider entity={mockEntity}>{children}</EntityProvider>
</TestApiProvider>
);
};
it('should export plugin', () => {
// const app = createDevApp().registerPlugin(argocdPlugin).build();
const [argoApi] = argocdPlugin.getApis();
expect(argocdPlugin).toBeDefined();
expect(
argoApi.factory({
identityApi: { getCredentials: () => {} },
configApi: mockConfiguration,
}),
).toBeDefined();
});
it('should render the deployment lifecycle extension', async () => {
await renderInTestApp(
<Wrapper>
<Routes>
<Route path="/" element={<ArgocdDeploymentLifecycle />} />
</Routes>
</Wrapper>,
{
mountedRoutes: {
'/': rootRouteRef,
},
},
);
await waitFor(() => {
expect(screen.queryByText('Deployment lifecycle')).toBeInTheDocument();
});
});
it('should render the deployment summary extension', async () => {
await renderInTestApp(
<Wrapper>
<Routes>
<Route path="/" element={<ArgocdDeploymentSummary />} />
</Routes>
</Wrapper>,
{
mountedRoutes: {
'/': rootRouteRef,
},
},
);
await waitFor(() => {
expect(screen.queryByText('Deployment summary')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,57 @@
import '@patternfly/react-core/dist/styles/base.css';
import '@patternfly/patternfly/patternfly-theme-dark.css';
import {
configApiRef,
createApiFactory,
createPlugin,
createRoutableExtension,
identityApiRef,
} from '@backstage/core-plugin-api';
import { ArgoCDApiClient, argoCDApiRef } from './api';
import { rootRouteRef } from './routes';
export const argocdPlugin = createPlugin({
id: 'rh-argocd',
routes: {
root: rootRouteRef,
},
apis: [
createApiFactory({
api: argoCDApiRef,
deps: {
identityApi: identityApiRef,
configApi: configApiRef,
},
factory: ({ identityApi, configApi }) =>
new ArgoCDApiClient({
identityApi,
backendBaseUrl: configApi.getString('backend.baseUrl'),
useNamespacedApps: Boolean(
configApi.getOptionalBoolean('argocd.namespacedApps'),
),
}),
}),
],
});
export const ArgocdDeploymentLifecycle = argocdPlugin.provide(
createRoutableExtension({
name: 'ArgocdDeploymentLifecycle',
component: () =>
import('./components/DeploymentLifeCycle').then(
m => m.DeploymentLifecycle,
),
mountPoint: rootRouteRef,
}),
);
export const ArgocdDeploymentSummary = argocdPlugin.provide(
createRoutableExtension({
name: 'ArgocdDeploymentSummary',
component: () =>
import('./components/DeploymentSummary').then(m => m.DeploymentSummary),
mountPoint: rootRouteRef,
}),
);

View File

@ -0,0 +1,5 @@
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
id: 'rh-argocd',
});

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom';

View File

@ -0,0 +1,176 @@
import { V1ObjectMeta } from '@kubernetes/client-node';
export interface Application {
apiVersion?: string;
kind?: string;
metadata: V1ObjectMeta & { instance: Instance };
spec: Spec;
status: Status;
operation?: Operation;
isAppOfAppsPattern?: boolean;
}
export interface Instance {
name: string;
}
export interface Spec {
source: Source;
destination: Destination;
project: string;
}
export interface Destination {
server: string;
namespace: string;
}
export interface Source {
chart?: string;
repoURL: string;
path: string;
helm?: {
parameters: {
name: string;
value: string;
}[];
};
targetRevision?: string;
}
export interface Status {
sync: StatusSync;
health: Health;
operationState: OperationState;
resources?: StatusResource[];
history?: History[];
reconciledAt?: string;
sourceType?: string;
summary: Summary;
}
export interface Health {
status: string;
}
export interface History {
revision: string;
deployedAt: string;
id: number;
source: Source;
deployStartedAt: string;
}
export interface OperationState {
operation: Operation;
phase?: string;
message?: string;
syncResult?: SyncResult;
startedAt?: string;
finishedAt?: string;
}
export interface Operation {
sync: OperationSync;
initiatedBy?: InitiatedBy;
retry?: {};
}
export interface InitiatedBy {
username: string;
}
export interface OperationSync {
prune?: boolean;
revision: string;
syncStrategy?: SyncStrategy;
syncOptions?: string[];
}
export interface SyncStrategy {
hook: {};
}
export interface SyncResult {
resources: SyncResultResource[];
revision: string;
source: Source;
}
export interface SyncResultResource {
group: string;
version: string;
kind: string;
namespace: string;
name: string;
status: string;
message: string;
hookPhase: string;
syncPhase: string;
}
export interface StatusResource {
version: string;
kind: string;
namespace: string;
name: string;
status: string;
health: Health;
group?: string;
}
export interface Summary {
images: string[];
}
export interface StatusSync {
status: string;
comparedTo?: {
source: Source;
destination: Destination;
};
revision?: string;
}
export interface Revision {
author: string;
date: Date;
message: string;
}
export enum HealthStatus {
Healthy = 'Healthy',
Suspended = 'Suspended',
Degraded = 'Degraded',
Progressing = 'Progressing',
Missing = 'Missing',
Unknown = 'Unknown',
}
export type SyncStatusCode = 'Unknown' | 'Synced' | 'OutOfSync';
export const SyncStatuses: { [key: string]: SyncStatusCode } = {
Synced: 'Synced',
Unknown: 'Unknown',
OutOfSync: 'OutOfSync',
};
export type OperationPhase =
| 'Running'
| 'Error'
| 'Failed'
| 'Succeeded'
| 'Terminating';
export const OperationPhases: { [key: string]: OperationPhase } = {
Running: 'Running',
Failed: 'Failed',
Error: 'Error',
Succeeded: 'Succeeded',
Terminating: 'Terminating',
};
export type Instances = {
name: string;
url: string;
}[];

View File

@ -0,0 +1,20 @@
import { mockEntity } from '../../../dev/__data__';
import { isArgocdConfigured } from '../isArgocdConfigured';
describe('isArgocdConfigured', () => {
test('should return false if argocd is not configured', () => {
expect(
isArgocdConfigured({
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {},
},
}),
).toBe(false);
});
test('should return true if argocd is configured', () => {
expect(isArgocdConfigured(mockEntity)).toBe(true);
});
});

View File

@ -0,0 +1,268 @@
import { mockApplication, mockEntity } from '../../../dev/__data__';
import { Application, History, Status } from '../../types';
import {
ArgoCdLabels,
getAppOperationState,
getAppSelector,
getArgoCdAppConfig,
getCommitUrl,
getGitProvider,
getInstanceName,
getProjectName,
getUniqueRevisions,
} from '../utils';
describe('Utils', () => {
describe('getAppSelector', () => {
test('should return an empty string if the app selector is not set', () => {
expect(
getAppSelector({
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {},
},
}),
).toBe('');
});
test('should return the app selector string', () => {
expect(getAppSelector(mockEntity)).toBe(
'rht-gitops.com/janus-argocd=quarkus-app-bootstrap',
);
});
});
describe('getInstanceName', () => {
test('should return an default instance if the instance name is not set', () => {
expect(
getInstanceName({
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {},
},
}),
).toBe('');
});
test('should return the instance name', () => {
expect(getInstanceName(mockEntity)).toBe('instance-1');
});
});
describe('getProjectName', () => {
test('should return undefined for project name', () => {
expect(
getProjectName({
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {},
},
}),
).toBeUndefined();
});
test('should return the project name', () => {
expect(getProjectName(mockEntity)).toBe('project-name');
});
});
describe('getGitProvider', () => {
test('should return unknown provider if the annotations are empty', () => {
expect(getGitProvider({})).toBe('unknown');
expect(getGitProvider(null as unknown as { [key: string]: string })).toBe(
'unknown',
);
});
test('should return gitlab if the url contain gitlab.com', () => {
expect(
getGitProvider({
'test.com/source-url': 'https://gitlab.com/testrepo',
}),
).toBe('gitlab');
});
test('should return github if the url contain github.com', () => {
expect(
getGitProvider({
'test.com/source-url': 'https://github.com/testrepo',
}),
).toBe('github');
});
test('should return gitlab based on the annotation key', () => {
expect(
getGitProvider({
'gitlab.com/source-url': 'https://custom-gl-url.com/testrepo',
}),
).toBe('gitlab');
});
test('should return github based on the annotation key', () => {
expect(
getGitProvider({
'github.com/source-url': 'https://custom-gh-url.com/testrepo',
'test-key': 'test-value',
}),
).toBe('github');
});
test('should return unknown if annotation key and url does not contain known providers', () => {
expect(
getGitProvider({
'bitbucket.com/source-url':
'https://custom-bitbucket-url.com/testrepo',
'test-key': 'test-value',
}),
).toBe('unknown');
});
});
describe('getCommitUrl', () => {
test('should return correct gitlab commit url', () => {
expect(
getCommitUrl('https://custom-gl-url.com/testrepo', '12345', {
'gitlab.com/source-url': 'https://custom-gl-url.com/testrepo',
}),
).toBe('https://custom-gl-url.com/testrepo/-/commit/12345');
});
test('should return correct github commit url', () => {
expect(
getCommitUrl('https://custom-gh-url.com/testrepo', '12345', {
'github.com/source-url': 'https://custom-gl-url.com/testrepo',
}),
).toBe('https://custom-gh-url.com/testrepo/commit/12345');
});
test('should return the repo url for unknown provider', () => {
expect(
getCommitUrl('https://custom-gh-url.com/testrepo', '12345', {
'bitbucket.com/source-url': 'https://custom-bb-url.com/testrepo',
}),
).toBe('https://custom-gh-url.com/testrepo');
});
});
describe('getAppOperationState', () => {
test('should return Succeeded if the operationState object is present', () => {
expect(getAppOperationState(mockApplication).phase).toBe('Succeeded');
});
test('should return Running if the application has operation object outside', () => {
expect(
getAppOperationState({
...mockApplication,
operation: {
...mockApplication.status.operationState.operation,
},
}).phase,
).toBe('Running');
});
test('should return Running if the application is marked for deletion', () => {
expect(
getAppOperationState({
...mockApplication,
metadata: {
...mockApplication.metadata,
deletionTimestamp: new Date(),
},
}).phase,
).toBe('Running');
});
});
describe('getUniqueRevisions', () => {
test('should return empty array for invalid values', () => {
expect(getUniqueRevisions([])).toHaveLength(0);
expect(getUniqueRevisions(null as unknown as Application[])).toHaveLength(
0,
);
expect(
getUniqueRevisions([
{
...mockApplication,
status: undefined as unknown as Status,
},
]),
).toHaveLength(0);
});
test('should return unique revision', () => {
expect(getUniqueRevisions([mockApplication])).toHaveLength(1);
});
test('should return unique revision for multiple applications', () => {
const mockApplicationTwo: Application = {
...mockApplication,
status: {
...mockApplication.status,
history: [
{
...(mockApplication?.status?.history?.[0] as History),
revision: '12345',
},
{
...(mockApplication?.status?.history?.[1] as History),
revision: '12345',
},
],
},
};
expect(getUniqueRevisions([mockApplication, mockApplicationTwo])).toEqual(
['90f9758b7033a4bbb7c33a35ee474d61091644bc', '12345'],
);
});
});
describe('getArgoCdAppConfig', () => {
test('should throw error if both the annotations are missing', () => {
expect(() =>
getArgoCdAppConfig({
entity: {
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {},
},
},
}),
).toThrow('Argo CD annotation is missing in the catalog');
});
test('should throw error if both the annotations are set', () => {
expect(() =>
getArgoCdAppConfig({
entity: {
...mockEntity,
metadata: {
...mockEntity.metadata,
annotations: {
[ArgoCdLabels.appSelector]: 'janus-idp/quarkus-app',
[ArgoCdLabels.appName]: 'quarkus-app',
},
},
},
}),
).toThrow(
`Cannot provide both ${ArgoCdLabels.appName} and ${ArgoCdLabels.appSelector} annotations`,
);
});
test('should return argocd configuration object when one of the annotations is set', () => {
const configuration = getArgoCdAppConfig({ entity: mockEntity });
expect(configuration).toStrictEqual({
appName: '',
appNamespace: '',
appSelector: 'rht-gitops.com%2Fjanus-argocd%3Dquarkus-app-bootstrap',
projectName: 'project-name',
url: '/argocd/api',
});
});
});
});

View File

@ -0,0 +1,7 @@
import { Entity } from '@backstage/catalog-model';
import { ArgoCdLabels } from './utils';
export const isArgocdConfigured = (entity: Entity) =>
Boolean(entity?.metadata.annotations?.[ArgoCdLabels.appSelector]) ||
Boolean(entity?.metadata.annotations?.[ArgoCdLabels.appName]);

View File

@ -0,0 +1,165 @@
import { Entity } from '@backstage/catalog-model';
import { Application, OperationPhases, OperationState } from '../types';
export const enum ArgoCdLabels {
appSelector = 'argocd/app-selector',
instanceName = 'argocd/instance-name',
projectName = 'argocd/project-name',
appName = 'argocd/app-name',
appNamespace = 'argocd/app-namespace',
}
export const getAppSelector = (entity: Entity): string => {
return entity?.metadata?.annotations?.[ArgoCdLabels.appSelector] ?? '';
};
export const getAppName = (entity: Entity): string => {
return entity?.metadata?.annotations?.[ArgoCdLabels.appName] ?? '';
};
export const getAppNamespace = (entity: Entity): string => {
return entity?.metadata?.annotations?.[ArgoCdLabels.appNamespace] ?? '';
};
export const getInstanceName = (entity: Entity): string => {
return entity?.metadata?.annotations?.[ArgoCdLabels.instanceName] ?? '';
};
export const getProjectName = (entity: Entity): string | undefined => {
return entity?.metadata?.annotations?.[ArgoCdLabels.projectName];
};
export const getArgoCdAppConfig = ({ entity }: { entity: Entity }) => {
const appName = getAppName(entity);
const appSelector = encodeURIComponent(getAppSelector(entity));
const appNamespace = getAppNamespace(entity);
const projectName = getProjectName(entity);
const url = '/argocd/api';
if (!(appName || appSelector)) {
throw new Error('Argo CD annotation is missing in the catalog');
} else if (appName && appSelector) {
throw new Error(
`Cannot provide both ${ArgoCdLabels.appName} and ${ArgoCdLabels.appSelector} annotations`,
);
}
return { url, appName, appSelector, appNamespace, projectName };
};
type ProviderType = 'github' | 'gitlab' | 'unknown';
export enum Providers {
github = 'github',
gitlab = 'gitlab',
unknown = 'unknown',
}
export enum KnownProviders {
gitlab = 'gitlab.com',
github = 'github.com',
}
export const isAppHelmChartType = (application: Application) =>
!!application?.spec?.source?.chart;
export const getGitProvider = (annotations: {
[key: string]: string;
}): ProviderType => {
const entityKeys = Object.keys(annotations ?? {});
if (entityKeys.length === 0) {
return Providers.unknown;
}
let provider: ProviderType = Providers.unknown;
entityKeys.forEach(key => {
if (provider !== Providers.unknown) {
return;
}
if (
key.startsWith(KnownProviders.gitlab) ||
annotations[key].includes(KnownProviders.gitlab)
) {
provider = Providers.gitlab;
} else if (
key.startsWith(KnownProviders.github) ||
annotations[key].includes(KnownProviders.github)
) {
provider = Providers.github;
} else {
provider = Providers.unknown;
}
});
return provider;
};
export const getCommitUrl = (
url: string,
revisionId: string,
annotations: { [key: string]: string },
) => {
const sanitizedUrl = url.replace('.git', '');
const providerCommitPrefix: { [key in ProviderType]: string } = {
gitlab: '/-/commit/',
github: '/commit/',
unknown: '',
};
let provider: ProviderType = Providers.unknown;
if (provider === Providers.unknown) {
provider = getGitProvider(annotations);
}
return provider === Providers.unknown
? sanitizedUrl
: `${sanitizedUrl}${providerCommitPrefix[provider]}${revisionId}`;
};
export const getAppOperationState = (app: Application): OperationState => {
if (app.operation) {
return {
phase: OperationPhases.Running,
message: app?.status?.operationState?.message || 'waiting to start',
startedAt: new Date().toISOString(),
operation: {
sync: {},
},
} as OperationState;
} else if (app.metadata.deletionTimestamp) {
return {
phase: OperationPhases.Running,
message: '',
startedAt: app.metadata.deletionTimestamp,
operation: {
sync: {},
},
} as any;
}
return app.status.operationState;
};
export const getUniqueRevisions = (apps: Application[]): string[] =>
apps
? apps.reduce((acc, app) => {
const history = app?.status?.history ?? [];
const revisions: string[] = [];
if (history.length > 0) {
history.forEach(h => {
if (
!revisions.includes(h.revision as string) &&
!isAppHelmChartType(app)
) {
revisions.push(h.revision);
}
});
}
revisions.forEach((rev: string) => {
if (!acc.includes(rev)) {
acc.push(rev);
}
});
return acc;
}, [] as string[])
: [];

View File

@ -0,0 +1,108 @@
import { expect, Page, test } from '@playwright/test';
import {
mockApplication,
preProdApplication,
prodApplication,
} from '../dev/__data__';
import { Common } from './argocdHelper';
import { verifyAppCard, verifyAppSidebar } from './utils';
test.describe('ArgoCD plugin', () => {
let argocdPage: Page;
let common: Common;
test.beforeAll(async ({ browser }) => {
const context = await browser.newContext();
argocdPage = await context.newPage();
common = new Common(argocdPage);
await common.loginAsGuest();
await expect(
argocdPage.getByRole('heading', { name: 'Deployment lifecycle' }),
).toBeVisible({ timeout: 20000 });
});
test.afterAll(async ({ browser }) => {
await browser.close();
});
const apps = [mockApplication, preProdApplication, prodApplication];
test.describe('Deployment lifecycle', () => {
for (const [index, app] of apps.entries()) {
/* eslint-disable-next-line no-loop-func */
test(`Verify ${app.metadata.name} card`, async () => {
const card = argocdPage.getByTestId(`${app.metadata.name}-card`);
await expect(card).toBeVisible();
await verifyAppCard(app, card, index);
});
/* eslint-disable-next-line no-loop-func */
test(`Verify ${app.metadata.name} side bar`, async () => {
await argocdPage.getByTestId(`${app.metadata.name}-card`).click();
const sideBar = argocdPage.locator(`.MuiDrawer-paper`);
await expect(sideBar).toBeVisible();
await verifyAppSidebar(app, sideBar, index);
await sideBar.getByRole('button', { name: 'Close the drawer' }).click();
await expect(sideBar).toBeVisible({ visible: false });
});
}
});
test.describe('Summary', () => {
const columns = [
'ArgoCD App',
'Namespace',
'Instance',
'Server',
'Revision',
'Last Deployed',
'Sync Status',
'Health Status',
];
test.beforeAll(async () => {
await argocdPage.getByRole('link', { name: 'Summary' }).click();
await expect(argocdPage.getByRole('heading')).toHaveText(
'Deployment summary',
);
});
test('Verify column names', async () => {
for (const col of columns) {
await expect(
argocdPage.getByRole('columnheader', { name: col }),
).toBeVisible();
}
});
for (const app of apps) {
const appName = app.metadata.name;
/* eslint-disable-next-line no-loop-func */
test(`Verify ${appName} row`, async () => {
const row = argocdPage.locator('.MuiTableRow-root', {
hasText: appName,
});
const revision = app.status.history
?.slice(-1)[0]
.revision.substring(0, 7);
await expect(
row.locator('td', { hasText: app.metadata.instance.name }),
).toBeVisible();
await expect(
row.locator('td', { hasText: app.spec.destination.server }),
).toBeVisible();
await expect(row.locator('td', { hasText: revision })).toBeVisible();
await expect(
row.locator('td', { hasText: app.status.health.status }),
).toBeVisible();
await expect(
row.locator('td', { hasText: app.status.sync.status }),
).toBeVisible();
});
}
});
});

View File

@ -0,0 +1,49 @@
import { expect, type Locator, type Page } from '@playwright/test';
export class Common {
page: Page;
constructor(page: Page) {
this.page = page;
}
async verifyHeading(heading: string) {
const headingLocator = this.page
.locator('h1, h2, h3, h4, h5, h6')
.filter({ hasText: heading })
.first();
await headingLocator.waitFor({ state: 'visible', timeout: 30000 });
await expect(headingLocator).toBeVisible();
}
async clickButton(
label: string,
clickOpts?: Parameters<Locator['click']>[0],
getByTextOpts: Parameters<Locator['getByText']>[1] = { exact: true },
) {
const muiButtonLabel = 'span[class^="MuiButton-label"]';
const selector = `${muiButtonLabel}:has-text("${label}")`;
const button = this.page
.locator(selector)
.getByText(label, getByTextOpts)
.first();
await button.waitFor({ state: 'visible' });
await button.click(clickOpts);
}
async waitForSideBarVisible() {
await this.page.waitForSelector('nav a', { timeout: 120000 });
}
async loginAsGuest() {
await this.page.goto('/');
// TODO - Remove it after https://issues.redhat.com/browse/RHIDP-2043. A Dynamic plugin for Guest Authentication Provider needs to be created
this.page.on('dialog', async dialog => {
await dialog.accept();
});
await this.verifyHeading('Select a sign-in method');
await this.clickButton('Enter');
await this.waitForSideBarVisible();
}
}

View File

@ -0,0 +1,130 @@
import { expect, Locator } from '@playwright/test';
import { mockArgocdConfig, mockRevisions } from '../dev/__data__';
import { Application, History } from '../src/types';
export const verifyHeader = async (app: Application, card: Locator) => {
const header = card.locator('.MuiCardHeader-content');
await expect(header.getByText(`${app.metadata.name}`)).toBeVisible();
const appUrl = `${mockArgocdConfig.argocd.baseUrl}/applications/${app.metadata.name}`;
await expect(header.getByRole('link')).toHaveAttribute('href', appUrl);
await expect(header.getByTestId('app-sync-status-chip')).toHaveText(
app.status.sync.status,
);
await expect(header.getByTestId('app-health-status-chip')).toHaveText(
app.status.health.status,
);
};
export const verifyItem = async (
name: string,
content: string | string[],
card: Locator,
unique = true,
) => {
const item = card.locator('.MuiGrid-item', { hasText: name });
const result = unique ? item : item.first();
await expect(result).toContainText(content);
};
export const verifyDeployments = async (
app: Application,
sideBar: Locator,
index: number,
) => {
const imageUrl = `https://${app.status.summary.images[0]}`;
const image = imageUrl.split('/').pop();
const latestDeploy = app.status.history?.slice(-1)[0];
const deployHistory = app.status.history?.slice(0, -1) as History[];
const shortRevision = `${latestDeploy?.revision.substring(0, 7)}`;
const latest = sideBar.locator('.MuiGrid-item', {
hasText: 'Latest deployment',
});
await expect(latest).toContainText(
`Image ${image}${mockRevisions[index].message} ${shortRevision}`,
);
await expect(latest.getByRole('link', { name: image })).toHaveAttribute(
'href',
imageUrl,
);
const revisionUrl = latestDeploy?.source.repoURL.substring(
0,
latestDeploy?.source.repoURL.lastIndexOf('.'),
);
await expect(
latest.getByRole('link', { name: shortRevision }),
).toHaveAttribute('href', `${revisionUrl}`);
const history = sideBar.locator('.MuiGrid-item', {
hasText: 'Deployment history',
});
const items = history.locator('.MuiCard-root', { hasText: 'Deployment' });
await expect(items).toHaveCount(deployHistory.length);
for (const item of await items.all()) {
await expect(item).toContainText(
`${mockRevisions[index].message} ${shortRevision}`,
);
}
};
export const verifyAppCard = async (
app: Application,
card: Locator,
index: number,
) => {
await verifyHeader(app, card);
await verifyItem('Instance', 'main', card);
await verifyItem(
'Server',
`${app.spec.destination.server} (in-cluster)`,
card,
);
await verifyItem('Namespace', app.spec.destination.namespace, card);
const revision = app.status.history
?.slice(-1)[0]
.revision.substring(0, 7) as string;
await verifyItem(
'Commit',
`${revision}${mockRevisions[index].message}`,
card,
);
const image = app.status.summary.images[0].split('/').pop();
await verifyItem('Deployment', `Image ${image}`, card);
};
export const verifyAppSidebar = async (
app: Application,
sideBar: Locator,
index: number,
) => {
await verifyItem(
`${app.metadata.name}`,
`${app.status.sync.status}${app.status.health.status}`,
sideBar,
false,
);
await verifyItem('Instance', 'main', sideBar);
await verifyItem(
'Server',
`${app.spec.destination.server} (in-cluster)`,
sideBar,
);
await verifyItem('Namespace', app.spec.destination.namespace, sideBar);
const revision = app.status.history
?.slice(-1)[0]
.revision.substring(0, 7) as string;
await verifyItem(
'Commit',
`${revision}${mockRevisions[index].message} by ${mockRevisions[index].author}`,
sideBar,
false,
);
await verifyDeployments(app, sideBar, index);
};

View File

@ -0,0 +1,9 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
"include": ["src", "dev"],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "../../dist-types/plugins/argocd",
"rootDir": "."
}
}

View File

@ -0,0 +1,9 @@
{
"extends": ["//"],
"pipeline": {
"tsc": {
"outputs": ["../../dist-types/plugins/argocd/**"],
"dependsOn": ["^tsc"]
}
}
}

View File

@ -0,0 +1,18 @@
{
"extends": "@backstage/cli/config/tsconfig.json",
"include": [
"packages/*/src",
"plugins/*/src",
"plugins/*/dev",
"plugins/*/migrations"
],
"files": ["node_modules/@backstage/cli/asset-types/asset-types.d.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"outDir": "dist-types",
"rootDir": ".",
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2022"],
"target": "ES2022",
"useUnknownInCatchVariables": false
}
}

File diff suppressed because it is too large Load Diff