add workflow templates component

This commit is contained in:
Manabu Mccloskey 2023-06-29 17:58:30 -07:00
parent b64e6c821f
commit a48fe8e471
13 changed files with 446 additions and 56 deletions

View file

@ -50,6 +50,9 @@ const overviewContent = (
<Grid item md={6}>
<EntityArgoWorkflowsOverviewCard />
</Grid>
<Grid item md={6}>
<EntityArgoWorkflowsTemplateOverviewCard />
</Grid>
</EntitySwitch.Case>
</EntitySwitch>
...

View file

@ -1,6 +1,9 @@
import { DiscoveryApi, FetchApi } from "@backstage/core-plugin-api";
import { KubernetesApi } from "@backstage/plugin-kubernetes";
import { IoArgoprojWorkflowV1alpha1WorkflowList } from "./generated";
import {
IoArgoprojWorkflowV1alpha1WorkflowList,
IoArgoprojWorkflowV1alpha1WorkflowTemplateList,
} from "./generated";
import { ArgoWorkflowsApi } from "./index";
const API_VERSION = "argoproj.io/v1alpha1";
@ -27,7 +30,7 @@ export class ArgoWorkflows implements ArgoWorkflowsApi {
async getWorkflowsFromK8s(
clusterName: string | undefined,
namespace: string | undefined,
namespace: string,
labels: string | undefined
): Promise<IoArgoprojWorkflowV1alpha1WorkflowList> {
const ns = namespace !== undefined ? namespace : "default";
@ -69,32 +72,37 @@ export class ArgoWorkflows implements ArgoWorkflowsApi {
return this.getWorkflowsFromProxy(namespace, labels);
}
async getWorkflowTemplates(
clusterName: string | undefined,
namespace: string,
labels: string | undefined
): Promise<IoArgoprojWorkflowV1alpha1WorkflowTemplateList> {
if (clusterName) {
return Promise.reject("t");
}
return this.getWorkflowTemplatesFromProxy(namespace, labels);
}
async getWorkflowsFromProxy(
namespace: string | undefined,
namespace: string,
labels: string | undefined
): Promise<IoArgoprojWorkflowV1alpha1WorkflowList> {
const proxyUrl = await this.discoveryApi.getBaseUrl("proxy");
const path = `/api/v1/workflows/${namespace}`;
const resp = await this.fetchFromPath(path, labels);
return await checkAndReturn<IoArgoprojWorkflowV1alpha1WorkflowTemplateList>(
resp
);
}
const ns = namespace !== undefined ? namespace : "default";
const url = `${proxyUrl}${DEFAULT_WORKFLOW_PROXY}/api/v1/workflows/${ns}`;
async getWorkflowTemplatesFromProxy(
namespace: string,
labels: string | undefined
): Promise<IoArgoprojWorkflowV1alpha1WorkflowTemplateList> {
const path = `/api/v1/workflow-templates/${namespace}`;
const query = new URLSearchParams({ [API_TIMEOUT]: "30" });
if (labels) {
query.set(API_LABEL_SELECTOR, labels);
}
const resp = await this.fetchApi.fetch(`${url}?${query.toString()}`, {});
if (!resp.ok) {
return Promise.reject(
`failed to fetch resources: ${resp.status}, ${
resp.statusText
}, ${await resp.text()}`
);
}
// need validation
return Promise.resolve(
JSON.parse(await resp.text()) as IoArgoprojWorkflowV1alpha1WorkflowList
const resp = await this.fetchFromPath(path, labels);
return await checkAndReturn<IoArgoprojWorkflowV1alpha1WorkflowTemplateList>(
resp
);
}
@ -105,4 +113,29 @@ export class ArgoWorkflows implements ArgoWorkflowsApi {
}
return Promise.reject("no clusters found in configuration");
}
async fetchFromPath(
path: string,
labels: string | undefined
): Promise<Response> {
const proxyUrl = await this.discoveryApi.getBaseUrl("proxy");
const url = `${proxyUrl}${DEFAULT_WORKFLOW_PROXY}${path}`;
const query = new URLSearchParams({ [API_TIMEOUT]: "30" });
if (labels) {
query.set(API_LABEL_SELECTOR, labels);
}
return this.fetchApi.fetch(`${url}?${query.toString()}`, {});
}
}
async function checkAndReturn<T>(resp: Response): Promise<T> {
if (!resp.ok) {
return Promise.reject(
`failed to fetch resources: ${resp.status}, ${
resp.statusText
}, ${await resp.text()}`
);
}
// need validation
return Promise.resolve(JSON.parse(await resp.text()) as T);
}

View file

@ -1,6 +1,9 @@
import { createApiRef } from "@backstage/core-plugin-api";
import { IoArgoprojWorkflowV1alpha1WorkflowList } from "./generated/";
import {
IoArgoprojWorkflowV1alpha1WorkflowList,
IoArgoprojWorkflowV1alpha1WorkflowTemplateList,
} from "./generated/";
export { ArgoWorkflows } from "./ArgoWorkflows";
@ -8,18 +11,14 @@ export const argoWorkflowsApiRef = createApiRef<ArgoWorkflowsApi>({
id: "plugin.argoworkflows",
});
export interface ArgoWorkflowsApi {
getWorkflowsFromK8s(
clusterName: string,
namespace: string | undefined,
labels: string | undefined
): Promise<IoArgoprojWorkflowV1alpha1WorkflowList>;
getWorkflows(
clusterName: string | undefined,
namespace: string | undefined,
labels: string | undefined
): Promise<IoArgoprojWorkflowV1alpha1WorkflowList>;
getWorkflowsFromProxy(
getWorkflowTemplates(
clusterName: string | undefined,
namespace: string,
labels: string | undefined
): Promise<IoArgoprojWorkflowV1alpha1WorkflowList>;
): Promise<IoArgoprojWorkflowV1alpha1WorkflowTemplateList>;
}

View file

@ -12,6 +12,7 @@ import { Grid } from "@material-ui/core";
import { OverviewTable } from "../WorkflowOverview/WorkflowOverview";
import { useEntity } from "@backstage/plugin-catalog-react";
import { isArgoWorkflowsAvailable } from "../../plugin";
import { WorkflowTemplateTable } from "../WorkflowTemplateOverview/WorkflowTemplateOverview";
export const ArgoWorkflowsOverviewPage = () => (
<Page themeId="tool">
@ -40,3 +41,15 @@ export const ArgoWorkflowsOverviewCard = () => {
}
return null;
};
export const ArgoWorkflowsTemplatesOverviewCard = () => {
const { entity } = useEntity();
if (isArgoWorkflowsAvailable(entity)) {
return (
<InfoCard {...{ title: "Argo Workflows Templates" }}>
<WorkflowTemplateTable />
</InfoCard>
);
}
return null;
};

View file

@ -1,4 +1,5 @@
export {
ArgoWorkflowsOverviewPage,
ArgoWorkflowsOverviewCard,
ArgoWorkflowsTemplatesOverviewCard,
} from "./Overview";

View file

@ -38,8 +38,7 @@ const mockKClient: jest.Mocked<KubernetesApi> = {
const noopFetchApi = new MockFetchApi({ baseImplementation: "none" });
const mockArgoWorkflows: jest.Mocked<ArgoWorkflowsApi> = {
getWorkflows: jest.fn(),
getWorkflowsFromK8s: jest.fn(),
getWorkflowsFromProxy: jest.fn(),
getWorkflowTemplates: jest.fn(),
};
const apis: [AnyApiRef, Partial<unknown>][] = [

View file

@ -6,14 +6,10 @@ import React from "react";
import Alert from "@material-ui/lab/Alert";
import { useEntity } from "@backstage/plugin-catalog-react";
import { IoArgoprojWorkflowV1alpha1WorkflowList } from "../../api/generated";
import {
ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION,
CLUSTER_NAME_ANNOTATION,
K8S_LABEL_SELECTOR_ANNOTATION,
K8S_NAMESPACE_ANNOTATION,
} from "../../plugin";
import { getAnnotationValues, trimBaseUrl } from "../utils";
type TableData = {
id: string;
name: string;
namespace: string;
phase?: string;
@ -26,24 +22,11 @@ export const OverviewTable = () => {
const { entity } = useEntity();
const apiClient = useApi(argoWorkflowsApiRef);
const configApi = useApi(configApiRef);
let argoWorkflowsBaseUrl = configApi.getOptionalString(
"argoWorkflows.baseUrl"
const argoWorkflowsBaseUrl = trimBaseUrl(
configApi.getOptionalString("argoWorkflows.baseUrl")
);
if (argoWorkflowsBaseUrl && argoWorkflowsBaseUrl.endsWith("/")) {
argoWorkflowsBaseUrl = argoWorkflowsBaseUrl.substring(
0,
argoWorkflowsBaseUrl.length - 1
);
}
const ln = entity.metadata.annotations?.[K8S_NAMESPACE_ANNOTATION];
const ns = ln !== undefined ? ln : "default";
const clusterName = entity.metadata.annotations?.[CLUSTER_NAME_ANNOTATION];
const labelSelector =
entity.metadata?.annotations?.[ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION] !==
undefined
? entity.metadata?.annotations?.[ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION]
: entity.metadata.annotations?.[K8S_LABEL_SELECTOR_ANNOTATION];
const { ns, clusterName, labelSelector } = getAnnotationValues(entity);
const columns: TableColumn[] = [
{
@ -99,11 +82,12 @@ export const OverviewTable = () => {
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
return <Alert severity="error">{`${error}`}</Alert>;
}
const data = value?.items?.map((val) => {
return {
id: val.metadata.name,
name: val.metadata.name,
namespace: val.metadata.namespace,
phase: val.status?.phase,
@ -117,6 +101,7 @@ export const OverviewTable = () => {
return (
<Table
options={{
padding: "dense",
paging: true,
search: true,
sorting: true,

View file

@ -0,0 +1,132 @@
import React from "react";
import {
MockConfigApi,
MockFetchApi,
renderInTestApp,
TestApiProvider,
} from "@backstage/test-utils";
import {
AnyApiRef,
configApiRef,
DiscoveryApi,
discoveryApiRef,
fetchApiRef,
} from "@backstage/core-plugin-api";
import { ArgoWorkflowsApi, argoWorkflowsApiRef } from "../../api";
import { KubernetesApi, kubernetesApiRef } from "@backstage/plugin-kubernetes";
import { EntityProvider } from "@backstage/plugin-catalog-react";
import { WorkflowTemplateTable } from "./WorkflowTemplateOverview";
import { simple } from "../../test-data/testResponseWorkflowTemplates";
import { IoArgoprojWorkflowV1alpha1WorkflowTemplateList } from "../../api/generated";
const BASE_URL = "https://backstage.io";
const mockConfigApi = new MockConfigApi({
argoWorkflows: { baseUrl: BASE_URL },
});
const mockDiscoveryApi: jest.Mocked<DiscoveryApi> = {
getBaseUrl: jest.fn().mockImplementation((id) => {
return Promise.resolve(`https://backstage.io/${id}`);
}),
};
const mockKClient: jest.Mocked<KubernetesApi> = {
getObjectsByEntity: jest.fn(),
getClusters: jest.fn(),
getWorkloadsByEntity: jest.fn(),
getCustomObjectsByEntity: jest.fn(),
proxy: jest.fn(),
};
const noopFetchApi = new MockFetchApi({ baseImplementation: "none" });
const mockArgoWorkflows: jest.Mocked<ArgoWorkflowsApi> = {
getWorkflows: jest.fn(),
getWorkflowTemplates: jest.fn(),
};
const apis: [AnyApiRef, Partial<unknown>][] = [
[discoveryApiRef, mockDiscoveryApi],
[kubernetesApiRef, mockKClient],
[configApiRef, mockConfigApi],
[fetchApiRef, noopFetchApi],
[argoWorkflowsApiRef, mockArgoWorkflows],
];
const entity = {
metadata: {
namespace: "default",
annotations: {
"backstage.io/kubernetes-label-selector": "my=env",
"backstage.io/kubernetes-namespace": "default",
},
name: "my-entity",
},
apiVersion: "backstage.io/v1alpha1",
kind: "Component",
};
afterEach(() => {
jest.clearAllMocks();
});
describe("WorkflowTemplateTable", () => {
it("should display workflows without link", async () => {
jest
.spyOn(mockArgoWorkflows, "getWorkflowTemplates")
.mockImplementation(
(
_n,
_ns,
_l
): Promise<IoArgoprojWorkflowV1alpha1WorkflowTemplateList> => {
return Promise.resolve(
simple as unknown as IoArgoprojWorkflowV1alpha1WorkflowTemplateList
);
}
);
jest.spyOn(mockConfigApi, "getOptionalString").mockImplementation((_) => {
return undefined;
});
const r = await renderInTestApp(
<TestApiProvider apis={apis}>
<EntityProvider entity={entity}>
<WorkflowTemplateTable />
</EntityProvider>
</TestApiProvider>
);
const c = r.getByText(simple.items[0].metadata.name);
expect(c).not.toHaveAttribute("href");
});
it("should display workflows wth link", async () => {
const spyWorkflows = jest
.spyOn(mockArgoWorkflows, "getWorkflowTemplates")
.mockImplementation(
(
_n,
_ns,
_l
): Promise<IoArgoprojWorkflowV1alpha1WorkflowTemplateList> => {
return Promise.resolve(
simple as unknown as IoArgoprojWorkflowV1alpha1WorkflowTemplateList
);
}
);
const spyConfigApi = jest
.spyOn(mockConfigApi, "getOptionalString")
.mockImplementation((_n) => {
return `https://backstage.io/`;
});
const r = await renderInTestApp(
<TestApiProvider apis={apis}>
<EntityProvider entity={entity}>
<WorkflowTemplateTable data-testid="test" />
</EntityProvider>
</TestApiProvider>
);
expect(spyWorkflows).toHaveBeenCalledWith(undefined, "default", "my=env");
expect(spyConfigApi).toHaveBeenCalledWith("argoWorkflows.baseUrl");
const c = r.getByText(simple.items[0].metadata.name);
expect(c).toHaveAttribute(
"href",
`${BASE_URL}/workflow-templates/default/${simple.items[0].metadata.name}`
);
});
});

View file

@ -0,0 +1,82 @@
import { useEntity } from "@backstage/plugin-catalog-react";
import { configApiRef, useApi } from "@backstage/core-plugin-api";
import { argoWorkflowsApiRef } from "../../api";
import { getAnnotationValues, trimBaseUrl } from "../utils";
import { Link, Progress, Table, TableColumn } from "@backstage/core-components";
import React from "react";
import useAsync from "react-use/lib/useAsync";
import Alert from "@material-ui/lab/Alert";
type TableData = {
id: string;
name: string;
namespace: string;
entrypoint: string | undefined;
};
export const WorkflowTemplateTable = () => {
const { entity } = useEntity();
const apiClient = useApi(argoWorkflowsApiRef);
const configApi = useApi(configApiRef);
const argoWorkflowsBaseUrl = trimBaseUrl(
configApi.getOptionalString("argoWorkflows.baseUrl")
);
const { ns, clusterName, labelSelector } = getAnnotationValues(entity);
const columns: TableColumn[] = [
{
title: "Name",
field: "name",
render: (data: any | TableData, _): any => {
if (data && argoWorkflowsBaseUrl) {
return (
<Link
to={`${argoWorkflowsBaseUrl}/workflow-templates/${data.namespace}/${data.name}`}
>
{data.name}
</Link>
);
}
return data.name;
},
defaultSort: "desc",
},
{ title: "namespace", field: "namespace", type: "string" },
{ title: "entrypoint", field: "entrypoint", type: "string" },
];
const { value, loading, error } = useAsync(async () => {
return await apiClient.getWorkflowTemplates(clusterName, ns, labelSelector);
});
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{`${error}`}</Alert>;
}
const data = value?.items?.map((val) => {
return {
id: val.metadata.name,
name: val.metadata.name,
namespace: val.metadata.namespace,
entrypoint: val.spec.entrypoint,
} as TableData;
});
if (data && data.length > 0) {
return (
<Table
options={{
padding: "dense",
paging: true,
search: true,
sorting: true,
}}
columns={columns}
data={data}
/>
);
}
return (
<Alert severity="info">"No workflows found with provided labels"</Alert>
);
};

View file

@ -0,0 +1,38 @@
import { Entity } from "@backstage/catalog-model";
import {
ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION,
CLUSTER_NAME_ANNOTATION,
K8S_LABEL_SELECTOR_ANNOTATION,
K8S_NAMESPACE_ANNOTATION,
} from "../plugin";
export function trimBaseUrl(argoWorkflowsBaseUrl: string | undefined) {
if (argoWorkflowsBaseUrl && argoWorkflowsBaseUrl.endsWith("/")) {
return argoWorkflowsBaseUrl.substring(0, argoWorkflowsBaseUrl.length - 1);
}
return argoWorkflowsBaseUrl;
}
export type getAnnotationValuesOutput = {
ns: string;
clusterName?: string;
labelSelector?: string;
};
export function getAnnotationValues(entity: Entity): getAnnotationValuesOutput {
const ns =
entity.metadata.annotations?.[K8S_NAMESPACE_ANNOTATION] !== undefined
? entity.metadata.annotations?.[K8S_NAMESPACE_ANNOTATION]
: "default";
const clusterName = entity.metadata.annotations?.[CLUSTER_NAME_ANNOTATION];
const labelSelector =
entity.metadata?.annotations?.[ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION] !==
undefined
? entity.metadata?.annotations?.[ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION]
: entity.metadata.annotations?.[K8S_LABEL_SELECTOR_ANNOTATION];
return {
ns: ns,
clusterName: clusterName,
labelSelector: labelSelector,
};
}

View file

@ -2,5 +2,6 @@ export {
argoWorkflowsPlugin,
ArgoWorkflowsPage,
EntityArgoWorkflowsOverviewCard,
EntityArgoWorkflowsTemplateOverviewCard,
isArgoWorkflowsAvailable,
} from "./plugin";

View file

@ -54,6 +54,18 @@ export const EntityArgoWorkflowsOverviewCard = argoWorkflowsPlugin.provide(
})
);
export const EntityArgoWorkflowsTemplateOverviewCard =
argoWorkflowsPlugin.provide(
createRoutableExtension({
name: "ArgoWorkflowsTemplatesOverviewCard",
component: () =>
import("./components/Overview").then(
(m) => m.ArgoWorkflowsTemplatesOverviewCard
),
mountPoint: rootRouteRef,
})
);
export const isArgoWorkflowsAvailable = (entity: Entity) =>
Boolean(
entity?.metadata.annotations?.[ARGO_WORKFLOWS_LABEL_SELECTOR_ANNOTATION]

View file

@ -0,0 +1,92 @@
export const simple = {
apiVersion: "v1",
items: [
{
apiVersion: "argoproj.io/v1alpha1",
kind: "WorkflowTemplate",
metadata: {
annotations: {
"kubectl.kubernetes.io/last-applied-configuration":
'{"apiVersion":"argoproj.io/v1alpha1","kind":"WorkflowTemplate","metadata":{"annotations":{},"name":"workflow-template-whalesay-template","namespace":"default"},"spec":{"entrypoint":"whalesay-template","templates":[{"inputs":{"parameters":[{"name":"message"}]},"name":"whalesay-template","steps":[[{"arguments":{"parameters":[{"name":"message","value":"{{inputs.parameters.message}}"}]},"name":"whalesay3","template":"whalesay-template-3"},{"name":"sleep","template":"sleep"}]]},{"container":{"args":["600"],"command":["sleep"],"image":"docker/whalesay"},"name":"sleep"},{"container":{"args":["{{inputs.parameters.message}}"],"command":["cowsay"],"image":"docker/whalesay"},"inputs":{"parameters":[{"name":"message"}]},"name":"whalesay-template-3"}],"ttlStrategy":{"secondsAfterCompletion":28800},"workflowMetadata":{"labels":{"env":"dev","my":"label"}}}}\n',
},
creationTimestamp: "2023-06-26T22:20:33Z",
generation: 1,
name: "workflow-template-whalesay-template",
namespace: "default",
resourceVersion: "76502835",
uid: "c2ed5f0a-9093-4cb2-bac5-c9918bb3f8d7",
},
spec: {
entrypoint: "whalesay-template",
templates: [
{
inputs: {
parameters: [
{
name: "message",
},
],
},
name: "whalesay-template",
steps: [
[
{
arguments: {
parameters: [
{
name: "message",
value: "{{inputs.parameters.message}}",
},
],
},
name: "whalesay3",
template: "whalesay-template-3",
},
{
name: "sleep",
template: "sleep",
},
],
],
},
{
container: {
args: ["600"],
command: ["sleep"],
image: "docker/whalesay",
},
name: "sleep",
},
{
container: {
args: ["{{inputs.parameters.message}}"],
command: ["cowsay"],
image: "docker/whalesay",
},
inputs: {
parameters: [
{
name: "message",
},
],
},
name: "whalesay-template-3",
},
],
ttlStrategy: {
secondsAfterCompletion: 28800,
},
workflowMetadata: {
labels: {
env: "dev",
my: "label",
},
},
},
},
],
kind: "List",
metadata: {
resourceVersion: "",
},
};