HCL Từ Trong Ra Ngoài: Block, Kiểu Dữ Liệu, Biểu Thức

K
Kai··8 min read

Hai bài trước ta đã viết HCL theo kiểu bắt chước: chép cú pháp từ ví dụ rồi đổi giá trị. Cách đó chạy được, nhưng tới khi cần viết biểu thức phức tạp hơn — một danh sách subnet sinh tự động, một tên resource ghép từ nhiều biến — thì phải hiểu ngôn ngữ chứ không thể đoán. Bài này mổ HCL từ gốc: một block gồm gì, có những kiểu dữ liệu nào, biểu thức hoạt động ra sao. Ta sẽ dùng terraform console để thử từng thứ ngay tại chỗ, không cần tạo resource.

Mục tiêu

Đọc được bất kỳ file .tf nào và biết chính xác mỗi dòng là gì: đâu là block, đâu là argument, giá trị thuộc kiểu nào, biểu thức sẽ cho ra cái gì. Nắm luôn block terraform{} khai báo được những gì ngoài required_providers.

Ba thành phần của cú pháp HCL

HCL (HashiCorp Configuration Language) được xây quanh hai khối cơ bản: argumentblock.

Một argument gán một giá trị cho một tên: region = "ap-southeast-1". Bên trái dấu = là một identifier, bên phải là một biểu thức. Tài liệu định nghĩa gọn: "một argument gán một giá trị cho một tên cụ thể".

Một block là vùng chứa nội dung khác. Nó gồm một type (kiểu block), không hoặc nhiều label (nhãn), và một body trong cặp ngoặc nhọn. Lấy lại resource bài trước và soi từng phần:

resource  "aws_s3_bucket"  "first"  {
   │           │              │      └── body (thân block)
   │           │              └───────── label 2: tên local
   │           └──────────────────────── label 1: kiểu resource
   └──────────────────────────────────── type: loại block

    bucket_prefix = "tf-series-bai2-"
    └── identifier ┘ └─ biểu thức ─┘
    └──────────── argument ────────────┘

    tags = {                # giá trị kiểu map
      Project = "terraform-series"
    }
}

Số lượng và ý nghĩa label tùy kiểu block. resource cần hai label (kiểu resource và tên local). provider cần một ("aws"). terraform không cần label nào. Đây là lý do bài 2 viết resource "aws_s3_bucket" "first" với hai chuỗi: chúng không phải tham số, mà là nhãn định danh block.

Identifier (tên argument, tên block, tên biến) gồm chữ, số, gạch dưới và gạch nối, và không được bắt đầu bằng số. Comment có ba kiểu: # cho một dòng (kiểu chuẩn, được khuyến nghị), // cũng một dòng (nhưng terraform fmt sẽ đổi thành #), và /* */ cho nhiều dòng.

terraform console: phòng thí nghiệm cho biểu thức

Trước khi nói về kiểu dữ liệu, làm quen với công cụ để thử chúng. terraform console mở một prompt tương tác, gõ biểu thức HCL vào là nó in kết quả. Đây là cách học nhanh nhất, vì không phải apply gì cả:

$ echo 'upper("hello")' | terraform console
"HELLO"
$ echo '5 + 3 * 2' | terraform console
11

Console tôn trọng thứ tự ưu tiên toán tử (3 * 2 trước, rồi + 5), nên 5 + 3 * 2 ra 11 chứ không phải 16. Phần dưới đây mọi kết quả đều lấy từ console thật.

Sáu kiểu giá trị

Terraform có sáu kiểu giá trị. Ba kiểu nguyên thủy:

string: chuỗi ký tự Unicode, đặt trong ngoặc kép như "ap-southeast-1". number: giá trị số, biểu diễn được cả số nguyên (15) lẫn số thực (6.283). bool: true hoặc false.

$ echo 'true && false' | terraform console
false

Hai nhóm kiểu gộp nhiều giá trị:

list / tuple: dãy giá trị có thứ tự, đánh số từ 0, ví dụ ["us-east-1a", "us-east-1c"]. map / object: nhóm giá trị gắn nhãn tên, ví dụ { name = "web", port = 443 }. Console in map ra dạng nhiều dòng:

$ echo '{ name = "web", port = 443 }' | terraform console
{
  "name" = "web"
  "port" = 443
}

Khác biệt list-với-tuple và map-với-object nằm ở chỗ phần tử có cùng kiểu hay không: list/map đòi mọi phần tử cùng kiểu, còn tuple/object cho phép mỗi vị trí một kiểu. Lúc viết cấu hình bạn cứ gõ [...]{...}, Terraform tự suy ra kiểu cụ thể; phân biệt này chỉ quan trọng khi khai báo kiểu cho biến (bài 9).

Kiểu thứ sáu là null — "giá trị biểu thị sự vắng mặt hoặc bỏ qua". Khi gán null cho một argument, Terraform xử như thể bạn không viết argument đó: nó dùng giá trị mặc định nếu có, hoặc báo lỗi nếu argument là bắt buộc. null khác hẳn chuỗi rỗng "" hay số 0: nó nghĩa là "không có giá trị", không phải "giá trị bằng rỗng".

Biểu thức: nối các giá trị lại

Argument hiếm khi chỉ là hằng. Biểu thức cho phép tính giá trị từ những giá trị khác.

Toán tử số học và logic hoạt động như mong đợi. So sánh và logic trả về bool. Toán tử ba ngôi (ternary) điều_kiện ? a : b chọn một trong hai nhánh:

$ echo '1 == 1 ? "yes" : "no"' | terraform console
"yes"

Ternary cực kỳ hay gặp để bật/tắt cấu hình theo môi trường (ví dụ prod thì bật xóa-bảo-vệ, dev thì không).

Nội suy chuỗi nhúng một biểu thức vào trong chuỗi bằng ${...}:

$ echo '"web-${1 + 1}"' | terraform console
"web-2"

Trong thực tế bạn sẽ viết "${var.env}-web" để ghép tên môi trường vào tên resource. Phần ${...} được tính trước rồi ghép vào chuỗi.

Hàm là phần làm HCL mạnh. Terraform có hàng trăm hàm dựng sẵn cho chuỗi, số, collection, mã hóa, mạng, thời gian. Vài ví dụ:

$ echo 'length(["a", "b", "c"])' | terraform console
3
$ echo 'tostring(42)' | terraform console
"42"
$ echo 'cidrsubnet("10.0.0.0/16", 8, 2)' | terraform console
"10.0.2.0/24"

cidrsubnet đáng chú ý: nó cắt một khối CIDR thành subnet con. Ở đây lấy 10.0.0.0/16, thêm 8 bit (thành /24), rồi lấy subnet thứ 2, ra 10.0.2.0/24. Loại hàm này là thứ giúp ta sinh dải mạng tự động ở bài VPC sau, thay vì gõ tay từng subnet. Terraform không cho tự định nghĩa hàm; bạn dùng tập hàm dựng sẵn (và sẽ tra cứu chúng thường xuyên).

Block terraform{}: khai báo về chính Terraform

Block terraform {} không mô tả hạ tầng. Nó khai báo các thiết lập về cách Terraform chạy cấu hình này, và nhận giá trị tĩnh (không dùng được biến trong đó). Sáu thứ nó chứa được:

required_version — "phiên bản Terraform CLI nào được phép chạy cấu hình này". Đặt nó để người dùng bản quá cũ bị chặn ngay.

required_providers — "tất cả provider plugin cần để tạo và quản lý resource trong cấu hình". Đây là cái ta đã dùng ở bài 2 để khai báo hashicorp/aws ~> 6.0.

backend là nơi cất file state (mặc định là local; bài 6 chuyển sang S3). cloud cấu hình để dùng HCP Terraform thay cho backend. experiments bật các tính năng ngôn ngữ đang thử nghiệm. provider_meta chứa metadata cho provider, hiếm khi dùng trực tiếp.

Trong cả series, ba thứ đầu là phần bạn động tới: required_version, required_providers, và backend. Ba thứ còn lại biết là đủ.

Vì sao thứ tự dòng không quan trọng

Một điểm dễ vấp khi quen với ngôn ngữ mệnh lệnh: trong HCL, thứ tự khai báo các block không quyết định thứ tự thực thi. Bạn có thể viết output trước resource, hay khai báo bucket sau security group dùng nó, kết quả không đổi. Lý do nằm ở bản chất khai báo: Terraform không đọc file từ trên xuống rồi làm theo. Nó nạp toàn bộ cấu hình, dựng một đồ thị phụ thuộc từ các tham chiếu giữa resource (aws_s3_bucket.first.arn tạo ra một cạnh phụ thuộc), rồi mới quyết định thứ tự. Bạn mô tả cái gì cần tồn tại, không phải làm theo bước nào. Đồ thị phụ thuộc đó là chủ đề bài 5.

HCL còn có một biến thể cú pháp JSON (file .tf.json) dành cho trường hợp sinh cấu hình bằng máy. Viết tay thì luôn dùng cú pháp HCL gốc như trên; biết biến thể JSON tồn tại là đủ, hiếm khi cần tới.

Tổng kết

HCL chỉ có hai khối: argument (tên = biểu_thức) và block (type, label, body). Sáu kiểu giá trị là string, number, bool, list/tuple, map/object, null, trong đó null nghĩa là "bỏ qua argument". Biểu thức ghép giá trị qua toán tử, ternary, nội suy ${...} và hàm dựng sẵn, và terraform console là chỗ thử chúng nhanh nhất. Block terraform{} khai báo về môi trường chạy: phiên bản, provider, backend. Thứ tự dòng không quan trọng vì Terraform dựng đồ thị từ tham chiếu chứ không chạy tuần tự.

Ta đã nhắc tới state vài lần mà chưa mổ kỹ. Bài tới đào vào file state: nó lưu chính xác cái gì, vì sao Terraform cần nó thay vì hỏi thẳng AWS mỗi lần, các lệnh state list / state show đọc gì, và vì sao file này phải được giữ cẩn thận.