Зачем модулю контракт
Модуль, это чёрный ящик для root'а. Root знает: «передаю эти input'ы, получаю эти output'ы». Что внутри, нерелевантно. Это позволяет:
- Переписать ресурсы внутри модуля без правки root'а.
- Использовать один модуль десятью разными root'ами.
- Менять провайдер (
aws_s3_bucket_v2вместоaws_s3_bucket) при обновлении без каскадных правок наружу.
Контракт = variable блоки и output блоки. Всё остальное в модуле,
деталь реализации.
Input variables
Каждый variable блок, параметр модуля. У него есть тип, описание,
опционально default, опционально validation:
# modules/s3-bucket/variables.tf
variable "name" {type = string
description = "Имя S3-бакета. Должно быть глобально уникально."
validation {condition = length(var.name) >= 3 && length(var.name) <= 63
error_message = "Bucket name должен быть 3-63 символа (правило AWS)."
}
validation { condition = can(regex("^[a-z0-9.-]+$", var.name))error_message = "Bucket name: только lowercase, цифры, точка, дефис."
}
}
variable "versioning_enabled" {type = bool
description = "Включить versioning. Для prod-бакетов обычно true."
default = false
}
variable "lifecycle_rules" { type = list(object({id = string
enabled = bool
transition = optional(object({days = number
target = string
}))
}))
description = "Lifecycle rules. См. examples/."
default = []
}
Без default, обязательный аргумент
Если у variable нет default, вызывающий обязан передать значение.
Иначе terraform ругается на plan. Это хорошо: обязательные имена
должны быть обязательными.
Тип имеет значение
type = string, пользователь может передать только строку. Не 42
(число), не ["a", "b"] (список). Опечатка типа ловится на plan, не
на apply. См. hcl-types.
Validation, защита границ модуля
Validation ловит то, что тип не ловит:
- «Длина 3-63», типу
stringвсё равно. - «Только lowercase», типу
stringвсё равно. - «days > 0», типу
numberвсё равно.
Validation запускается на plan, до любых API-вызовов. Это дешёвый
способ отсечь невалидные конфигурации. Без него ошибка вылезет в AWS как
«InvalidBucketName», и пользователь модуля не сразу поймёт что не так.
Output values
Outputs, то что модуль возвращает родителю:
# modules/s3-bucket/outputs.tf
output "arn" {value = aws_s3_bucket.this.arn
description = "ARN бакета. Используется для IAM-политик."
}
output "bucket" {value = aws_s3_bucket.this.bucket
description = "Имя бакета (как передано в input)."
}
output "regional_domain_name" {value = aws_s3_bucket.this.bucket_regional_domain_name
description = "DNS-имя для прямых S3-запросов."
}
В root-модуле читаются как module.<имя>.<output_name>:
output "logs_arn" {value = module.logs.arn
}
resource "aws_iam_policy" "logs_writer" { policy = jsonencode({ Statement = [{Effect = "Allow"
Action = "s3:PutObject"
Resource = "${module.logs.arn}/*"}]
})
}
sensitive output
Если в output попадает секрет, пометь:
output "access_key_secret" {value = aws_iam_access_key.user.secret
sensitive = true
}
Это не шифрует значение. State по-прежнему содержит секрет в открытом
виде (плохо, см. tf-state). sensitive = true лишь скрывает значение
из CLI-вывода terraform output и plan. Реальная защита секретов,
через secret manager (Vault, SSM Parameter Store), не через
sensitive = true.
Output не выполняет depends_on неявно
Если output зависит от ресурса, который существует только при определённых
условиях, добавь depends_on явно:
output "lifecycle_rule_id" {value = length(aws_s3_bucket_lifecycle_configuration.this) > 0 ? aws_s3_bucket_lifecycle_configuration.this[0].id : null
depends_on = [aws_s3_bucket_lifecycle_configuration.this]
}
Как проектировать контракт
Параметризуй то, что меняется
Не делай переменную «на всякий случай». Variable должна отражать реальное различие между use-case'ами модуля.
# ПЛОХО: на всякий случай
variable "force_destroy" {type = bool
default = false
}
variable "object_ownership" {type = string
default = "BucketOwnerEnforced"
}
# ещё 15 variable которые никто не меняет
# ХОРОШО: только то, что реально разное между use-case'ами
variable "name" { type = string }variable "tags" { type = map(string), default = {} }variable "lifecycle_rules" { type = list(object({...})), default = [] }Не-параметризованные значения держи в locals внутри модуля.
Имена, это контракт
Переименуешь variable "name" → variable "bucket_name", это breaking
change для всех root'ов, которые используют модуль. Те же правила, что
с API: ломать имена нельзя, или это новая major-версия. См.
tf-module-versioning.
Группируй сложные параметры в object
Десять плоских variable "rule_X_..." хуже, чем один variable "rules" типа list(object(...)). С object-типом юзер видит сразу всю схему, не
гадает какие из переменных идут вместе.
Подводные камни
-
Validation запускается на каждый plan. Если в validation тяжёлая регулярка или большой
contains([...100 элементов...], var.x), это время. Обычно неважно, но в модулях которые вызываются 50 раз черезfor_each, может быть заметно. -
descriptionнужен. Безdescriptionterraform-docsне сгенерит нормальный README, и пользователь модуля будет читать сам HCL чтобы понять что значитbucket_acl. Заодно, это бесплатная документация которую IDE показывает на hover. -
default = null≠ нет default.default = nullозначает «по умолчанию значение null, и это валидно». Если хочешь сделать аргумент обязательным, убирай default целиком, а не ставь null. -
sensitive = trueна input не шифрует, только маскирует. Те же оговорки что для output: значение всё равно в state. См. tf-state. -
Output, ссылающийся на ресурс через
count, ломается когда count = 0.aws_s3_bucket.this[0].arnупадёт с «index 0 out of range» если ресурса нет. Используйtry(aws_s3_bucket.this[0].arn, null)или splat:aws_s3_bucket.this[*].arn(вернёт список, возможно пустой). -
Нельзя сделать validation, ссылающуюся на другой variable. TF 1.9+ добавил cross-variable validation через
preconditionв lifecycle, но в самом variable блоке всё ещё только self-validation.