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):
-
Local Checks Runner: Đọc danh sách các câu lệnh khai báo trong
preflight.local_checkscủ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 đó. -
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
usernamecủa HTTP Basic Auth và để trống phầnpassword(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