Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions workspaces/argocd/.changeset/sweet-moles-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage-community/plugin-argocd-backend': patch
---

Registered actions for ArgoCD application management: `argocd:find-applications`, `argocd:get-application`, `argocd:list-applications`, `argocd:get-revision-details`
Comment thread
drodil marked this conversation as resolved.
7 changes: 0 additions & 7 deletions workspaces/argocd/plugins/argocd-backend/knip-report.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,2 @@
# Knip report

## Unused dependencies (2)

| Name | Location | Severity |
| :---------------- | :---------------- | :------- |
| @backstage/errors | package.json:38:6 | error |
| undici | package.json:42:6 | error |

3 changes: 1 addition & 2 deletions workspaces/argocd/plugins/argocd-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,7 @@
"@backstage/errors": "^1.2.7",
"@backstage/plugin-permission-common": "^0.9.7",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"undici": "^7.24.2"
"express-promise-router": "^4.1.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "^1.11.1",
Expand Down
17 changes: 0 additions & 17 deletions workspaces/argocd/plugins/argocd-backend/report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,11 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { BackendFeature } from '@backstage/backend-plugin-api';
import { LoggerService } from '@backstage/backend-plugin-api';
import { RootConfigService } from '@backstage/backend-plugin-api';

// @public
const argoCDPlugin: BackendFeature;
export default argoCDPlugin;

// @public
export function createArgoCDActions(options: {
actionsRegistry: ActionsRegistryService;
config: RootConfigService;
logger: LoggerService;
}): void;

// @public
export function createArgoCDResourceAction(options: {
actionsRegistry: ActionsRegistryService;
config: RootConfigService;
logger: LoggerService;
}): void;

// (No @packageDocumentation comment for this package)
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { NotAllowedError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { createFindApplicationsAction } from './createFindApplicationsAction';
import { ArgoCDService } from '@backstage-community/plugin-argocd-node';

const mockInstance = {
name: 'prod',
url: 'https://argocd.example.com',
appName: ['my-app'],
applications: [
{
metadata: {
name: 'my-app',
namespace: 'production',
},
spec: {
project: 'default',
destination: {
server: 'https://kubernetes.default.svc',
namespace: 'production',
},
},
status: {
sync: { status: 'Synced', revision: 'abc123' },
health: { status: 'Healthy' },
resources: [],
history: [],
operationState: { phase: 'Succeeded', message: '' },
},
},
],
};

describe('createFindApplicationsAction', () => {
let mockActionsRegistry: { register: jest.Mock };
let mockArgoCDService: jest.Mocked<ArgoCDService>;
let mockPermissions: {
authorize: jest.Mock;
authorizeConditional: jest.Mock;
};

beforeEach(() => {
mockActionsRegistry = { register: jest.fn() };

mockArgoCDService = {
findApplications: jest.fn().mockResolvedValue([mockInstance]),
} as any;

mockPermissions = {
authorize: jest
.fn()
.mockResolvedValue([{ result: AuthorizeResult.ALLOW }]),
authorizeConditional: jest.fn(),
};

createFindApplicationsAction({
actionsRegistry: mockActionsRegistry as any,
argoCDService: mockArgoCDService,
permissions: mockPermissions as any,
});
});

it('registers the argocd:find-applications action', () => {
expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1);
const reg = mockActionsRegistry.register.mock.calls[0][0];
expect(reg.name).toBe('argocd:find-applications');
expect(reg.attributes.readOnly).toBe(true);
expect(reg.attributes.destructive).toBe(false);
expect(reg.attributes.idempotent).toBe(true);
});

it('returns applications across all instances', async () => {
const reg = mockActionsRegistry.register.mock.calls[0][0];
const credentials = mockCredentials.user();

const result = await reg.action({
input: { appName: 'my-app' },
credentials,
logger: mockServices.logger.mock(),
});

expect(mockArgoCDService.findApplications).toHaveBeenCalledWith({
appName: 'my-app',
project: undefined,
appNamespace: undefined,
expand: 'applications',
});
expect(result.output.instances).toHaveLength(1);
expect(result.output.instances[0].instanceName).toBe('prod');
expect(result.output.instances[0].instanceUrl).toBe(
'https://argocd.example.com',
);
expect(result.output.instances[0].applications).toHaveLength(1);
const app = result.output.instances[0].applications[0];
expect(app.name).toBe('my-app');
expect(app.syncStatus).toBe('Synced');
expect(app.healthStatus).toBe('Healthy');
expect(app.revision).toBe('abc123');
});

it('passes optional filters to findApplications', async () => {
const reg = mockActionsRegistry.register.mock.calls[0][0];
const credentials = mockCredentials.user();

await reg.action({
input: { appName: 'my-app', project: 'my-project', appNamespace: 'ns' },
credentials,
logger: mockServices.logger.mock(),
});

expect(mockArgoCDService.findApplications).toHaveBeenCalledWith({
appName: 'my-app',
project: 'my-project',
appNamespace: 'ns',
expand: 'applications',
});
});

it('returns empty applications array when instance has no apps', async () => {
mockArgoCDService.findApplications.mockResolvedValue([
{ name: 'prod', url: 'https://argocd.example.com', appName: [] },
]);
const reg = mockActionsRegistry.register.mock.calls[0][0];
const credentials = mockCredentials.user();

const result = await reg.action({
input: { appName: 'missing-app' },
credentials,
logger: mockServices.logger.mock(),
});

expect(result.output.instances[0].applications).toHaveLength(0);
});

it('throws NotAllowedError when permission is denied', async () => {
mockPermissions.authorize.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const reg = mockActionsRegistry.register.mock.calls[0][0];
const credentials = mockCredentials.user();

await expect(
reg.action({
input: { appName: 'my-app' },
credentials,
logger: mockServices.logger.mock(),
}),
).rejects.toThrow(NotAllowedError);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
import { PermissionsService } from '@backstage/backend-plugin-api';
import { NotAllowedError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { argocdViewPermission } from '@backstage-community/plugin-argocd-common';
import { ArgoCDService } from '@backstage-community/plugin-argocd-node';

/**
* Registers the `argocd:find-applications` action.
* @internal
*/
export function createFindApplicationsAction(options: {
actionsRegistry: ActionsRegistryService;
argoCDService: ArgoCDService;
permissions: PermissionsService;
}) {
const { actionsRegistry, argoCDService, permissions } = options;

actionsRegistry.register({
name: 'argocd:find-applications',
title: 'Find ArgoCD Applications',
description:
'Find all ArgoCD applications across all configured instances by application name. Returns sync status, health, and deployment details. Use the app name from the catalog entity annotation `argocd/app-name`.',
attributes: {
readOnly: true,
destructive: false,
idempotent: true,
},
visibilityPermission: argocdViewPermission,
schema: {
input: z =>
z.object({
appName: z.string().describe('ArgoCD application name to search for'),
project: z
.string()
.optional()
.describe('Filter by ArgoCD project name'),
appNamespace: z
.string()
.optional()
.describe('Filter by application namespace'),
}),
output: z =>
z.object({
instances: z.array(
z.object({
instanceName: z.string(),
instanceUrl: z.string(),
applications: z.array(
z.object({
name: z.string(),
namespace: z.string().optional(),
project: z.string().optional(),
syncStatus: z.string(),
healthStatus: z.string(),
revision: z.string().optional(),
destination: z.object({
server: z.string().optional(),
namespace: z.string().optional(),
}),
}),
),
}),
),
}),
},
async action({ input, credentials, logger }) {
const decision = await permissions.authorize(
[{ permission: argocdViewPermission }],
{ credentials },
);
if (decision[0].result === AuthorizeResult.DENY) {
throw new NotAllowedError(
'Unauthorized: missing argocd.view.read permission',
);
}

logger.debug(`Finding ArgoCD applications with name: ${input.appName}`);

const results = await argoCDService.findApplications({
appName: input.appName,
project: input.project,
appNamespace: input.appNamespace,
expand: 'applications',
});

return {
output: {
instances: results.map(r => ({
instanceName: r.name,
instanceUrl: r.url,
applications: (r.applications ?? []).map(app => ({
name: app.metadata.name ?? '',
namespace: app.metadata.namespace,
project: app.spec?.project,
syncStatus: app.status?.sync?.status ?? 'Unknown',
healthStatus: app.status?.health?.status ?? 'Unknown',
revision: app.status?.sync?.revision,
destination: {
server: app.spec?.destination?.server,
namespace: app.spec?.destination?.namespace,
},
})),
})),
},
};
},
});
}
Loading
Loading