Update deploy.yaml #27
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: | ||
| github_runner: | ||
| required: true | ||
| type: string | ||
| namespace: | ||
| required: true | ||
| type: string | ||
| target: | ||
| description: > | ||
| The environment to deploy to (e.g., `dev`, `qa`, `prod`). | ||
| - If the environment maps to a **single cluster** in `env_map`, this is all you need. | ||
| - If the environment maps to **multiple clusters**, you must also set `target_cluster`. | ||
| required: true | ||
| default: "dev" | ||
| type: string | ||
| # REQUIRED when env has >1 clusters | ||
| target_cluster: | ||
| description: > | ||
| The specific cluster to deploy to when the selected `target` environment | ||
| has more than one cluster in `env_map`. | ||
| - For single-cluster environments (like `dev`, `qa`), leave this empty. | ||
| - For multi-cluster environments (like `prod` with both `aks-prod-weu` and `aks-prod-use`), | ||
| you must provide one of the valid cluster names from `env_map`. | ||
| Example: `aks-prod-weu` | ||
| required: false | ||
| default: "" | ||
| type: string | ||
| ref: | ||
| description: "The github ref to use for checking out files" | ||
| required: false | ||
| type: string | ||
| default: ${{ github.ref || github.sha }} | ||
| delete_first: | ||
| description: "Delete the namespaced app first before deploying it." | ||
| required: false | ||
| type: boolean | ||
| cd_repo: | ||
| required: true | ||
| type: string | ||
| cd_repo_org: | ||
| required: true | ||
| type: string | ||
| github_environment: | ||
| required: true | ||
| type: string | ||
| # Mode A (single app) | ||
| application: | ||
| required: false | ||
| type: string | ||
| deploy_path: | ||
| description: "The repo path to the deployment files" | ||
| required: false | ||
| type: string | ||
| default: Deployments | ||
| image_tag: | ||
| required: false | ||
| type: string | ||
| image_base_name: | ||
| required: false | ||
| type: string | ||
| image_base_names: | ||
| required: false | ||
| type: string | ||
| overlay_dir: | ||
| required: true | ||
| type: string | ||
| default: "" | ||
| # Mode B (multi app) | ||
| application_details: | ||
| description: >- | ||
| JSON array where each item maps: | ||
| { "name" => application, "images" => image_base_names[], "path" => deploy_path } | ||
| 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: boolean | ||
| insecure_argo: | ||
| required: false | ||
| default: false | ||
| type: boolean | ||
| 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.github_runner }} | ||
| environment: ${{ inputs.github_environment }} | ||
| outputs: | ||
| cd_path: ${{ steps.cdroot.outputs.cd_root }} | ||
| steps: | ||
| - name: Checkout repo | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| fetch-depth: 1 | ||
| repository: ${{ github.repository }} | ||
| path: source | ||
| ref: ${{ inputs.ref }} | ||
| - name: Generate GitHub App token | ||
| id: generate_token | ||
| uses: actions/create-github-app-token@v2 | ||
| with: | ||
| app-id: ${{ secrets.CONTINUOUS_DEPLOYMENT_GH_APP_ID }} | ||
| private-key: ${{ secrets.CONTINUOUS_DEPLOYMENT_GH_APP_PRIVATE_KEY }} | ||
| owner: ${{ inputs.cd_repo_org }} | ||
| repositories: ${{ inputs.cd_repo }} | ||
| - name: Checkout reusable workflow repo | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| repository: gitopsmanager/k8s-deploy | ||
| ref: v2 | ||
| path: reusable | ||
| - name: Checkout continuous-deployment repo | ||
| uses: actions/checkout@v4 | ||
| with: | ||
| repository: ${{ inputs.cd_repo_org }}/${{ inputs.cd_repo }} | ||
| token: ${{ steps.generate_token.outputs.token }} | ||
| path: continuous-deployment | ||
| - name: Detect cloud | ||
| id: cloud | ||
| uses: gitopsmanager/detect-cloud@main | ||
| with: | ||
| timeout-ms: 800 | ||
| - 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.target }} | ||
| CLUSTER_IN: ${{ inputs.target_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; | ||
| } | ||
| let selected; | ||
| if (clusterIn) { | ||
| // Search ALL clusters across all environments | ||
| const allClusters = Object.entries(map) | ||
| .flatMap(([env, val]) => (val.clusters || []).map(c => ({ ...c, __env: env }))); | ||
| selected = allClusters.find(c => String(c.cluster).toLowerCase() === clusterIn.toLowerCase()); | ||
| if (!selected) { | ||
| const available = allClusters.map(c => `${c.cluster} (env=${c.__env})`); | ||
| core.setFailed(`❌ target_cluster '${clusterIn}' not found. Available: ${available.join(', ')}`); | ||
| return; | ||
| } | ||
| core.info(`Cluster override: using cluster '${selected.cluster}' from env '${selected.__env}'`); | ||
| } else { | ||
| // Fallback: normal target-based selection | ||
| 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; | ||
| } | ||
| if (entry.clusters.length > 1) { | ||
| const names = entry.clusters.map(c => c.cluster).filter(Boolean); | ||
| core.setFailed(`❌ '${envName}' has multiple clusters. Provide target_cluster. Options: ${names.join(', ')}`); | ||
| return; | ||
| } | ||
| selected = entry.clusters[0]; | ||
| core.info(`Selected single cluster '${selected.cluster}' from env '${envName}'`); | ||
| } | ||
| 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 : []; | ||
| if (!cluster) { core.setFailed('❌ Selected cluster name is empty'); return; } | ||
| if (!dnsZone) { core.setFailed(`❌ Cluster '${cluster}' is missing dns_zone`); return; } | ||
| 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.application_details }} | ||
| APPLICATION: ${{ inputs.application }} | ||
| DEPLOY_PATH: ${{ inputs.deploy_path }} | ||
| 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(`❌ application_details is not valid JSON: ${e.message}`); return; } | ||
| if (!Array.isArray(arr)) { core.setFailed('❌ application_details 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(`❌ application_details[${i}].name is required`); return; } | ||
| if (!path) { core.setFailed(`❌ application_details[${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 application_details is not provided'); return; } | ||
| if (!path) { core.setFailed('❌ deploy_path is required when application_details is not provided'); return; } | ||
| apps.push({ name, path, images }); | ||
| } | ||
| core.setOutput('apps', JSON.stringify(apps)); | ||
| core.setOutput('count', String(apps.length)); | ||
| - name: Download Nunjucks UMD bundle | ||
| run: | | ||
| curl -sSL https://cdnjs.cloudflare.com/ajax/libs/nunjucks/3.2.4/nunjucks.min.js -o nunjucks.js | ||
| - name: Render manifests with Nunjucks (Jinja2-style) | ||
| id: render | ||
| 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 fs = require('fs'); | ||
| const path = require('path'); | ||
| const nunjucks = require('./nunjucks.js'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| let dirs = []; | ||
| const vars = { | ||
| CLUSTER_NAME: process.env.CLUSTER_NAME, | ||
| DNS_ZONE: process.env.DNS_ZONE, | ||
| NAMESPACE: process.env.NAMESPACE, | ||
| APPLICATION: process.env.APPLICATION_DEFAULT, | ||
| CONTAINER_REGISTRY: process.env.CONTAINER_REGISTRY, | ||
| }; | ||
| nunjucks.configure({ autoescape: false }); | ||
| for (const app of apps) { | ||
| const srcDir = path.join('source', app.path); | ||
| dirs.push(srcDir); | ||
| const files = fs.existsSync(srcDir) | ||
| ? fs.readdirSync(srcDir, { withFileTypes: true }).map(e => path.join(srcDir, e.name)) | ||
| : []; | ||
| for (const f of files) { | ||
| if (/\.(ya?ml|json)$/i.test(f)) { | ||
| const template = fs.readFileSync(f, 'utf8'); | ||
| const rendered = nunjucks.renderString(template, vars); | ||
| fs.writeFileSync(f, rendered, 'utf8'); | ||
| console.log(`Rendered ${f}`); | ||
| } | ||
| } | ||
| } | ||
| // github-script: anything you return is exposed as "steps.render.outputs.result" | ||
| return dirs.join("\n"); | ||
| - name: Copy manifests to CD repo | ||
| id: copy | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| CLUSTER_NAME: ${{ steps.env.outputs.cluster }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| RENDERED_DIRS: ${{ steps.render.outputs.result }} | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { execSync } = require('child_process'); | ||
| const cluster = process.env.CLUSTER_NAME; | ||
| const namespace = process.env.NAMESPACE; | ||
| const renderedDirs = (process.env.RENDERED_DIRS || '').trim().split('\n').filter(Boolean); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| // ✅ Copy into the continuous-deployment repo | ||
| const cdRoot = path.join('continuous-deployment', cluster, namespace); | ||
| const cdRootRel = path.join(cluster, namespace); | ||
| fs.mkdirSync(cdRoot, { recursive: true }); | ||
| // assume apps[] order matches renderedDirs[] order | ||
| renderedDirs.forEach((dir, i) => { | ||
| const app = apps[i]; | ||
| if (!app) return; | ||
| const destDir = path.join(cdRoot, app.name); | ||
| // Delete just the app’s directory first | ||
| if (fs.existsSync(destDir)) { | ||
| console.log(`Removing old directory for app ${app.name}: ${destDir}`); | ||
| execSync(`rm -rf "${destDir}"`); | ||
| } | ||
| fs.mkdirSync(destDir, { recursive: true }); | ||
| console.log(`Copying ${dir} -> ${destDir}`); | ||
| execSync(`cp -r "${dir}/." "${destDir}/"`, { stdio: 'inherit' }); | ||
| }); | ||
| core.setOutput('cd_path', cdRoot); | ||
| core.setOutput('cd_path_rel', cdRootRel); | ||
| - name: Upload CD repo manifests | ||
| if: always() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: templated-source-manifests-${{ steps.env.outputs.cluster }} | ||
| path: ${{ steps.copy.outputs.cd_path }} | ||
| - name: Debug structure | ||
| if: ${{ inputs.debug }} | ||
| run: find "${{ steps.copy.outputs.cd_path }}" || 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_PATH: ${{ steps.copy.outputs.cd_path }} | ||
| IMAGE_TAG: ${{ inputs.image_tag }} | ||
| OVERLAY_DIR: ${{ inputs.overlay_dir }} | ||
| CONTAINER_REGISTRY: ${{ steps.env.outputs.container_registry }} | ||
| with: | ||
| script: | | ||
| const { execSync } = require('child_process'); | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| const overlayDir = (process.env.OVERLAY_DIR || '').trim(); | ||
| const registry = process.env.CONTAINER_REGISTRY.replace(/\/+$/, ''); // strip trailing slash if any | ||
| for (const app of apps) { | ||
| // Normalized layout: just app.name (+ overlays if defined) | ||
| const base = overlayDir | ||
| ? `${process.env.CD_PATH}/${app.name}/overlays/${overlayDir}` | ||
| : `${process.env.CD_PATH}/${app.name}`; | ||
| if ((app.images || []).length === 0) continue; | ||
| for (const img of app.images) { | ||
| // full replacement: <registry>/<image>:<tag> | ||
| const newImage = `${registry}/${img}:${process.env.IMAGE_TAG}`; | ||
| execSync( | ||
| `bash -lc 'cd "${base}" && kustomize edit set image "${img}=${newImage}"'`, | ||
| { stdio: 'inherit' } | ||
| ); | ||
| } | ||
| } | ||
| - name: Run kustomize build (concat per app) | ||
| id: kustomize | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CD_PATH: ${{ steps.copy.outputs.cd_path }} | ||
| 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 | ||
| ? `${process.env.CD_PATH}/${app.name}/overlays/${overlayDir}` | ||
| : `${process.env.CD_PATH}/${app.name}`; | ||
| const yaml = execSync(`bash -lc 'cd "${dir}" && kustomize build .'`, { encoding: 'utf8' }); | ||
| fs.appendFileSync(outFile, (first ? '' : '\n---\n') + yaml); | ||
| first = false; | ||
| } | ||
| return outFile; | ||
| - name: Upload built manifest as artifact | ||
| if: always() | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: built-kustomize-manifest-${{ steps.env.outputs.cluster }} | ||
| path: build-output.yaml | ||
| - name: Commit and push to continuous-deployment repo | ||
| run: | | ||
| set -euo pipefail | ||
| git -C continuous-deployment config user.name "k8s-deploy" | ||
| git -C continuous-deployment config user.email "actions@gitopsmanager.com" | ||
| echo "Adding files from: ${{ steps.copy.outputs.cd_path_rel }}/" | ||
| git -C continuous-deployment ls -la "${{ steps.copy.outputs.cd_path_rel }}/" || echo "⚠️ Directory not found" | ||
| echo "Before staging:" | ||
| git -C continuous-deployment status --short | ||
| git -C continuous-deployment add -A "${{ steps.copy.outputs.cd_path_rel }}/" | ||
| echo "After staging:" | ||
| git -C continuous-deployment status --short | ||
| git -C continuous-deployment diff --cached --stat || true | ||
| if git -C continuous-deployment diff --cached --quiet; then | ||
| echo "✅ No changes to commit." | ||
| exit 0 | ||
| fi | ||
| git -C continuous-deployment commit -m "Deploy to ${{ steps.env.outputs.cluster }}/${{ inputs.namespace }} (apps: ${{ steps.apps.outputs.count }})" | ||
| git -C continuous-deployment 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: Delete ArgoCD apps (per app) | ||
| if: ${{ inputs.delete_first }} | ||
| 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 }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| const apps = JSON.parse(process.env.APPS || '[]'); | ||
| for (const app of apps) { | ||
| const appName = `${process.env.NAMESPACE}-${app.name}`; | ||
| const appUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`; | ||
| core.info(`🗑️ Deleting ArgoCD Application: ${appName}`); | ||
| const delRes = await fetch(appUrl, { | ||
| method: 'DELETE', | ||
| headers: { | ||
| Authorization: `Bearer ${process.env.ARGOCD_TOKEN}`, | ||
| 'Content-Type': 'application/json' | ||
| } | ||
| }); | ||
| const delText = await delRes.text(); | ||
| core.info(`Delete response [${delRes.status}]: ${delText}`); | ||
| if (![200, 202, 204].includes(delRes.status)) { | ||
| core.setFailed(`❌ Failed initial delete for ${appName}: HTTP ${delRes.status}`); | ||
| return; | ||
| } | ||
| // Wait until we see 403 = gone | ||
| const timeout = Date.now() + 120000; // 2 minutes | ||
| while (Date.now() < timeout) { | ||
| const res = await fetch(appUrl, { | ||
| headers: { Authorization: `Bearer ${process.env.ARGOCD_TOKEN}` } | ||
| }); | ||
| if (res.status === 403) { | ||
| core.info(`✅ Application ${appName} fully deleted (HTTP 403)`); | ||
| break; | ||
| } | ||
| core.info(`⏳ Still deleting ${appName} (HTTP ${res.status})...`); | ||
| await new Promise(r => setTimeout(r, 5000)); | ||
| } | ||
| } | ||
| - name: Check or create ArgoCD applications (per app) | ||
| uses: actions/github-script@v7 | ||
| env: | ||
| APPS: ${{ steps.apps.outputs.apps }} | ||
| CD_PATH_REL: ${{ steps.copy.outputs.cd_path_rel }} | ||
| OVERLAY_DIR: ${{ inputs.overlay_dir }} | ||
| CD_REPO: ${{ inputs.cd_repo }} | ||
| CD_REPO_ORG: ${{ inputs.cd_repo_org }} | ||
| ARGOCD_URL: ${{ steps.argocd_conn.outputs.argocd_url }} | ||
| ARGOCD_TOKEN: ${{ steps.argocd_conn.outputs.token }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const nunjucks = require('./nunjucks.js'); | ||
| async function deleteApp(appName) { | ||
| const appUrl = `${process.env.ARGOCD_URL}/api/v1/applications/${appName}`; | ||
| core.info(`🗑️ Deleting ArgoCD Application: ${appName}`); | ||
| const delRes = await fetch(appUrl, { | ||
| method: 'DELETE', | ||
| headers: { | ||
| Authorization: `Bearer ${process.env.ARGOCD_TOKEN}`, | ||
| 'Content-Type': 'application/json' | ||
| } | ||
| }); | ||
| const delText = await delRes.text(); | ||
| core.info(`Delete response [${delRes.status}]: ${delText}`); | ||
| if (![200, 202, 204].includes(delRes.status)) { | ||
| core.setFailed(`❌ Failed initial delete for ${appName}: HTTP ${delRes.status}`); | ||
| return false; | ||
| } | ||
| // Wait until we see 403 = gone | ||
| const timeout = Date.now() + 120000; // 2 minutes | ||
| while (Date.now() < timeout) { | ||
| const res = await fetch(appUrl, { | ||
| headers: { Authorization: `Bearer ${process.env.ARGOCD_TOKEN}` } | ||
| }); | ||
| if (res.status === 403) { | ||
| core.info(`✅ Application ${appName} fully deleted (HTTP 403)`); | ||
| return true; | ||
| } | ||
| core.info(`⏳ Still deleting ${appName} (HTTP ${res.status})...`); | ||
| await new Promise(r => setTimeout(r, 5000)); | ||
| } | ||
| core.setFailed(`❌ Timeout waiting for ${appName} to delete`); | ||
| return false; | ||
| } | ||
| 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 basePath = path.join( | ||
| process.env.CD_PATH_REL, | ||
| app.name, | ||
| 'overlays', | ||
| process.env.OVERLAY_DIR | ||
| ); | ||
| const res = await fetch(statusUrl, { | ||
| headers: { Authorization: `Bearer ${process.env.ARGOCD_TOKEN}` } | ||
| }); | ||
| if (res.status === 200) { | ||
| const existing = await res.json(); | ||
| const currentPath = existing?.spec?.source?.path; | ||
| if (currentPath !== basePath) { | ||
| core.warning(`⚠️ ${appName} path mismatch (current=${currentPath}, expected=${basePath}). Will delete and recreate.`); | ||
| const deleted = await deleteApp(appName); | ||
| if (!deleted) return; | ||
| } else { | ||
| core.info(`✅ Argo app ${appName} exists with correct path (${basePath}).`); | ||
| continue; | ||
| } | ||
| } else if (res.status !== 403) { | ||
| core.setFailed(`❌ Unexpected ArgoCD response for ${appName}: HTTP ${res.status}`); | ||
| return; | ||
| } | ||
| // Render JSON with Nunjucks template | ||
| const templatePath = 'reusable/templates/argocd-app-template-v5.json'; | ||
| const template = fs.readFileSync(templatePath, 'utf8'); | ||
| const rendered = nunjucks.renderString(template, { | ||
| APP_NAME: appName, | ||
| NAMESPACE: process.env.NAMESPACE, | ||
| CD_REPO: process.env.CD_REPO, | ||
| CD_REPO_ORG: process.env.CD_REPO_ORG, | ||
| CD_PATH: basePath | ||
| }); | ||
| core.startGroup(`Rendered ArgoCD app spec for ${appName}`); | ||
| core.info(rendered); | ||
| core.endGroup(); | ||
| const createRes = await fetch(`${process.env.ARGOCD_URL}/api/v1/applications`, { | ||
| method: 'POST', | ||
| headers: { | ||
| Authorization: `Bearer ${process.env.ARGOCD_TOKEN}`, | ||
| 'Content-Type': 'application/json' | ||
| }, | ||
| body: rendered | ||
| }); | ||
| if (!createRes.ok) { | ||
| const text = await createRes.text(); | ||
| core.setFailed(`❌ Failed to create Argo app ${appName}: ${createRes.status} ${text}`); | ||
| return; | ||
| } | ||
| 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 }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| 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 res = await fetch(syncUrl, { | ||
| method: 'POST', | ||
| headers: { | ||
| Authorization: `Bearer ${process.env.ARGOCD_TOKEN}`, | ||
| 'Content-Type': 'application/json' | ||
| }, | ||
| body: '{}' | ||
| }); | ||
| const text = await res.text(); | ||
| core.info(`Sync response [${res.status}] for ${appName}: ${text}`); | ||
| if (![200, 201].includes(res.status)) { | ||
| core.setFailed(`❌ Failed to trigger sync for ${appName}: HTTP ${res.status}`); | ||
| return; | ||
| } | ||
| core.info(`🚀 Sync triggered: ${appName}`); | ||
| } | ||
| - name: Wait for ArgoCD sync (per app) | ||
| if: ${{ not(inputs.skip_status_check) }} | ||
| 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 }} | ||
| NAMESPACE: ${{ inputs.namespace }} | ||
| with: | ||
| script: | | ||
| 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}`; | ||
| core.info(`⏳ Waiting for sync of ${appName}...`); | ||
| let finalStatus = null; | ||
| for (let i = 0; i < 12; i++) { // 12 * 10s = 2 minutes | ||
| const res = await fetch(statusUrl, { | ||
| headers: { Authorization: `Bearer ${process.env.ARGOCD_TOKEN}` } | ||
| }); | ||
| if (!res.ok) { | ||
| core.info(`Attempt ${i+1}: got HTTP ${res.status}`); | ||
| } else { | ||
| const json = await res.json(); | ||
| const syncStatus = json?.status?.sync?.status; | ||
| finalStatus = syncStatus; | ||
| core.info(`Attempt ${i+1}: syncStatus=${syncStatus}`); | ||
| if (syncStatus === 'Synced') { | ||
| core.info(`✅ ArgoCD sync completed for ${appName}.`); | ||
| break; | ||
| } | ||
| if (syncStatus === 'Error' || syncStatus === 'Failed') { | ||
| core.setFailed(`❌ ArgoCD sync failed for ${appName} (status=${syncStatus})`); | ||
| return; | ||
| } | ||
| } | ||
| await new Promise(r => setTimeout(r, 10000)); | ||
| } | ||
| if (finalStatus !== 'Synced') { | ||
| if (finalStatus === 'OutOfSync' || finalStatus === 'Unknown') { | ||
| core.warning(`⚠️ ArgoCD sync for ${appName} is still pending (status=${finalStatus}).`); | ||
| } else { | ||
| core.setFailed(`❌ ArgoCD sync did not complete in time for ${appName} (last status=${finalStatus})`); | ||
| return; | ||
| } | ||
| } | ||
| } | ||