Skip to content

Commit 59c9adf

Browse files
committed
feat(argocd): register ActionsRegistryService actions for application management
Signed-off-by: Hellgren Heikki <heikki.hellgren@op.fi>
1 parent e7d78a0 commit 59c9adf

16 files changed

Lines changed: 1128 additions & 37 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@backstage-community/plugin-argocd-backend': patch
3+
---
4+
5+
Registered actions for ArgoCD application management: `argocd:find-applications`, `argocd:get-application`, `argocd:list-applications`, `argocd:get-revision-details`
Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,2 @@
11
# Knip report
22

3-
## Unused dependencies (2)
4-
5-
| Name | Location | Severity |
6-
| :---------------- | :---------------- | :------- |
7-
| @backstage/errors | package.json:38:6 | error |
8-
| undici | package.json:42:6 | error |
9-

workspaces/argocd/plugins/argocd-backend/package.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@
3838
"@backstage/errors": "^1.2.7",
3939
"@backstage/plugin-permission-common": "^0.9.7",
4040
"express": "^4.17.1",
41-
"express-promise-router": "^4.1.0",
42-
"undici": "^7.24.2"
41+
"express-promise-router": "^4.1.0"
4342
},
4443
"devDependencies": {
4544
"@backstage/backend-test-utils": "^1.11.1",

workspaces/argocd/plugins/argocd-backend/report.api.md

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,11 @@
33
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
44
55
```ts
6-
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
76
import { BackendFeature } from '@backstage/backend-plugin-api';
8-
import { LoggerService } from '@backstage/backend-plugin-api';
9-
import { RootConfigService } from '@backstage/backend-plugin-api';
107

118
// @public
129
const argoCDPlugin: BackendFeature;
1310
export default argoCDPlugin;
1411

15-
// @public
16-
export function createArgoCDActions(options: {
17-
actionsRegistry: ActionsRegistryService;
18-
config: RootConfigService;
19-
logger: LoggerService;
20-
}): void;
21-
22-
// @public
23-
export function createArgoCDResourceAction(options: {
24-
actionsRegistry: ActionsRegistryService;
25-
config: RootConfigService;
26-
logger: LoggerService;
27-
}): void;
28-
2912
// (No @packageDocumentation comment for this package)
3013
```
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/*
2+
* Copyright 2025 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
17+
import { NotAllowedError } from '@backstage/errors';
18+
import { AuthorizeResult } from '@backstage/plugin-permission-common';
19+
import { createFindApplicationsAction } from './createFindApplicationsAction';
20+
import { ArgoCDService } from '@backstage-community/plugin-argocd-node';
21+
22+
const mockInstance = {
23+
name: 'prod',
24+
url: 'https://argocd.example.com',
25+
appName: ['my-app'],
26+
applications: [
27+
{
28+
metadata: {
29+
name: 'my-app',
30+
namespace: 'production',
31+
},
32+
spec: {
33+
project: 'default',
34+
destination: {
35+
server: 'https://kubernetes.default.svc',
36+
namespace: 'production',
37+
},
38+
},
39+
status: {
40+
sync: { status: 'Synced', revision: 'abc123' },
41+
health: { status: 'Healthy' },
42+
resources: [],
43+
history: [],
44+
operationState: { phase: 'Succeeded', message: '' },
45+
},
46+
},
47+
],
48+
};
49+
50+
describe('createFindApplicationsAction', () => {
51+
let mockActionsRegistry: { register: jest.Mock };
52+
let mockArgoCDService: jest.Mocked<ArgoCDService>;
53+
let mockPermissions: {
54+
authorize: jest.Mock;
55+
authorizeConditional: jest.Mock;
56+
};
57+
58+
beforeEach(() => {
59+
mockActionsRegistry = { register: jest.fn() };
60+
61+
mockArgoCDService = {
62+
findApplications: jest.fn().mockResolvedValue([mockInstance]),
63+
} as any;
64+
65+
mockPermissions = {
66+
authorize: jest
67+
.fn()
68+
.mockResolvedValue([{ result: AuthorizeResult.ALLOW }]),
69+
authorizeConditional: jest.fn(),
70+
};
71+
72+
createFindApplicationsAction({
73+
actionsRegistry: mockActionsRegistry as any,
74+
argoCDService: mockArgoCDService,
75+
permissions: mockPermissions as any,
76+
});
77+
});
78+
79+
it('registers the argocd:find-applications action', () => {
80+
expect(mockActionsRegistry.register).toHaveBeenCalledTimes(1);
81+
const reg = mockActionsRegistry.register.mock.calls[0][0];
82+
expect(reg.name).toBe('argocd:find-applications');
83+
expect(reg.attributes.readOnly).toBe(true);
84+
expect(reg.attributes.destructive).toBe(false);
85+
expect(reg.attributes.idempotent).toBe(true);
86+
});
87+
88+
it('returns applications across all instances', async () => {
89+
const reg = mockActionsRegistry.register.mock.calls[0][0];
90+
const credentials = mockCredentials.user();
91+
92+
const result = await reg.action({
93+
input: { appName: 'my-app' },
94+
credentials,
95+
logger: mockServices.logger.mock(),
96+
});
97+
98+
expect(mockArgoCDService.findApplications).toHaveBeenCalledWith({
99+
appName: 'my-app',
100+
project: undefined,
101+
appNamespace: undefined,
102+
});
103+
expect(result.output.instances).toHaveLength(1);
104+
expect(result.output.instances[0].instanceName).toBe('prod');
105+
expect(result.output.instances[0].instanceUrl).toBe(
106+
'https://argocd.example.com',
107+
);
108+
expect(result.output.instances[0].applications).toHaveLength(1);
109+
const app = result.output.instances[0].applications[0];
110+
expect(app.name).toBe('my-app');
111+
expect(app.syncStatus).toBe('Synced');
112+
expect(app.healthStatus).toBe('Healthy');
113+
expect(app.revision).toBe('abc123');
114+
});
115+
116+
it('passes optional filters to findApplications', async () => {
117+
const reg = mockActionsRegistry.register.mock.calls[0][0];
118+
const credentials = mockCredentials.user();
119+
120+
await reg.action({
121+
input: { appName: 'my-app', project: 'my-project', appNamespace: 'ns' },
122+
credentials,
123+
logger: mockServices.logger.mock(),
124+
});
125+
126+
expect(mockArgoCDService.findApplications).toHaveBeenCalledWith({
127+
appName: 'my-app',
128+
project: 'my-project',
129+
appNamespace: 'ns',
130+
});
131+
});
132+
133+
it('returns empty applications array when instance has no apps', async () => {
134+
mockArgoCDService.findApplications.mockResolvedValue([
135+
{ name: 'prod', url: 'https://argocd.example.com', appName: [] },
136+
]);
137+
const reg = mockActionsRegistry.register.mock.calls[0][0];
138+
const credentials = mockCredentials.user();
139+
140+
const result = await reg.action({
141+
input: { appName: 'missing-app' },
142+
credentials,
143+
logger: mockServices.logger.mock(),
144+
});
145+
146+
expect(result.output.instances[0].applications).toHaveLength(0);
147+
});
148+
149+
it('throws NotAllowedError when permission is denied', async () => {
150+
mockPermissions.authorize.mockResolvedValue([
151+
{ result: AuthorizeResult.DENY },
152+
]);
153+
const reg = mockActionsRegistry.register.mock.calls[0][0];
154+
const credentials = mockCredentials.user();
155+
156+
await expect(
157+
reg.action({
158+
input: { appName: 'my-app' },
159+
credentials,
160+
logger: mockServices.logger.mock(),
161+
}),
162+
).rejects.toThrow(NotAllowedError);
163+
});
164+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Copyright 2025 The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha';
17+
import { PermissionsService } from '@backstage/backend-plugin-api';
18+
import { NotAllowedError } from '@backstage/errors';
19+
import { AuthorizeResult } from '@backstage/plugin-permission-common';
20+
import { argocdViewPermission } from '@backstage-community/plugin-argocd-common';
21+
import { ArgoCDService } from '@backstage-community/plugin-argocd-node';
22+
23+
/**
24+
* Registers the `argocd:find-applications` action.
25+
* @internal
26+
*/
27+
export function createFindApplicationsAction(options: {
28+
actionsRegistry: ActionsRegistryService;
29+
argoCDService: ArgoCDService;
30+
permissions: PermissionsService;
31+
}) {
32+
const { actionsRegistry, argoCDService, permissions } = options;
33+
34+
actionsRegistry.register({
35+
name: 'argocd:find-applications',
36+
title: 'Find ArgoCD Applications',
37+
description:
38+
'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`.',
39+
attributes: {
40+
readOnly: true,
41+
destructive: false,
42+
idempotent: true,
43+
},
44+
visibilityPermission: argocdViewPermission,
45+
schema: {
46+
input: z =>
47+
z.object({
48+
appName: z.string().describe('ArgoCD application name to search for'),
49+
project: z
50+
.string()
51+
.optional()
52+
.describe('Filter by ArgoCD project name'),
53+
appNamespace: z
54+
.string()
55+
.optional()
56+
.describe('Filter by application namespace'),
57+
}),
58+
output: z =>
59+
z.object({
60+
instances: z.array(
61+
z.object({
62+
instanceName: z.string(),
63+
instanceUrl: z.string(),
64+
applications: z.array(
65+
z.object({
66+
name: z.string(),
67+
namespace: z.string().optional(),
68+
project: z.string().optional(),
69+
syncStatus: z.string(),
70+
healthStatus: z.string(),
71+
revision: z.string().optional(),
72+
destination: z.object({
73+
server: z.string().optional(),
74+
namespace: z.string().optional(),
75+
}),
76+
}),
77+
),
78+
}),
79+
),
80+
}),
81+
},
82+
async action({ input, credentials, logger }) {
83+
const decision = await permissions.authorize(
84+
[{ permission: argocdViewPermission }],
85+
{ credentials },
86+
);
87+
if (decision[0].result === AuthorizeResult.DENY) {
88+
throw new NotAllowedError(
89+
'Unauthorized: missing argocd.view.read permission',
90+
);
91+
}
92+
93+
logger.debug(`Finding ArgoCD applications with name: ${input.appName}`);
94+
95+
const results = await argoCDService.findApplications({
96+
appName: input.appName,
97+
project: input.project,
98+
appNamespace: input.appNamespace,
99+
});
100+
101+
return {
102+
output: {
103+
instances: results.map(r => ({
104+
instanceName: r.name,
105+
instanceUrl: r.url,
106+
applications: (r.applications ?? []).map(app => ({
107+
name: app.metadata.name ?? '',
108+
namespace: app.metadata.namespace,
109+
project: app.spec?.project,
110+
syncStatus: app.status?.sync?.status ?? 'Unknown',
111+
healthStatus: app.status?.health?.status ?? 'Unknown',
112+
revision: app.status?.sync?.revision,
113+
destination: {
114+
server: app.spec?.destination?.server,
115+
namespace: app.spec?.destination?.namespace,
116+
},
117+
})),
118+
})),
119+
},
120+
};
121+
},
122+
});
123+
}

0 commit comments

Comments
 (0)