Skip to content

Commit

Permalink
Merge pull request #1126 from TRIMM/main
Browse files Browse the repository at this point in the history
Adds support for Applications in any namespace
  • Loading branch information
Xantier authored Nov 13, 2023
2 parents 6d2ccc4 + 9ff3729 commit d378227
Show file tree
Hide file tree
Showing 14 changed files with 446 additions and 32 deletions.
5 changes: 5 additions & 0 deletions .changeset/ninety-starfishes-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@roadiehq/backstage-plugin-argo-cd': minor
---

Adds support for Applications in any namespace (as per https://argo-cd.readthedocs.io/en/stable/operator-manual/app-any-namespace/)
20 changes: 20 additions & 0 deletions plugins/frontend/backstage-plugin-argo-cd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,26 @@ The Argo plugin will fetch the Argo CD instances an app is deployed to and use t

Please visit the [Argo CD Backend Plugin](https://www.npmjs.com/package/@roadiehq/backstage-plugin-argo-cd-backend) for more information

## Support for apps in any namespace beta feature

If you want to use the "Applications in any namespace" beta [feature](https://argo-cd.readthedocs.io/en/stable/operator-manual/app-any-namespace/), you have to explicitly enable it in the configuration.

In the configuration file, you need to toggle the feature:

```yaml
argocd:
...
namespacedApps: true
```

After enabling the feature, you can use the newly available `argocd/app-namespace` annotation on entities:

```yaml
metadata:
annotations:
argocd/app-namespace: my-test-ns
```

## Develop plugin locally

You can run the application by running `yarn dev` at the root of this monorepo.
Expand Down
5 changes: 5 additions & 0 deletions plugins/frontend/backstage-plugin-argo-cd/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export interface Config {
* @visibility frontend
*/
baseUrl?: string;
/**
* Support for the ArgoCD beta feature "Applications in any namespace"
* @visibility frontend
*/
namespacedApps?: boolean;
/**
* The base url of the ArgoCD instance.
* @visibility frontend
Expand Down
38 changes: 36 additions & 2 deletions plugins/frontend/backstage-plugin-argo-cd/src/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@ describe('API calls', () => {
backendBaseUrl: '',
searchInstances: true,
identityApi: getIdentityApiStub,
useNamespacedApps: false,
});

const response = await client.serviceLocatorUrl({ appName: 'test' });
const response = await client.serviceLocatorUrl({
appName: 'test',
appNamespace: 'my-test-ns',
});

// Let's verify the fetch was called with the requested URL and Authorization header
expect(global.fetch).toHaveBeenCalledWith(
expect.anything(),
expect.not.stringMatching('appNamespace'),
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
Expand All @@ -51,6 +55,7 @@ describe('API calls', () => {
backendBaseUrl: '',
searchInstances: true,
identityApi: getIdentityApiStub,
useNamespacedApps: false,
});

const response = await client.serviceLocatorUrl({ appName: 'test' });
Expand All @@ -63,10 +68,39 @@ describe('API calls', () => {
backendBaseUrl: '',
searchInstances: true,
identityApi: getIdentityApiStub,
useNamespacedApps: false,
});

const error = new Error('Need to provide appName or appSelector');

await expect(client.serviceLocatorUrl({})).rejects.toThrow(error);
});

it('serviceLocatorUrl: fetches namespaced applications', async () => {
const client = new ArgoCDApiClient({
discoveryApi: getDiscoveryApiStub,
backendBaseUrl: '',
searchInstances: true,
identityApi: getIdentityApiStub,
useNamespacedApps: true,
});

const response = await client.serviceLocatorUrl({
appName: 'test',
appNamespace: 'my-test-ns',
});

// Let's verify the fetch was called with the requested URL and Authorization header
expect(global.fetch).toHaveBeenCalledWith(
expect.stringMatching('appNamespace=my-test-ns'),
expect.objectContaining({
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer fake-id-token`,
},
}),
);

expect(response).toEqual(getServiceListStub);
});
});
80 changes: 61 additions & 19 deletions plugins/frontend/backstage-plugin-argo-cd/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,32 +19,38 @@ import {
isDecodeError as tsIsDecodeError,
} from 'io-ts-promise';
import reporter from 'io-ts-reporters';
import { ARGOCD_ANNOTATION_APP_NAMESPACE } from '../components/useArgoCDAppData';

export interface ArgoCDApi {
listApps(options: {
url: string;
appSelector?: string;
appNamespace?: string;
projectName?: string;
}): Promise<ArgoCDAppList>;
getRevisionDetails(options: {
url: string;
app: string;
appNamespace?: string;
revisionID: string;
instanceName?: string;
}): Promise<ArgoCDAppDeployRevisionDetails>;
getAppDetails(options: {
url: string;
appName: string;
appNamespace?: string;
instance?: string;
}): Promise<ArgoCDAppDetails>;
getAppListDetails(options: {
url: string;
appSelector: string;
appNamespace?: string;
instance?: string;
}): Promise<ArgoCDAppList>;
serviceLocatorUrl(options: {
appName?: string;
appSelector?: string;
appNamespace?: string;
}): Promise<ArgoCDServiceList | Error>;
}

Expand All @@ -58,19 +64,22 @@ export type Options = {
searchInstances: boolean;
identityApi: IdentityApi;
proxyPath?: string;
useNamespacedApps: boolean;
};

export class ArgoCDApiClient implements ArgoCDApi {
private readonly discoveryApi: DiscoveryApi;
private readonly backendBaseUrl: string;
private readonly searchInstances: boolean;
private readonly identityApi: IdentityApi;
private readonly useNamespacedApps: boolean;

constructor(options: Options) {
this.discoveryApi = options.discoveryApi;
this.backendBaseUrl = options.backendBaseUrl;
this.searchInstances = options.searchInstances;
this.identityApi = options.identityApi;
this.useNamespacedApps = options.useNamespacedApps;
}

async getBaseUrl() {
Expand All @@ -80,6 +89,21 @@ export class ArgoCDApiClient implements ArgoCDApi {
return await this.discoveryApi.getBaseUrl('proxy');
}

getQueryParams(params: { [p: string]: string | undefined }) {
const result = Object.keys(params)
.filter(key => params[key] !== undefined)
.filter(
key =>
this.useNamespacedApps || key === ARGOCD_ANNOTATION_APP_NAMESPACE,
)
.map(
k =>
`${encodeURIComponent(k)}=${encodeURIComponent(params[k] as string)}`,
)
.join('&');
return result ? `?${result}` : '';
}

async fetchDecode<A, O, I>(url: string, typeCodec: tsType<A, O, I>) {
const { token } = await this.identityApi.getCredentials();
const response = await fetch(url, {
Expand Down Expand Up @@ -114,33 +138,32 @@ export class ArgoCDApiClient implements ArgoCDApi {
async listApps(options: {
url: string;
appSelector?: string;
appNamespace?: string;
projectName?: string;
}) {
const proxyUrl = await this.getBaseUrl();
const params: { [key: string]: string | undefined } = {
const query = this.getQueryParams({
selector: options.appSelector,
project: options.projectName,
};
const query = Object.keys(params)
.filter(key => params[key] !== undefined)
.map(
k =>
`${encodeURIComponent(k)}=${encodeURIComponent(params[k] as string)}`,
)
.join('&');
appNamespace: options.appNamespace,
});
return this.fetchDecode(
`${proxyUrl}${options.url}/applications?${query}`,
`${proxyUrl}${options.url}/applications${query}`,
argoCDAppList,
);
}

async getRevisionDetails(options: {
url: string;
app: string;
appNamespace?: string;
revisionID: string;
instanceName?: string;
}) {
const proxyUrl = await this.getBaseUrl();
const query = this.getQueryParams({
appNamespace: options.appNamespace,
});
if (this.searchInstances) {
return this.fetchDecode(
`${proxyUrl}/argoInstance/${
Expand All @@ -149,7 +172,7 @@ export class ArgoCDApiClient implements ArgoCDApi {
options.app as string,
)}/revisions/${encodeURIComponent(
options.revisionID as string,
)}/metadata`,
)}/metadata${query}`,
argoCDAppDeployRevisionDetails,
);
}
Expand All @@ -158,67 +181,86 @@ export class ArgoCDApiClient implements ArgoCDApi {
options.app as string,
)}/revisions/${encodeURIComponent(
options.revisionID as string,
)}/metadata`,
)}/metadata${query}`,
argoCDAppDeployRevisionDetails,
);
}

async getAppDetails(options: {
url: string;
appName: string;
appNamespace?: string;
instance?: string;
}) {
const proxyUrl = await this.getBaseUrl();
const query = this.getQueryParams({
appNamespace: options.appNamespace,
});
if (this.searchInstances) {
return this.fetchDecode(
`${proxyUrl}/argoInstance/${
options.instance
}/applications/name/${encodeURIComponent(options.appName as string)}`,
}/applications/name/${encodeURIComponent(
options.appName as string,
)}${query}`,
argoCDAppDetails,
);
}
return this.fetchDecode(
`${proxyUrl}${options.url}/applications/${encodeURIComponent(
options.appName as string,
)}`,
)}${query}`,
argoCDAppDetails,
);
}

async getAppListDetails(options: {
url: string;
appSelector: string;
appNamespace?: string;
instance?: string;
}) {
const proxyUrl = await this.getBaseUrl();
const query = this.getQueryParams({
appNamespace: options.appNamespace,
});
if (this.searchInstances) {
return this.fetchDecode(
`${proxyUrl}/argoInstance/${
options.instance
}/applications/selector/${encodeURIComponent(
options.appSelector as string,
)}`,
)}${query}`,
argoCDAppList,
);
}
return this.fetchDecode(
`${proxyUrl}${options.url}/applications/selector/${encodeURIComponent(
options.appSelector as string,
)}`,
)}${query}`,
argoCDAppList,
);
}

async serviceLocatorUrl(options: { appName?: string; appSelector?: string }) {
async serviceLocatorUrl(options: {
appName?: string;
appSelector?: string;
appNamespace?: string;
}) {
if (!options.appName && !options.appSelector) {
throw new Error('Need to provide appName or appSelector');
}
const baseUrl = await this.getBaseUrl();
const query = this.getQueryParams({
appNamespace: options.appNamespace,
});
const url = options.appName
? `${baseUrl}/find/name/${encodeURIComponent(options.appName as string)}`
? `${baseUrl}/find/name/${encodeURIComponent(
options.appName as string,
)}${query}`
: `${baseUrl}/find/selector/${encodeURIComponent(
options.appSelector as string,
)}`;
)}${query}`;

return this.fetchDecode(url, argoCDServiceList).catch(_ => {
throw new Error('Cannot get argo location(s) for service');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,15 @@ const ArgoCDDetails = ({
entity: Entity;
extraColumns: TableColumn[];
}) => {
const { url, appName, appSelector, projectName } = useArgoCDAppData({
entity,
});
const { url, appName, appSelector, appNamespace, projectName } =
useArgoCDAppData({
entity,
});
const { loading, value, error, retry } = useAppDetails({
url,
appName,
appSelector,
appNamespace,
projectName,
});
if (loading) {
Expand Down
Loading

0 comments on commit d378227

Please sign in to comment.