0

Pre-flight Checks Bài 4: Quality Gate & Local Dependencies (Linter, Unit Test & SonarQube API)

Dù Git đã sạch sẽ và đồng bộ, vẫn còn một rủi ro lớn: Code hợp lệ về mặt quản lý phiên bản nhưng lại chứa lỗi cú pháp, vi phạm chuẩn coding convention, hoặc chứa lỗ hổng bảo mật chưa qua kiểm duyệt.

Trong Bài 4 này, chúng ta sẽ xây dựng 2 chốt chặn chất lượng (Quality Gates):

  1. Local Checks Runner: Đọc danh sách các câu lệnh khai báo trong preflight.local_checks của file .deploy.yaml (ví dụ: ./vendor/bin/pint --test, php artisan test, npm run lint, go vet ./...) và thực thi tuần tự. Nếu bất kỳ lệnh nào trả về mã lỗi (exit code != 0), lập tức hủy deploy và in ra đúng đoạn log lỗi đó.

  2. SonarQube Quality Gate Check: Gọi trực tiếp REST API của SonarQube Server (/api/qualitygates/project_status) để kiểm tra xem nhánh hiện tại đã vượt qua bài quét chất lượng code (OK) hay đang bị đánh rớt (ERROR).

1. Nâng cấp cấu trúc .deploy.yaml và internal/config/config.go

Để hỗ trợ SonarQube một cách linh hoạt (dự án nào cấu hình thì mới kiểm tra, dự án nào không dùng thì tự động bỏ qua), chúng ta bổ sung mục sonarqube vào dưới preflight. Lưu ý: Tuyệt đối không lưu trực tiếp Token bảo mật vào file YAML, mà chỉ khai báo tên biến môi trường (ví dụ: SONAR_TOKEN) để tool tự đọc từ máy local hoặc CI/CD runner.

Cập nhật file .deploy.yaml:

YAML

project:
  name: "order-service"
  repository: "git@github.com:my-team/order-service.git"

preflight:
  min_disk_free_gb: 5
  require_clean_git: true
  local_checks:
    - "go vet ./..."
    - "go test ./..."
  sonarqube:
    enabled: true
    host_url: "https://sonar.internal.company.com"
    project_key: "order-service-backend"
    token_env: "SONAR_TOKEN" # Tên biến môi trường chứa Token

environments:
  staging:
    branch: "develop"
    deploy_path: "/var/www/order-service-staging"
    user: "deploy"
    port: 22
    hosts:
      - "10.0.1.15"
    healthcheck_url: "https://staging-orders.internal/health"
# ... (giữ nguyên phần production)

Cập nhật struct trong internal/config/config.go:

Mở file internal/config/config.go, bổ sung struct SonarConfig vào trong PreflightConfig và thêm kiểm tra trong hàm Validate:

Go

type PreflightConfig struct {
	MinDiskFreeGB   int         `yaml:"min_disk_free_gb"`
	RequireCleanGit bool        `yaml:"require_clean_git"`
	LocalChecks     []string    `yaml:"local_checks"`
	SonarQube       SonarConfig `yaml:"sonarqube"`
}

type SonarConfig struct {
	Enabled    bool   `yaml:"enabled"`
	HostURL    string `yaml:"host_url"`
	ProjectKey string `yaml:"project_key"`
	TokenEnv   string `yaml:"token_env"`
}

Và thêm đoạn kiểm tra nhỏ này vào mục // 2. Kiểm tra thông số Preflight bên trong hàm Validate(targetEnv string) của internal/config/config.go:

Go

	if c.Preflight.SonarQube.Enabled {
		if strings.TrimSpace(c.Preflight.SonarQube.HostURL) == "" {
			errs = append(errs, "Thiếu địa chỉ SonarQube (`preflight.sonarqube.host_url`) khi đang bật `enabled: true`")
		}
		if strings.TrimSpace(c.Preflight.SonarQube.ProjectKey) == "" {
			errs = append(errs, "Thiếu mã dự án SonarQube (`preflight.sonarqube.project_key`)")
		}
		if strings.TrimSpace(c.Preflight.SonarQube.TokenEnv) == "" {
			c.Preflight.SonarQube.TokenEnv = "SONAR_TOKEN" // Mặc định tìm biến SONAR_TOKEN
		}
	}

2. Xây dựng Module Chạy Linter & Unit Test (internal/preflight/local_cmd_check.go)

Để các câu lệnh trong local_checks có thể sử dụng đầy đủ tính năng của Shell (như nối lệnh &&, chuyển hướng luồng |, hoặc biến môi trường), chúng ta sẽ gọi qua sh -c (trên Linux/macOS) hoặc cmd /C (trên Windows).

Tạo file internal/preflight/local_cmd_check.go:

Go

package preflight

import (
	"bytes"
	"context"
	"fmt"
	"os/exec"
	"runtime"
	"strings"
)

type LocalCmdCheck struct {
	ConfigStep *ConfigCheck
}

func (c *LocalCmdCheck) Name() string {
	return "Local Quality Checks (Linter & Unit Tests)"
}

func (c *LocalCmdCheck) Run(ctx context.Context) Result {
	if c.ConfigStep == nil || c.ConfigStep.LoadedConfig == nil {
		return Result{
			Status:  StatusFail,
			Message: "Chưa có dữ liệu cấu hình từ bước trước.",
		}
	}

	commands := c.ConfigStep.LoadedConfig.Preflight.LocalChecks
	if len(commands) == 0 {
		return Result{
			Status:  StatusWarn,
			Message: "Không khai báo `preflight.local_checks` trong file cấu hình (Bỏ qua).",
		}
	}

	for i, rawCmd := range commands {
		rawCmd = strings.TrimSpace(rawCmd)
		if rawCmd == "" {
			continue
		}

		// Tự động chọn trình thông dịch Shell phù hợp với Hệ điều hành
		var cmd *exec.Cmd
		if runtime.GOOS == "windows" {
			cmd = exec.CommandContext(ctx, "cmd", "/C", rawCmd)
		} else {
			cmd = exec.CommandContext(ctx, "sh", "-c", rawCmd)
		}

		var combinedOut bytes.Buffer
		cmd.Stdout = &combinedOut
		cmd.Stderr = &combinedOut

		if err := cmd.Run(); err != nil {
			output := strings.TrimSpace(combinedOut.String())
			// Cắt gọn output nếu log lỗi quá dài (giữ lại 500 ký tự cuối cùng)
			if len(output) > 500 {
				output = "...\n" + output[len(output)-500:]
			}

			return Result{
				Status: StatusFail,
				Message: fmt.Sprintf("Lệnh [%d/%d] `%s` thất bại!\n         Chi tiết lỗi:\n         %s",
					i+1, len(commands), rawCmd, strings.ReplaceAll(output, "\n", "\n         ")),
				FixHint: fmt.Sprintf("Chạy trực tiếp lệnh `%s` trên terminal để sửa hết các lỗi trước khi deploy.", rawCmd),
			}
		}
	}

	return Result{
		Status:  StatusPass,
		Message: fmt.Sprintf("Đã vượt qua %d bài kiểm tra chất lượng dưới local (%s).", len(commands), strings.Join(commands, ", ")),
	}
}

3. Xây dựng Module Kiểm tra SonarQube API (internal/preflight/sonar_check.go)

SonarQube cung cấp sẵn API GET /api/qualitygates/project_status?projectKey=<key>&branch=<branch>.

  • Cơ chế xác thực của SonarQube: Truyền User Token vào phần username của HTTP Basic Auth và để trống phần password (req.SetBasicAuth(token, "")).

  • Cấu trúc JSON trả về từ SonarQube:

    JSON

    {
      "projectStatus": {
        "status": "ERROR",
        "conditions": [
          {
            "status": "ERROR",
            "metricKey": "new_coverage",
            "comparator": "LT",
            "errorThreshold": "80",
            "actualValue": "45.5"
          }
        ]
      }
    }
    
    

Chúng ta sẽ parse chính xác các conditions bị lỗi để báo cho Dev biết rõ vì sao SonarQube chặn (ví dụ: thiếu độ phủ code coverage, hay phát sinh bug mới).

Tạo file internal/preflight/sonar_check.go:

Go

package preflight

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"os"
	"strings"
	"time"
)

type SonarCheck struct {
	ConfigStep *ConfigCheck
}

// Các struct dùng để hứng JSON trả về từ SonarQube API
type sonarResponse struct {
	ProjectStatus struct {
		Status     string           `json:"status"` // "OK", "WARN", "ERROR", "NONE"
		Conditions []sonarCondition `json:"conditions"`
	} `json:"projectStatus"`
}

type sonarCondition struct {
	Status         string `json:"status"`
	MetricKey      string `json:"metricKey"`
	ActualValue    string `json:"actualValue"`
	ErrorThreshold string `json:"errorThreshold"`
}

func (c *SonarCheck) Name() string {
	return "SonarQube Quality Gate"
}

func (c *SonarCheck) Run(ctx context.Context) Result {
	if c.ConfigStep == nil || c.ConfigStep.LoadedConfig == nil {
		return Result{
			Status:  StatusFail,
			Message: "Chưa có dữ liệu cấu hình từ bước trước.",
		}
	}

	cfg := c.ConfigStep.LoadedConfig
	sonarCfg := cfg.Preflight.SonarQube
	envCfg := cfg.Environments[c.ConfigStep.TargetEnv]

	// 1. Nếu dự án không bật SonarQube thì cho qua nhanh
	if !sonarCfg.Enabled {
		return Result{
			Status:  StatusPass,
			Message: "Kiểm tra SonarQube đang tắt trong cấu hình (Bỏ qua).",
		}
	}

	// 2. Lấy Token bảo mật từ biến môi trường của máy
	token := strings.TrimSpace(os.Getenv(sonarCfg.TokenEnv))
	if token == "" {
		return Result{
			Status:  StatusFail,
			Message: fmt.Sprintf("Không tìm thấy biến môi trường `%s` chứa mã xác thực SonarQube.", sonarCfg.TokenEnv),
			FixHint: fmt.Sprintf("Chạy lệnh `export %s=\"squ_xxxx...\"` trên terminal trước khi deploy.", sonarCfg.TokenEnv),
		}
	}

	// 3. Khởi tạo HTTP Request tới SonarQube API
	baseURL := strings.TrimRight(sonarCfg.HostURL, "/")
	apiURL := fmt.Sprintf("%s/api/qualitygates/project_status?projectKey=%s&branch=%s",
		baseURL,
		url.QueryEscape(sonarCfg.ProjectKey),
		url.QueryEscape(envCfg.Branch),
	)

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
	if err != nil {
		return Result{
			Status:  StatusFail,
			Message: fmt.Sprintf("Không thể tạo request tới SonarQube: %v", err),
		}
	}

	// Chuẩn xác thực của SonarQube: Token nằm ở phần Username của Basic Auth, Password để rỗng
	req.SetBasicAuth(token, "")

	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return Result{
			Status:  StatusFail,
			Message: fmt.Sprintf("Không thể kết nối tới máy chủ SonarQube (%s): %v", baseURL, err),
			FixHint: "Kiểm tra lại kết nối VPN nội bộ hoặc địa chỉ `host_url` trong .deploy.yaml.",
		}
	}
	defer resp.Body.Close()

	if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
		return Result{
			Status:  StatusFail,
			Message: fmt.Sprintf("SonarQube từ chối truy cập (HTTP %d) - Token không hợp lệ hoặc không có quyền xem dự án.", resp.StatusCode),
			FixHint: fmt.Sprintf("Kiểm tra lại giá trị của biến môi trường `%s`.", sonarCfg.TokenEnv),
		}
	}

	if resp.StatusCode != http.StatusOK {
		return Result{
			Status:  StatusFail,
			Message: fmt.Sprintf("SonarQube API trả về mã lỗi bất thường: HTTP %d", resp.StatusCode),
			FixHint: fmt.Sprintf("Kiểm tra xem `project_key: %s` và nhánh `%s` đã được quét trên SonarQube chưa.", sonarCfg.ProjectKey, envCfg.Branch),
		}
	}

	// 4. Đọc và kiểm tra trạng thái Quality Gate
	var sonarRes sonarResponse
	if err := json.NewDecoder(resp.Body).Decode(&sonarRes); err != nil {
		return Result{
			Status:  StatusFail,
			Message: fmt.Sprintf("Không thể đọc dữ liệu JSON từ SonarQube: %v", err),
		}
	}

	gateStatus := sonarRes.ProjectStatus.Status
	switch gateStatus {
	case "OK":
		return Result{
			Status:  StatusPass,
			Message: fmt.Sprintf("Dự án '%s' (nhánh '%s') đạt chuẩn Quality Gate [OK].", sonarCfg.ProjectKey, envCfg.Branch),
		}
	case "WARN":
		return Result{
			Status:  StatusWarn,
			Message: fmt.Sprintf("Quality Gate của dự án '%s' đang ở mức Cảnh báo [WARN].", sonarCfg.ProjectKey),
		}
	case "ERROR":
		// Lọc ra các tiêu chí bị rớt để báo cáo cụ thể cho Dev
		var failedMetrics []string
		for _, cond := range sonarRes.ProjectStatus.Conditions {
			if cond.Status == "ERROR" {
				failedMetrics = append(failedMetrics, fmt.Sprintf("%s (Thực tế: %s, Ngưỡng yêu cầu: %s)",
					cond.MetricKey, cond.ActualValue, cond.ErrorThreshold))
			}
		}
		detail := strings.Join(failedMetrics, "; ")
		if detail == "" {
			detail = "Không đạt tiêu chuẩn chất lượng"
		}
		return Result{
			Status:  StatusFail,
			Message: fmt.Sprintf("SonarQube Quality Gate báo ĐỎ [ERROR]! Các chỉ số vi phạm: %s", detail),
			FixHint: fmt.Sprintf("Truy cập %s/dashboard?id=%s&branch=%s để xem chi tiết và fix bug/code smell.", baseURL, sonarCfg.ProjectKey, envCfg.Branch),
		}
	default:
		return Result{
			Status:  StatusWarn,
			Message: fmt.Sprintf("SonarQube trả về trạng thái '%s' (chưa có dữ liệu quét cho nhánh '%s').", gateStatus, envCfg.Branch),
		}
	}
}

4. Ghép nối vào Pipeline trong cmd/main.go

Mở file cmd/main.go và thêm localCmdCheck cùng sonarCheck vào danh sách checks:

Go

package main

import (
	"context"
	"fmt"
	"godeploy/internal/preflight"
	"os"
	"time"
)

func main() {
	targetEnv := "staging"
	if len(os.Args) > 1 {
		targetEnv = os.Args[1]
	}

	// Tăng timeout lên 2 phút vì bước chạy Unit Test hoặc Linter có thể tốn thời gian
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
	defer cancel()

	// Khởi tạo các bước kiểm tra theo đúng trình tự logic
	configCheck := &preflight.ConfigCheck{
		ConfigPath: ".deploy.yaml",
		TargetEnv:  targetEnv,
	}
	gitCheck := &preflight.GitCheck{
		ConfigStep: configCheck,
	}
	localCmdCheck := &preflight.LocalCmdCheck{
		ConfigStep: configCheck,
	}
	sonarCheck := &preflight.SonarCheck{
		ConfigStep: configCheck,
	}

	checks := []preflight.Check{
		configCheck,
		gitCheck,
		localCmdCheck,
		sonarCheck,
	}

	runner := preflight.NewRunner(true, checks...)

	if !runner.Execute(ctx) {
		os.Exit(1)
	}

	cfg := configCheck.LoadedConfig
	fmt.Printf("📦 Sẵn sàng deploy dự án '%s' (Commit: %s) tới %v\n\n",
		cfg.Project.Name, gitCheck.CommitHash, cfg.Environments[targetEnv].Hosts)
}

5. Thực hành kiểm thử (Test Cases)

(Lưu ý: Trước khi chạy, nhớ git add . && git commit -m "feat: add quality gate checks" để vượt qua bước Git Guard ở Bài 3).

Kịch bản 1: Bật SonarQube nhưng quên chưa export SONAR_TOKEN

Chạy lệnh:

Bash

go run cmd/main.go staging

Kết quả:

Plaintext

🚀 BẮT ĐẦU PRE-FLIGHT CHECKS (KIỂM TRA TRƯỚC GIỜ G)...
------------------------------------------------------------
[1/4] Đang kiểm tra: Đọc & Validate cấu hình (staging)...        [PASS] (1ms)
      └─ Dự án 'order-service' | Nhánh: 'develop' | Máy chủ đích: 1 host(s)
[2/4] Đang kiểm tra: Git Guard (Nhánh, Working Tree & Đồng bộ Remote)... [WARN] (22ms)
      └─ ⚠️  Nhánh 'develop' (@4c91b2a) hợp lệ, nhưng chưa thể kết nối tới 'origin/develop' để kiểm tra đồng bộ.
[3/4] Đang kiểm tra: Local Quality Checks (Linter & Unit Tests)... [PASS] (415ms)
      └─ Đã vượt qua 2 bài kiểm tra chất lượng dưới local (go vet ./..., go test ./...).
[4/4] Đang kiểm tra: SonarQube Quality Gate...                   [FAIL] (0ms)
      ├─ ❌ Lỗi: Không tìm thấy biến môi trường `SONAR_TOKEN` chứa mã xác thực SonarQube.
      └─ 💡 Cách xử lý: Chạy lệnh `export SONAR_TOKEN="squ_xxxx..."` trên terminal trước khi deploy.
------------------------------------------------------------
⛔ QUY TRÌNH DEPLOY BỊ CHẶN ĐỨNG DO LỖI PRE-FLIGHT!

Kịch bản 2: Giả lập Linter / Unit Test bị lỗi

Nếu bạn thử đổi lệnh trong .deploy.yaml thành một lệnh trả về lỗi (hoặc viết một bài test bị fail), Bước [3/4] sẽ lập tức bắt trọn output của trình biên dịch và dừng ngay mà không cần gọi tới SonarQube nữa.

(Để tiếp tục test mượt trên máy local khi chưa kết nối VPN tới SonarQube thật, bạn có thể đổi sonarqube.enabled: false trong .deploy.yaml).

Đến đây, toàn bộ các chốt chặn ở phía Client / Local & CI (Config, Git, Linter, Unit Test, SonarQube) đã hoàn tất 100%! Từ Bài 5, chúng ta sẽ bắt đầu vươn tay sang phía Remote Server thông qua giao thức SSH.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.