19-June-25

Create pipeline with lint & unit test, terraform, iaac testing (using terratest), Docker Build & Security Scan (Trivy), Deploy, Smoke Test & Metrics Scrape, Cleanup & Notification

19-June-25
Photo by nbtrisna A silent observer of the city night, a black cat pauses on the pavement, watching the world pass by.

Archon Quest: Realm of CI/CD

Create pipeline with lint & unit test, terraform, iaac testing (using terratest), Docker Build & Security Scan (Trivy), Deploy, Smoke Test & Metrics Scrape, Cleanup & Notification

Objective

  • Lint & Unit Test for both Node.js & Go using a matrix strategy.
  • Terraform Plan & Apply on main branch (dev environment).
  • Infrastructure Testing with Terratest.
  • Docker Build & Security Scan (Trivy).
  • Deploy to Staging (auto) then Production (manual approvals).
  • Smoke Tests & Metrics Scrape (Prometheus).
  • Cleanup & Notification.

Action!

Repository : https://github.com/ngurah-bagus-trisna/realm-of-cicd

  1. Define three environments in repo Settings: dev, staging, production (Add required reviewers in production)

Create new repository first called realm-of-cicd, after that creating new environment by accesing Settings > Environment > New Environmet

Pasted image 20250619031254

Result

Pasted image 20250619031818
  1. Setup linter for nodejs-apps

Reference :

  • https://medium.com/opportunities-in-the-world-of-tech/how-i-set-up-ci-cd-for-a-node-js-app-with-eslint-jest-and-docker-hub-7d3cacf7add8
npm init -y  
npm install express

npm install --save-dev jest supertest
npx eslint --init

Create basic helloWorld node-js apps using express js.

const express = require('express')
const app = express()

app.get('/', (req, res) => {
  res.send('Hello World!')
})

module.exports = app; 
const app = require('./index');
const port = 8080;

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`);
});

Create test unit using jest

const request = require('supertest');
const app = require('../'); 

describe('GET /', () => {
  it('responds with hello message', async () => {
    const res = await request(app).get('/');
    expect(res.statusCode).toEqual(200);
    expect(res.text).toContain(`Hello World!`);
  });
});

Configure eslint.config.mjs for lint jest

import js from "@eslint/js";
import globals from "globals";
import { defineConfig } from "eslint/config";


export default defineConfig([
  { 
    files: ["**/*.{js,mjs,cjs}"], 
    plugins: { js }, 
    extends: ["js/recommended"] 
  },
  { 
    files: ["**/*.{js,mjs,cjs}"], 
    languageOptions: { 
      globals: globals.node 
    } 
  },
  { 
    files: ["**/*.test.{js,mjs,cjs}"], 
    languageOptions: { 
      globals: globals.jest  
    } 
  }
]);

Configure package.json, add script for lint,test and dev

  "scripts": {
    "lint": "npx eslint .",
    "test": "jest",
    "dev": "node server.js"
  },

Try to run first in terminal, makesure all passed.

Pasted image 20250619183255
  1. In quest, need to plan main.tf to create hello.txt.

Create main.tf

terraform {
  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.0"
    }
  }

  required_version = ">= 1.0"
}

provider "local" {
  # No configuration needed for local provider
  
}

resource "local_file" "hello_world" {
  content  = "Hello, OpenTofu!"
  filename = "${path.module}/test/hello.txt"
  
}
It will create a text file with the content Hello, Opentofu!

Create terratest to makesure terraform can apply main.tf

package test

import (
	"testing"

	"github.com/gruntwork-io/terratest/modules/terraform"
	"github.com/stretchr/testify/assert"
	"os"
)

func TestHelloFile(t *testing.T) {
	// retryable errors in terraform testing.
	terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
		TerraformDir: "../",
	})

	defer terraform.Destroy(t, terraformOptions)

	terraform.InitAndApply(t, terraformOptions)
	
	content, err := os.ReadFile("hello.txt")
	assert.NoError(t, err)
	assert.Contains(t, string(content), "Hello, OpenTofu!")
}	
It will apply main.tf, and destroy after the check finished

Init go modules & install modules

go mod init helo_test
go mod tidy

go test
Pasted image 20250619184125
  1. Create github workflows .github/workflows/archon-ci.yml
name: Archon CI
on:
  push:
    branches: [main]
jobs:
  lint-test:
    strategy:
      matrix:
        language: [node, go]
        node-version: [20, 18]
        go-version: ["1.20", "1.21"]
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Node.js
        if: matrix.language == 'node'
        uses: actions/setup-node@v2
        with:
          node-version: ${{ matrix.node-version }}       

      - name: Run lint and unit tests on nodejs
        if: matrix.language == 'node'
        run: |
          npm install
          npm run lint
          npm run test

  IaC-apply:
    needs: lint-test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.1

      - name: Configure terraform plugin cache
        run: |
          echo "TF_PLUGIN_CACHE_DIR=$HOME/.terraform.d/plugin-cache" >>"$GITHUB_ENV"
          mkdir -p $HOME/.terraform.d/plugin-cache

      - name: Caching terraform providers
        uses: actions/cache@v4
        with:
          key: terraform-${{ runner.os }}-${{ hashFiles('**/.terraform.lock.hcl') }}
          path: |
            $HOME/.terraform.d/plugin-cache
          restore-keys: |
            terraform-${{ runner.os }}-

      - name: Apply terraform
        run: |
          terraform init 
          terraform apply -auto-approve

      - name: Export to artifact
        uses: actions/upload-artifact@v4
        with:
          name: Output files
          path: |
            tests/hello.txt
  
  build-image:
    needs: lint-test
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
    
      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v3
      
      - name: Login to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Build docker image with layer cache
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: ${{ secrets.DOCKER_USERNAME }}/archon-image:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max
        
      - name: Pull image
        run: |
          docker pull ${{ secrets.DOCKER_USERNAME }}/archon-image:latest

      - name: Scan docker image
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: ${{ secrets.DOCKER_USERNAME }}/archon-image:latest
          format: 'table'
          severity: CRITICAL,HIGH
          ignore-unfixed: true
          exit-code: 1

      - name: Push docker image sha
        run: |
          # Add your docker push commands here, e.g.:
          docker tag ${{ secrets.DOCKER_USERNAME }}/archon-image:latest ${{ secrets.DOCKER_USERNAME }}/archon-image:${{ github.sha }}
          docker push ${{ secrets.DOCKER_USERNAME }}/archon-image:${{ github.sha }}

  deploy-development:
    needs: [build-image, IaC-apply]
    uses: ./.github/workflows/deploy.yaml
    with:
      environment: Development

  deploy-staging:
    needs: [deploy-development]
    uses: ./.github/workflows/deploy.yaml
    with:
      environment: Staging

  deploy-production:
    needs: [deploy-staging]
    uses: ./.github/workflows/deploy.yaml
    with:
      environment: Production

Explanation : This workflow automates linting, testing, infrastructure deployment, Docker image building, and multi-environment deployments using a matrix strategy and job dependencies.

Next create reusable workflow deploy.yml

name: Deploy Workflow
on:
  workflow_call:
    inputs:
      environment:
        description: 'The environment to deploy to'
        required: true
        type: string
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
    - name: Checkout repository
      uses: actions/checkout@v4

    - name: Deploy to ${{ inputs.environment }}
      run: |
        echo "Deploy to ${{ inputs.environment }}"
        docker run -d --name archon-${{ inputs.environment}} -p 8080:8080 ngurahbagustrisna/archon-image:latest
    
    - name: Wait for service to be ready
      run: |
        echo "Waiting for service to be ready"
        sleep 20  # Adjust the sleep time as necessary

    - name: Testing to hit using smoke tests on environment ${{ inputs.environment }}
      run: |
        echo "Running smoke tests"
        # Add your smoke test commands here, e.g.:
        chmod +x ./tests/smoke_test
        bash ./tests/smoke_test
        echo "Finished deploy to ${{ inputs.environment }}"
        docker rm -f archon-${{ inputs.environment}} || true

Push to github repository, and makesure all job passed.

Pasted image 20250619184844