0

Pre-flight Checks Bài 2: Đọc & Validate cấu hình .deploy.yaml

Trong một công cụ deploy dùng chung cho cả team, bạn tuyệt đối không được hard-code (gán cứng) địa chỉ IP server, nhánh Git hay đường dẫn thư mục vào trong code Go. Thay vào đó, mỗi dự án (Backend, Frontend, Worker...) sẽ đặt một file .deploy.yaml ở thư mục gốc để tự khai báo "luật chơi" của riêng mình.

Ở bài học này, chúng ta sẽ giải quyết 3 bài toán cốt lõi của Pre-flight:

  1. Thiết kế chuẩn file .deploy.yaml hỗ trợ đa môi trường (staging, production).

  2. Dùng gopkg.in/yaml.v3 kết hợp tính năng KnownFields(true) để bắt lỗi ngay lập tức nếu anh em trong team gõ sai chính tả tên trường (ví dụ: gõ nhầm branch thành branhc).

  3. Xây dựng Check ConfigCheck để kiểm tra tính hợp lệ của toàn bộ thông số trước khi cho phép kết nối Git hay SSH ở các bài sau.

1. Thiết kế chuẩn file .deploy.yaml

Hãy tạo nội dung cho file .deploy.yaml ở thư mục gốc dự án godeploy. Cấu trúc này được chia làm 2 phần: cấu hình thông tin chung của dự án (project, preflight) và cấu hình riêng cho từng môi trường (environments):

YAML

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

# Cấu hình các thông số kiểm tra trước giờ G
preflight:
  min_disk_free_gb: 5          # Dung lượng ổ cứng tối thiểu phải còn trống trên Server (GB)
  require_clean_git: true      # Bắt buộc không có file sửa dở (uncommitted) dưới local
  local_checks:                # Các lệnh kiểm tra chất lượng code trước khi đẩy lên
    - "go vet ./..."
    - "go test ./..."

# Khai báo các môi trường đích
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"

  production:
    branch: "main"
    deploy_path: "/var/www/order-service"
    user: "deploy"
    port: 22
    hosts:
      - "10.0.2.101"
      - "10.0.2.102"
    healthcheck_url: "https://orders.example.com/health"

2. Cài đặt thư viện yaml.v3 và cập nhật cấu trúc thư mục

Tại thư mục godeploy, chạy lệnh cài đặt gói yaml.v3 chính chuẩn của cộng đồng Go:

Bash

go get gopkg.in/yaml.v3

Chúng ta sẽ thêm package internal/config để quản lý việc đọc file và thêm config_check.go vào internal/preflight:

Plaintext

godeploy/
├── .deploy.yaml                # File cấu hình mẫu vừa tạo
├── cmd/
│   └── main.go
├── internal/
│   ├── config/
│   │   └── config.go           # [MỚI] Struct ánh xạ YAML & hàm Validate
│   └── preflight/
│       ├── check.go
│       ├── runner.go
│       └── config_check.go     # [MỚI] Thay thế dummy_check.go ở Bài 1
└── go.mod

(Bạn có thể xóa file internal/preflight/dummy_check.go của Bài 1 đi vì config_check.go sẽ đảm nhiệm luôn việc kiểm tra sự tồn tại của file).

3. Xây dựng bộ Parser & Validator (internal/config/config.go)

Điểm ăn tiền nhất khi viết CLI cho team là báo lỗi tập trung: Thay vì báo lỗi từng dòng rồi bắt dev chạy lại lệnh nhiều lần để sửa từng chỗ, hàm Validate() của chúng ta sẽ gom tất cả các lỗi cấu hình trong 1 lần quét và hiển thị ra cùng lúc.

Tạo thư mục internal/config và file internal/config/config.go:

Go

package config

import (
	"bytes"
	"fmt"
	"os"
	"path/filepath"
	"strings"

	"gopkg.in/yaml.v3"
)

type Config struct {
	Project      ProjectConfig        `yaml:"project"`
	Preflight    PreflightConfig      `yaml:"preflight"`
	Environments map[string]EnvConfig `yaml:"environments"`
}

type ProjectConfig struct {
	Name       string `yaml:"name"`
	Repository string `yaml:"repository"`
}

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

type EnvConfig struct {
	Branch         string   `yaml:"branch"`
	DeployPath     string   `yaml:"deploy_path"`
	User           string   `yaml:"user"`
	Port           int      `yaml:"port"`
	Hosts          []string `yaml:"hosts"`
	HealthcheckURL string   `yaml:"healthcheck_url"`
}

// Load đọc file YAML với chế độ Strict (bắt lỗi cả khi gõ sai tên trường)
func Load(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}

	var cfg Config
	decoder := yaml.NewDecoder(bytes.NewReader(data))
	// Bật KnownFields(true) để chặn việc dev gõ sai chính tả key trong YAML
	decoder.KnownFields(true)

	if err := decoder.Decode(&cfg); err != nil {
		return nil, fmt.Errorf("cú pháp YAML không hợp lệ: %w", err)
	}

	// Gán giá trị mặc định (Default values) nếu người dùng bỏ trống
	if cfg.Preflight.MinDiskFreeGB == 0 {
		cfg.Preflight.MinDiskFreeGB = 2 // Mặc định yêu cầu trống tối thiểu 2GB
	}
	for envName, env := range cfg.Environments {
		if env.Port == 0 {
			env.Port = 22
			cfg.Environments[envName] = env
		}
	}

	return &cfg, nil
}

// Validate kiểm tra toàn bộ các thông số bắt buộc cho môi trường đích (targetEnv)
func (c *Config) Validate(targetEnv string) []string {
	var errs []string

	// 1. Kiểm tra thông tin Project
	if strings.TrimSpace(c.Project.Name) == "" {
		errs = append(errs, "Thiếu tên dự án (`project.name`)")
	}
	if strings.TrimSpace(c.Project.Repository) == "" {
		errs = append(errs, "Thiếu địa chỉ Git (`project.repository`)")
	}

	// 2. Kiểm tra thông số Preflight
	if c.Preflight.MinDiskFreeGB < 0 {
		errs = append(errs, "`preflight.min_disk_free_gb` không được là số âm")
	}

	// 3. Kiểm tra môi trường đích (ví dụ: staging, production)
	env, exists := c.Environments[targetEnv]
	if !exists {
		available := make([]string, 0, len(c.Environments))
		for k := range c.Environments {
			available = append(available, k)
		}
		errs = append(errs, fmt.Sprintf("Môi trường '%s' chưa được định nghĩa (các môi trường hiện có: %s)", targetEnv, strings.Join(available, ", ")))
		return errs // Không cần kiểm tra tiếp bên trong env nếu env không tồn tại
	}

	// 4. Kiểm tra chi tiết bên trong môi trường đích
	prefix := fmt.Sprintf("environments.%s", targetEnv)

	if strings.TrimSpace(env.Branch) == "" {
		errs = append(errs, fmt.Sprintf("Thiếu nhánh Git triển khai (`%s.branch`)", prefix))
	}
	if strings.TrimSpace(env.User) == "" {
		errs = append(errs, fmt.Sprintf("Thiếu SSH user (`%s.user`)", prefix))
	}
	if len(env.Hosts) == 0 {
		errs = append(errs, fmt.Sprintf("Danh sách `%s.hosts` phải có ít nhất 1 địa chỉ IP/Domain", prefix))
	}
	if !filepath.IsAbs(env.DeployPath) {
		errs = append(errs, fmt.Sprintf("Đường dẫn `%s.deploy_path` phải là đường dẫn tuyệt đối bắt đầu bằng dấu '/' (hiện tại: '%s')", prefix, env.DeployPath))
	}
	if env.Port < 1 || env.Port > 65535 {
		errs = append(errs, fmt.Sprintf("Cổng SSH `%s.port` không hợp lệ (%d)", prefix, env.Port))
	}

	return errs
}

4. Tích hợp vào Pre-flight Runner (internal/preflight/config_check.go)

Bây giờ chúng ta viết module ConfigCheck thực thi interface Check đã định nghĩa ở Bài 1. Điểm đặc biệt là sau khi đọc xong, ConfigCheck sẽ lưu lại đối tượng LoadedConfig để các bài kiểm tra tiếp theo (Git, SSH, Disk) lấy ra sử dụng mà không cần đọc lại file từ ổ cứng.

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

Go

package preflight

import (
	"context"
	"errors"
	"fmt"
	"godeploy/internal/config"
	"os"
	"strings"
)

type ConfigCheck struct {
	ConfigPath   string
	TargetEnv    string
	LoadedConfig *config.Config // Lưu lại kết quả sau khi parse thành công cho các bước sau dùng
}

func (c *ConfigCheck) Name() string {
	return fmt.Sprintf("Đọc & Validate cấu hình (%s)", c.TargetEnv)
}

func (c *ConfigCheck) Run(ctx context.Context) Result {
	// Bước 1: Đọc và parse cú pháp YAML
	cfg, err := config.Load(c.ConfigPath)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return Result{
				Status:  StatusFail,
				Message: fmt.Sprintf("Không tìm thấy file '%s' tại thư mục hiện tại.", c.ConfigPath),
				FixHint: fmt.Sprintf("Tạo file '%s' ở thư mục gốc của dự án theo mẫu chuẩn của team.", c.ConfigPath),
			}
		}
		return Result{
			Status:  StatusFail,
			Message: err.Error(),
			FixHint: "Kiểm tra lại lề (indentation) hoặc tên các trường bị gõ sai chính tả trong file YAML.",
		}
	}

	// Bước 2: Validate các trường bắt buộc theo môi trường đích
	validationErrs := cfg.Validate(c.TargetEnv)
	if len(validationErrs) > 0 {
		return Result{
			Status:  StatusFail,
			Message: "Cấu hình không hợp lệ:\n         • " + strings.Join(validationErrs, "\n         • "),
			FixHint: fmt.Sprintf("Bổ sung đầy đủ các trường còn thiếu trong mục `environments.%s` của file %s.", c.TargetEnv, c.ConfigPath),
		}
	}

	// Bước 3: Lưu lại cấu hình đã chuẩn hóa
	c.LoadedConfig = cfg
	envCfg := cfg.Environments[c.TargetEnv]

	// Nếu không cấu hình Healthcheck URL thì đưa ra cảnh báo nhẹ (StatusWarn) nhưng vẫn cho đi tiếp
	if strings.TrimSpace(envCfg.HealthcheckURL) == "" {
		return Result{
			Status:  StatusWarn,
			Message: fmt.Sprintf("Dự án '%s' (nhánh '%s' -> %d host), nhưng chưa cấu hình `healthcheck_url`.", cfg.Project.Name, envCfg.Branch, len(envCfg.Hosts)),
		}
	}

	return Result{
		Status:  StatusPass,
		Message: fmt.Sprintf("Dự án '%s' | Nhánh: '%s' | Máy chủ đích: %d host(s)", cfg.Project.Name, envCfg.Branch, len(envCfg.Hosts)),
	}
}

5. Cập nhật cmd/main.go để nhận tham số Môi trường từ CLI

Chúng ta nâng cấp cmd/main.go để anh em trong team có thể gõ go run cmd/main.go staging hoặc go run cmd/main.go production.

Cập nhật file cmd/main.go:

Go

package main

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

func main() {
	// Lấy tên môi trường từ tham số dòng lệnh (Mặc định là "staging" nếu không truyền)
	targetEnv := "staging"
	if len(os.Args) > 1 {
		targetEnv = os.Args[1]
	}

	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	// Khởi tạo bước kiểm tra Config
	configCheck := &preflight.ConfigCheck{
		ConfigPath: ".deploy.yaml",
		TargetEnv:  targetEnv,
	}

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

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

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

	// Minh họa cách lấy dữ liệu từ configCheck cho các bước tiếp theo
	cfg := configCheck.LoadedConfig
	fmt.Printf("📦 Dữ liệu Config đã nạp vào bộ nhớ: Sẵn sàng deploy '%s' tới %v\n\n",
		cfg.Project.Name, cfg.Environments[targetEnv].Hosts)
}

6. Chạy thử nghiệm các tình huống thực tế

Tình huống 1: Cấu hình chuẩn chỉnh (staging hoặc production)

Bash

go run cmd/main.go production

Kết quả:

Plaintext

🚀 BẮT ĐẦU PRE-FLIGHT CHECKS (KIỂM TRA TRƯỚC GIỜ G)...
------------------------------------------------------------
[1/1] Đang kiểm tra: Đọc & Validate cấu hình (production)... [PASS] (1ms)
      └─ Dự án 'order-service' | Nhánh: 'main' | Máy chủ đích: 2 host(s)
------------------------------------------------------------
✅ TẤT CẢ KIỂM TRA HOÀN TẤT (1ms) - SẴN SÀNG DEPLOY!

📦 Dữ liệu Config đã nạp vào bộ nhớ: Sẵn sàng deploy 'order-service' tới [10.0.2.101 10.0.2.102]

Tình huống 2: Giả lập Dev cấu hình sai (Sửa thử .deploy.yaml) Hãy thử xóa trường branch của staging và sửa deploy_path thành var/www/app (thiếu dấu / ở đầu), sau đó chạy go run cmd/main.go staging:

Plaintext

🚀 BẮT ĐẦU PRE-FLIGHT CHECKS (KIỂM TRA TRƯỚC GIỜ G)...
------------------------------------------------------------
[1/1] Đang kiểm tra: Đọc & Validate cấu hình (staging)... [FAIL] (1ms)
      ├─ ❌ Lỗi: Cấu hình không hợp lệ:
         • Thiếu nhánh Git triển khai (`environments.staging.branch`)
         • Đường dẫn `environments.staging.deploy_path` phải là đường dẫn tuyệt đối bắt đầu bằng dấu '/' (hiện tại: 'var/www/app')
      └─ 💡 Cách xử lý: Bổ sung đầy đủ các trường còn thiếu trong mục `environments.staging` của file .deploy.yaml.
------------------------------------------------------------
⛔ QUY TRÌNH DEPLOY BỊ CHẶN ĐỨNG DO LỖI PRE-FLIGHT!
exit status 1

Tình huống 3: Gõ sai tên môi trường (go run cmd/main.go dev)

Plaintext

[1/1] Đang kiểm tra: Đọc & Validate cấu hình (dev)... [FAIL] (1ms)
      ├─ ❌ Lỗi: Cấu hình không hợp lệ:
         • Môi trường 'dev' chưa được định nghĩa (các môi trường hiện có: staging, producti

All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí