Bootstrap — keyless CI/CD¶
Bootstrap to najtrudniejsza część projektu koncepcyjnie — musisz zbudować maszynę, która zbuduje wszystko inne, zanim ta maszyna istnieje. Robimy to raz, lokalnie, i nigdy więcej do tego nie wracamy.
Problem: jak CI/CD uwierzytelnia się do GCP?¶
Tradycyjne podejście: wygeneruj klucz Service Account (JSON), wrzuć do GitHub Secrets, użyj w Actions.
Dlaczego to złe:
- Klucz ma ważność do ręcznego usunięcia — jeśli wycieknie (GitHub breach, logi, commit), atakujący ma dostęp przez lata
- Rotacja kluczy jest ręczna i często pomijana
- JSON key to wektor ataku #1 w GCP według Google Security
Nasze podejście: Workload Identity Federation (WIF)
GitHub Actions Runner
│
│ 1. prosi GitHub o OIDC token (JWT)
▼
GitHub OIDC Provider
│ 2. wystawia token z claims: repo, branch, sha
▼
Google Cloud STS
│ 3. weryfikuje token (Google ufa GitHub OIDC)
│ 4. sprawdza attribute_condition: repository == 'benhornbeam/repo'
▼
Service Account Token (krótkoterminowy, 1h)
│ 5. tylko gh-infra-worker SA może być impersonowany
▼
Zasoby GCP (Terraform plan/apply)
Żaden klucz nigdzie nie istnieje. Token ważny 1 godzinę. Tylko ten repo może impersonować ten SA.
Co robi bootstrap¶
Dwa etapy: skrypt bash + warstwa Terraform.
Etap 1: bob_budowniczy.sh (bash, lokalnie)¶
7-etapowy idempotentny skrypt. "Idempotentny" = możesz uruchomić wielokrotnie bez skutków ubocznych — każdy krok sprawdza czy zasób już istnieje.
# Co robi skrypt w kolejności:
# 1. Tworzy prywatne repo na GitHubie
# 2. Tworzy projekt GCP w organizacji
# 3. Podpina billing account
# 4. Aktywuje ~10 wymaganych API (IAM, Cloud Run, Artifact Registry, ...)
# 5. Tworzy Bootstrap SA (tf-bootstrap-admin) z minimalnymi uprawnieniami
# 6. Tworzy GCS bucket na stan Terraform
# 7. Uruchamia `terraform plan` dla warstwy bootstrap
# Uruchomienie:
cd scripts && ./run.sh
# run.sh auto-generuje project ID w formacie gcp-prototype-1-YYYYMMDD
# i przekazuje do bob_budowniczy.sh
Pułapka: IAM propagation delay
Po nadaniu roli SA, GCP potrzebuje ~30 sekund na propagację. Skrypt ma wbudowane sleep 30 po kluczowych operacjach IAM. Jeśli Terraform plan rzuca Permission denied chwilę po nadaniu roli — poczekaj minutę i spróbuj ponownie.
Etap 2: tf/bootstrap/ (Terraform, lokalnie)¶
Tworzy wszystko, co CI/CD potrzebuje do działania.
# tf/bootstrap/main.tf — uproszczony
# Service Account dla GitHub Actions
resource "google_service_account" "github_sa" {
account_id = "gh-infra-worker"
display_name = "GitHub Actions Infra Worker"
project = var.project_id
}
# Role — principle of least privilege
resource "google_project_iam_member" "worker_roles" {
for_each = toset([
"roles/compute.networkAdmin",
"roles/storage.admin",
"roles/run.admin",
"roles/artifactregistry.admin",
"roles/iam.securityAdmin", # (1)
"roles/secretmanager.admin",
"roles/apigateway.admin",
"roles/monitoring.admin",
# ... (pełna lista w tf/bootstrap/main.tf)
])
project = var.project_id
role = each.key
member = "serviceAccount:${google_service_account.github_sa.email}"
}
iam.securityAdminzamiastprojectIamAdmin— masetIamPolicy, ale węższy zakres. Świadoma decyzja: używamy węższej roli gdy tylko możliwe.
# WIF Pool — rejestruje GitHub jako zaufany issuer OIDC
resource "google_iam_workload_identity_pool" "github_pool" {
workload_identity_pool_id = "github-actions-pool-v3"
}
resource "google_iam_workload_identity_pool_provider" "github_provider" {
workload_identity_pool_id = google_iam_workload_identity_pool.github_pool.workload_identity_pool_id
workload_identity_pool_provider_id = "github-provider"
attribute_mapping = {
"google.subject" = "assertion.sub" # (1)
"attribute.repository" = "assertion.repository" # (2)
}
# Tylko ten repo może impersonować SA — bezpieczeństwo przez warunek
attribute_condition = "assertion.repository == '${var.github_repo}'"
oidc {
issuer_uri = "https://token.actions.githubusercontent.com"
}
}
sub= unikalny identyfikator workflow w GitHubie- Mapujemy
repositoryz tokenu GitHub → atrybut Google, żeby użyć go wattribute_condition
# Binding: GitHub Actions Runner może impersonować gh-infra-worker
resource "google_service_account_iam_member" "wif_binding" {
service_account_id = google_service_account.github_sa.name
role = "roles/iam.workloadIdentityUser"
member = "principalSet://iam.googleapis.com/${
google_iam_workload_identity_pool.github_pool.name
}/attribute.repository/${var.github_repo}"
}
Jak działa w GitHub Actions¶
# .github/workflows/deploy.yml — fragment
permissions:
id-token: write # (1) wymagane — bez tego Actions nie może pobrać OIDC token
contents: read
steps:
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} # (2)
service_account: ${{ secrets.GCP_SA_EMAIL }} # (3)
id-token: write— kluczowa linia. Bez niej GitHub nie udostępni OIDC tokenu jobowi.- Format:
projects/NUMBER/locations/global/workloadIdentityPools/POOL/providers/PROVIDER - Format:
gh-infra-worker@PROJECT_ID.iam.gserviceaccount.com
Akcja google-github-actions/auth robi całą wymianę tokenu automatycznie. Po tym kroku gcloud i Terraform mają dostęp przez ADC (Application Default Credentials).
Stan Terraform: separacja warstw¶
Jeden bucket GCS, osobny prefiks per warstwa:
tf-state-gcp-prototype-1-20260224/
├── terraform/bootstrap/state/
├── terraform/infra/state/
├── terraform/backend/state/
├── terraform/auth/state/
├── terraform/api-gateway/state/
├── terraform/database/state/
├── terraform/frontend/state/
└── terraform/monitoring/state/
# tf/backend/provider.tf — przykład konfiguracji backendu
terraform {
backend "gcs" {
# wartości przekazywane przez -backend-config (nie hardkodujemy project ID)
# bucket = "tf-state-gcp-prototype-1-20260224"
# prefix = "terraform/backend/state"
}
}
# Init z dynamicznym konfiguracją
terraform init \
-backend-config="bucket=tf-state-${PROJECT_ID}" \
-backend-config="prefix=terraform/backend/state"
Dlaczego osobne prefiksy, nie osobne buckety?
Jeden bucket = jeden zasób IAM do zarządzania, jeden zasób TF w bootstrapie, jedna polityka lifecycle. Osobne prefiksy = izolacja stanów (Terraform nie widzi stanu innych warstw). Best of both worlds.
Pułapki¶
tf/bootstrap/ NIE deployuj przez CI/CD
Warstwa bootstrap tworzy SA i WIF — czyli infrastrukturę, którą CI/CD używa do uwierzytelnienia. Jeśli deployujesz bootstrap przez CI/CD, które zależy od bootstrapu → błędne koło. Bootstrap zawsze lokalnie, przez tf-bootstrap-admin SA z impersonacją lub bezpośrednio przez ADC jako owner projektu.
Nowa rola dla gh-infra-worker → najpierw apply bootstrap
Jeśli nowa warstwa TF potrzebuje roli, której gh-infra-worker jeszcze nie ma (np. monitoring.admin dla warstwy monitoring):
1. Dodaj rolę do tf/bootstrap/main.tf
2. terraform apply lokalnie dla bootstrapu
3. Dopiero potem deployuj nową warstwę przez CI
Jeśli tego nie zrobisz, workflow wywali się na Permission denied bez czytelnego komunikatu.
State bucket: public_access_prevention = enforced
Bucket ze stanem TF musi mieć public_access_prevention = "enforced". Stan Terraform może zawierać wrażliwe informacje (endpointy, SA emails). Domyślnie GCS bucket jest prywatny, ale warto to explicite ustawić i sprawdzić w security review.
Weryfikacja¶
Po wykonaniu bootstrapu:
# Sprawdź czy WIF pool istnieje
gcloud iam workload-identity-pools list \
--location=global \
--project=gcp-prototype-1-20260224
# Sprawdź role gh-infra-worker
gcloud projects get-iam-policy gcp-prototype-1-20260224 \
--flatten="bindings[].members" \
--filter="bindings.members:gh-infra-worker" \
--format="table(bindings.role)"
# Sprawdź GitHub Secrets (powinny być ustawione)
gh secret list --repo benhornbeam/gcp-prototype-1-20260224
# Oczekiwane: GCP_PROJECT_ID, GCP_WIF_PROVIDER, GCP_SA_EMAIL
Jeśli wszystko OK — masz CI/CD bez kluczy, gotowy do deployowania kolejnych warstw.