|
| 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