Terraform CI/CD 구축 (2)

Manx·2025년 8월 13일

DevOps

목록 보기
13/14
post-thumbnail

0. 서론

1탄에 이어 2탄이다.
Jenkins Pipeline을 만들어보자.

위의 Flow로 진행될 예정이다.


1. Directory

Jenkins UI에서 설정한 디렉토리에 jenkinsfile을 만들어야 한다.
필자는 scripts/jenkinsFile로 지정했다.


2. environment

노출되어서는 안될 것들이다.
Jenkins credentails로 관리할 값이기도 하다.

ASSUME_ROLE_ARN은 디렉토리 마다 권한을 다르게 만들어, 승격 시키면서 써야한다. ( 이 부분은 아직 TODO 이다. )

environment {
    AWS_ACCESS_KEY_ID     = credentials('AWS_ACCESS_KEY_ID')
    AWS_SECRET_ACCESS_KEY = credentials('AWS_SECRET_ACCESS_KEY')
    ASSUME_ROLE_ARN       = credentials('ASSUME_ROLE_ARN')
    GITHUB_TOKEN          = credentials('GITHUB_TOKEN')
  }

3. Set PR to Pending

PR을 Pending으로 만들어 주는 Step이다.
Jenkins UI에서 Pending으로 만드는 체크를 했지만,
이걸 사용하다 보니 check가 2개 생기는 오류가 발생했다.
수동으로 Pending으로 만들어주자.

stage('Set PR to Pending') {
      steps {
        script {
          def commitSha = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
          githubNotify(
            account: 'account',
            repo: 'repo',
            context: 'Terraform/CI',
            description: 'Terraform Plan Queued',
            status: 'PENDING',
            sha: commitSha,
            credentialsId: 'Github-Common'
          )
        }
      }
    }

4. Find Terraform Plan Directory

이 부분이 중요하다.

나의 디렉토리는 이런 식으로 구성되어 있다.

Project/
├─ .idea/
├─ scripts/
│ └─ jenkinsFile
├─ terraform/
│ ├─ ec2/
│ │ ├─ _module/
│ │ ├─ app_d_apnortheast2/
│ │ ├─ app_p_apnortheast2/
│ │ ├─ app_s_apnortheast2/
│ ├─ rds/
│ │ ├─ app_p_apnortheast2/
│ │ └─ app_s_apnortheast2/
│ ├─ security_group/
│ └─ vpc/
├─ .gitignore
└─ README.md

-> 즉 terraform plan을 해야 하는 디렉토리가 다르다.
-> 수정된 사항의 위치를 파악해서 그 곳에서 plan을 해야 한다.

stage('Detect Changed Terraform Directories') {
        steps {
            script {
            
                // 현재 브랜치에서 바뀐 파일 추출
                def diffFiles = sh(
                    script: '''git diff --name-only origin/main...HEAD | grep -E '\\.tf$|\\.tfvars$' || true''',
                    returnStdout: true
                ).trim().split('\n').findAll { it?.trim() }
                
				// 바뀐 파일이 없다면, SUCCESS로 종료.
                if (diffFiles.isEmpty()) {
                    echo "No changed .tf or .tfvars files detected."
                    postCommentToGitHubPR('.', 'No Terraform changes detected.')
                    currentBuild.result = 'SUCCESS'
                    return
                }
                
                // 각 디렉토리마다 provider.tf는 무조건 존재한다. -> 여기서 Plan을 해야 한다.
                def tfDirs = diffFiles
                    .collect {
                        def path = it.contains('/') ? it.substring(0, it.lastIndexOf('/')) : '.'
                        return path
                    }
                    .unique()
                    .findAll { dir ->
                        fileExists("${dir}/provider.tf")
                    }

                if (tfDirs.isEmpty()) {
                    echo "No relevant Terraform directories found."
                    postCommentToGitHubPR('.', 'No Terraform changes detected.')
                    currentBuild.result = 'SUCCESS'
                    return
                }

                env.CHANGED_DIRS = tfDirs.join(',')
                echo "Detected changed Terraform directories: ${env.CHANGED_DIRS}"
            }
        }
    }

5. Terraform Plan

이제 실제 디렉토리로 이동해서 Terraform Plan을 수행한다.
그러면서, GitHub에는 올릴 수 없는 값들을 Jenkins Credential로 관리해서 tfvars에 채워준다.

변수 관리 방식도 바꿔야 하지만, 일단은 이렇게 진행했다.

stage('Terraform Plan for Each Changed Directory') {
      when {
        allOf {
          expression { return env.CHANGED_DIRS }
          expression { return params.GITHUB_PR_NUMBER != null }
        }
      }
      steps {
        script {
          def secrets = [
            'assume_role_arn',
          ]

          def creds = secrets.collect {
            string(credentialsId: "TF_VAR_${it}", variable: "TF_VAR_${it}")
          }

          def dirs = env.CHANGED_DIRS.tokenize(',')
          for (dir in dirs) {
            echo "Running plan for ${dir}"
            withCredentials(creds) {
              def sedScript = secrets.collect {
                "sed -i 's|\\\${${it}}|'\"\$TF_VAR_${it}\"'|' terraform.tfvars || true"
              }.join('\n')

              sh """
                cd ${dir}
                ${sedScript}
                terraform init -input=false
                terraform validate
                terraform plan -input=false -no-color > plan_output.txt
              """

              def output = readFile("${dir}/plan_output.txt")
              def escaped = output.take(60000).replaceAll(/[`\\]/, '\\\\$0')
              postCommentToGitHubPR(dir, escaped)
            }
          }
        }
      }
    }

postCommentToGitHubPR는 github PR에 댓글을 다는 함수이다.
-> comment의 위아래 \는 제거해야 한다.

def postCommentToGitHubPR(String dir, String comment) {
  if (!params.GITHUB_PR_NUMBER?.trim()) return

  def header = (dir == '.' || dir.trim() == '')
    ? "### ✅ Terraform File No Changed"
    : "### ✅ Terraform Plan Result for `${dir}`"
    
  def body = """${header}
\`\`\`
${comment}
\`\`\`
"""

  writeFile file: 'payload.json', text: groovy.json.JsonOutput.toJson([body: body])

  withCredentials([string(credentialsId: 'GITHUB_TOKEN', variable: 'GITHUB_TOKEN')]) {
    sh """
      curl -s -X POST \
        -H "Authorization: token $GITHUB_TOKEN" \
        -H "Content-Type: application/json" \
        --data @payload.json \
        https://api.github.com/repos/${params.GITHUB_PR_SOURCE_REPO_OWNER}/Terraform/issues/${params.GITHUB_PR_NUMBER}/comments
    """
  }
}

6. PR 상태 변경

이제 모든 Step이 끝났으므로, PR의 상태를 SUCCESS or FAILE로 변경해준다.

post {
    success {
        script {
            def commitSha = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()

            githubNotify(
                account: 'account',
                repo: 'Terraform',
                context: 'Terraform/CI',
                description: 'Terraform Plan SUCCESS',
                status: 'SUCCESS',
                targetUrl: "${env.BUILD_URL}",
                sha: commitSha,
                credentialsId: 'Github-Common'
            )
        }
    }
    failure {
        script {
            def commitSha = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()

            githubNotify(
                account: 'account',
                repo: 'Terraform',
                context: 'Terraform/CI',
                description: 'Terraform Plan FAILURE',
                status: 'FAILURE',
                targetUrl: "${env.BUILD_URL}",
                sha: commitSha,
                credentialsId: 'Github-Common'
            )
        }
    }
  }

전체 Jenkins File

pipeline {
  agent any

  environment {
    AWS_ACCESS_KEY_ID     = credentials('AWS_ACCESS_KEY_ID')
    AWS_SECRET_ACCESS_KEY = credentials('AWS_SECRET_ACCESS_KEY')
    ASSUME_ROLE_ARN       = credentials('ASSUME_ROLE_ARN')
    GITHUB_TOKEN          = credentials('GITHUB_TOKEN')
  }

  stages {
    
    stage('Set PR to Pending') {
      steps {
        script {
          def commitSha = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()
          githubNotify(
            account: 'account',
            repo: 'Terraform',
            context: 'Terraform/CI',
            description: 'Terraform Plan Queued',
            status: 'PENDING',
            sha: commitSha,
            credentialsId: 'Github-Common'
          )
        }
      }
    }
    
    stage('Detect Changed Terraform Directories') {
        steps {
            script {
                def diffFiles = sh(
                    script: '''git diff --name-only origin/main...HEAD | grep -E '\\.tf$|\\.tfvars$' || true''',
                    returnStdout: true
                ).trim().split('\n').findAll { it?.trim() }

                if (diffFiles.isEmpty()) {
                    echo "No changed .tf or .tfvars files detected."
                    postCommentToGitHubPR('.', 'No Terraform changes detected.')
                    currentBuild.result = 'SUCCESS'
                    return
                }

                def tfDirs = diffFiles
                    .collect {
                        def path = it.contains('/') ? it.substring(0, it.lastIndexOf('/')) : '.'
                        return path
                    }
                    .unique()
                    .findAll { dir ->
                        fileExists("${dir}/provider.tf")
                    }

                if (tfDirs.isEmpty()) {
                    echo "No relevant Terraform directories found."
                    postCommentToGitHubPR('.', 'No Terraform changes detected.')
                    currentBuild.result = 'SUCCESS'
                    return
                }

                env.CHANGED_DIRS = tfDirs.join(',')
                echo "Detected changed Terraform directories: ${env.CHANGED_DIRS}"
            }
        }
    }

    stage('Terraform Plan for Each Changed Directory') {
      when {
        allOf {
          expression { return env.CHANGED_DIRS }
          expression { return params.GITHUB_PR_NUMBER != null }
        }
      }
      steps {
        script {
          def secrets = [
            'assume_role_arn',
          ]

          def creds = secrets.collect {
            string(credentialsId: "TF_VAR_${it}", variable: "TF_VAR_${it}")
          }

          def dirs = env.CHANGED_DIRS.tokenize(',')
          for (dir in dirs) {
            echo "Running plan for ${dir}"
            withCredentials(creds) {
              def sedScript = secrets.collect {
                "sed -i 's|\\\${${it}}|'\"\$TF_VAR_${it}\"'|' terraform.tfvars || true"
              }.join('\n')

              sh """
                cd ${dir}
                ${sedScript}
                terraform init -input=false
                terraform validate
                terraform plan -input=false -no-color > plan_output.txt
              """

              def output = readFile("${dir}/plan_output.txt")
              def escaped = output.take(60000).replaceAll(/[`\\]/, '\\\\$0')
              postCommentToGitHubPR(dir, escaped)
            }
          }
        }
      }
    }
  }

  post {
    success {
        script {
            def commitSha = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()

            githubNotify(
                account: 'account',
                repo: 'Terraform',
                context: 'Terraform/CI',
                description: 'Terraform Plan SUCCESS',
                status: 'SUCCESS',
                targetUrl: "${env.BUILD_URL}",
                sha: commitSha,
                credentialsId: 'Github-Common'
            )
        }
    }
    failure {
        script {
            def commitSha = sh(script: 'git rev-parse HEAD', returnStdout: true).trim()

            githubNotify(
                account: 'account',
                repo: 'Terraform',
                context: 'Terraform/CI',
                description: 'Terraform Plan FAILURE',
                status: 'FAILURE',
                targetUrl: "${env.BUILD_URL}",
                sha: commitSha,
                credentialsId: 'Github-Common'
            )
        }
    }
  }
}


def postCommentToGitHubPR(String dir, String comment) {
  if (!params.GITHUB_PR_NUMBER?.trim()) return

  def header = (dir == '.' || dir.trim() == '')
    ? "### ✅ Terraform File No Changed"
    : "### ✅ Terraform Plan Result for `${dir}`"
    
  def body = """${header}
\`\`\`
${comment}
\`\`\`
"""

  writeFile file: 'payload.json', text: groovy.json.JsonOutput.toJson([body: body])

  withCredentials([string(credentialsId: 'GITHUB_TOKEN', variable: 'GITHUB_TOKEN')]) {
    sh """
      curl -s -X POST \
        -H "Authorization: token $GITHUB_TOKEN" \
        -H "Content-Type: application/json" \
        --data @payload.json \
        https://api.github.com/repos/${params.GITHUB_PR_SOURCE_REPO_OWNER}/Terraform/issues/${params.GITHUB_PR_NUMBER}/comments
    """
  }
}

그럼 이제 PR을 올릴 때 마다 댓글로 다음과 같이 작성된다.

0개의 댓글