Основная цель этой серии постов – более подробно рассказать о создании трубопровода Terraform, который не требует хранения ключей доступа в ваших трубопроводах GitHub, GitLab или BitBucket.
Предварительные условия
- Установка Terraform
- Установите Terraform CLI
- Создайте и активируйте учетную запись AWS
Первоначальная настройка
Нам потребуется ведро состояния, чтобы ваши репозитории Terraform могли читать и записывать изменения состояния при каждом выполнении, а также пользователь IAM, который может использоваться для локального запуска Terraform, пока не будут реализованы другие методы, такие как SSO или Active Directory.
Итак, первым шагом будет вход в учетную запись AWS, переход к CloudFormation и добавление следующего шаблона.
cloudformation.yml
AWSTemplateFormatVersion: 2010-09-09
Resources:
TfBucket:
Type: AWS::S3::Bucket
Properties:
VersioningConfiguration:
Status: Enabled
TfUser:
Type: AWS::IAM::User
Properties:
Policies:
- PolicyName: PermissionForOpenIdConnectModule
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- iam:*OpenIDConnectProvider
Resource:
- Fn::Sub:
- 'arn:aws:iam::${AccountId}:oidc-provider/*'
- AccountId: !Ref AWS::AccountId
- Effect: Allow
Action:
- iam:*Role*
Resource:
- Fn::Sub:
- 'arn:aws:iam::${AccountId}:role/identity-provider-github-assume-role'
- AccountId: !Ref AWS::AccountId
- Fn::Sub:
- 'arn:aws:iam::${AccountId}:role/identity-provider-gitlab-assume-role'
- AccountId: !Ref AWS::AccountId
- Fn::Sub:
- 'arn:aws:iam::${AccountId}:role/identity-provider-bitbucket-assume-role'
- AccountId: !Ref AWS::AccountId
- PolicyName: PermissionToBucketState
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- s3:ListBucket
Resource:
- !GetAtt TfBucket.Arn
- Effect: Allow
Action:
- s3:GetObject
- s3:PutObject
- s3:DeleteObject
Resource:
- Fn::Sub:
- "${BucketArn}/*"
- BucketArn: !GetAtt TfBucket.Arn
# Generates the Access Key
TfAccessKey:
Type: AWS::IAM::AccessKey
Properties:
# Increment serial to rotate key
Serial: 1
Status: Active
UserName: !Ref TfUser
# Adds the Credentials to Secrets Manager
TfSecret:
Type: AWS::SecretsManager::Secret
Properties:
Description: Credentials for Terraform.
Name: TERRAFORM_CREDENTIALS
SecretString: !Sub
- '{"AWS_ACCESS_KEY_ID":"${AWS_ACCESS_KEY_ID}","AWS_SECRET_ACCESS_KEY":"${AWS_SECRET_ACCESS_KEY}","AWS_DEFAULT_REGION":"${AWS_DEFAULT_REGION}"}'
- AWS_ACCESS_KEY_ID: !Ref TfAccessKey
AWS_SECRET_ACCESS_KEY: !GetAtt TfAccessKey.SecretAccessKey
AWS_DEFAULT_REGION: !Ref "AWS::Region"
Outputs:
BucketName:
Description: The name of the state bucket used for Terraform State.
Value: !Ref TfBucket
BucketRegion:
Description: The region of the state bucket that will be used for Terraform State.
Value: !Ref AWS::Region
Это даст вам имя ведра и ARN в качестве выходных данных, поскольку мы позволяем шаблону автоматически генерировать имя для предотвращения конфликтов, однако вы можете использовать свойство BucketName
, чтобы указать это при создании шаблона.
Структура проекта
Мы создадим структуру проекта, как показано ниже, и начнем с создания модуля.
├── backend.tf
├── main.tf
├── modules
│ └── openid_connect
│ ├── experiments.tf
│ ├── locals.tf
│ ├── main.tf
│ └── variables.tf
├── providers.tf
└── terraform.tfplan
Создание модуля
Я абстрагировал это в модуль под названием openid_connect
, это создаст разделение между остальным кодом и улучшит читаемость.
Это будет первый файл, который нам нужно создать, он будет содержать значения, которые мы хотим передать извне модуля, мы используем ограничение типа Optional, потому что хотим создать наши значения по умолчанию с другими переменными позже.
openid_connect/variables.tf
variable "github" {
type = object({
enabled = bool
workspace_name = string
repositories = optional(string)
permission_statements = optional(list(
object({
Effect = string
Action = list(string)
Resource = string
})
))
})
default = {
enabled = false
workspace_name = ""
permission_statements = []
}
}
variable "gitlab" {
type = object({
enabled = bool
group_url = string
permission_statements = optional(list(
object({
Effect = string
Action = list(string)
Resource = string
})
))
})
default = {
enabled = false
group_url = ""
}
}
variable "bitbucket" {
type = object({
enabled = bool
workspace_name = string
workspace_uuid = string
repository_uuids = optional(string)
permission_statements = optional(list(
object({
Effect = string
Action = list(string)
Resource = string
})
))
})
default = {
enabled = false
workspace_name = ""
workspace_uuid = ""
}
validation {
condition = (
var.bitbucket.enabled ? (
length(var.bitbucket.workspace_name) > 0 &&
length(var.bitbucket.workspace_uuid) > 0
) : true
)
error_message = "Workspace name and uuid is required. These can be found from OpenId Connect under the pipeline settings."
}
}
Эксперименты должны быть включены, чтобы использовать необязательный атрибут, используемый в variables.tf
openid_connect/experiments.tf
terraform {
experiments = [module_variable_optional_attrs]
}
Locals будет использоваться для обхода карты провайдеров и выбора только включенных провайдеров.
Кроме того, переменные по умолчанию будут перезаписаны пользовательскими значениями, которые вы передадите с помощью функции merge.
openid_connect/locals.tf
locals {
providers = {
github = var.github,
gitlab = var.gitlab,
bitbucket = var.bitbucket
}
default = {
github = {
identity_provider_url = "token.actions.githubusercontent.com"
audience = "sts.amazonaws.com"
repositories = ["*"]
permission_statements = [{
Effect : "Allow",
Resource : "*",
Action : [
"*"
]
}]
}
gitlab = {
identity_provider_url = "gitlab.com"
audience = "https://gitlab.com"
project_slug = "*"
permission_statements = [{
Effect : "Allow",
Resource : "*",
Action : [
"*"
]
}]
}
bitbucket = {
identity_provider_url = format("api.bitbucket.org/2.0/workspaces/%s/pipelines-config/identity/oidc", var.bitbucket.workspace_name)
audience = format("ari:cloud:bitbucket::workspace/%s", replace(var.bitbucket.workspace_uuid, "/[{}]/", ""))
repository_uuids = ["*"]
permission_statements = [{
Effect : "Allow",
Resource : "*",
Action : [
"*"
]
}]
}
}
enabled_providers = { for provider_key, provider_value in local.providers : provider_key => merge(
# Add default settings
local.default[provider_key],
# Override with module input options
{ for item_key, item_value in provider_value : item_key => item_value if item_value != null }
) if provider_value.enabled }
}
В файле main.tf мы будем генерировать провайдеров OpenID Connect на основе хэша, возвращаемого через tls_certification к URL провайдера.
Мы также сгенерируем роль для каждого провайдера и изменим формат в зависимости от того, какой провайдер системы контроля версий вы используете. Обратите внимание, что это пример, поэтому он может быть изменен в соответствии с вашими потребностями.
openid_connect/main.tf
data "tls_certificate" "oid_provider" {
for_each = local.enabled_providers
url = format("https://%s", each.value.identity_provider_url)
}
resource "aws_iam_openid_connect_provider" "oid_provider" {
for_each = local.enabled_providers
url = format("https://%s", each.value.identity_provider_url)
client_id_list = [
each.value.audience
]
thumbprint_list = [
data.tls_certificate.oid_provider[each.key].certificates.0.sha1_fingerprint
]
}
data "aws_iam_policy_document" "assuming_role" {
for_each = local.enabled_providers
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
// Allow federated access with the oid provider's arn
principals {
type = "Federated"
identifiers = [
aws_iam_openid_connect_provider.oid_provider[each.key].arn
]
}
// Allow access when audience is matching the audience value
condition {
test = "StringLike"
variable = format("%s:aud", each.value.identity_provider_url)
values = [
each.value.audience
]
}
condition {
test = "StringLike"
variable = format("%s:sub", each.value.identity_provider_url)
values = concat(
# Github
each.key == "github" ? formatlist("repo:%s/%s:*", each.value.workspace_name, each.value.repositories) : [],
# Bitbucket
each.key == "bitbucket" ? formatlist("%s:*", each.value.repository_uuids) : [],
compact([
# Gitlab
each.key == "gitlab" ? format(
"*:%s:*:*:*:*",
join("/",
slice(
split("/", replace(each.value.group_url, "https://", "")),
1,
length(
split("/", replace(each.value.group_url, "https://", ""))
)
)
)
) : null,
])
)
}
}
}
resource "aws_iam_role" "assuming_role" {
for_each = local.enabled_providers
name = format("identity-provider-%s-assume-role", each.key)
assume_role_policy = data.aws_iam_policy_document.assuming_role[each.key].json
inline_policy {
name = "identity-provider-permissions"
policy = jsonencode({
Version = "2012-10-17",
Statement = each.value.permission_statements
})
}
}
Добавление модуля
Теперь, когда мы создали модуль, нам нужно импортировать его, создав корневые файлы, бэкенд должен быть настроен, чтобы знать, где управлять файлами состояния.
backend.tf
terraform {
backend "s3" {
region = "eu-west-1"
bucket = "test-tfbucket-bgbo1k17nnkd"
key = "local.tfstate"
}
}
Провайдер определяет, какого провайдера использовать (например, AWS, GCP и т.д.) и применяет теги по умолчанию для всех ресурсов.
provider.tf
provider "aws" {
region = "eu-west-1"
default_tags {
tags = {
test-pipeline = true
}
}
}
Теперь нам нужно создать модуль для импорта модуля, а также разрешения, которыми будет обладать роль, поскольку мы используем terraform, нам понадобятся разрешения s3, однако это можно ограничить с помощью утверждения и передачи bucket arn в качестве ресурса.
main.tf
locals {
permission_statements = [
{
Effect : "Allow",
Action : [
"sns:*",
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
Resource : "*"
}
]
}
module "openid_connect" {
source = "./modules/openid_connect"
github = {
enabled = true
workspace_name = "lewisstevens1"
permission_statements = local.permission_statements
}
gitlab = {
enabled = true
group_url = "https://www.gitlab.com/lewisstevens1"
permission_statements = local.permission_statements
}
bitbucket = {
enabled = true
workspace_name = "lewisstevens1"
workspace_uuid = "{fg5125adw3-ab7e-49528-af15-c61ed55112f}"
permission_statements = local.permission_statements
}
}
Применение Terraform
Чтобы применить Terraform, нам сначала нужно экспортировать учетные данные.
Для этого вы можете перейти в менеджер секретов и получить секретное значение для секрета “TERRAFORM_CREDENTIALS”.
С его помощью вы можете экспортировать переменные, а затем выполнить следующие действия:
Настройка конвейеров
Теперь, когда в учетной записи AWS настроены провайдеры OpenId Connect, нам нужно настроить трубопроводы.
Они имеют небольшие различия в каждом провайдере, и для каждого из этих шаблонов требуется переменная AWS_DEFAULT_REGION и ACCOUNT_ID.
Токен BitBucket – BITBUCKET_STEP_OIDC_TOKEN и требует, чтобы oidc
был установлен в true для экспорта этой переменной, GitLab же автоматически экспортирует CI_JOB_JWT_V2.
Bitbucket
bitbucket-pipelines.yml
# This is a basic image with just terraform and aws cli installed onto it.
image: lewisstevens1/amazon-linux-terraform
aws-login: &aws-login |-
STS=($(
aws sts assume-role-with-web-identity
--role-session-name terraform-execution
--role-arn arn:aws:iam::$ACCOUNT_ID:role/identity_provider_bitbucket_assume_role
--web-identity-token $BITBUCKET_STEP_OIDC_TOKEN
--query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]"
--output text
));
export AWS_ACCESS_KEY_ID=${STS[0]};
export AWS_SECRET_ACCESS_KEY=${STS[1]};
export AWS_SESSION_TOKEN=${STS[2]};
export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION;
pipelines:
branches:
master:
- step:
name: plan-terraform
oidc: true
script:
- *aws-login
- terraform init && terraform plan
- step:
name: apply-terraform
trigger: 'manual'
oidc: true
script:
- *aws-login
- terraform init && terraform plan -out terraform.tfplan
- terraform apply terraform.tfplan
Github
.github/workflows/ci.yml
name: Terraform Pipeline
permissions:
id-token: write
contents: read
on:
push:
branches: [ master ]
jobs:
github-pipeline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v1
with:
role-to-assume: arn:aws:iam::${{ secrets.ACCOUNT_ID }}:role/identity_provider_github_assume_role
aws-region: $AWS_DEFAULT_REGION
- name: plan-terraform
working-directory: ./
run: terraform init && terraform plan -out terraform.tfplan
- name: apply-terraform
working-directory: ./
run: terraform apply terraform.tfplan
Gitlab
.gitlab-ci.yml
# This is a basic image with just terraform and aws cli installed onto it.
image: lewisstevens1/amazon-linux-terraform
stages:
- test
- deploy
.aws-login: &aws-login
- STS=($(
aws sts assume-role-with-web-identity
--role-session-name terraform-execution
--role-arn arn:aws:iam::$ACCOUNT_ID:role/identity_provider_gitlab_assume_role
--web-identity-token $CI_JOB_JWT_V2
--query "Credentials.[AccessKeyId,SecretAccessKey,SessionToken]"
--output text
));
- |
export AWS_ACCESS_KEY_ID=${STS[0]};
export AWS_SECRET_ACCESS_KEY=${STS[1]};
export AWS_SESSION_TOKEN=${STS[2]};
export AWS_DEFAULT_REGION=$AWS_DEFAULT_REGION;
plan-terraform:
stage: test
script:
- *aws-login
- terraform init && terraform plan
apply-terraform:
stage: deploy
when: manual
script:
- *aws-login
- terraform init && terraform plan -out terraform.tfplan
- terraform apply terraform.tfplan
Заключение
Теперь у вас остался трубопровод, в который вы можете добавить свои трубопроводы Terraform, и каждый коммит на master будет вызывать Plan и Apply Terraform.
В следующей статье мы обсудим способы улучшения настройки конвейера.
Репозиторий:
https://github.com/lewisstevens1/terraform_openid_connect