Update README.md #12
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # deploy.yaml | ||
| # Copyright (c) 2025 Affinity7 Consulting Ltd | ||
| # Version: v2 (reusable, minimal auth precedence) | ||
| # SPDX-License-Identifier: MIT | ||
| on: | ||
| workflow_call: | ||
| inputs: | ||
| runner: | ||
| required: false | ||
| type: string | ||
| default: ubuntu-latest | ||
| cd_repo: | ||
| required: true | ||
| type: string | ||
| environment: | ||
| required: true | ||
| type: string | ||
| # REQUIRED when env has >1 clusters | ||
| cluster: | ||
| required: false | ||
| type: string | ||
| # Mode A (single app) | ||
| application: | ||
| required: false | ||
| type: string | ||
| deployFilesPath: | ||
| description: "The repo path to the deployment files (if not using an artifact)" | ||
| required: false | ||
| type: string | ||
| image_tag: | ||
| required: false | ||
| type: string | ||
| image_base_name: | ||
| required: false | ||
| type: string | ||
| image_base_names: | ||
| required: false | ||
| type: string | ||
| overlay_dir: | ||
| required: false | ||
| type: string | ||
| default: "" | ||
| # Mode B (multi app) | ||
| applicationDetails: | ||
| description: >- | ||
| JSON array where each item maps: | ||
| { "name" => application, "images" => image_base_names[], "path" => deployFilesPath } | ||
| Example: | ||
| [ | ||
| {"name":"app1","images":["repo/app1","repo/sidecar"],"path":"services/app1/overlays/prod"}, | ||
| {"name":"app2","images":["repo/app2"],"path":"apps/app2/overlays/prod"} | ||
| ] | ||
| required: false | ||
| type: string | ||
| default: '' | ||
| # Environment map (single supported shape) | ||
| env_map: | ||
| description: >- | ||
| ONLY this JSON shape is supported: | ||
| { | ||
| "<env>": { | ||
| "cluster_count": N, | ||
| "clusters": [ | ||
| { "cluster": "...", "dns_zone": "...", "container_registry": "...", "uami_map": [...] } | ||
| ] | ||
| } | ||
| } | ||
| required: false | ||
| type: string | ||
| # Argo / misc | ||
| argocd_auth_token: | ||
| required: false | ||
| type: string | ||
| argocd_username: | ||
| required: false | ||
| type: string | ||
| argocd_password: | ||
| required: false | ||
| type: string | ||
| kustomize_version: | ||
| required: false | ||
| type: string | ||
| default: "5.0.1" | ||
| skip_status_check: | ||
| required: false | ||
| default: 'false' | ||
| type: string | ||
| insecure_argo: | ||
| required: false | ||
| default: false | ||
| type: boolean | ||
| convert_jinja: | ||
| required: false | ||
| type: boolean | ||
| default: false | ||
| debug: | ||
| required: false | ||
| type: boolean | ||
| default: false | ||
| secrets: | ||
| CONTINUOUS_DEPLOYMENT_GH_APP_ID: | ||
| required: true | ||
| CONTINUOUS_DEPLOYMENT_GH_APP_PRIVATE_KEY: | ||
| required: true | ||
| ARGOCD_CA_CERT: | ||
| required: false | ||
| ARGOCD_USERNAME: | ||
| required: false | ||
| ARGOCD_PASSWORD: | ||
| required: false | ||
| jobs: | ||
| deploy: | ||
| runs-on: ${{ inputs.runner }} | ||
| outputs: | ||
| cd_path: ${{ steps.cdroot.outputs.cd_root }} | ||
| steps: | ||
| - name: Checkout repo | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| repository: ${{ inputs.repo }} | ||
| path: source | ||
| ref: ${{ inputs.repo_commit_id != '' && inputs.repo_commit_id || inputs.branch_name }} | ||
| - name: Ensure envsubst installed | ||
| shell: bash | ||
| run: | | ||
| set -e | ||
| if ! command -v envsubst >/dev/null 2>&1; then | ||
| sudo apt-get update | ||
| sudo apt-get install -y gettext | ||
| fi | ||
| - name: Generate GitHub App token | ||
| id: generate_token | ||
| uses: tibdex/github-app-token@v1 | ||
| with: | ||
| app_id: ${{ secrets.CONTINUOUS_DEPLOYMENT_GH_APP_ID }} | ||
| private_key: ${{ secrets.CONTINUOUS_DEPLOYMENT_GH_APP_PRIVATE_KEY }} | ||
| repository: ${{ inputs.cd_repo }} | ||
| - name: Checkout reusable workflow repo | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| repository: gitopsmanager/k8s-deploy | ||
| path: reusable | ||
| - name: Checkout continuous-deployment repo | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| repository: ${{ inputs.cd_repo }} | ||
| token: ${{ steps.generate_token.outputs.token }} | ||
| path: cd | ||
| - name: Detect cloud | ||
| id: cloud | ||
| uses: gitopsmanager/detect-cloud@main | ||
| with: | ||
| timeout-ms: 800 | ||
| - name: Report cloud provider | ||
| run: echo "☁️ Cloud provider detected: '${{ steps.cloud.outputs.provider }}'" | ||
| - name: Warn if GitHub-hosted (unknown cloud) | ||
| if: ${{ steps.cloud.outputs.provider == 'unknown' }} | ||
| run: | | ||
| echo "⚠️ Running on a GitHub-hosted runner." | ||
| echo "Workload Identity mappings for AWS/Azure self-hosted runners will not apply." | ||
| # Strict env_map parsing + cluster selection (with ENV_MAP fallback) | ||
| - name: Load environment config (JSON) | ||
| id: env | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| INLINE_ENV_MAP: ${{ inputs.env_map }} # workflow input (preferred) | ||
| ENV_MAP: ${{ env.ENV_MAP }} # fallback env var (e.g., injected from ConfigMap on self-hosted) | ||
| ENVIRONMENT: ${{ inputs.environment }} | ||
| CLUSTER_IN: ${{ inputs.cluster }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| const sourceInput = (process.env.INLINE_ENV_MAP || '').trim(); | ||
| const sourceEnv = (process.env.ENV_MAP || '').trim(); | ||
| const raw = sourceInput || sourceEnv; | ||
| if (!raw) { | ||
| core.setFailed('❌ No env_map provided. Pass inputs.env_map OR set ENV_MAP environment variable.'); | ||
| return; | ||
| } | ||
| core.info(`Using env_map from ${sourceInput ? 'workflow input (inputs.env_map)' : 'ENV_MAP environment variable'}.`); | ||
| let map; | ||
| try { map = JSON.parse(raw); } | ||
| catch (err) { core.setFailed(`❌ env_map is not valid JSON: ${err.message}`); return; } | ||
| const envName = process.env.ENVIRONMENT; | ||
| const clusterIn = (process.env.CLUSTER_IN || '').trim(); | ||
| if (!Object.prototype.hasOwnProperty.call(map, envName)) { | ||
| core.setFailed(`❌ Environment '${envName}' not found. Available: ${Object.keys(map).join(', ')}`); | ||
| return; | ||
| } | ||
| const entry = map[envName]; | ||
| if (!entry || typeof entry !== 'object' || !Array.isArray(entry.clusters)) { | ||
| core.setFailed(`❌ env_map['${envName}'] must be { cluster_count, clusters: [...] }`); | ||
| return; | ||
| } | ||
| if (entry.clusters.length === 0) { | ||
| core.setFailed(`❌ env_map['${envName}'].clusters is empty`); | ||
| return; | ||
| } | ||
| const names = []; | ||
| for (let i = 0; i < entry.clusters.length; i++) { | ||
| const c = entry.clusters[i] || {}; | ||
| const name = String(c.cluster || '').trim(); | ||
| const dns = String(c.dns_zone || '').trim(); | ||
| if (!name) { core.setFailed(`❌ clusters[${i}].cluster is required`); return; } | ||
| if (!dns) { core.setFailed(`❌ clusters[${i}].dns_zone is required`); return; } | ||
| names.push(name); | ||
| } | ||
| if (entry.clusters.length > 1 && !clusterIn) { | ||
| core.setFailed(`❌ '${envName}' has multiple clusters. Provide 'cluster'. Options: ${names.join(', ')}`); | ||
| return; | ||
| } | ||
| let selected = entry.clusters[0]; | ||
| if (entry.clusters.length > 1) { | ||
| selected = entry.clusters.find(c => String(c.cluster).toLowerCase() === clusterIn.toLowerCase()); | ||
| if (!selected) { | ||
| core.setFailed(`❌ Cluster '${clusterIn}' not found. Options: ${names.join(', ')}`); | ||
| return; | ||
| } | ||
| } | ||
| const cluster = String(selected.cluster); | ||
| const dnsZone = String(selected.dns_zone); | ||
| const registry = String(selected.container_registry || ''); | ||
| const uami = Array.isArray(selected.uami_map) ? selected.uami_map : []; | ||
| core.setOutput('cluster', cluster); | ||
| core.setOutput('dns_zone', dnsZone); | ||
| core.setOutput('container_registry', registry); | ||
| core.setOutput('uami_map', JSON.stringify(uami)); | ||
| core.setOutput('namespace', process.env.NAMESPACE); | ||
| - name: Export CD_ROOT | ||
| id: cdroot | ||
| run: | | ||
| CD_ROOT="continuous-deployment/${{ steps.env.outputs.cluster }}/${{ inputs.namespace }}" | ||
| echo "CD_ROOT=$CD_ROOT" >> "$GITHUB_ENV" | ||
| echo "cd_root=$CD_ROOT" >> "$GITHUB_OUTPUT" | ||
| # Export UAMI env vars (uami_name => client_id) | ||
| - name: Export UAMI env vars (uami_name => client_id) | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| UAMI_JSON: ${{ steps.env.outputs.uami_map }} | ||
| CLUSTER: ${{ steps.env.outputs.cluster }} | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const clusterName = (process.env.CLUSTER || '').trim(); | ||
| let arr = []; | ||
| try { arr = JSON.parse(process.env.UAMI_JSON || '[]'); } | ||
| catch { core.setFailed('❌ Selected cluster uami_map is not valid JSON'); return; } | ||
| if (!Array.isArray(arr) || arr.length === 0) { | ||
| core.info('No UAMI entries for selected cluster.'); | ||
| return; | ||
| } | ||
| const seen = new Set(); | ||
| const exported = []; | ||
| for (const [i, u] of arr.entries()) { | ||
| let name = String(u.uami_name || '').trim(); | ||
| const rg = String(u.uami_resource_group || '').trim(); | ||
| const cid = String(u.client_id || '').trim(); | ||
| if (!name || !cid) { | ||
| core.warning(`Skipping UAMI index ${i}: missing uami_name or client_id.`); | ||
| continue; | ||
| } | ||
| // Remove "<clusterName>-" prefix if present | ||
| const prefix = clusterName + "-"; | ||
| if (name.toLowerCase().startsWith(prefix.toLowerCase())) { | ||
| name = name.substring(prefix.length); | ||
| } | ||
| // Replace '-' with '_' | ||
| let varName = name.replace(/-/g, "_"); | ||
| // Ensure valid shell identifier | ||
| if (!/^[A-Za-z_]/.test(varName)) varName = `_${varName}`; | ||
| if (seen.has(varName)) { | ||
| core.warning(`Duplicate UAMI var '${varName}' (rg='${rg}'). Skipping duplicate.`); | ||
| continue; | ||
| } | ||
| fs.appendFileSync(process.env.GITHUB_ENV, `${varName}=${cid}\n`); | ||
| seen.add(varName); | ||
| exported.push({ varName, cid }); | ||
| } | ||
| if (exported.length === 0) { | ||
| core.info("No UAMI vars exported."); | ||
| } else { | ||
| core.info("Exported UAMI vars for templating:"); | ||
| for (const e of exported) { | ||
| core.info(` ${e.varName}=${e.cid}`); | ||
| } | ||
| } | ||
| # Unify to a single apps[] list for either mode | ||
| - name: Resolve apps (single or multi) | ||
| id: apps | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APP_DETAILS: ${{ inputs.applicationDetails }} | ||
| APPLICATION: ${{ inputs.application }} | ||
| DEPLOY_PATH: ${{ inputs.deployFilesPath }} | ||
| IMG_ONE: ${{ inputs.image_base_name }} | ||
| IMG_LIST: ${{ inputs.image_base_names }} | ||
| with: | ||
| script: | | ||
| const detailsRaw = (process.env.APP_DETAILS || '').trim(); | ||
| let apps = []; | ||
| if (detailsRaw) { | ||
| let arr; | ||
| try { arr = JSON.parse(detailsRaw); } | ||
| catch(e){ core.setFailed(`❌ applicationDetails is not valid JSON: ${e.message}`); return; } | ||
| if (!Array.isArray(arr)) { core.setFailed('❌ applicationDetails must be a JSON array'); return; } | ||
| for (let i=0;i<arr.length;i++){ | ||
| const it = arr[i] || {}; | ||
| const name = String(it.name || '').trim(); | ||
| const path = String(it.path || '').trim(); | ||
| const images = Array.isArray(it.images) ? it.images.map(String) : []; | ||
| if (!name) { core.setFailed(`❌ applicationDetails[${i}].name is required`); return; } | ||
| if (!path) { core.setFailed(`❌ applicationDetails[${i}].path is required`); return; } | ||
| apps.push({ name, path, images }); | ||
| } | ||
| } else { | ||
| // Single app mode | ||
| const name = (process.env.APPLICATION || '').trim(); | ||
| const path = (process.env.DEPLOY_PATH || '').trim(); | ||
| const images = []; | ||
| if ((process.env.IMG_ONE || '').trim()) images.push(process.env.IMG_ONE.trim()); | ||
| if ((process.env.IMG_LIST || '').trim()) { | ||
| for (const s of process.env.IMG_LIST.split(',').map(x => x.trim()).filter(Boolean)) images.push(s); | ||
| } | ||
| if (!name) { core.setFailed('❌ application is required when applicationDetails is not provided'); return; } | ||
| if (!path) { core.setFailed('❌ deployFilesPath is required when applicationDetails is not provided'); return; } | ||
| apps.push({ name, path, images }); | ||
| } | ||
| core.setOutput('apps', JSON.stringify(apps)); | ||
| core.setOutput('count', String(apps.length)); | ||
| # Optional Jinja → envsubst conversion (per app paths) | ||
| - name: Convert Jinja2 {{ var }} to envsubst ${var} (skip Jinja control files) | ||
| if: ${{ inputs.convert_jinja }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| for (const app of apps) { | ||
| const src = `source/${app.path}`; | ||
| execSync(`bash -lc 'if [ -d "${src}" ]; then \ | ||
| find "${src}" \\( -name "*.yaml" -o -name "*.yml" -o -name "*.json" \\) -type f -print0 | \ | ||
| while IFS= read -r -d "" f; do \ | ||
| if ! grep -q "{%" "$f"; then sed -E -i "s/\\{\\{\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\}\\}/\\$\\{\\1\\}/g" "$f"; fi; \ | ||
| done; fi'`, { stdio: 'inherit' }); | ||
| } | ||
| - name: Template manifest files with envsubst (per app) | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CLUSTER_NAME: ${{ steps.env.outputs.cluster }} | ||
| DNS_ZONE: ${{ steps.env.outputs.dns_zone }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| APPLICATION_DEFAULT: ${{ inputs.application }} | ||
| CONTAINER_REGISTRY: ${{ steps.env.outputs.container_registry }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| for (const app of apps) { | ||
| const src = `source/${app.path}`; | ||
| // APPLICATION env for templating: prefer per-app name | ||
| execSync(`bash -lc 'export CLUSTER_NAME="${process.env.CLUSTER_NAME}"; \ | ||
| export DNS_ZONE="${process.env.DNS_ZONE}"; \ | ||
| export NAMESPACE="${process.env.NAMESPACE}"; \ | ||
| export APPLICATION="${app.name}"; \ | ||
| export CONTAINER_REGISTRY="${process.env.CONTAINER_REGISTRY}"; \ | ||
| if [ -d "${src}" ]; then \ | ||
| find "${src}" \\( -name "*.yaml" -o -name "*.yml" -o -name "*.json" \\) -type f -print0 | \ | ||
| while IFS= read -r -d "" f; do \ | ||
| if grep -q "\\${" "$f"; then envsubst < "$f" > "$f.tmp" && mv "$f.tmp" "$f"; fi; \ | ||
| done; \ | ||
| fi'`, { stdio: 'inherit' }); | ||
| } | ||
| - name: Copy templated files to CD repo (per app) | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CD_ROOT: ${{ env.CD_ROOT }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| for (const app of apps) { | ||
| const src = `source/${app.path}`; | ||
| const dst = `cd/${process.env.CD_ROOT}/${app.name}/${app.path}`; | ||
| execSync(`bash -lc 'mkdir -p "${dst}"; rm -rf "${dst}"/* || true; cp -r "${src}/." "${dst}/"'`, { stdio: 'inherit' }); | ||
| } | ||
| - name: Debug structure | ||
| if: ${{ inputs.debug }} | ||
| run: find "cd/${{ env.CD_ROOT }}" || echo "Nothing copied!" | ||
| - name: Setup Kustomize | ||
| uses: imranismail/setup-kustomize@v2 | ||
| with: | ||
| kustomize-version: ${{ inputs.kustomize_version }} | ||
| - name: Patch image tag(s) (per app) | ||
| if: ${{ inputs.image_tag != '' }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CD_ROOT: ${{ env.CD_ROOT }} | ||
| OVERLAY_DIR: ${{ inputs.overlay_dir }} | ||
| IMAGE_TAG: ${{ inputs.image_tag }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| const overlayDir = (process.env.OVERLAY_DIR || '').trim(); | ||
| for (const app of apps) { | ||
| const base = overlayDir ? `cd/${process.env.CD_ROOT}/${app.name}/${app.path}/overlays/${overlayDir}` : `cd/${process.env.CD_ROOT}/${app.name}/${app.path}`; | ||
| if ((app.images || []).length === 0) continue; | ||
| for (const img of app.images) { | ||
| execSync(`bash -lc 'cd "${base}" && kustomize edit set image "${img}=*:${process.env.IMAGE_TAG}"'`, { stdio: 'inherit' }); | ||
| } | ||
| } | ||
| - name: Run kustomize build (concat per app) | ||
| id: kustomize | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CD_ROOT: ${{ env.CD_ROOT }} | ||
| OVERLAY_DIR: ${{ inputs.overlay_dir }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const fs = require('fs'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| const overlayDir = (process.env.OVERLAY_DIR || '').trim(); | ||
| const outFile = `${process.env.GITHUB_WORKSPACE}/build-output.yaml`; | ||
| fs.writeFileSync(outFile, ''); | ||
| let first = true; | ||
| for (const app of apps) { | ||
| const dir = overlayDir ? `cd/${process.env.CD_ROOT}/${app.name}/${app.path}/overlays/${overlayDir}` : `cd/${process.env.CD_ROOT}/${app.name}/${app.path}`; | ||
| const yaml = execSync(`bash -lc 'cd "${dir}" && kustomize build .'`, { encoding: 'utf8' }); | ||
| fs.appendFileSync(outFile, (first ? '' : '\n---\n') + yaml); | ||
| first = false; | ||
| } | ||
| - name: Upload built manifest as artifact | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: built-kustomize-manifest | ||
| path: build-output.yaml | ||
| - name: Upload templated source manifests as artifact | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: templated-source-manifests | ||
| path: source | ||
| - name: Commit and push to continuous-deployment repo | ||
| run: | | ||
| cd cd | ||
| git config user.name "GitHub Actions" | ||
| git config user.email "actions@github.com" | ||
| git add "${{ env.CD_ROOT }}/" | ||
| git diff --cached --quiet && exit 0 | ||
| git commit -m "Deploy to ${{ steps.env.outputs.cluster }}/${{ inputs.namespace }} (apps: ${{ steps.apps.outputs.count }})" | ||
| git push | ||
| - name: Resolve ArgoCD auth | ||
| id: resolve_auth | ||
| env: | ||
| IN_TOKEN: ${{ inputs.argocd_auth_token }} | ||
| IN_USER: ${{ inputs.argocd_username }} | ||
| IN_PASS: ${{ inputs.argocd_password }} | ||
| SEC_USER: ${{ secrets.ARGOCD_USERNAME }} | ||
| SEC_PASS: ${{ secrets.ARGOCD_PASSWORD }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ -n "${IN_TOKEN:-}" ]; then | ||
| echo "mode=token" >> "$GITHUB_OUTPUT" | ||
| echo "token=$IN_TOKEN" >> "$GITHUB_OUTPUT" | ||
| elif [ -n "${IN_USER:-}" ] && [ -n "${IN_PASS:-}" ]; then | ||
| echo "mode=basic" >> "$GITHUB_OUTPUT" | ||
| echo "username=$IN_USER" >> "$GITHUB_OUTPUT" | ||
| echo "password=$IN_PASS" >> "$GITHUB_OUTPUT" | ||
| elif [ -n "${SEC_USER:-}" ] && [ -n "${SEC_PASS:-}" ]; then | ||
| echo "mode=basic" >> "$GITHUB_OUTPUT" | ||
| echo "username=$SEC_USER" >> "$GITHUB_OUTPUT" | ||
| echo "password=$SEC_PASS" >> "$GITHUB_OUTPUT" | ||
| else | ||
| echo "❌ No ArgoCD auth provided." | ||
| exit 1 | ||
| fi | ||
| - name: Set ArgoCD connection (token & URLs) | ||
| id: argocd_conn | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| MODE: ${{ steps.resolve_auth.outputs.mode }} | ||
| TOKEN: ${{ steps.resolve_auth.outputs.token }} | ||
| USERNAME: ${{ steps.resolve_auth.outputs.username }} | ||
| PASSWORD: ${{ steps.resolve_auth.outputs.password }} | ||
| ARGOCD_CA_CERT: ${{ secrets.ARGOCD_CA_CERT }} | ||
| CLUSTER: ${{ steps.env.outputs.cluster }} | ||
| DNS_ZONE: ${{ steps.env.outputs.dns_zone }} | ||
| INSECURE_ARGO: ${{ inputs.insecure_argo }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const fs = require('fs'); | ||
| const cluster = process.env.CLUSTER; | ||
| const dnsZone = process.env.DNS_ZONE; | ||
| const argocdUrl = `https://${cluster}-argocd-argocd-web-ui.${dnsZone}`; | ||
| let curlSslFlags = ""; | ||
| if (String(process.env.INSECURE_ARGO) === "true") curlSslFlags = "-k"; | ||
| else if (process.env.ARGOCD_CA_CERT) { fs.writeFileSync('/tmp/argocd-ca.crt', process.env.ARGOCD_CA_CERT); curlSslFlags = "--cacert /tmp/argocd-ca.crt"; } | ||
| let finalToken = process.env.TOKEN; | ||
| if (process.env.MODE !== "token") { | ||
| const body = JSON.stringify({ username: process.env.USERNAME, password: process.env.PASSWORD }); | ||
| const resp = execSync(`curl -s ${curlSslFlags} -X POST "${argocdUrl}/api/v1/session" -H "Content-Type: application/json" -d '${body.replace(/'/g,"'\\''")}'`).toString(); | ||
| try { finalToken = JSON.parse(resp).token; } catch { core.setFailed("❌ Failed to parse ArgoCD session response"); return; } | ||
| } | ||
| if (!finalToken) { core.setFailed("❌ Failed to obtain ArgoCD token."); return; } | ||
| core.setOutput('argocd_url', argocdUrl); | ||
| core.setOutput('curl_ssl_flags', curlSslFlags); | ||
| core.setOutput('token', finalToken); | ||
| - name: Check or create ArgoCD applications (per app) | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CD_ROOT: ${{ env.CD_ROOT }} | ||
| OVERLAY_DIR: ${{ inputs.overlay_dir }} | ||
| CD_REPO: ${{ inputs.cd_repo }} | ||
| ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }} | ||
| ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }} | ||
| CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| for (const app of apps) { | ||
| const appName = `${process.env.NAMESPACE}-${app.name}`; | ||
| const statusUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`; | ||
| const cdPath = (process.env.OVERLAY_DIR || '').trim() | ||
| ? `${process.env.CD_ROOT}/${app.name}/${app.path}/overlays/${process.env.OVERLAY_DIR}` | ||
| : `${process.env.CD_ROOT}/${app.name}/${app.path}`; | ||
| const http = execSync(`bash -lc 'curl ${process.env.CURL_SSL_FLAGS} -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${statusUrl}"'`, { encoding: 'utf8' }).trim(); | ||
| if (http === '200') { core.info(`✅ Argo app ${appName} exists.`); continue; } | ||
| if (http !== '403') { core.setFailed(`❌ Unexpected ArgoCD response for ${appName}: HTTP ${http}`); return; } | ||
| # Create app from template via envsubst | ||
| execSync(`bash -lc 'APP_NAME="${appName}" CD_REPO="${process.env.CD_REPO}" CD_PATH="${cdPath}" NAMESPACE="${process.env.NAMESPACE}" \ | ||
| envsubst < reusable/templates/argocd-application-template.json > rendered-app.json && \ | ||
| curl ${process.env.CURL_SSL_FLAGS} -s -X POST "${process.env.ARGOCD_URL}/api/v1/applications" \ | ||
| -H "Content-Type: application/json" -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" \ | ||
| --data-binary @rendered-app.json'`, { stdio: 'inherit' }); | ||
| core.info(`✅ Created Argo app ${appName}.`); | ||
| } | ||
| - name: Sync ArgoCD apps (per app) | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }} | ||
| ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }} | ||
| CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| for (const app of apps) { | ||
| const appName = `${process.env.NAMESPACE}-${app.name}`; | ||
| const syncUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}/sync`; | ||
| const http = execSync(`bash -lc 'curl ${process.env.CURL_SSL_FLAGS} -s -w "%{http_code}" -o /tmp/resp.txt -X POST "${syncUrl}" -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" -d "{}"'`, { encoding: 'utf8' }).trim(); | ||
| if (http !== '200' && http !== '201') { | ||
| execSync('bash -lc "cat /tmp/resp.txt"',{stdio:'inherit'}); | ||
| core.setFailed(`❌ Failed to trigger sync for ${appName}: HTTP ${http}`); return; | ||
| } | ||
| core.info(`🚀 Sync triggered: ${appName}`); | ||
| } | ||
| - name: Wait for ArgoCD sync (per app) | ||
| if: ${{ inputs.skip_status_check != 'true' }} | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }} | ||
| ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }} | ||
| CURL_SSL_FLAGS: ${{ steps.argocd_conn.outputs.curl_ssl_flags }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| for (const app of apps) { | ||
| const appName = `${process.env.NAMESPACE}-${app.name}`; | ||
| const statusUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`; | ||
| let ok = false; | ||
| for (let i=0;i<12;i++){ | ||
| const resp = execSync(`bash -lc 'curl ${process.env.CURL_SSL_FLAGS} -s -H "Authorization: Bearer ${process.env.ARGOCD_TOKEN}" "${statusUrl}"'`, { encoding: 'utf8' }); | ||
| try { | ||
| const json = JSON.parse(resp); | ||
| if ((json?.status?.sync?.status || '') === 'Synced') { ok = true; break; } | ||
| } catch {} | ||
| execSync('bash -lc "sleep 10"'); | ||
| } | ||
| if (!ok) { core.setFailed(`❌ ArgoCD sync did not complete in time for ${appName}`); return; } | ||
| core.info(`✅ ArgoCD sync completed for ${appName}.`); | ||
| } | ||