diff --git a/plugins/backstage-plugin-flux/README.md b/plugins/backstage-plugin-flux/README.md index 88bda62..ef733e4 100644 --- a/plugins/backstage-plugin-flux/README.md +++ b/plugins/backstage-plugin-flux/README.md @@ -3,6 +3,7 @@ The Flux plugin for Backstage provides views of [Flux](https://fluxcd.io/) resources available in Kubernetes clusters. + ![EntityFluxSourcesCard](https://raw.githubusercontent.com/weaveworks/weaveworks-backstage/main/plugins/backstage-plugin-flux/sources_card.png) ## Content @@ -31,6 +32,7 @@ As with other Backstage plugins, you can compose the UI you need. The Kubernetes plugins including `@backstage/plugin-kubernetes` and `@backstage/plugin-kubernetes-backend` are to be installed and configured by following the installation and configuration [guides](https://backstage.io/docs/features/kubernetes/installation/#adding-the-kubernetes-frontend-plugin). After they are installed, make sure to import the frontend plugin by adding the "Kubernetes" tab wherever needed. + ```tsx // In packages/app/src/components/catalog/EntityPage.tsx import { EntityKubernetesContent } from '@backstage/plugin-kubernetes'; @@ -45,6 +47,7 @@ const serviceEntityPage = ( ); ``` + If you are using the [`config`](https://backstage.io/docs/features/kubernetes/configuration#config) method for configuring your clusters, and connecting using a `ServiceAccount`, you will need to bind the `ServiceAccount` to the `ClusterRole` `flux-view-flux-system` that is created with these [permissions](https://github.com/fluxcd/flux2/blob/44d69d6fc0c353e79c1bad021a4aca135033bce8/manifests/rbac/view.yaml) by Flux. ```yaml @@ -62,13 +65,13 @@ subjects: namespace: flux-system ``` -The "sync" button requires additional permissions, it implements same functionality as [flux reconcile](https://fluxcd.io/flux/cmd/flux_reconcile/) for resources. +The "sync", "suspend/resume" button requires additional permissions, it implements same functionality as [flux reconcile](https://fluxcd.io/flux/cmd/flux_reconcile/) for resources. ```yaml apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - name: sync-flux-resources + name: patch-flux-resources rules: - apiGroups: - source.toolkit.fluxcd.io @@ -80,9 +83,9 @@ rules: - ocirepositories verbs: - patch - - apiGroups: + - apiGroups: - kustomize.toolkit.fluxcd.io - resources: + resources: - kustomizations verbs: - patch @@ -96,11 +99,11 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: - name: backstage-sync-flux-resources-rolebinding + name: backstage-patch-flux-resources-rolebinding roleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole - name: sync-flux-resources + name: patch-flux-resources subjects: - kind: ServiceAccount name: backstage # replace with the name of the SA that your Backstage runs as @@ -271,7 +274,6 @@ kubernetes: skipMetricsLookup: true serviceAccountToken: ABC123 caData: LS0tLS1CRUdJTiBDRVJUSUZJQ0... - ``` ## Verification @@ -280,9 +282,10 @@ For the resources where we display a Verification status, if the Flux resource has no verification configured, the column will be blank. + ![Verification status for resources](https://raw.githubusercontent.com/weaveworks/weaveworks-backstage/main/plugins/backstage-plugin-flux/verification.png) You can configure verification for the following resources: - * [Git Repositories](https://fluxcd.io/flux/components/source/gitrepositories/#verification) - * [OCI Repositories](https://fluxcd.io/flux/components/source/ocirepositories/#verification) +- [Git Repositories](https://fluxcd.io/flux/components/source/gitrepositories/#verification) +- [OCI Repositories](https://fluxcd.io/flux/components/source/ocirepositories/#verification) diff --git a/plugins/backstage-plugin-flux/dev/helpers.ts b/plugins/backstage-plugin-flux/dev/helpers.ts index 500a6f2..e4dd31b 100644 --- a/plugins/backstage-plugin-flux/dev/helpers.ts +++ b/plugins/backstage-plugin-flux/dev/helpers.ts @@ -22,22 +22,33 @@ const copy = (obj: any): any => { return JSON.parse(JSON.stringify(obj)); }; -const removeVerifiedCondition = (conditions: Condition[]): Condition[] => copy(conditions).filter((cond: Condition) => cond.type !== 'SourceVerified'); +const removeVerifiedCondition = (conditions: Condition[]): Condition[] => + copy(conditions).filter((cond: Condition) => cond.type !== 'SourceVerified'); -const applyReadyCondition = (status: boolean, conditions: Condition[]): Condition[] => { +const applyReadyCondition = ( + status: boolean, + conditions: Condition[], +): Condition[] => { const ready = conditions.find(cond => cond.type === 'Ready'); if (ready === undefined) { return conditions; } ready.status = Boolean(status) === true ? 'True' : 'False'; - const result = conditions.filter((cond: Condition) => cond.type !== 'Ready') + const result = conditions.filter((cond: Condition) => cond.type !== 'Ready'); result.unshift(ready); return result; -} +}; -const configureFixture = (name: string, url: string, fixture: any, verifiedFixture: any, unverifiedFixture: any, opts?: RepoOpts) => { +const configureFixture = ( + name: string, + url: string, + fixture: any, + verifiedFixture: any, + unverifiedFixture: any, + opts?: RepoOpts, +) => { let result = copy(fixture); if (opts?.verify) { @@ -49,11 +60,16 @@ const configureFixture = (name: string, url: string, fixture: any, verifiedFixtu } if (opts?.verify && opts?.pending) { - result.status.conditions = removeVerifiedCondition(result.status.conditions); + result.status.conditions = removeVerifiedCondition( + result.status.conditions, + ); } if (opts?.ready !== undefined) { - result.status.conditions = applyReadyCondition(opts.ready!, result.status.conditions); + result.status.conditions = applyReadyCondition( + opts.ready!, + result.status.conditions, + ); } result.spec.url = url; @@ -63,53 +79,77 @@ const configureFixture = (name: string, url: string, fixture: any, verifiedFixtu .toISO()!; return result; -} +}; export const newTestOCIRepository = ( name: string, url: string, - opts?: RepoOpts + opts?: RepoOpts, ) => { - return configureFixture(name, url, ociRepository, verifiedOCIRepository, unverifiedOCIRepository, opts); + return configureFixture( + name, + url, + ociRepository, + verifiedOCIRepository, + unverifiedOCIRepository, + opts, + ); }; export const newTestGitRepository = ( name: string, url: string, - opts?: RepoOpts + opts?: RepoOpts, ) => { - return configureFixture(name, url, gitRepository, verifiedGitRepository, unverifiedGitRepository, opts); + return configureFixture( + name, + url, + gitRepository, + verifiedGitRepository, + unverifiedGitRepository, + opts, + ); }; export const newTestKustomization = ( name: string, path: string, ready: boolean, + suspend: boolean, ) => { - const result = copy(kustomization); + const result = copy(kustomization); - result.metadata.name = name; - result.spec.path = path; + result.metadata.name = name; + result.spec.path = path; - result.metadata.name = name; - result.spec.path = path; - result.status.conditions = applyReadyCondition(ready, result.status.conditions); + result.metadata.name = name; + result.spec.path = path; + result.status.conditions = applyReadyCondition( + ready, + result.status.conditions, + ); + result.spec.suspend = suspend; - return result; + return result; }; export const newTestHelmRepository = ( name: string, url: string, ready: boolean = true, + suspend: boolean, ) => { - const result = copy(helmRepository); + const result = copy(helmRepository); - result.metadata.name = name; - result.spec.url = url; - result.status.conditions = applyReadyCondition(ready, result.status.conditions); + result.metadata.name = name; + result.spec.url = url; + result.status.conditions = applyReadyCondition( + ready, + result.status.conditions, + ); + result.spec.suspend = suspend; - return result; + return result; }; export const newTestHelmRelease = ( @@ -117,6 +157,7 @@ export const newTestHelmRelease = ( chart: string, version: string, ready: string = 'True', + suspend: boolean, ) => { return { apiVersion: 'helm.toolkit.fluxcd.io/v2beta1', @@ -131,6 +172,7 @@ export const newTestHelmRelease = ( namespace: 'default', }, spec: { + suspend, interval: '5m', chart: { spec: { @@ -174,4 +216,4 @@ export const newTestHelmRelease = ( observedGeneration: 12, }, }; -}; \ No newline at end of file +}; diff --git a/plugins/backstage-plugin-flux/dev/index.tsx b/plugins/backstage-plugin-flux/dev/index.tsx index f2c20e8..edb7699 100644 --- a/plugins/backstage-plugin-flux/dev/index.tsx +++ b/plugins/backstage-plugin-flux/dev/index.tsx @@ -115,7 +115,7 @@ class StubKubernetesClient implements KubernetesApi { }; } - // this is only used by sync right now, so it looks a little bit funny + // this is only used by sync and suspend/resume async proxy({ init, path, @@ -124,14 +124,24 @@ class StubKubernetesClient implements KubernetesApi { path: string; init?: RequestInit | undefined; }): Promise { - // wait 100ms + // wait 100ms so the UI can show a loader or something await new Promise(resolve => setTimeout(resolve, 100)); // Assumption: The initial request! - // Generates 2 more subsequent requests that can be retrieved in order via GET'ing + // In the case of "sync" it generates 2 more subsequent requests that can be + // retrieved in order via GET'ing. This simulate the polling the UI will do. // if (init?.method === 'PATCH') { const data = JSON.parse(init.body as string); + + // We're getting a request to suspend/resume the resource + // just return 200 all good, no polling here. + if (data.spec && 'suspend' in data.spec) { + return { + ok: true, + } as Response; + } + const reconiliationRequestedAt = data.metadata.annotations[ReconcileRequestAnnotation]; this.mockResponses[path] = [ @@ -208,21 +218,33 @@ createDevApp() 'prometheus1', 'kube-prometheus-stack', '6.3.5', + 'True', + false, ), newTestHelmRelease( 'prometheus2', 'kube-prometheus-stack', '6.3.5', + 'True', + false, ), newTestHelmRelease( 'prometheus3', 'kube-prometheus-stack', '6.3.5', + 'False', + true, + ), + newTestHelmRelease('redis1', 'redis', '7.0.1', 'False', false), + newTestHelmRelease('redis2', 'redis', '7.0.1', 'True', true), + newTestHelmRelease('http-api', 'redis', '1.2.5', 'False', false), + newTestHelmRelease( + 'queue-runner', + 'redis', + '1.0.1', + 'True', + false, ), - newTestHelmRelease('redis1', 'redis', '7.0.1', 'False'), - newTestHelmRelease('redis2', 'redis', '7.0.1'), - newTestHelmRelease('http-api', 'redis', '1.2.5', 'False'), - newTestHelmRelease('queue-runner', 'redis', '1.0.1'), ]), ], [kubernetesAuthProvidersApiRef, new StubKubernetesAuthProvidersApi()], @@ -360,11 +382,13 @@ createDevApp() 'flux-system', './clusters/my-cluster', true, + true, ), newTestKustomization( 'test-kustomization', './clusters/my-test-cluster', true, + false, ), ]), ], @@ -398,6 +422,8 @@ createDevApp() newTestHelmRepository( 'podinfo', 'https://stefanprodan.github.io/podinfo', + true, + false, ), ]), ], @@ -432,11 +458,14 @@ createDevApp() 'flux-system', './clusters/my-cluster', true, + false, ), newTestHelmRelease( 'prometheus1', 'kube-prometheus-stack', '6.3.5', + 'True', + true, ), ]), ], @@ -470,6 +499,8 @@ createDevApp() newTestHelmRepository( 'podinfo', 'https://stefanprodan.github.io/podinfo', + true, + true, ), newTestOCIRepository( 'podinfo', diff --git a/plugins/backstage-plugin-flux/sources_card.png b/plugins/backstage-plugin-flux/sources_card.png index e67620c..af9eb4c 100644 Binary files a/plugins/backstage-plugin-flux/sources_card.png and b/plugins/backstage-plugin-flux/sources_card.png differ diff --git a/plugins/backstage-plugin-flux/src/__fixtures__/helm_repository.json b/plugins/backstage-plugin-flux/src/__fixtures__/helm_repository.json index 8d1d691..88606b8 100644 --- a/plugins/backstage-plugin-flux/src/__fixtures__/helm_repository.json +++ b/plugins/backstage-plugin-flux/src/__fixtures__/helm_repository.json @@ -1,54 +1,53 @@ { - "apiVersion": "source.toolkit.fluxcd.io/v1beta2", - "kind": "HelmRepository", - "metadata": { - "annotations": { - "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"source.toolkit.fluxcd.io/v1beta2\",\"kind\":\"HelmRepository\",\"metadata\":{\"annotations\":{},\"name\":\"podinfo\",\"namespace\":\"backstage\"},\"spec\":{\"interval\":\"1m\",\"url\":\"https://stefanprodan.github.io/podinfo\"}}\n" - }, - "creationTimestamp": "2023-06-29T13:50:21Z", - "finalizers": [ - "finalizers.fluxcd.io" - ], - "generation": 1, - "name": "podinfo", - "namespace": "default", - "resourceVersion": "1054927", - "uid": "39f589d3-260d-4f93-a4f2-fcd6dc26b8a8" + "apiVersion": "source.toolkit.fluxcd.io/v1beta2", + "kind": "HelmRepository", + "metadata": { + "annotations": { + "kubectl.kubernetes.io/last-applied-configuration": "{\"apiVersion\":\"source.toolkit.fluxcd.io/v1beta2\",\"kind\":\"HelmRepository\",\"metadata\":{\"annotations\":{},\"name\":\"podinfo\",\"namespace\":\"backstage\"},\"spec\":{\"interval\":\"1m\",\"url\":\"https://stefanprodan.github.io/podinfo\"}}\n" }, - "spec": { - "interval": "1m", - "provider": "generic", - "timeout": "60s", - "url": "https://stefanprodan.github.io/podinfo" + "creationTimestamp": "2023-06-29T13:50:21Z", + "finalizers": ["finalizers.fluxcd.io"], + "generation": 1, + "name": "podinfo", + "namespace": "default", + "resourceVersion": "1054927", + "uid": "39f589d3-260d-4f93-a4f2-fcd6dc26b8a8" + }, + "spec": { + "interval": "1m", + "provider": "generic", + "timeout": "60s", + "url": "https://stefanprodan.github.io/podinfo", + "suspend": false + }, + "status": { + "artifact": { + "digest": "sha256:80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e", + "lastUpdateTime": "2023-06-29T13:50:22Z", + "path": "helmrepository/backstage/podinfo/index-80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e.yaml", + "revision": "sha256:80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e", + "size": 43126, + "url": "http://source-controller.flux-system.svc.cluster.local./helmrepository/backstage/podinfo/index-80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e.yaml" }, - "status": { - "artifact": { - "digest": "sha256:80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e", - "lastUpdateTime": "2023-06-29T13:50:22Z", - "path": "helmrepository/backstage/podinfo/index-80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e.yaml", - "revision": "sha256:80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e", - "size": 43126, - "url": "http://source-controller.flux-system.svc.cluster.local./helmrepository/backstage/podinfo/index-80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e.yaml" - }, - "conditions": [ - { - "lastTransitionTime": "2023-07-03T09:18:10Z", - "message": "stored artifact: revision 'sha256:80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e'", - "observedGeneration": 1, - "reason": "Succeeded", - "status": "True", - "type": "Ready" - }, - { - "lastTransitionTime": "2023-06-29T13:50:22Z", - "message": "stored artifact: revision 'sha256:80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e'", - "observedGeneration": 1, - "reason": "Succeeded", - "status": "True", - "type": "ArtifactInStorage" - } - ], + "conditions": [ + { + "lastTransitionTime": "2023-07-03T09:18:10Z", + "message": "stored artifact: revision 'sha256:80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e'", "observedGeneration": 1, - "url": "http://source-controller.flux-system.svc.cluster.local./helmrepository/backstage/podinfo/index.yaml" - } + "reason": "Succeeded", + "status": "True", + "type": "Ready" + }, + { + "lastTransitionTime": "2023-06-29T13:50:22Z", + "message": "stored artifact: revision 'sha256:80b091a3a69b9ecfebde40ce2a5f19e95f8f8ea956bd5635a31701f7fad1616e'", + "observedGeneration": 1, + "reason": "Succeeded", + "status": "True", + "type": "ArtifactInStorage" + } + ], + "observedGeneration": 1, + "url": "http://source-controller.flux-system.svc.cluster.local./helmrepository/backstage/podinfo/index.yaml" + } } diff --git a/plugins/backstage-plugin-flux/src/__fixtures__/kustomization.json b/plugins/backstage-plugin-flux/src/__fixtures__/kustomization.json index 912c2f3..ed020c2 100644 --- a/plugins/backstage-plugin-flux/src/__fixtures__/kustomization.json +++ b/plugins/backstage-plugin-flux/src/__fixtures__/kustomization.json @@ -1,200 +1,199 @@ { - "apiVersion": "kustomize.toolkit.fluxcd.io/v1", - "kind": "Kustomization", - "metadata": { - "annotations": { - "reconcile.fluxcd.io/requestedAt": "2023-07-03T17:18:03.990333+01:00" - }, - "creationTimestamp": "2023-06-29T08:06:59Z", - "finalizers": [ - "finalizers.fluxcd.io" - ], - "generation": 1, - "labels": { - "kustomize.toolkit.fluxcd.io/name": "flux-system", - "kustomize.toolkit.fluxcd.io/namespace": "flux-system" - }, - "name": "flux-system", - "namespace": "flux-system", - "resourceVersion": "1181625", - "uid": "ab33ae5b-a282-40b1-9fdc-d87f05401628" + "apiVersion": "kustomize.toolkit.fluxcd.io/v1", + "kind": "Kustomization", + "metadata": { + "annotations": { + "reconcile.fluxcd.io/requestedAt": "2023-07-03T17:18:03.990333+01:00" }, - "spec": { - "force": false, - "interval": "10m0s", - "path": "./clusters/my-cluster", - "prune": true, - "sourceRef": { - "kind": "GitRepository", - "name": "flux-system" + "creationTimestamp": "2023-06-29T08:06:59Z", + "finalizers": ["finalizers.fluxcd.io"], + "generation": 1, + "labels": { + "kustomize.toolkit.fluxcd.io/name": "flux-system", + "kustomize.toolkit.fluxcd.io/namespace": "flux-system" + }, + "name": "flux-system", + "namespace": "flux-system", + "resourceVersion": "1181625", + "uid": "ab33ae5b-a282-40b1-9fdc-d87f05401628" + }, + "spec": { + "force": false, + "interval": "10m0s", + "path": "./clusters/my-cluster", + "prune": true, + "sourceRef": { + "kind": "GitRepository", + "name": "flux-system" + }, + "suspend": false + }, + "status": { + "conditions": [ + { + "lastTransitionTime": "2023-07-03T16:18:04Z", + "message": "Applied revision: main@sha1:c933408394a3af8fa7208af8c9abf7fe430f99d4", + "observedGeneration": 1, + "reason": "ReconciliationSucceeded", + "status": "True", + "type": "Ready" + } + ], + "inventory": { + "entries": [ + { + "id": "_alerts.notification.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", + "v": "v1" + }, + { + "id": "_buckets.source.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", + "v": "v1" + }, + { + "id": "_gitrepositories.source.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", + "v": "v1" + }, + { + "id": "_helmcharts.source.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", + "v": "v1" + }, + { + "id": "_helmreleases.helm.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", + "v": "v1" + }, + { + "id": "_helmrepositories.source.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", + "v": "v1" + }, + { + "id": "_kustomizations.kustomize.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", + "v": "v1" + }, + { + "id": "_ocirepositories.source.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", + "v": "v1" + }, + { + "id": "_providers.notification.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", + "v": "v1" + }, + { + "id": "_receivers.notification.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", + "v": "v1" + }, + { + "id": "_flux-system__Namespace", + "v": "v1" + }, + { + "id": "flux-system_critical-pods-flux-system__ResourceQuota", + "v": "v1" + }, + { + "id": "flux-system_helm-controller__ServiceAccount", + "v": "v1" + }, + { + "id": "flux-system_kustomize-controller__ServiceAccount", + "v": "v1" + }, + { + "id": "flux-system_notification-controller__ServiceAccount", + "v": "v1" + }, + { + "id": "flux-system_source-controller__ServiceAccount", + "v": "v1" + }, + { + "id": "_crd-controller-flux-system_rbac.authorization.k8s.io_ClusterRole", + "v": "v1" + }, + { + "id": "_flux-edit-flux-system_rbac.authorization.k8s.io_ClusterRole", + "v": "v1" + }, + { + "id": "_flux-view-flux-system_rbac.authorization.k8s.io_ClusterRole", + "v": "v1" + }, + { + "id": "_cluster-reconciler-flux-system_rbac.authorization.k8s.io_ClusterRoleBinding", + "v": "v1" + }, + { + "id": "_crd-controller-flux-system_rbac.authorization.k8s.io_ClusterRoleBinding", + "v": "v1" + }, + { + "id": "flux-system_notification-controller__Service", + "v": "v1" + }, + { + "id": "flux-system_source-controller__Service", + "v": "v1" + }, + { + "id": "flux-system_webhook-receiver__Service", + "v": "v1" + }, + { + "id": "flux-system_helm-controller_apps_Deployment", + "v": "v1" + }, + { + "id": "flux-system_kustomize-controller_apps_Deployment", + "v": "v1" + }, + { + "id": "flux-system_notification-controller_apps_Deployment", + "v": "v1" + }, + { + "id": "flux-system_source-controller_apps_Deployment", + "v": "v1" + }, + { + "id": "flux-system_flux-system_kustomize.toolkit.fluxcd.io_Kustomization", + "v": "v1" + }, + { + "id": "flux-system_allow-egress_networking.k8s.io_NetworkPolicy", + "v": "v1" + }, + { + "id": "flux-system_allow-scraping_networking.k8s.io_NetworkPolicy", + "v": "v1" + }, + { + "id": "flux-system_allow-webhooks_networking.k8s.io_NetworkPolicy", + "v": "v1" + }, + { + "id": "default_podinfo_source.toolkit.fluxcd.io_GitRepository", + "v": "v1" + }, + { + "id": "default_podinfo-shard1_source.toolkit.fluxcd.io_GitRepository", + "v": "v1" + }, + { + "id": "default_podinfo-shard2_source.toolkit.fluxcd.io_GitRepository", + "v": "v1" + }, + { + "id": "flux-system_flux-system_source.toolkit.fluxcd.io_GitRepository", + "v": "v1" + }, + { + "id": "flux-system_source-controller-shardset_templates.weave.works_FluxShardSet", + "v": "v1alpha1" } + ] }, - "status": { - "conditions": [ - { - "lastTransitionTime": "2023-07-03T16:18:04Z", - "message": "Applied revision: main@sha1:c933408394a3af8fa7208af8c9abf7fe430f99d4", - "observedGeneration": 1, - "reason": "ReconciliationSucceeded", - "status": "True", - "type": "Ready" - } - ], - "inventory": { - "entries": [ - { - "id": "_alerts.notification.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", - "v": "v1" - }, - { - "id": "_buckets.source.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", - "v": "v1" - }, - { - "id": "_gitrepositories.source.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", - "v": "v1" - }, - { - "id": "_helmcharts.source.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", - "v": "v1" - }, - { - "id": "_helmreleases.helm.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", - "v": "v1" - }, - { - "id": "_helmrepositories.source.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", - "v": "v1" - }, - { - "id": "_kustomizations.kustomize.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", - "v": "v1" - }, - { - "id": "_ocirepositories.source.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", - "v": "v1" - }, - { - "id": "_providers.notification.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", - "v": "v1" - }, - { - "id": "_receivers.notification.toolkit.fluxcd.io_apiextensions.k8s.io_CustomResourceDefinition", - "v": "v1" - }, - { - "id": "_flux-system__Namespace", - "v": "v1" - }, - { - "id": "flux-system_critical-pods-flux-system__ResourceQuota", - "v": "v1" - }, - { - "id": "flux-system_helm-controller__ServiceAccount", - "v": "v1" - }, - { - "id": "flux-system_kustomize-controller__ServiceAccount", - "v": "v1" - }, - { - "id": "flux-system_notification-controller__ServiceAccount", - "v": "v1" - }, - { - "id": "flux-system_source-controller__ServiceAccount", - "v": "v1" - }, - { - "id": "_crd-controller-flux-system_rbac.authorization.k8s.io_ClusterRole", - "v": "v1" - }, - { - "id": "_flux-edit-flux-system_rbac.authorization.k8s.io_ClusterRole", - "v": "v1" - }, - { - "id": "_flux-view-flux-system_rbac.authorization.k8s.io_ClusterRole", - "v": "v1" - }, - { - "id": "_cluster-reconciler-flux-system_rbac.authorization.k8s.io_ClusterRoleBinding", - "v": "v1" - }, - { - "id": "_crd-controller-flux-system_rbac.authorization.k8s.io_ClusterRoleBinding", - "v": "v1" - }, - { - "id": "flux-system_notification-controller__Service", - "v": "v1" - }, - { - "id": "flux-system_source-controller__Service", - "v": "v1" - }, - { - "id": "flux-system_webhook-receiver__Service", - "v": "v1" - }, - { - "id": "flux-system_helm-controller_apps_Deployment", - "v": "v1" - }, - { - "id": "flux-system_kustomize-controller_apps_Deployment", - "v": "v1" - }, - { - "id": "flux-system_notification-controller_apps_Deployment", - "v": "v1" - }, - { - "id": "flux-system_source-controller_apps_Deployment", - "v": "v1" - }, - { - "id": "flux-system_flux-system_kustomize.toolkit.fluxcd.io_Kustomization", - "v": "v1" - }, - { - "id": "flux-system_allow-egress_networking.k8s.io_NetworkPolicy", - "v": "v1" - }, - { - "id": "flux-system_allow-scraping_networking.k8s.io_NetworkPolicy", - "v": "v1" - }, - { - "id": "flux-system_allow-webhooks_networking.k8s.io_NetworkPolicy", - "v": "v1" - }, - { - "id": "default_podinfo_source.toolkit.fluxcd.io_GitRepository", - "v": "v1" - }, - { - "id": "default_podinfo-shard1_source.toolkit.fluxcd.io_GitRepository", - "v": "v1" - }, - { - "id": "default_podinfo-shard2_source.toolkit.fluxcd.io_GitRepository", - "v": "v1" - }, - { - "id": "flux-system_flux-system_source.toolkit.fluxcd.io_GitRepository", - "v": "v1" - }, - { - "id": "flux-system_source-controller-shardset_templates.weave.works_FluxShardSet", - "v": "v1alpha1" - } - ] - }, - "lastAppliedRevision": "main@sha1:c933408394a3af8fa7208af8c9abf7fe430f99d4", - "lastAttemptedRevision": "main@sha1:c933408394a3af8fa7208af8c9abf7fe430f99d4", - "lastHandledReconcileAt": "2023-07-03T17:18:03.990333+01:00", - "observedGeneration": 1 - } + "lastAppliedRevision": "main@sha1:c933408394a3af8fa7208af8c9abf7fe430f99d4", + "lastAttemptedRevision": "main@sha1:c933408394a3af8fa7208af8c9abf7fe430f99d4", + "lastHandledReconcileAt": "2023-07-03T17:18:03.990333+01:00", + "observedGeneration": 1 + } } diff --git a/plugins/backstage-plugin-flux/src/components/EntityFluxDeploymentsCard/FluxDeploymentsTable.tsx b/plugins/backstage-plugin-flux/src/components/EntityFluxDeploymentsCard/FluxDeploymentsTable.tsx index 9565c5a..40f8d80 100644 --- a/plugins/backstage-plugin-flux/src/components/EntityFluxDeploymentsCard/FluxDeploymentsTable.tsx +++ b/plugins/backstage-plugin-flux/src/components/EntityFluxDeploymentsCard/FluxDeploymentsTable.tsx @@ -6,7 +6,7 @@ import { nameAndClusterNameColumn, statusColumn, updatedColumn, - syncColumn, + actionColumn, Deployment, repoColumn, sourceColumn, @@ -25,7 +25,7 @@ export const defaultColumns: TableColumn[] = [ sourceColumn(), statusColumn(), updatedColumn(), - syncColumn(), + actionColumn(), ]; type Props = { diff --git a/plugins/backstage-plugin-flux/src/components/EntityFluxSourcesCard/FluxSourcesTable.tsx b/plugins/backstage-plugin-flux/src/components/EntityFluxSourcesCard/FluxSourcesTable.tsx index aec7f27..cbce68d 100644 --- a/plugins/backstage-plugin-flux/src/components/EntityFluxSourcesCard/FluxSourcesTable.tsx +++ b/plugins/backstage-plugin-flux/src/components/EntityFluxSourcesCard/FluxSourcesTable.tsx @@ -6,7 +6,7 @@ import { urlColumn, statusColumn, updatedColumn, - syncColumn, + actionColumn, Source, artifactColumn, typeColumn, @@ -30,7 +30,7 @@ const commonEndColumns: TableColumn[] = [ artifactColumn(), statusColumn(), updatedColumn(), - syncColumn(), + actionColumn(), ]; export const sourceDefaultColumns = [ diff --git a/plugins/backstage-plugin-flux/src/components/helpers.tsx b/plugins/backstage-plugin-flux/src/components/helpers.tsx index 5fc8d4a..e0b9e8c 100644 --- a/plugins/backstage-plugin-flux/src/components/helpers.tsx +++ b/plugins/backstage-plugin-flux/src/components/helpers.tsx @@ -9,6 +9,8 @@ import { } from '@backstage/core-components'; import { Box, IconButton, Tooltip } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; +import PauseIcon from '@material-ui/icons/Pause'; +import PlayArrowIcon from '@material-ui/icons/PlayArrow'; import VerifiedUserIcon from '@material-ui/icons/VerifiedUser'; import { useSyncResource, useWeaveFluxDeepLink } from '../hooks'; import { @@ -28,6 +30,7 @@ import { import Flex from './Flex'; import KubeStatusIndicator, { getIndicatorInfo } from './KubeStatusIndicator'; import { helm, kubernetes, oci, git } from '../images/icons'; +import { useToggleSuspendResource } from '../hooks/useToggleSuspendResource'; export type Source = GitRepository | OCIRepository | HelmRepository; export type Deployment = HelmRelease | Kustomization; @@ -72,38 +75,132 @@ export const Url = ({ resource }: { resource: Source }): JSX.Element => { ); }; -export function SyncButton({ resource }: { resource: Source | Deployment }) { - const { sync, isSyncing } = useSyncResource(resource); +export function SyncButton({ + resource, + sync, + status, +}: { + resource: Source | Deployment; + sync: () => Promise; + status: boolean; +}) { + const classes = useStyles(); + const label = `${resource.namespace}/${resource.name}`; + const title = status ? `Syncing ${label}` : `Sync ${label}`; + return ( + +
+ + + +
+
+ ); +} + +export function SuspendButton({ + resource, + toggleSuspend, + status, +}: { + resource: Source | Deployment; + toggleSuspend: () => Promise; + status: boolean; +}) { + const classes = useStyles(); + const label = `${resource.namespace}/${resource.name}`; + const title = status ? `Suspending ${label}` : `Suspend ${label}`; + + return ( + +
+ + + +
+
+ ); +} + +export function ResumeButton({ + resource, + toggleResume, + status, +}: { + resource: Source | Deployment; + toggleResume: () => Promise; + status: boolean; +}) { const classes = useStyles(); const label = `${resource.namespace}/${resource.name}`; - const title = isSyncing ? `Syncing ${label}` : `Sync ${label}`; + const title = status ? `Resuming ${label}` : `Resume ${label}`; return ( - {/* can't handle forwardRef (?) so we wrap in a div */}
- {isSyncing ? ( - - ) : ( - - - - )} + + +
); } -export function syncColumn() { +export function GroupAction({ resource }: { resource: Source | Deployment }) { + const { sync, isSyncing } = useSyncResource(resource); + const { loading: isSuspending, toggleSuspend } = useToggleSuspendResource( + resource, + true, + ); + const { loading: isResuming, toggleSuspend: toogleResume } = + useToggleSuspendResource(resource, false); + const isLoading = isSyncing || isSuspending || isResuming; + + return ( + <> + {isLoading ? ( + + ) : ( + + + + + + )} + + ); +} + +export function actionColumn() { return { - title: 'Sync', - render: row => , + title: 'Actions', + render: row => , width: '24px', } as TableColumn; } diff --git a/plugins/backstage-plugin-flux/src/components/utils.ts b/plugins/backstage-plugin-flux/src/components/utils.ts index 9d1ce52..a18b60c 100644 --- a/plugins/backstage-plugin-flux/src/components/utils.ts +++ b/plugins/backstage-plugin-flux/src/components/utils.ts @@ -32,7 +32,7 @@ export const useStyles = makeStyles(theme => ({ fontWeight: 600, marginBottom: '6px', }, - syncButton: { + actionButton: { padding: 0, margin: '-5px 0', }, diff --git a/plugins/backstage-plugin-flux/src/hooks/useToggleSuspendResource.test.ts b/plugins/backstage-plugin-flux/src/hooks/useToggleSuspendResource.test.ts new file mode 100644 index 0000000..ad6440e --- /dev/null +++ b/plugins/backstage-plugin-flux/src/hooks/useToggleSuspendResource.test.ts @@ -0,0 +1,210 @@ +import { kubernetesApiRef } from '@backstage/plugin-kubernetes'; +import { + getRequest, + pathForResource, + requestToggleSuspendResource, + toggleSuspendRequest, + toggleSuspendResource, +} from './useToggleSuspendResource'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { HelmRelease } from '../objects'; + +describe('pathForResource', () => { + it('returns the correct path', () => { + const name = 'test-name'; + const namespace = 'test-namespace'; + const gvk = { + group: 'test-group', + apiVersion: 'test-api-version', + plural: 'test-plural', + }; + + expect(pathForResource(name, namespace, gvk)).toEqual( + '/apis/test-group/test-api-version/namespaces/test-namespace/test-plural/test-name', + ); + }); +}); + +describe('toggleSuspendRequest', () => { + it('returns the correct request', () => { + const name = 'test-name'; + const namespace = 'test-namespace'; + const clusterName = 'test-cluster-name'; + const gvk = { + group: 'test-group', + apiVersion: 'test-api-version', + plural: 'test-plural', + }; + const suspend = false; + const expected = { + clusterName: 'test-cluster-name', + init: { + body: `{"spec":{"suspend":false}}`, + headers: { + 'Content-Type': 'application/merge-patch+json', + }, + method: 'PATCH', + }, + path: '/apis/test-group/test-api-version/namespaces/test-namespace/test-plural/test-name', + }; + + expect( + toggleSuspendRequest(name, namespace, clusterName, gvk, suspend), + ).toEqual(expected); + }); +}); + +describe('getRequest', () => { + it('returns the correct request', () => { + const name = 'test-name'; + const namespace = 'test-namespace'; + const clusterName = 'test-cluster-name'; + const gvk = { + group: 'test-group', + apiVersion: 'test-api-version', + plural: 'test-plural', + }; + + const expected = { + clusterName: 'test-cluster-name', + path: '/apis/test-group/test-api-version/namespaces/test-namespace/test-plural/test-name', + }; + + expect(getRequest(name, namespace, clusterName, gvk)).toEqual(expected); + }); +}); + +function makeMockKubernetesApi() { + return { + getObjectsByEntity: jest.fn(), + getClusters: jest.fn(), + getWorkloadsByEntity: jest.fn(), + getCustomObjectsByEntity: jest.fn(), + proxy: jest.fn(), + } as jest.Mocked; +} + +function makeMockAlertApi() { + return { + post: jest.fn(), + alert$: jest.fn(), + } as jest.Mocked; +} + +describe('requestToggleSuspendResource', () => { + it('resolves to undefined if everything goes okay', async () => { + const kubernetesApi = makeMockKubernetesApi(); + // mock values in a sequence, first time the api is called return a 200 + + // Make the request + kubernetesApi.proxy.mockResolvedValueOnce({ + ok: true, + } as Response); + + const gvk = { + group: 'test-group', + apiVersion: 'test-api-version', + plural: 'test-plural', + }; + + await expect( + requestToggleSuspendResource( + kubernetesApi, + 'test-name', + 'test-namespace', + 'test-cluster-name', + gvk, + false, + ), + ).resolves.toBeUndefined(); + }); + + it('throws an error if k8s api responds with not ok response', async () => { + const kubernetesApi = makeMockKubernetesApi(); + kubernetesApi.proxy.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: () => Promise.resolve({}), + } as Response); + + const gvk = { + group: 'test-group', + apiVersion: 'test-api-version', + plural: 'test-plural', + }; + + await expect( + requestToggleSuspendResource( + kubernetesApi, + 'test-name', + 'test-namespace', + 'test-cluster-name', + gvk, + false, + ), + ).rejects.toThrow('Failed to Resume resource: 500 Internal Server Error'); + }); +}); + +describe('toggleSuspendResource', () => { + const helmRelease = { + type: 'HelmRelease', + name: 'test-name', + namespace: 'test-namespace', + sourceRef: { + kind: 'HelmRepository', + name: 'test-source-name', + }, + clusterName: 'test-clusterName', + } as HelmRelease; + + it('should Suspend resource', async () => { + const kubernetesApi = makeMockKubernetesApi(); + const alertApi = makeMockAlertApi(); + kubernetesApi.proxy.mockResolvedValue({ + ok: true, + status: 200, + } as Response); + + await toggleSuspendResource(helmRelease, kubernetesApi, alertApi, false); + + // ASSERT we tried to PATCH the resource + expect(kubernetesApi.proxy).toHaveBeenCalledWith({ + clusterName: 'test-clusterName', + init: { + body: `{"spec":{"suspend":false}}`, + headers: { + 'Content-Type': 'application/merge-patch+json', + }, + method: 'PATCH', + }, + path: '/apis/helm.toolkit.fluxcd.io/v2beta1/namespaces/test-namespace/helmreleases/test-name', + }); + + expect(alertApi.post).toHaveBeenCalledWith({ + display: 'transient', + message: 'Resume request successful', + severity: 'success', + }); + }); + + it('should post an error if something goes wrong', async () => { + const kubernetesApi = makeMockKubernetesApi(); + const alertApi = makeMockAlertApi(); + + kubernetesApi.proxy.mockResolvedValue({ + ok: false, + status: 403, + statusText: 'Forbidden', + } as Response); + + await toggleSuspendResource(helmRelease, kubernetesApi, alertApi, false); + + expect(alertApi.post).toHaveBeenCalledWith({ + display: 'transient', + message: 'Resume error: Failed to Resume resource: 403 Forbidden', + severity: 'error', + }); + }); +}); diff --git a/plugins/backstage-plugin-flux/src/hooks/useToggleSuspendResource.ts b/plugins/backstage-plugin-flux/src/hooks/useToggleSuspendResource.ts new file mode 100644 index 0000000..ead3017 --- /dev/null +++ b/plugins/backstage-plugin-flux/src/hooks/useToggleSuspendResource.ts @@ -0,0 +1,134 @@ +import { AlertApi, alertApiRef, useApi } from '@backstage/core-plugin-api'; +import { KubernetesApi, kubernetesApiRef } from '@backstage/plugin-kubernetes'; +import { CustomResourceMatcher } from '@backstage/plugin-kubernetes-common'; +import { useAsyncFn } from 'react-use'; +import { gvkFromKind } from '../objects'; +import { Deployment, Source } from '../components/helpers'; + +export const pathForResource = ( + name: string, + namespace: string, + gvk: CustomResourceMatcher, +): string => { + const basePath = [ + '/apis', + gvk.group, + gvk.apiVersion, + 'namespaces', + namespace, + gvk.plural, + name, + ].join('/'); + + return basePath; +}; + +export function toggleSuspendRequest( + name: string, + namespace: string, + clusterName: string, + gvk: CustomResourceMatcher, + suspend: boolean, +) { + return { + clusterName, + path: pathForResource(name, namespace, gvk), + init: { + method: 'PATCH', + headers: { + 'Content-Type': 'application/merge-patch+json', + }, + body: JSON.stringify({ + spec: { + suspend, + }, + }), + }, + }; +} + +export function getRequest( + name: string, + namespace: string, + clusterName: string, + gvk: CustomResourceMatcher, +) { + return { + clusterName, + path: pathForResource(name, namespace, gvk), + }; +} + +export async function requestToggleSuspendResource( + kubernetesApi: KubernetesApi, + name: string, + namespace: string, + clusterName: string, + gvk: CustomResourceMatcher, + suspend: boolean, +) { + const res = await kubernetesApi.proxy( + toggleSuspendRequest(name, namespace, clusterName, gvk, suspend), + ); + const key = suspend ? 'Suspend' : 'Resume'; + if (!res.ok) { + throw new Error( + `Failed to ${key} resource: ${res.status} ${res.statusText}`, + ); + } +} + +export async function toggleSuspendResource( + resource: Source | Deployment, + kubernetesApi: KubernetesApi, + alertApi: AlertApi, + suspend: boolean, +) { + const key = suspend ? 'Suspend' : 'Resume'; + try { + const gvk = gvkFromKind(resource.type); + if (!gvk) { + throw new Error(`Unknown resource type: ${resource.type}`); + } + + await requestToggleSuspendResource( + kubernetesApi, + resource.name, + resource.namespace, + resource.clusterName, + gvk, + suspend, + ); + + alertApi.post({ + message: `${key} request successful`, + severity: 'success', + display: 'transient', + }); + } catch (e: any) { + alertApi.post({ + message: `${key} error: ${(e && e.message) || e}`, + severity: 'error', + display: 'transient', + }); + } +} + +/** + * + * @public + */ +export function useToggleSuspendResource( + resource: Source | Deployment, + suspend: boolean, +) { + const kubernetesApi = useApi(kubernetesApiRef); + const alertApi = useApi(alertApiRef); + + const [{ loading }, toggleSuspend] = useAsyncFn( + () => toggleSuspendResource(resource, kubernetesApi, alertApi, suspend), + [resource, kubernetesApi, alert], + ); + + return { loading, toggleSuspend }; +}