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:
-
Thiết kế chuẩn file
.deploy.yamlhỗ trợ đa môi trường (staging,production). -
Dùng
gopkg.in/yaml.v3kết hợp tính năngKnownFields(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ầmbranchthànhbranhc). -
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