Skip to content

Commit c82db3e

Browse files
separate argocd action in k8s-deploy
1 parent aa6ed9f commit c82db3e

2 files changed

Lines changed: 409 additions & 383 deletions

File tree

Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
name: "ArgoCD Manage Applications"
2+
description: "Handles full ArgoCD lifecycle: auth, connect, delete, create, sync, wait-for-sync. Supports no-fail mode for restore cluster runs."
3+
4+
inputs:
5+
argocd_auth_token:
6+
required: false
7+
argocd_username:
8+
required: false
9+
argocd_password:
10+
required: false
11+
insecure_argo:
12+
required: false
13+
default: false
14+
argocd_ca_cert:
15+
required: false
16+
namespace:
17+
required: true
18+
cd_repo:
19+
required: true
20+
cd_repo_org:
21+
required: true
22+
overlay_dir:
23+
required: false
24+
default: ""
25+
apps:
26+
description: "JSON array of {name, path, images}"
27+
required: true
28+
delete_first:
29+
required: false
30+
default: false
31+
delete_only:
32+
required: false
33+
default: false
34+
skip_status_check:
35+
required: false
36+
default: false
37+
no_fail:
38+
description: "If true, ArgoCD create/sync/wait errors are logged as warnings but do not fail the job."
39+
required: false
40+
default: false
41+
42+
outputs:
43+
argocd_url:
44+
curl_ssl_flags:
45+
token:
46+
baselines:
47+
48+
runs:
49+
using: composite
50+
steps:
51+
52+
- name: Resolve ArgoCD auth
53+
id: resolve_auth
54+
shell: bash
55+
env:
56+
IN_TOKEN: ${{ inputs.argocd_auth_token }}
57+
IN_USER: ${{ inputs.argocd_username }}
58+
IN_PASS: ${{ inputs.argocd_password }}
59+
SEC_USER: ${{ secrets.ARGOCD_USERNAME }}
60+
SEC_PASS: ${{ secrets.ARGOCD_PASSWORD }}
61+
run: |
62+
set -euo pipefail
63+
if [ -n "${IN_TOKEN:-}" ]; then
64+
echo "mode=token" >> "$GITHUB_OUTPUT"
65+
echo "token=$IN_TOKEN" >> "$GITHUB_OUTPUT"
66+
elif [ -n "${IN_USER:-}" ] && [ -n "${IN_PASS:-}" ]; then
67+
echo "mode=basic" >> "$GITHUB_OUTPUT"
68+
echo "username=$IN_USER" >> "$GITHUB_OUTPUT"
69+
echo "password=$IN_PASS" >> "$GITHUB_OUTPUT"
70+
elif [ -n "${SEC_USER:-}" ] && [ -n "${SEC_PASS:-}" ]; then
71+
echo "mode=basic" >> "$GITHUB_OUTPUT"
72+
echo "username=$SEC_USER" >> "$GITHUB_OUTPUT"
73+
echo "password=$SEC_PASS" >> "$GITHUB_OUTPUT"
74+
else
75+
echo "❌ No ArgoCD auth provided."
76+
exit 1
77+
fi
78+
79+
- name: Debug ARGOCD_CA_CERT presence
80+
run: |
81+
if [ -z "${ARGOCD_CA_CERT}" ]; then
82+
echo "❌ ARGOCD_CA_CERT is not set"
83+
else
84+
echo "✅ ARGOCD_CA_CERT is set (length: ${#ARGOCD_CA_CERT})"
85+
fi
86+
shell: bash
87+
env:
88+
ARGOCD_CA_CERT: ${{ inputs.argocd_ca_cert }}
89+
90+
- name: Set ArgoCD connection (token & URLs)
91+
id: argocd_conn
92+
uses: actions/github-script@v7
93+
env:
94+
MODE: ${{ steps.resolve_auth.outputs.mode }}
95+
TOKEN: ${{ steps.resolve_auth.outputs.token }}
96+
USERNAME: ${{ steps.resolve_auth.outputs.username }}
97+
PASSWORD: ${{ steps.resolve_auth.outputs.password }}
98+
ARGOCD_CA_CERT: ${{ inputs.argocd_ca_cert }}
99+
CLUSTER: ${{ github.event.inputs.target_cluster || github.event.inputs.cluster }}
100+
DNS_ZONE: ${{ github.event.inputs.dns_zone }}
101+
INSECURE_ARGO: ${{ inputs.insecure_argo }}
102+
with:
103+
script: |
104+
const { execSync } = require('child_process');
105+
const fs = require('fs');
106+
const cluster = process.env.CLUSTER;
107+
const dnsZone = process.env.DNS_ZONE;
108+
const argocdUrl = `https://${cluster}-argocd-argocd-web-ui.${dnsZone}`;
109+
110+
let curlSslFlags = "";
111+
if (String(process.env.INSECURE_ARGO) === "true") {
112+
curlSslFlags = "-k";
113+
core.warning("⚠️ Using insecure connection (curl -k).");
114+
} else if (process.env.ARGOCD_CA_CERT) {
115+
let cert = process.env.ARGOCD_CA_CERT;
116+
if (cert.includes('\\n')) cert = cert.replace(/\\n/g, '\n');
117+
fs.writeFileSync('/tmp/argocd-ca.crt', cert);
118+
curlSslFlags = "--cacert /tmp/argocd-ca.crt";
119+
core.info("✅ Using provided CA cert with curl.");
120+
}
121+
122+
let finalToken = process.env.TOKEN;
123+
if (process.env.MODE !== "token") {
124+
const body = JSON.stringify({ username: process.env.USERNAME, password: process.env.PASSWORD });
125+
const cmd = `curl -s ${curlSslFlags} -X POST "${argocdUrl}/api/v1/session" -H "Content-Type: application/json" -d '${body.replace(/'/g,"'\\''")}'`;
126+
const resp = execSync(cmd).toString();
127+
try {
128+
finalToken = JSON.parse(resp).token;
129+
} catch {
130+
core.error(`Response: ${resp}`);
131+
core.setFailed("❌ Failed to parse ArgoCD session response");
132+
return;
133+
}
134+
}
135+
if (!finalToken) {
136+
core.setFailed("❌ Failed to obtain ArgoCD token.");
137+
return;
138+
}
139+
core.info("✅ Successfully obtained ArgoCD token.");
140+
core.setOutput('argocd_url', argocdUrl);
141+
core.setOutput('curl_ssl_flags', curlSslFlags);
142+
core.setOutput('token', finalToken);
143+
144+
- name: Delete ArgoCD apps (per app)
145+
if: ${{ inputs.delete_first == 'true' || inputs.delete_only == 'true' }}
146+
uses: actions/github-script@v7
147+
env:
148+
APPS: ${{ inputs.apps }}
149+
ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }}
150+
ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }}
151+
CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }}
152+
NAMESPACE: ${{ inputs.namespace }}
153+
NO_FAIL: ${{ inputs.no_fail }}
154+
with:
155+
script: |
156+
const { execSync } = require('child_process');
157+
const apps = JSON.parse(process.env.APPS || '[]');
158+
const allowSoftFail = String(process.env.NO_FAIL) === "true";
159+
function failOrWarn(msg) {
160+
if (allowSoftFail) core.warning(msg); else core.setFailed(msg);
161+
}
162+
function httpCode(cmd) {
163+
return parseInt(execSync(`${cmd} -o /dev/null -w "%{http_code}"`).toString().trim(), 10);
164+
}
165+
for (const app of apps) {
166+
const appName = `${process.env.NAMESPACE}-${app.name}`;
167+
const appUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`;
168+
core.info(`🗑️ Deleting ArgoCD Application: ${appName}`);
169+
const delCmd = `curl -s ${process.env.CURL_SSL_FLAGS} -X DELETE "${appUrl}" -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" -H "Content-Type: application/json"`;
170+
const code = httpCode(delCmd);
171+
core.info(`Delete response HTTP ${code}`);
172+
if (![200,202,204].includes(code)) {
173+
failOrWarn(`❌ Failed to delete ${appName}: HTTP ${code}`);
174+
continue;
175+
}
176+
const timeout = Date.now() + 120000;
177+
while (Date.now() < timeout) {
178+
const checkCmd = `curl -s ${process.env.CURL_SSL_FLAGS} -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${appUrl}"`;
179+
const checkCode = httpCode(checkCmd);
180+
if (checkCode === 403) {
181+
core.info(`✅ ${appName} deleted (HTTP 403)`);
182+
break;
183+
}
184+
core.info(`⏳ Still deleting ${appName} (HTTP ${checkCode})...`);
185+
await new Promise(r => setTimeout(r, 5000));
186+
}
187+
}
188+
189+
- name: Check or create ArgoCD applications (per app)
190+
if: ${{ inputs.delete_only == 'false' }}
191+
uses: actions/github-script@v7
192+
env:
193+
APPS: ${{ inputs.apps }}
194+
CD_PATH_REL: ${{ inputs.cd_repo }}
195+
OVERLAY_DIR: ${{ inputs.overlay_dir }}
196+
CD_REPO: ${{ inputs.cd_repo }}
197+
CD_REPO_ORG: ${{ inputs.cd_repo_org }}
198+
ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }}
199+
ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }}
200+
CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }}
201+
NAMESPACE: ${{ inputs.namespace }}
202+
NO_FAIL: ${{ inputs.no_fail }}
203+
with:
204+
script: |
205+
const { execSync } = require('child_process');
206+
const fs = require('fs');
207+
const path = require('path');
208+
const nunjucks = require('./nunjucks.js');
209+
const apps = JSON.parse(process.env.APPS || '[]');
210+
const allowSoftFail = String(process.env.NO_FAIL) === "true";
211+
function failOrWarn(msg) {
212+
if (allowSoftFail) core.warning(msg); else core.setFailed(msg);
213+
}
214+
function curlJson(url) {
215+
try { return execSync(`curl -s ${process.env.CURL_SSL_FLAGS} -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${url}"`).toString(); }
216+
catch { return ""; }
217+
}
218+
for (const app of apps) {
219+
const appName = `${process.env.NAMESPACE}-${app.name}`;
220+
const statusUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`;
221+
const basePath = path.join(process.env.CD_PATH_REL, app.name, 'overlays', process.env.OVERLAY_DIR);
222+
const status = curlJson(statusUrl);
223+
try {
224+
if (status) {
225+
const existing = JSON.parse(status);
226+
if (existing?.spec?.source?.path === basePath) {
227+
core.info(`✅ Argo app ${appName} exists with correct path.`);
228+
continue;
229+
} else {
230+
core.warning(`⚠️ Path mismatch for ${appName}, recreating`);
231+
execSync(`curl -s ${process.env.CURL_SSL_FLAGS} -X DELETE "${statusUrl}" -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}"`);
232+
}
233+
}
234+
const template = fs.readFileSync('reusable/templates/argocd-app-template-v6.json', 'utf8');
235+
const rendered = nunjucks.renderString(template, {
236+
APP_NAME: appName,
237+
NAMESPACE: process.env.NAMESPACE,
238+
CD_REPO: process.env.CD_REPO,
239+
CD_REPO_ORG: process.env.CD_REPO_ORG,
240+
CD_PATH: basePath
241+
});
242+
execSync(`curl -s ${process.env.CURL_SSL_FLAGS} -X POST "${process.env.ARGOCD_URL}/api/v1/applications" -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" -H "Content-Type: application/json" -d '${rendered.replace(/'/g,"'\\''")}'`);
243+
core.info(`✅ Created Argo app ${appName}`);
244+
} catch (err) {
245+
failOrWarn(`❌ Error creating ${appName}: ${err.message}`);
246+
}
247+
}
248+
249+
- name: Sync ArgoCD apps (per app)
250+
id: sync
251+
if: ${{ inputs.delete_only == 'false' }}
252+
uses: actions/github-script@v7
253+
env:
254+
APPS: ${{ inputs.apps }}
255+
ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }}
256+
ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }}
257+
CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }}
258+
NAMESPACE: ${{ inputs.namespace }}
259+
NO_FAIL: ${{ inputs.no_fail }}
260+
with:
261+
script: |
262+
const { execSync } = require('child_process');
263+
const apps = JSON.parse(process.env.APPS || '[]');
264+
const baselines = {};
265+
const allowSoftFail = String(process.env.NO_FAIL) === "true";
266+
function failOrWarn(msg) {
267+
if (allowSoftFail) core.warning(msg); else core.setFailed(msg);
268+
}
269+
for (const app of apps) {
270+
const appName = `${process.env.NAMESPACE}-${app.name}`;
271+
const statusUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`;
272+
let baseline = 0;
273+
try {
274+
const status = execSync(`curl -s ${process.env.CURL_SSL_FLAGS} -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${statusUrl}"`).toString();
275+
const json = JSON.parse(status);
276+
if (json?.status?.operationState?.startedAt)
277+
baseline = new Date(json.status.operationState.startedAt).getTime();
278+
} catch {}
279+
baselines[appName] = baseline;
280+
try {
281+
execSync(`curl -s ${process.env.CURL_SSL_FLAGS} -X POST "${statusUrl}/sync" -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" -H "Content-Type: application/json" -d '{"prune":true,"force":true}'`);
282+
core.info(`🚀 Force-prune sync triggered: ${appName}`);
283+
} catch (err) {
284+
failOrWarn(`❌ Failed to sync ${appName}: ${err.message}`);
285+
}
286+
}
287+
core.setOutput('baselines', JSON.stringify(baselines));
288+
289+
- name: Wait for ArgoCD sync (per app)
290+
if: ${{ inputs.skip_status_check == 'false' && inputs.delete_only == 'false' }}
291+
uses: actions/github-script@v7
292+
env:
293+
APPS: ${{ inputs.apps }}
294+
ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }}
295+
ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }}
296+
CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }}
297+
NAMESPACE: ${{ inputs.namespace }}
298+
BASELINES: ${{ steps.sync.outputs.baselines }}
299+
NO_FAIL: ${{ inputs.no_fail }}
300+
with:
301+
script: |
302+
const { execSync } = require('child_process');
303+
const apps = JSON.parse(process.env.APPS || '[]');
304+
const baselines = JSON.parse(process.env.BASELINES || '{}');
305+
const allowSoftFail = String(process.env.NO_FAIL) === "true";
306+
function failOrWarn(msg) {
307+
if (allowSoftFail) core.warning(msg); else core.setFailed(msg);
308+
}
309+
function allResourcesSynced(json) {
310+
const results = json?.status?.operationState?.syncResult?.resources || json?.status?.operationState?.results || [];
311+
return Array.isArray(results) && results.length > 0 && results.every(r => r.status === 'Synced' || r.hookPhase === 'Succeeded');
312+
}
313+
for (const app of apps) {
314+
const appName = `${process.env.NAMESPACE}-${app.name}`;
315+
const statusUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`;
316+
const baseline = baselines[appName] || 0;
317+
core.startGroup(`⏳ Waiting for sync of ${appName}, baseline=${baseline}`);
318+
let ok = false, lastPhase, lastSync, lastHealth, consecutiveErrors = 0;
319+
for (let i = 0; i < 36; i++) {
320+
try {
321+
const status = execSync(`curl -s ${process.env.CURL_SSL_FLAGS} -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${statusUrl}"`).toString();
322+
const json = JSON.parse(status);
323+
const op = json?.status?.operationState;
324+
lastPhase = op?.phase;
325+
lastSync = json?.status?.sync?.status;
326+
lastHealth = json?.status?.health?.status;
327+
core.info(`🔄 Iteration ${i + 1}/36 for ${appName}: phase=${lastPhase}, sync=${lastSync}, health=${lastHealth}`);
328+
const startedAt = op?.startedAt ? new Date(op.startedAt).getTime() : 0;
329+
const allSynced = allResourcesSynced(json);
330+
if (startedAt > baseline) {
331+
if (lastHealth === 'Degraded') {
332+
failOrWarn(`❌ ${appName} is Synced but health=Degraded.`);
333+
break;
334+
}
335+
if (lastPhase === 'Succeeded' && lastSync === 'Synced' && lastHealth === 'Healthy') {
336+
core.info(`✅ ${appName} synced after baseline and is Healthy.`);
337+
ok = true;
338+
break;
339+
} else if (allSynced && lastHealth === 'Healthy') {
340+
core.info(`✅ ${appName} has all synced results and is Healthy.`);
341+
ok = true;
342+
break;
343+
}
344+
} else if (lastSync === 'Synced' && lastHealth === 'Healthy') {
345+
core.info(`✅ ${appName} was already Synced and Healthy before baseline.`);
346+
ok = true;
347+
break;
348+
}
349+
} catch (err) {
350+
consecutiveErrors++;
351+
if (consecutiveErrors > 3) {
352+
failOrWarn(`❌ Failed to fetch status for ${appName}: ${err.message}`);
353+
break;
354+
} else {
355+
core.warning(`⚠️ Failed to fetch status for ${appName} (attempt ${consecutiveErrors}): ${err.message}`);
356+
}
357+
}
358+
await new Promise(r => setTimeout(r, 10000));
359+
}
360+
if (!ok) failOrWarn(`❌ Sync did not complete in time for ${appName} (phase=${lastPhase}, sync=${lastSync}, health=${lastHealth})`);
361+
core.endGroup();
362+
}
363+

0 commit comments

Comments
 (0)