@forgeportal/plugin-argocd 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/LICENSE +21 -0
- package/dist/ArgocdTab.d.ts +8 -0
- package/dist/ArgocdTab.d.ts.map +1 -0
- package/dist/ArgocdTab.js +69 -0
- package/dist/ArgocdTab.js.map +1 -0
- package/dist/__tests__/api-client.test.d.ts +2 -0
- package/dist/__tests__/api-client.test.d.ts.map +1 -0
- package/dist/__tests__/api-client.test.js +129 -0
- package/dist/__tests__/api-client.test.js.map +1 -0
- package/dist/actions.d.ts +23 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +106 -0
- package/dist/actions.js.map +1 -0
- package/dist/api-client.d.ts +35 -0
- package/dist/api-client.d.ts.map +1 -0
- package/dist/api-client.js +73 -0
- package/dist/api-client.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/routes.d.ts +13 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +92 -0
- package/dist/routes.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +11 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +17 -0
- package/dist/ui.js.map +1 -0
- package/forgeportal-plugin.json +32 -0
- package/package.json +51 -0
- package/src/ArgocdTab.tsx +305 -0
- package/src/__tests__/api-client.test.ts +156 -0
- package/src/actions.ts +118 -0
- package/src/api-client.ts +90 -0
- package/src/index.ts +46 -0
- package/src/routes.ts +127 -0
- package/src/types.ts +65 -0
- package/src/ui.ts +18 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ArgocdApiClient } from '../api-client.js';
|
|
3
|
+
import type { ArgocdConfig } from '../types.js';
|
|
4
|
+
|
|
5
|
+
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const CONFIG: ArgocdConfig = {
|
|
8
|
+
url: 'https://argocd.test.internal',
|
|
9
|
+
token: 'test-token',
|
|
10
|
+
insecure: false,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const APP_FIXTURE = {
|
|
14
|
+
metadata: { name: 'payments-api', namespace: 'default' },
|
|
15
|
+
spec: { source: { repoURL: 'https://github.com/acme/payments', path: 'k8s', targetRevision: 'HEAD' } },
|
|
16
|
+
status: {
|
|
17
|
+
sync: { status: 'Synced', revision: 'abc1234' },
|
|
18
|
+
health: { status: 'Healthy' },
|
|
19
|
+
reconciledAt: '2026-02-20T10:00:00Z',
|
|
20
|
+
history: [
|
|
21
|
+
{ id: 0, revision: 'old123', deployedAt: '2026-02-18T08:00:00Z' },
|
|
22
|
+
{ id: 1, revision: 'abc1234', deployedAt: '2026-02-20T10:00:00Z' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function mockFetch(body: unknown, status = 200): ReturnType<typeof vi.fn> {
|
|
30
|
+
const mock = vi.fn().mockResolvedValue({
|
|
31
|
+
ok: status >= 200 && status < 300,
|
|
32
|
+
status,
|
|
33
|
+
statusText: status === 200 ? 'OK' : 'Error',
|
|
34
|
+
json: () => Promise.resolve(body),
|
|
35
|
+
text: () => Promise.resolve(JSON.stringify(body)),
|
|
36
|
+
});
|
|
37
|
+
vi.stubGlobal('fetch', mock);
|
|
38
|
+
return mock;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeEach(() => vi.restoreAllMocks());
|
|
42
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
43
|
+
|
|
44
|
+
// ── Constructor ───────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
describe('ArgocdApiClient constructor', () => {
|
|
47
|
+
it('strips trailing slash from baseUrl', async () => {
|
|
48
|
+
const client = new ArgocdApiClient({ ...CONFIG, url: 'https://argocd.test.internal/' });
|
|
49
|
+
const fetchMock = mockFetch(APP_FIXTURE);
|
|
50
|
+
await client.getApp('payments-api');
|
|
51
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
52
|
+
expect(url).toBe('https://argocd.test.internal/api/v1/applications/payments-api');
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── getApp ────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
describe('ArgocdApiClient.getApp', () => {
|
|
59
|
+
const client = new ArgocdApiClient(CONFIG);
|
|
60
|
+
|
|
61
|
+
it('fetches app status and returns the response', async () => {
|
|
62
|
+
mockFetch(APP_FIXTURE);
|
|
63
|
+
const app = await client.getApp('payments-api');
|
|
64
|
+
expect(app.status.sync.status).toBe('Synced');
|
|
65
|
+
expect(app.status.health.status).toBe('Healthy');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('URL-encodes the app name', async () => {
|
|
69
|
+
const fetchMock = mockFetch(APP_FIXTURE);
|
|
70
|
+
await client.getApp('my app/with spaces');
|
|
71
|
+
const [url] = fetchMock.mock.calls[0] as [string];
|
|
72
|
+
expect(url).toContain('my%20app%2Fwith%20spaces');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('sends Bearer token in Authorization header', async () => {
|
|
76
|
+
const fetchMock = mockFetch(APP_FIXTURE);
|
|
77
|
+
await client.getApp('payments-api');
|
|
78
|
+
const [, init] = fetchMock.mock.calls[0] as [string, Record<string, Record<string, string>>];
|
|
79
|
+
expect(init.headers['Authorization']).toBe('Bearer test-token');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('throws with status code on non-ok response', async () => {
|
|
83
|
+
mockFetch({ message: 'Not Found' }, 404);
|
|
84
|
+
await expect(client.getApp('ghost')).rejects.toThrow('404');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('throws on 403 Forbidden', async () => {
|
|
88
|
+
mockFetch({ message: 'permission denied' }, 403);
|
|
89
|
+
await expect(client.getApp('payments-api')).rejects.toThrow('403');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── getHistory ────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe('ArgocdApiClient.getHistory', () => {
|
|
96
|
+
const client = new ArgocdApiClient(CONFIG);
|
|
97
|
+
|
|
98
|
+
it('returns history entries in reverse chronological order', async () => {
|
|
99
|
+
mockFetch(APP_FIXTURE);
|
|
100
|
+
const history = await client.getHistory('payments-api');
|
|
101
|
+
expect(history).toHaveLength(2);
|
|
102
|
+
// reversed — most recent first
|
|
103
|
+
expect(history[0]?.revision).toBe('abc1234');
|
|
104
|
+
expect(history[1]?.revision).toBe('old123');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns empty array when history is absent', async () => {
|
|
108
|
+
const noHistory = { ...APP_FIXTURE, status: { ...APP_FIXTURE.status, history: undefined } };
|
|
109
|
+
mockFetch(noHistory);
|
|
110
|
+
const history = await client.getHistory('payments-api');
|
|
111
|
+
expect(history).toHaveLength(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('limits history to 10 entries', async () => {
|
|
115
|
+
const manyHistory = Array.from({ length: 15 }, (_, i) => ({
|
|
116
|
+
id: i, revision: `rev${i}`, deployedAt: '2026-01-01T00:00:00Z',
|
|
117
|
+
}));
|
|
118
|
+
mockFetch({ ...APP_FIXTURE, status: { ...APP_FIXTURE.status, history: manyHistory } });
|
|
119
|
+
const history = await client.getHistory('payments-api');
|
|
120
|
+
expect(history).toHaveLength(10);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── syncApp ───────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
describe('ArgocdApiClient.syncApp', () => {
|
|
127
|
+
const client = new ArgocdApiClient(CONFIG);
|
|
128
|
+
|
|
129
|
+
it('sends a POST request to the sync endpoint', async () => {
|
|
130
|
+
const fetchMock = mockFetch({}, 200);
|
|
131
|
+
await client.syncApp('payments-api');
|
|
132
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, Record<string, unknown>];
|
|
133
|
+
expect(url).toContain('/applications/payments-api/sync');
|
|
134
|
+
expect(init.method).toBe('POST');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('throws on ArgoCD error response', async () => {
|
|
138
|
+
mockFetch({ message: 'application is being synced' }, 409);
|
|
139
|
+
await expect(client.syncApp('payments-api')).rejects.toThrow('409');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ── rollbackApp ───────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
describe('ArgocdApiClient.rollbackApp', () => {
|
|
146
|
+
const client = new ArgocdApiClient(CONFIG);
|
|
147
|
+
|
|
148
|
+
it('sends a POST request with the history ID', async () => {
|
|
149
|
+
const fetchMock = mockFetch({}, 200);
|
|
150
|
+
await client.rollbackApp('payments-api', 0);
|
|
151
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, Record<string, unknown>];
|
|
152
|
+
expect(url).toContain('/applications/payments-api/rollback');
|
|
153
|
+
expect(init.method).toBe('POST');
|
|
154
|
+
expect(JSON.parse(init.body as string)).toEqual({ id: 0 });
|
|
155
|
+
});
|
|
156
|
+
});
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { ActionProvider } from '@forgeportal/plugin-sdk';
|
|
2
|
+
import type { ArgocdConfig } from './types.js';
|
|
3
|
+
import { ArgocdApiClient } from './api-client.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* argocd.syncApp@v1
|
|
7
|
+
*
|
|
8
|
+
* Triggers a manual sync of an ArgoCD application.
|
|
9
|
+
* Usable in templates and scorecard fix actions.
|
|
10
|
+
*
|
|
11
|
+
* Input:
|
|
12
|
+
* - appName: string (required) — ArgoCD application name
|
|
13
|
+
*/
|
|
14
|
+
export function createSyncAppAction(config: ArgocdConfig): ActionProvider {
|
|
15
|
+
return {
|
|
16
|
+
id: 'argocd.syncApp',
|
|
17
|
+
version: 'v1',
|
|
18
|
+
schema: {
|
|
19
|
+
input: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
required: ['appName'],
|
|
22
|
+
properties: {
|
|
23
|
+
appName: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
title: 'Application Name',
|
|
26
|
+
description: 'Name of the ArgoCD application to sync.',
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
output: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: {
|
|
33
|
+
syncTriggeredAt: { type: 'string', description: 'ISO timestamp when sync was triggered.' },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async handler(ctx, input) {
|
|
39
|
+
const appName = input['appName'] as string;
|
|
40
|
+
ctx.logger.info(`Triggering ArgoCD sync for application "${appName}"`);
|
|
41
|
+
|
|
42
|
+
const client = new ArgocdApiClient(config);
|
|
43
|
+
await client.syncApp(appName);
|
|
44
|
+
|
|
45
|
+
const syncTriggeredAt = new Date().toISOString();
|
|
46
|
+
ctx.logger.info(`Sync triggered for "${appName}" at ${syncTriggeredAt}`);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
status: 'success',
|
|
50
|
+
outputs: { syncTriggeredAt },
|
|
51
|
+
links: [
|
|
52
|
+
{ title: `Open ${appName} in ArgoCD`, url: `${config.url}/applications/${appName}` },
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* argocd.rollbackApp@v1
|
|
61
|
+
*
|
|
62
|
+
* Rolls back an ArgoCD application to a specific history entry.
|
|
63
|
+
*
|
|
64
|
+
* Input:
|
|
65
|
+
* - appName: string (required)
|
|
66
|
+
* - historyId: number (required) — history entry ID from the app history
|
|
67
|
+
*/
|
|
68
|
+
export function createRollbackAppAction(config: ArgocdConfig): ActionProvider {
|
|
69
|
+
return {
|
|
70
|
+
id: 'argocd.rollbackApp',
|
|
71
|
+
version: 'v1',
|
|
72
|
+
schema: {
|
|
73
|
+
input: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
required: ['appName', 'historyId'],
|
|
76
|
+
properties: {
|
|
77
|
+
appName: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
title: 'Application Name',
|
|
80
|
+
description: 'Name of the ArgoCD application to roll back.',
|
|
81
|
+
},
|
|
82
|
+
historyId: {
|
|
83
|
+
type: 'number',
|
|
84
|
+
title: 'History ID',
|
|
85
|
+
description: 'History entry ID to roll back to (from the sync history list).',
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
output: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: {
|
|
92
|
+
rolledBackAt: { type: 'string', description: 'ISO timestamp of the rollback trigger.' },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async handler(ctx, input) {
|
|
98
|
+
const appName = input['appName'] as string;
|
|
99
|
+
const historyId = input['historyId'] as number;
|
|
100
|
+
|
|
101
|
+
ctx.logger.info(`Rolling back ArgoCD application "${appName}" to history entry ${historyId}`);
|
|
102
|
+
|
|
103
|
+
const client = new ArgocdApiClient(config);
|
|
104
|
+
await client.rollbackApp(appName, historyId);
|
|
105
|
+
|
|
106
|
+
const rolledBackAt = new Date().toISOString();
|
|
107
|
+
ctx.logger.info(`Rollback triggered for "${appName}" at ${rolledBackAt}`);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
status: 'success',
|
|
111
|
+
outputs: { rolledBackAt },
|
|
112
|
+
links: [
|
|
113
|
+
{ title: `Open ${appName} in ArgoCD`, url: `${config.url}/applications/${appName}` },
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ArgocdApp,
|
|
3
|
+
ArgocdHistoryItem,
|
|
4
|
+
ArgocdConfig,
|
|
5
|
+
ArgocdResourceTree,
|
|
6
|
+
} from './types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Thin HTTP client for the ArgoCD REST API v1.
|
|
10
|
+
* Authenticates with a Bearer token (service-account or user token).
|
|
11
|
+
*/
|
|
12
|
+
export class ArgocdApiClient {
|
|
13
|
+
private readonly baseUrl: string;
|
|
14
|
+
private readonly token: string;
|
|
15
|
+
private readonly rejectUnauthorized: boolean;
|
|
16
|
+
|
|
17
|
+
constructor(config: ArgocdConfig) {
|
|
18
|
+
this.baseUrl = config.url.replace(/\/$/, '');
|
|
19
|
+
this.token = config.token;
|
|
20
|
+
this.rejectUnauthorized = !config.insecure;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
24
|
+
const url = `${this.baseUrl}/api/v1${path}`;
|
|
25
|
+
const res = await (fetch as (url: string, init: Record<string, unknown>) => Promise<Response>)(url, {
|
|
26
|
+
...options,
|
|
27
|
+
headers: {
|
|
28
|
+
Authorization: `Bearer ${this.token}`,
|
|
29
|
+
'Content-Type': 'application/json',
|
|
30
|
+
...(options.headers as Record<string, string> | undefined),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const body = await res.text().catch(() => '');
|
|
36
|
+
throw new Error(`ArgoCD API ${res.status}: ${body || res.statusText}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return res.json() as Promise<T>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* GET /api/v1/applications/{appName}
|
|
44
|
+
*/
|
|
45
|
+
async getApp(appName: string): Promise<ArgocdApp> {
|
|
46
|
+
return this.request<ArgocdApp>(`/applications/${encodeURIComponent(appName)}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* GET /api/v1/applications/{appName}/revisions
|
|
51
|
+
* Returns the last 10 sync history entries.
|
|
52
|
+
*/
|
|
53
|
+
async getHistory(appName: string): Promise<ArgocdHistoryItem[]> {
|
|
54
|
+
const app = await this.getApp(appName);
|
|
55
|
+
// History is embedded in the application status
|
|
56
|
+
const history = (app as unknown as { status: { history?: ArgocdHistoryItem[] } })
|
|
57
|
+
.status?.history ?? [];
|
|
58
|
+
return history.slice(-10).reverse();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* GET /api/v1/applications/{appName}/resource-tree
|
|
63
|
+
*/
|
|
64
|
+
async getResourceTree(appName: string): Promise<ArgocdResourceTree> {
|
|
65
|
+
return this.request<ArgocdResourceTree>(
|
|
66
|
+
`/applications/${encodeURIComponent(appName)}/resource-tree`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* POST /api/v1/applications/{appName}/sync
|
|
72
|
+
*/
|
|
73
|
+
async syncApp(appName: string): Promise<void> {
|
|
74
|
+
await this.request(`/applications/${encodeURIComponent(appName)}/sync`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
body: JSON.stringify({}),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* POST /api/v1/applications/{appName}/rollback
|
|
82
|
+
* Rolls back to a specific history entry by id.
|
|
83
|
+
*/
|
|
84
|
+
async rollbackApp(appName: string, historyId: number): Promise<void> {
|
|
85
|
+
await this.request(`/applications/${encodeURIComponent(appName)}/rollback`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
body: JSON.stringify({ id: historyId }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ForgeBackendPluginSDK } from '@forgeportal/plugin-sdk';
|
|
2
|
+
import type { ArgocdConfig } from './types.js';
|
|
3
|
+
import { createRoutes } from './routes.js';
|
|
4
|
+
import { createSyncAppAction, createRollbackAppAction } from './actions.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Backend entry point for the ArgoCD plugin.
|
|
8
|
+
* Called by the ForgePortal plugin loader at startup.
|
|
9
|
+
*
|
|
10
|
+
* Configuration (forgeportal.yaml -> plugins.argocd.config):
|
|
11
|
+
* url: string — ArgoCD server URL (e.g. https://argocd.internal)
|
|
12
|
+
* insecure: boolean — skip TLS verification (default: false)
|
|
13
|
+
*
|
|
14
|
+
* Token comes from the environment:
|
|
15
|
+
* FORGEPORTAL_PLUGIN_ARGOCD_TOKEN
|
|
16
|
+
*/
|
|
17
|
+
export function registerBackendPlugin(sdk: ForgeBackendPluginSDK): void {
|
|
18
|
+
const url = sdk.config.get<string>('url');
|
|
19
|
+
const insecure = sdk.config.get<boolean>('insecure') ?? false;
|
|
20
|
+
const token = process.env['FORGEPORTAL_PLUGIN_ARGOCD_TOKEN'] ?? '';
|
|
21
|
+
|
|
22
|
+
if (!url) {
|
|
23
|
+
sdk.logger.error(
|
|
24
|
+
'argocd plugin: "url" is required in plugins.argocd.config. Plugin disabled.',
|
|
25
|
+
);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!token) {
|
|
30
|
+
sdk.logger.warn(
|
|
31
|
+
'argocd plugin: FORGEPORTAL_PLUGIN_ARGOCD_TOKEN env var is not set. API calls will fail.',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const config: ArgocdConfig = { url, token, insecure };
|
|
36
|
+
|
|
37
|
+
sdk.logger.info(`argocd plugin: connected to ${url} (insecure=${insecure})`);
|
|
38
|
+
|
|
39
|
+
sdk.registerBackendRoute({
|
|
40
|
+
path: '',
|
|
41
|
+
handler: createRoutes(config),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
sdk.registerActionProvider(createSyncAppAction(config));
|
|
45
|
+
sdk.registerActionProvider(createRollbackAppAction(config));
|
|
46
|
+
}
|
package/src/routes.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
2
|
+
import type { ArgocdConfig } from './types.js';
|
|
3
|
+
import { ArgocdApiClient } from './api-client.js';
|
|
4
|
+
|
|
5
|
+
interface EntityParams { entityId: string }
|
|
6
|
+
interface SyncBody { appName: string }
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Creates Fastify route handlers for the ArgoCD plugin.
|
|
10
|
+
* All routes are mounted under /api/v1/plugins/argocd/ by the plugin loader.
|
|
11
|
+
*
|
|
12
|
+
* Routes:
|
|
13
|
+
* GET entities/:entityId/app — app summary (status, health, revision)
|
|
14
|
+
* GET entities/:entityId/history — last 10 sync operations
|
|
15
|
+
* POST entities/:entityId/sync — trigger sync
|
|
16
|
+
*/
|
|
17
|
+
export function createRoutes(config: ArgocdConfig) {
|
|
18
|
+
const client = new ArgocdApiClient(config);
|
|
19
|
+
|
|
20
|
+
return async function handler(fastify: FastifyInstance): Promise<void> {
|
|
21
|
+
/**
|
|
22
|
+
* GET /entities/:entityId/app?appName=<override>
|
|
23
|
+
*
|
|
24
|
+
* Returns the ArgoCD application summary: sync status, health, revision, operation state.
|
|
25
|
+
* The app name is read from the query param or entity annotations.
|
|
26
|
+
*/
|
|
27
|
+
fastify.get(
|
|
28
|
+
'entities/:entityId/app',
|
|
29
|
+
async (
|
|
30
|
+
request: FastifyRequest<{
|
|
31
|
+
Params: EntityParams;
|
|
32
|
+
Querystring: { appName?: string };
|
|
33
|
+
}>,
|
|
34
|
+
reply: FastifyReply,
|
|
35
|
+
) => {
|
|
36
|
+
const appName = request.query.appName;
|
|
37
|
+
|
|
38
|
+
if (!appName) {
|
|
39
|
+
return reply.status(400).send({
|
|
40
|
+
error: 'Bad Request',
|
|
41
|
+
message: 'Query parameter "appName" is required, or set the forgeportal.dev/argocd-app-name annotation.',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const app = await client.getApp(appName);
|
|
47
|
+
return reply.send({ data: app });
|
|
48
|
+
} catch (err) {
|
|
49
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
50
|
+
request.log.error({ err }, 'argocd plugin: getApp failed');
|
|
51
|
+
if (message.includes('404')) {
|
|
52
|
+
return reply.status(404).send({ error: 'Not Found', message: `ArgoCD app "${appName}" not found.` });
|
|
53
|
+
}
|
|
54
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* GET /entities/:entityId/history?appName=<override>
|
|
61
|
+
*
|
|
62
|
+
* Returns the last 10 sync history entries for the ArgoCD application.
|
|
63
|
+
*/
|
|
64
|
+
fastify.get(
|
|
65
|
+
'entities/:entityId/history',
|
|
66
|
+
async (
|
|
67
|
+
request: FastifyRequest<{
|
|
68
|
+
Params: EntityParams;
|
|
69
|
+
Querystring: { appName?: string };
|
|
70
|
+
}>,
|
|
71
|
+
reply: FastifyReply,
|
|
72
|
+
) => {
|
|
73
|
+
const appName = request.query.appName;
|
|
74
|
+
|
|
75
|
+
if (!appName) {
|
|
76
|
+
return reply.status(400).send({
|
|
77
|
+
error: 'Bad Request',
|
|
78
|
+
message: 'Query parameter "appName" is required.',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const history = await client.getHistory(appName);
|
|
84
|
+
return reply.send({ data: history });
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
87
|
+
request.log.error({ err }, 'argocd plugin: getHistory failed');
|
|
88
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* POST /entities/:entityId/sync
|
|
95
|
+
*
|
|
96
|
+
* Body: { appName: string }
|
|
97
|
+
* Triggers a manual sync of the ArgoCD application.
|
|
98
|
+
*/
|
|
99
|
+
fastify.post(
|
|
100
|
+
'entities/:entityId/sync',
|
|
101
|
+
async (
|
|
102
|
+
request: FastifyRequest<{ Params: EntityParams; Body: SyncBody }>,
|
|
103
|
+
reply: FastifyReply,
|
|
104
|
+
) => {
|
|
105
|
+
const { appName } = request.body ?? {};
|
|
106
|
+
|
|
107
|
+
if (!appName) {
|
|
108
|
+
return reply.status(400).send({
|
|
109
|
+
error: 'Bad Request',
|
|
110
|
+
message: 'Body field "appName" is required.',
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
await client.syncApp(appName);
|
|
116
|
+
return reply.status(202).send({
|
|
117
|
+
data: { appName, syncTriggeredAt: new Date().toISOString() },
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
121
|
+
request.log.error({ err }, 'argocd plugin: syncApp failed');
|
|
122
|
+
return reply.status(502).send({ error: 'Bad Gateway', message });
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// ─── ArgoCD API response shapes ──────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface ArgocdAppStatus {
|
|
4
|
+
sync: {
|
|
5
|
+
status: 'Synced' | 'OutOfSync' | 'Unknown';
|
|
6
|
+
revision: string;
|
|
7
|
+
};
|
|
8
|
+
health: {
|
|
9
|
+
status: 'Healthy' | 'Degraded' | 'Progressing' | 'Suspended' | 'Missing' | 'Unknown';
|
|
10
|
+
};
|
|
11
|
+
operationState?: {
|
|
12
|
+
phase: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
startedAt: string;
|
|
15
|
+
finishedAt?: string;
|
|
16
|
+
};
|
|
17
|
+
reconciledAt?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ArgocdApp {
|
|
21
|
+
metadata: {
|
|
22
|
+
name: string;
|
|
23
|
+
namespace: string;
|
|
24
|
+
};
|
|
25
|
+
spec: {
|
|
26
|
+
project: string;
|
|
27
|
+
source?: {
|
|
28
|
+
repoURL: string;
|
|
29
|
+
targetRevision: string;
|
|
30
|
+
path?: string;
|
|
31
|
+
};
|
|
32
|
+
destination: {
|
|
33
|
+
server: string;
|
|
34
|
+
namespace: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
status: ArgocdAppStatus;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ArgocdHistoryItem {
|
|
41
|
+
id: number;
|
|
42
|
+
revision: string;
|
|
43
|
+
deployedAt: string;
|
|
44
|
+
initiatedBy?: { username?: string; automated?: boolean };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ArgocdResourceNode {
|
|
48
|
+
kind: string;
|
|
49
|
+
name: string;
|
|
50
|
+
namespace?: string;
|
|
51
|
+
status?: string;
|
|
52
|
+
health?: { status: string };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ArgocdResourceTree {
|
|
56
|
+
nodes: ArgocdResourceNode[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ─── Plugin-internal config ───────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export interface ArgocdConfig {
|
|
62
|
+
url: string;
|
|
63
|
+
token: string;
|
|
64
|
+
insecure: boolean;
|
|
65
|
+
}
|
package/src/ui.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ForgePluginSDK } from '@forgeportal/plugin-sdk';
|
|
2
|
+
import { ArgocdTab } from './ArgocdTab.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* UI entry point for the ArgoCD plugin.
|
|
6
|
+
* Called by the ForgePortal UI shell at startup.
|
|
7
|
+
*
|
|
8
|
+
* Registration in apps/ui/src/plugins/index.ts:
|
|
9
|
+
* import { registerPlugin as registerArgocd } from '@forgeportal/plugin-argocd/ui';
|
|
10
|
+
* registerPluginById('argocd', registerArgocd);
|
|
11
|
+
*/
|
|
12
|
+
export function registerPlugin(sdk: ForgePluginSDK): void {
|
|
13
|
+
sdk.registerEntityTab({
|
|
14
|
+
id: 'argocd-tab',
|
|
15
|
+
title: 'ArgoCD',
|
|
16
|
+
component: ArgocdTab,
|
|
17
|
+
});
|
|
18
|
+
}
|