lesson ── terraform-production ── ~15 мин ── 5 шагов
На реальном AWS GitHub Actions ходит через OIDC: workflow получает JWT, STS обменивает на временные credentials, никаких долгих access-keys. LocalStack Community OIDC не поддерживает, поэтому эмулируем, создаём IAM-роль с trust policy на «федерати», assume'им её через AssumeRole, и смотрим, как это выглядит. Концепция та же, что и с реальным AWS.
интерактивный sandbox
Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.
stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя
В реальном AWS это команда:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
На LocalStack, то же самое:
cd /home/student/tf-oidc
aws --endpoint-url=http://localstack:4566 \
iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 2>&1 || true
aws --endpoint-url=http://localstack:4566 \
iam list-open-id-connect-providers
LocalStack может вернуть ошибку «not implemented», это нормально, Community не поддерживает OIDC-provider. Мы продолжаем, нам важна сама модель, не endpoint.
Создадим простую IAM-роль с trust на AssumeRole (это LocalStack поддерживает):
cat > trust.json <<'EOF'
{"Version": "2012-10-17",
"Statement": [{"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},"Action": "sts:AssumeRole"
}]
}
EOF
aws --endpoint-url=http://localstack:4566 \
iam create-role \
--role-name tf-runner-demo \
--assume-role-policy-document file://trust.json
aws --endpoint-url=http://localstack:4566 \
iam attach-role-policy \
--role-name tf-runner-demo \
--policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
Роль создана. У неё доверие к ec2-сервису (эмуляция; в реальном OIDC trust был бы к federated identity GitHub).
✓ Роль создана. Сейчас её assume'нем.
CREDS=$(aws --endpoint-url=http://localstack:4566 \
sts assume-role \
--role-arn arn:aws:iam::000000000000:role/tf-runner-demo \
--role-session-name local-demo)
echo "$CREDS" | jq '.Credentials.AccessKeyId, .Credentials.SecretAccessKey | .[0:10]'
Получили временные creds. Они короткоживущие (1 час по дефолту), не ротируются как обычные access keys.
В реальном GitHub Actions это делает aws-actions/configure-aws-credentials:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123:role/tf-runner-demo
aws-region: us-east-1
Под капотом, ровно та же команда sts assume-role-with-web-identity
(с OIDC JWT) или sts assume-role (для self-assumed).
✓ Временные creds получены. Это и есть модель CI без секретов.
eval "$(aws --endpoint-url=http://localstack:4566 \
sts assume-role \
--role-arn arn:aws:iam::000000000000:role/tf-runner-demo \
--role-session-name terraform-run | jq -r '
.Credentials |
"export AWS_ACCESS_KEY_ID=" + .AccessKeyId,
"export AWS_SECRET_ACCESS_KEY=" + .SecretAccessKey,
"export AWS_SESSION_TOKEN=" + .SessionToken
')"
cat > main.tf <<'EOF'
resource "aws_s3_bucket" "oidc_demo" {bucket = "linuxlab-oidc-demo"
}
EOF
terraform init -no-color > /dev/null
terraform apply -auto-approve -no-color
terraform state list
Terraform отработал на временных credentials, не на долгоживущих. Это то, что делает реальный CI с OIDC.
✓ Apply прошёл на временных creds. Принцип OIDC реализован.
На реальном AWS trust выглядит иначе:
cat > trust-real-oidc.json <<'EOF'
{"Version": "2012-10-17",
"Statement": [{"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": { "StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {"token.actions.githubusercontent.com:sub": "repo:linuxlab/terraform-infra:ref:refs/heads/main"
}
}
}]
}
EOF
cat trust-real-oidc.json | jq
Ключевые поля:
Federated, ARN OIDC-провайдера GitHub в твоём AWS-аккаунте.Action: sts:AssumeRoleWithWebIdentity, не AssumeRole, а
federated.Condition.StringEquals.aud, gh выдаёт audience = sts.amazonaws.com.Condition.StringLike.sub, главный фильтр; разрешает assume
только из конкретного repo + конкретной ref/environment.Sub-claim, это то, что ограничивает blast-radius. repo:org/*:* =
vulnerable. repo:org/specific-repo:ref:refs/heads/main =
tight.
✓ Trust policy для реального OIDC написана. На реальном AWS, copy-paste.
OpenTofu держит CLI и state совместимыми с Terraform по командам
этого шага: миграция обычно проходит через mv .terraform .terraform.bak; tofu init -upgrade. Но при первом переходе
сделай backup state и прогон на feature-branch - расхождения
концентрируются в новых фичах (variables в backend,
state-encryption, OCI registry-backed модули). См.
tf-opentofu-parity для полной матрицы.
Antipattern, одна роль gh-tf-runner для dev+stage+prod.
Compromise dev-CI = compromise prod.
Правильно, три роли, разные trust:
cat > roles-design.md <<'EOF'
| Role | Trust sub | Permissions |
|---|---|---|
| gh-tf-runner-dev | repo:linuxlab/infra:ref:refs/heads/* | dev-resources |
| gh-tf-runner-stage | repo:linuxlab/infra:environment:stage | stage-resources |
| gh-tf-runner-prod | repo:linuxlab/infra:environment:prod | prod-resources |
EOF
cat roles-design.md
Workflow указывает свою role-to-assume в зависимости от target-env. Environment-gating через GitHub UI (required reviewers, branch filter) ограничивает кто может assume prod-role.
На LocalStack создадим аналогичную модель, три отдельных role:
for env in dev stage prod; do
aws --endpoint-url=http://localstack:4566 \
iam create-role \
--role-name "tf-runner-${env}" \--assume-role-policy-document file://trust.json
done
aws --endpoint-url=http://localstack:4566 iam list-roles \
--query 'Roles[*].RoleName' --output text
Видишь три tf-runner-* роли. В реальной системе каждая привязана к разным environment'ам в GitHub.
✓ Multi-role pattern реализован. В prod это даёт изоляцию blast-radius.
Бонусный паттерн: plan-job не нужны permissions писать.
gh-tf-runner-plan , read-only (ReadOnlyAccess)
trust: pull_request
gh-tf-runner-apply , full (Custom managed policy)
trust: push на main
Plan на PR гоняется под read-only, даже compromised PR с злонамеренным HCL не может ничего создать. Apply возможен только после merge в main (другой trust).
В trust policy:
"Condition": { "StringLike": {"token.actions.githubusercontent.com:sub": "repo:linuxlab/infra:pull_request"
}
}
pull_request, keyword в sub-claim для PR-context'а. Любой PR в
этом repo получает доступ к read-only роли. Apply-role требует
:ref:refs/heads/main, недоступен из PR-context'а.
Это снижает blast-radius при compromised contributor: он может сделать PR с злонамеренным HCL → plan-job его прогонит → reviewer должен ещё одобрить merge → apply-job уже на main → Сразу видно что было apply'нуто. Audit-trail сохранён.
LocalStack Community поддерживает AssumeRole, но не реальный OIDC.
Что делаем: IAM-роль с минимальными правами для S3, ассамируем её
через sts assume-role, временные credentials получаем, это
модель того, как реальный workflow получает creds через
aws-actions/configure-aws-credentials.
команды
aws iam create-role ...создать роль с trust policy.aws iam attach-role-policy ...привязать permissions к роли.aws sts assume-role --role-arn ...получить временные credentials.AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... terraform applyиспользовать временные creds для terraform.концепции