CI/CD без ключів
Тестуємо, збираємо й деплоїмо Sniplink з GitHub через Cloud Build і сервіс-акаунт з мінімальними привілеями, без жодного довгоживучого ключа.
Що ламається
Section titled “Що ламається”У цьому лозі немає нічого екзотичного. Комусь треба було, щоб CI деплоїв, тож він створив JSON-ключ для сервіс-акаунта, видав акаунту Owner, «щоб більше не було помилок з правами», і вставив ключ у CI-секрет. Через два з половиною роки debug-прапорець вивів змінні середовища, лог джоби міг читати будь-хто з доступом до репозиторію, і хтось три дні майнив цим ключем криптовалюту. Ключ увесь цей час був валідним, бо ключ сервіс-акаунта не спливає, якщо ви самі цього не налаштуєте.
Досі ви деплоїли Sniplink зі свого ноутбука, від власного імені. Це не масштабується далі однієї людини, і такий деплой неможливо переглянути: ніхто не бачить, який коміт зараз працює. У цьому модулі деплой переїжджає в CI, і питання, на яке він відповідає: як пайплайну довести Google Cloud, хто він такий, без секрету, який може витекти?
Чому довгоживучі ключі і є проблемою
Section titled “Чому довгоживучі ключі і є проблемою”Ключ сервіс-акаунта є приватним ключем у JSON-файлі. Хто тримає файл, той і є сервіс-акаунтом, з будь-якої машини в інтернеті, доки ключ не видалять. Порівняйте з усім, що ви використовували в курсі досі:
- Ваш ноутбук використовує ваші користувацькі облікові дані через
gcloud auth application-default login. Вони прив’язані до вашого Google-акаунта, MFA і політик сесій. - Cloud Run отримує короткоживучі токени для
sniplink-runtimeвід metadata-сервера. Жодного файлу не існує. Токен живе приблизно годину і працює лише для цієї ідентичності.
JSON-ключ ламає цю модель у чотирьох місцях. За замовчуванням у нього немає терміну дії. Він переносний, тож працює з машини зловмисника так само, як з вашої. Його копіюють (CI-секрети, файли .env, тека Downloads колеги), і ви не можете знати всі копії. А ротація ручна, тож на практиці її не відбувається.
Google Cloud дає два способи запускати CI без ключів:
- Запускати пайплайн усередині Google Cloud. Cloud Build виконує кожен білд від імені сервіс-акаунта, який ви обрали. Білд отримує короткоживучі токени від metadata-сервера, точно як Cloud Run. Ключа, який треба десь зберігати, просто немає.
- Федеративно довіряти зовнішньому CI. GitHub Actions, GitLab та інші видають підписаний OIDC-токен для кожної джоби. Workload Identity Federation дозволяє Google Cloud довіряти цьому токену, перевіряти його claims (який репозиторій, яка гілка) і обмінювати його на короткоживучий Google-токен. Знову без ключа.
Основним шляхом у цьому курсі буде Cloud Build, бо це власний інструмент Google і з ідентичністю тут усе найпростіше. GitHub Actions з Workload Identity Federation буде далі окремою вкладкою, і це цілком добрий вибір.
Зробіть ключі неможливими, а не просто невикористаними
Section titled “Зробіть ключі неможливими, а не просто невикористаними”Найкращий захист від інциденту вище дає політика, яка взагалі не дає створювати ключі. Обмеження організаційної політики iam.disableServiceAccountKeyCreation робить саме це. Google вмикає його за замовчуванням, у новішій керованій формі iam.managed.disableServiceAccountKeyCreation, для кожної організації, створеної 3 травня 2024 року або пізніше, як частину свого security baseline (у деяких організаціях, створених у лютому-квітні 2024 року, воно теж є). Якщо ваш проєкт належить до організації, перевірте, що до нього застосовано:
export PROJECT_ID=your-project-idexport REGION=europe-west1
# Shows the effective policy, including inherited rulesgcloud org-policies describe iam.disableServiceAccountKeyCreation \ --project="$PROJECT_ID" --effective# The managed form that new organisations enforce by defaultgcloud org-policies describe iam.managed.disableServiceAccountKeyCreation \ --project="$PROJECT_ID" --effectiveЯкщо у вашого курсового проєкту немає організації (особистий Gmail-акаунт), org policies просто нема де налаштувати, і команди про це повідомлять. Для курсу це нормально: лаба закінчується самоперевіркою, що в проєкті немає ключів, якими керує користувач. У компанії я вважаю це обмеження таким, що не обговорюється, а рідкісні винятки закриваю тимчасовим override політики на одному проєкті.
Ідентичність для білдів з мінімальними привілеями
Section titled “Ідентичність для білдів з мінімальними привілеями”Коли ви створюєте тригер Cloud Build, ви обираєте сервіс-акаунт, від імені якого виконуються його білди. У старіших проєктах був «legacy Cloud Build service account» з широкими ролями на рівні проєкту; не використовуйте його, і default compute SA теж не використовуйте. Створіть окремий: sniplink-build.
Правило те саме, що й для sniplink-runtime у модулі 3: кожну роль видаємо на найвужчий ресурс, який її підтримує. Ось що потрібно пайплайну і чому.
| Роль | Скоуп | Чому |
|---|---|---|
roles/artifactregistry.writer |
репозиторій sniplink |
Пушити образи. Включає читання, тож білд також може резолвити digest-и. Нічого поза цим репозиторієм. |
roles/run.developer |
сервіс sniplink-api |
Оновлювати сервіс до нової ревізії. Не run.admin: developer не може змінити IAM-політику сервісу, тож CI не зробить приватний сервіс публічним. |
roles/run.viewer |
проєкт | Дочекатися завершення оновлення. Pulumi опитує тривалу операцію, а операції Cloud Run (run.operations.get) живуть на рівні проєкту, поза IAM-політикою сервісу. Лише читання: CI бачить сервіси й ревізії, але не змінює жодного з них. |
roles/iam.serviceAccountUser |
SA sniplink-runtime |
Щоб задеплоїти ревізію, яка працює від імені sniplink-runtime, потрібен дозвіл «діяти від імені» цього SA. Видається на цей один SA, тож CI не може задеплоїти щось, що працює від імені будь-якої іншої ідентичності. |
roles/storage.objectAdmin |
бакет PROJECT_ID-pulumi-state |
Читати й писати state Pulumi та його lock-файли. Лише на рівні об’єктів; CI не може видалити бакет чи змінити його налаштування. |
roles/cloudkms.cryptoKeyEncrypterDecrypter |
ключ pulumi/state |
Pulumi розшифровує секрети конфігу (ключ лаби) і шифрує секретні outputs у state. |
roles/secretmanager.admin |
секрет sniplink-lab-key |
Stack керує цим секретом: додає версії під час ротації, знищує старі, а також прив’язку runtime SA до нього. Admin на одному секреті, а не на всьому Secret Manager. |
roles/logging.logWriter |
проєкт | Білди з SA, заданим користувачем, пишуть логи в Cloud Logging. Вужчого скоупу в цієї ролі немає. |
Зверніть увагу, чого тут немає: ні Editor, ні Owner, ні run.admin на рівні проєкту, ні iam.serviceAccountAdmin, ні доступу до секрету з GitHub-токеном, який ви створите пізніше.
Додайте ідентичність для білдів у новий файл. Бакет для state і KMS-ключ створює bootstrap-скрипт з модуля 0, тож stack посилається на них за іменем, а не створює їх.
using Pulumi;using Gcp = Pulumi.Gcp;
namespace Sniplink.Infra;
public static class BuildIdentity{ public static Gcp.ServiceAccount.Account Create( string project, string region, Gcp.ArtifactRegistry.Repository repo, Gcp.CloudRunV2.Service service, Gcp.ServiceAccount.Account runtimeSa, Gcp.SecretManager.Secret labSecret) { var buildSa = new Gcp.ServiceAccount.Account("sniplink-build", new() { AccountId = "sniplink-build", DisplayName = "Sniplink CI/CD (Cloud Build)", }); var member = buildSa.Email.Apply(e => $"serviceAccount:{e}");
new Gcp.ArtifactRegistry.RepositoryIamMember("build-ar-writer", new() { Location = repo.Location, Repository = repo.Name, Role = "roles/artifactregistry.writer", Member = member, });
new Gcp.CloudRunV2.ServiceIamMember("build-run-developer", new() { Location = service.Location, Name = service.Name, Role = "roles/run.developer", Member = member, });
new Gcp.Projects.IAMMember("build-run-viewer", new() { Project = project, Role = "roles/run.viewer", Member = member, });
new Gcp.ServiceAccount.IAMMember("build-actas-runtime", new() { ServiceAccountId = runtimeSa.Name, Role = "roles/iam.serviceAccountUser", Member = member, });
new Gcp.Storage.BucketIAMMember("build-state-bucket", new() { Bucket = $"{project}-pulumi-state", Role = "roles/storage.objectAdmin", Member = member, });
new Gcp.Kms.CryptoKeyIAMMember("build-state-key", new() { CryptoKeyId = $"projects/{project}/locations/{region}/keyRings/pulumi/cryptoKeys/state", Role = "roles/cloudkms.cryptoKeyEncrypterDecrypter", Member = member, });
new Gcp.SecretManager.SecretIamMember("build-labkey-admin", new() { SecretId = labSecret.SecretId, Role = "roles/secretmanager.admin", Member = member, });
new Gcp.Projects.IAMMember("build-log-writer", new() { Project = project, Role = "roles/logging.logWriter", Member = member, });
return buildSa; }}using Sniplink.Infra;// …var gcpConfig = new Config("gcp");var project = gcpConfig.Require("project");var region = gcpConfig.Require("region");// … after repo, service, runtimeSa and labSecret are createdvar buildSa = BuildIdentity.Create(project, region, repo, service, runtimeSa, labSecret);BuildIdentity живе в просторі імен Sniplink.Infra, а top-level statements у Program.cs ні, тому потрібен using. Програма з модуля 5 читала лише gcp:region; для build identity потрібен ще й ID проєкту, тож тепер програма читає також gcp:project. Обидва ключі лежать у Pulumi.dev.yaml ще з модуля 1.
resource "google_service_account" "build" { account_id = "sniplink-build" display_name = "Sniplink CI/CD (Cloud Build)"}
locals { build_member = "serviceAccount:${google_service_account.build.email}"}
resource "google_artifact_registry_repository_iam_member" "build_writer" { location = google_artifact_registry_repository.sniplink.location repository = google_artifact_registry_repository.sniplink.name role = "roles/artifactregistry.writer" member = local.build_member}
resource "google_cloud_run_v2_service_iam_member" "build_developer" { location = google_cloud_run_v2_service.sniplink_api.location name = google_cloud_run_v2_service.sniplink_api.name role = "roles/run.developer" member = local.build_member}
resource "google_project_iam_member" "build_run_viewer" { project = var.project role = "roles/run.viewer" member = local.build_member}
resource "google_service_account_iam_member" "build_actas_runtime" { service_account_id = google_service_account.runtime.name role = "roles/iam.serviceAccountUser" member = local.build_member}
resource "google_storage_bucket_iam_member" "build_state" { bucket = "${var.project}-pulumi-state" role = "roles/storage.objectAdmin" member = local.build_member}
resource "google_kms_crypto_key_iam_member" "build_state_key" { crypto_key_id = "projects/${var.project}/locations/${var.region}/keyRings/pulumi/cryptoKeys/state" role = "roles/cloudkms.cryptoKeyEncrypterDecrypter" member = local.build_member}
resource "google_secret_manager_secret_iam_member" "build_labkey" { secret_id = google_secret_manager_secret.lab_key.secret_id role = "roles/secretmanager.admin" member = local.build_member}
resource "google_project_iam_member" "build_logs" { project = var.project role = "roles/logging.logWriter" member = local.build_member}У Terraform-версії цього курсу state лежав би в GCS backend і KMS-прив’язка була б не потрібна; я залишив її, щоб вкладки можна було порівнювати.
Чесна частина: CI, який керує IAM
Section titled “Чесна частина: CI, який керує IAM”Перечитайте таблицю очима зловмисника. pulumi up у CI застосовує все, що написано в коді. Якщо код у main додає прив’язку, яка видає комусь Owner, CI спробує її застосувати. Зупиняють це саме мінімальні привілеї build SA: sniplink-build не має права змінювати IAM проєкту, власні прив’язки чи IAM-політику сервісу, тож така зміна падає з 403, а не проходить.
Це працює, бо Pulumi викликає API лише для ресурсів, які змінилися. Звичайний коміт змінює образ, ім’я ревізії та кілька змінних середовища, тож CI чіпає тільки сервіс Cloud Run. Коли коміт змінює BuildIdentity.cs або тригер, CI падає, і застосувати таку зміну має людина з ширшими правами зі своєї машини. Я вважаю це падіння фічею: зміни в тому, хто що може робити, за самою конструкцією отримують другу пару очей.
Ціна в тому, що в одному stack тепер два види ресурсів: ресурси застосунку, які застосовує CI, і ресурси платформи (build SA, його прив’язки, підключення до GitHub, тригер), які застосовує лише людина. Є й друга, тихіша ціна. CI має cryptoKeyEncrypterDecrypter на ключі, який шифрує кожен секрет конфігу в цьому stack, тож усе, що ви зберігаєте в Pulumi.dev.yaml, білд може прочитати.
Обидві проблеми прибирає патерн із двома stack: bootstrap- або platform-stack, яким володіє адмін-група і який застосовується з захищеного середовища, з ідентичностями, тригерами, підключенням і власним KMS-ключем; і app-stack, який застосовує CI і який отримує лише посилання (email build SA, runtime SA) через stack references. Для курсу з одним студентом і одним сервісом я тримаю один stack і приймаю цей компроміс. У команді я б розділив їх з першого дня. Просто усвідомлюйте, по який бік лінії ви перебуваєте.
Підключення GitHub: другий свідомий виняток
Section titled “Підключення GitHub: другий свідомий виняток”2nd-gen repositories у Cloud Build з’єднують підключення (connection) Cloud Build (одне на GitHub-акаунт чи організацію, на регіон) з репозиторіями всередині нього. Тригери потім вказують на ресурс репозиторію. Усе це є кодом на Pulumi, крім одного кроку, який кодом бути не може: GitHub має погодитися дати Google читати ваш репозиторій. Ця згода означає встановлення GitHub App Google Cloud Build на ваш акаунт в інтерфейсі GitHub. Це другий і останній виняток з правила «нуль ClickOps», і він прийнятний з тієї ж причини, що й bootstrap-скрипт: це відбувається один раз, у GitHub, а не у вашому хмарному проєкті, і все, що після нього, є кодом.
-
Встановіть застосунок Google Cloud Build з GitHub Marketplace на свій GitHub-акаунт. Коли GitHub спитає, які репозиторії, оберіть Only select repositories і вкажіть свій репозиторій Sniplink. Застосунок має бачити один репозиторій, а не всі.
-
Знайдіть installation ID. Відкрийте в GitHub Settings → Applications → Installed GitHub Apps → Google Cloud Build → Configure. URL закінчується на
/installations/12345678; це число і є ID. -
Створіть GitHub personal access token, через який Cloud Build читатиме репозиторій. Використайте classic token зі скоупами
repoіread:user(додайтеread:org, якщо застосунок встановлено в організації) і задайте йому термін дії. Fine-grained токени Cloud Build тут не приймає. -
Збережіть токен та installation ID у конфігу stack. Токен шифрується вашим KMS-ключем, як ключ лаби в модулі 3.
cd infrapulumi config set --secret sniplink:githubToken ghp_xxx # paste your tokenpulumi config set sniplink:githubAppInstallationId 12345678pulumi config set sniplink:githubRepo YOUR_GITHUB_USER/sniplinkpulumi config set sniplink:projectNumber "$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')"Так, тепер у токена є термін дії, а отже підключення зламається, коли він спливе. Це правильний спосіб зламатися: ви ротуєте токен через pulumi config set --secret і pulumi up, і в Secret Manager з’являється нова версія.
Підключення, репозиторій і тригери в коді
Section titled “Підключення, репозиторій і тригери в коді”Токен іде в Secret Manager, бо підключення посилається на версію секрету, а не на сирий рядок. Читає його Cloud Build service agent (service-PROJECT_NUMBER@gcp-sa-cloudbuild.iam.gserviceaccount.com, ідентичність, якою керує Google і яку Cloud Build використовує для власної внутрішньої кухні). Це інша ідентичність, ніж sniplink-build, і sniplink-build доступу до токена не отримує ніколи.
Два тригери вказують на той самий репозиторій:
- Push у
mainзапускаєcloudbuild.yaml: тести, білд, push, деплой. - Pull request у
mainзапускаєcloudbuild.pr.yaml: тести іpulumi preview, без деплою.
Обидва працюють від імені sniplink-build, заданого через ServiceAccount.
using Pulumi;using Gcp = Pulumi.Gcp;
namespace Sniplink.Infra;
public static class GitHubPipeline{ public static void Create(string project, string region, Config config, Gcp.ServiceAccount.Account buildSa) { // From config, not a GetProject invoke: invokes run on every `pulumi up`, // and sniplink-build has no permission to read project metadata. var projectNumber = config.Require("projectNumber"); var cloudBuildAgent = $"serviceAccount:service-{projectNumber}@gcp-sa-cloudbuild.iam.gserviceaccount.com";
// 1. GitHub token in Secret Manager var tokenSecret = new Gcp.SecretManager.Secret("github-token", new() { SecretId = "sniplink-github-token", Replication = new Gcp.SecretManager.Inputs.SecretReplicationArgs { Auto = new Gcp.SecretManager.Inputs.SecretReplicationAutoArgs(), }, }); var tokenVersion = new Gcp.SecretManager.SecretVersion("github-token-v", new() { Secret = tokenSecret.Id, SecretData = config.RequireSecret("githubToken"), }); var agentAccess = new Gcp.SecretManager.SecretIamMember("cb-agent-token", new() { SecretId = tokenSecret.SecretId, Role = "roles/secretmanager.secretAccessor", Member = cloudBuildAgent, });
// 2. Connection (one per GitHub account, per region) var connection = new Gcp.CloudBuildV2.Connection("github", new() { Location = region, Name = "github", GithubConfig = new Gcp.CloudBuildV2.Inputs.ConnectionGithubConfigArgs { AppInstallationId = config.RequireInt32("githubAppInstallationId"), AuthorizerCredential = new Gcp.CloudBuildV2.Inputs.ConnectionGithubConfigAuthorizerCredentialArgs { OauthTokenSecretVersion = tokenVersion.Id, }, }, }, new CustomResourceOptions { DependsOn = { agentAccess } });
// 3. Repository inside the connection var githubRepo = config.Require("githubRepo"); // "owner/sniplink" var repository = new Gcp.CloudBuildV2.Repository("sniplink", new() { Location = region, Name = "sniplink", ParentConnection = connection.Name, RemoteUri = $"https://github.com/{githubRepo}.git", });
var substitutions = new InputMap<string> { ["_REGION"] = region, };
// 4a. Push to main → deploy new Gcp.CloudBuild.Trigger("deploy-main", new() { Location = region, Name = "sniplink-deploy-main", RepositoryEventConfig = new Gcp.CloudBuild.Inputs.TriggerRepositoryEventConfigArgs { Repository = repository.Id, Push = new Gcp.CloudBuild.Inputs.TriggerRepositoryEventConfigPushArgs { Branch = "^main$", }, }, Filename = "cloudbuild.yaml", ServiceAccount = buildSa.Id, // projects/PROJECT/serviceAccounts/EMAIL Substitutions = substitutions, });
// 4b. Pull request → test + preview new Gcp.CloudBuild.Trigger("preview-pr", new() { Location = region, Name = "sniplink-preview-pr", RepositoryEventConfig = new Gcp.CloudBuild.Inputs.TriggerRepositoryEventConfigArgs { Repository = repository.Id, PullRequest = new Gcp.CloudBuild.Inputs.TriggerRepositoryEventConfigPullRequestArgs { Branch = "^main$", // Forks need a maintainer's /gcbrun comment before a build runs CommentControl = "COMMENTS_ENABLED_FOR_EXTERNAL_CONTRIBUTORS_ONLY", }, }, Filename = "cloudbuild.pr.yaml", ServiceAccount = buildSa.Id, Substitutions = substitutions, }); }}// …var buildSa = BuildIdentity.Create(project, region, repo, service, runtimeSa, labSecret);GitHubPipeline.Create(project, region, config, buildSa);data "google_project" "this" {}
resource "google_secret_manager_secret" "github_token" { secret_id = "sniplink-github-token" replication { auto {} }}
resource "google_secret_manager_secret_version" "github_token" { secret = google_secret_manager_secret.github_token.id secret_data = var.github_token # sensitive variable}
resource "google_secret_manager_secret_iam_member" "cb_agent_token" { secret_id = google_secret_manager_secret.github_token.secret_id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:service-${data.google_project.this.number}@gcp-sa-cloudbuild.iam.gserviceaccount.com"}
resource "google_cloudbuildv2_connection" "github" { location = var.region name = "github"
github_config { app_installation_id = var.github_app_installation_id authorizer_credential { oauth_token_secret_version = google_secret_manager_secret_version.github_token.id } }
depends_on = [google_secret_manager_secret_iam_member.cb_agent_token]}
resource "google_cloudbuildv2_repository" "sniplink" { location = var.region name = "sniplink" parent_connection = google_cloudbuildv2_connection.github.name remote_uri = "https://github.com/${var.github_repo}.git"}
resource "google_cloudbuild_trigger" "deploy_main" { location = var.region name = "sniplink-deploy-main"
repository_event_config { repository = google_cloudbuildv2_repository.sniplink.id push { branch = "^main$" } }
filename = "cloudbuild.yaml" service_account = google_service_account.build.id substitutions = { _REGION = var.region }}
resource "google_cloudbuild_trigger" "preview_pr" { location = var.region name = "sniplink-preview-pr"
repository_event_config { repository = google_cloudbuildv2_repository.sniplink.id pull_request { branch = "^main$" comment_control = "COMMENTS_ENABLED_FOR_EXTERNAL_CONTRIBUTORS_ONLY" } }
filename = "cloudbuild.pr.yaml" service_account = google_service_account.build.id substitutions = { _REGION = var.region }}Кілька значень заслуговують на окреме речення:
Location = regionна підключенні та тригерах: 2nd-gen repositories регіональні, і тригер має жити в тому ж регіоні, що й його підключення.Branch = "^main$"є регулярним виразом. Без якорівmainтакож матчитьnot-main-yet.CommentControlважливий, бо ваш репозиторій публічний. Pull request з форку приносить власнийcloudbuild.pr.yaml, і цей файл виконується від іменіsniplink-build, який може розшифрувати секрети вашого stack. З цим налаштуванням PR з форку збирається лише після того, як власник чи колаборатор напише коментар/gcbrun. Прочитайте diff, перш ніж писати цей коментар.ServiceAccountприймає повне ім’я ресурсуprojects/PROJECT_ID/serviceAccounts/EMAIL, і саме його повертаєAccount.Id.
Застосуйте це один раз зі свого ноутбука. Ви Owner проєкту, тож маєте дозвіл iam.serviceAccounts.actAs, потрібний для створення тригера з власним сервіс-акаунтом.
cd infrapulumi upНа цьому етапі код сервісу ще з модуля 5, і конфіг stack досі містить ваші значення з модуля 5, тож preview має показати лише створення (build SA, прив’язки, секрет з токеном, підключення, репозиторій, два тригери) і жодних змін у sniplink-api. Якщо він хоче оновити сервіс, зупиніться і з’ясуйте чому, перш ніж іти далі.
Пайплайн: cloudbuild.yaml
Section titled “Пайплайн: cloudbuild.yaml”Cloud Build читає YAML-файл зі списком кроків (steps). Кожен крок складається з образу контейнера плюс аргументів. За замовчуванням кроки виконуються по черзі, мають спільну директорію /workspace (ваш склонований репозиторій) і спільний Docker daemon, тож образ, зібраний на одному кроці, видно на наступному.
Перш ніж братися за файл, програму на Pulumi треба змінити у трьох місцях: імена ревізій беруться з білда, сервіс дізнається, який коміт і білд його створили, а stack експортує те, що задеплоїв.
Від релізів з ручними іменами до однієї ревізії на білд
Section titled “Від релізів з ручними іменами до однієї ревізії на білд”У модулі 5 ви називали кожну ревізію самі (sniplink-api-v1, sniplink-api-v2) через sniplink:release і керували трафіком трьома ключами конфігу. CI залишає другу половину і замінює першу:
- Ім’я ревізії. Кожен білд створює ревізію з іменем
sniplink-api-<shortSha>, наприкладsniplink-api-3f9c2ab. Білд передає$SHORT_SHAякsniplink:revisionSuffix, тож ви ніколи не вводите ім’я вручну, два білди ніколи не конфліктують, і кожне ім’я ревізії вказує на коміт. Короткі SHA шістнадцяткові, тож вони ніколи не перетнуться з іменамиv1/v2з модуля 5, які лишаються у списку ревізій і далі є валідними цілями для відкату. - Трафік. Як і раніше, конфіг stack, закомічений у git, змінюється через pull request. Один новий ключ,
sniplink:trafficMode, обирає між двома формами:promote: ревізія, яку створює цей білд, отримує 100 %. Це щоденний режим.canary:sniplink:stableRevision(ім’я, яке ви комітите) залишає собі100 - canaryPercent%, а ревізія, яку створює цей білд, отримуєcanaryPercent% і тегcanary.
| Ключ | Хто задає | Приклад | Значення |
|---|---|---|---|
sniplink:image |
CI, на кожен білд | …/api@sha256:… |
Digest образу для ревізії цього білда |
sniplink:revisionSuffix |
CI, на кожен білд | 3f9c2ab |
Ім’я ревізії стає sniplink-api-3f9c2ab |
sniplink:commitSha, sniplink:buildId |
CI, на кожен білд | повний SHA, UUID | Змінні середовища COMMIT_SHA і BUILD_ID |
sniplink:version |
ви, через PR | 2.1.0 |
Змінна середовища APP_VERSION |
sniplink:trafficMode |
ви, через PR | promote |
promote або canary |
sniplink:stableRevision |
ви, через PR | sniplink-api-3f9c2ab |
Тримає решту трафіку в режимі canary |
sniplink:canaryPercent |
ви, через PR | 0 |
Частка ревізії цього білда в режимі canary |
sniplink:studentId |
ви, один раз (модуль 1) | cd_… |
Змінна середовища LABKIT_STUDENT_ID |
Реліз тепер виглядає так. Мерджите зміну з trafficMode: canary, stableRevision, що дорівнює ревізії, яка обслуговує трафік сьогодні, і canaryPercent: 0; білд деплоїть новий код за тегом canary. Піднімаєте canaryPercent через PR. Промоутите через PR з trafficMode: promote. Відкочуєтесь через PR з trafficMode: canary, stableRevision, що дорівнює старій ревізії, і canaryPercent: 0. Кожен такий мердж запускає білд, і білд отримує нове ім’я ревізії, навіть якщо змінився лише конфіг: ревізія за тегом перейменовується, а код у неї той самий. Маршрутизація змінюється в момент, коли запускається pulumi up; хвилини до цього займає сам білд. На випадок аварії запасний вихід gcloud run services update-traffic з модуля 5 досі працює, але після нього потрібен PR, який приводить git у відповідність.
Перемкніть закомічений конфіг один раз, перш ніж пушити нову програму:
cd infrapulumi config rm sniplink:release # replaced by revisionSuffix from CIpulumi config rm sniplink:canaryRevision # in canary mode the canary is "this build"pulumi config rm sniplink:image # CI passes the digest on every runpulumi config set sniplink:trafficMode promotepulumi config set sniplink:canaryPercent 0# sniplink:version and sniplink:stableRevision stay; from now on you change them by PRВидалення sniplink:image навмисне: pulumi up з ноутбука тепер падає з помилкою про відсутній конфіг, а не тихо передеплоює старий образ.
sniplink:studentId з модуля 1 теж залишається. Це закомічений конфіг stack, а не секрет, тож білд читає його з Pulumi.dev.yaml, як і будь-який інший ключ, і cloudbuild.yaml не потребує нічого додаткового для LABKIT_STUDENT_ID.
using Pulumi.Gcp.CloudRunV2; // same usings as in module 5using Pulumi.Gcp.CloudRunV2.Inputs;using Sniplink.Infra;// …// config is Config("sniplink"); repo, runtimeSa, labSecret, labKeyAccess,// labKeyVersion from modules 1 and 3const string serviceName = "sniplink-api";var gcpConfig = new Config("gcp");var project = gcpConfig.Require("project");var region = gcpConfig.Require("region");
var image = config.Require("image"); // repo@sha256:… from CIvar revisionSuffix = config.Require("revisionSuffix"); // $SHORT_SHA from CIvar version = config.Require("version"); // APP_VERSION, committedvar commitSha = config.Get("commitSha") ?? "local";var buildId = config.Get("buildId") ?? "local";var trafficMode = config.Get("trafficMode") ?? "promote";var stableRevision = config.Get("stableRevision");var canaryPercent = config.GetInt32("canaryPercent") ?? 0;var studentId = config.Require("studentId");
var revisionName = $"{serviceName}-{revisionSuffix}";
// Fail at preview time, not halfway through an update.if (trafficMode is not ("promote" or "canary")) throw new ArgumentException("trafficMode must be 'promote' or 'canary'");if (canaryPercent is < 0 or > 100) throw new ArgumentException("canaryPercent must be 0..100");if (trafficMode == "canary" && (stableRevision is null || stableRevision == revisionName)) throw new ArgumentException("canary mode needs a stableRevision other than this build's revision");
var traffics = new InputList<ServiceTrafficArgs>();if (trafficMode == "promote"){ traffics.Add(new ServiceTrafficArgs { Type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", Revision = revisionName, Percent = 100, });}else{ traffics.Add(new ServiceTrafficArgs { Type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", Revision = stableRevision!, Percent = 100 - canaryPercent, }); traffics.Add(new ServiceTrafficArgs { Type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION", Revision = revisionName, Percent = canaryPercent, Tag = "canary", });}
var service = new Service(serviceName, new ServiceArgs{ Name = serviceName, Location = region, DeletionProtection = false, Ingress = "INGRESS_TRAFFIC_ALL", Template = new ServiceTemplateArgs { Revision = revisionName, ServiceAccount = runtimeSa.Email, // … MaxInstanceRequestConcurrency, Scaling, Volumes from modules 3 and 4 Containers = { new ServiceTemplateContainerArgs { Image = image, Envs = { new ServiceTemplateContainerEnvArgs { Name = "LABKIT_STUDENT_ID", Value = studentId }, new ServiceTemplateContainerEnvArgs { Name = "APP_VERSION", Value = version }, new ServiceTemplateContainerEnvArgs { Name = "COMMIT_SHA", Value = commitSha }, new ServiceTemplateContainerEnvArgs { Name = "BUILD_ID", Value = buildId }, }, // … ports, resources, probes and volume mounts from modules 1, 3 and 4 }, }, }, Traffics = traffics,}, new CustomResourceOptions { DependsOn = { repo, labKeyAccess, labKeyVersion } });
// … public invoker binding from module 1
var buildSa = BuildIdentity.Create(project, region, repo, service, runtimeSa, labSecret);GitHubPipeline.Create(project, region, config, buildSa);
return new Dictionary<string, object?>{ ["url"] = service.Uri, ["canaryUrl"] = service.TrafficStatuses.Apply(statuses => statuses.FirstOrDefault(s => s.Tag == "canary")?.Uri ?? ""), ["deployedRevision"] = revisionName, ["deployedImage"] = image, ["deployedRevisionSuffix"] = revisionSuffix, ["deployedCommitSha"] = commitSha, ["deployedBuildId"] = buildId,};variable "image" { type = string }variable "revision_suffix" { type = string }variable "app_version" { type = string }variable "commit_sha" { type = string default = "local"}variable "build_id" { type = string default = "local"}variable "traffic_mode" { type = string default = "promote" validation { condition = contains(["promote", "canary"], var.traffic_mode) error_message = "traffic_mode must be promote or canary." }}variable "stable_revision" { type = string default = null}variable "canary_percent" { type = number default = 0}
locals { revision_name = "sniplink-api-${var.revision_suffix}"}
resource "google_cloud_run_v2_service" "sniplink_api" { name = "sniplink-api" location = var.region deletion_protection = false ingress = "INGRESS_TRAFFIC_ALL"
template { revision = local.revision_name service_account = google_service_account.runtime.email # … concurrency, scaling, volumes from modules 3 and 4 containers { image = var.image env { name = "LABKIT_STUDENT_ID" value = var.student_id } env { name = "APP_VERSION" value = var.app_version } env { name = "COMMIT_SHA" value = var.commit_sha } env { name = "BUILD_ID" value = var.build_id } # … ports, resources, probes, volume mounts } }
# promote: this build's revision gets 100 % dynamic "traffic" { for_each = var.traffic_mode == "promote" ? [1] : [] content { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION" revision = local.revision_name percent = 100 } }
# canary: the committed stable revision keeps the rest dynamic "traffic" { for_each = var.traffic_mode == "canary" ? [1] : [] content { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION" revision = var.stable_revision percent = 100 - var.canary_percent } }
dynamic "traffic" { for_each = var.traffic_mode == "canary" ? [1] : [] content { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION" revision = local.revision_name percent = var.canary_percent tag = "canary" } }}
output "deployed_image" { value = var.image }Що змінилося порівняно з модулем 5, рядок за рядком:
Revision = $"{serviceName}-{revisionSuffix}". Те саме правило префікса, що й у модулі 5, але суфікс тепер приходить з білда. Ключаreleaseбільше немає.trafficModeзамінюєcanaryRevision. У модулі 5 canary був ревізією, яку ви називали. У CI роль canary завжди виконує «ревізія, яку створює цей білд», тож називати нічого не треба. У конфігу названо лише stable-бік, бо це та одна ревізія, яку ви свідомо залишаєте.- Обидві цілі залишаються закріпленими за іменем. Ніщо не вказує на «latest», тож білд отримує трафік лише тому, що так каже закомічений разом з ним конфіг.
COMMIT_SHAіBUILD_ID./_lab/infoу LabKit читає їх з оточення. Локально замість них підставляєтьсяlocal, тож деплой з ноутбука помітно відрізняється від деплою з CI.- Outputs
deployed*дозволяють PR-пайплайну (нижче) і вашому ноутбуку робити preview відносно того, що CI насправді задеплоїв.
Для лаби залишайте trafficMode: promote, щоб основний URL обслуговував ревізію коміту, який ви щойно запушили. Фінальний проєкт використовує режим canary саме з цією програмою.
Пайплайн деплою
Section titled “Пайплайн деплою”Закомітьте цей файл у корінь репозиторію.
# Runs on push to main as sniplink-build. No keys anywhere.substitutions: _REGION: europe-west1 _PULUMI_IMAGE: pulumi/pulumi-dotnet-10.0:3.267.0 # pinned, see the note below
steps: # 1. Unit tests. A red test stops the build here. - id: test name: mcr.microsoft.com/dotnet/sdk:10.0 entrypoint: dotnet args: ['test', 'Sniplink.sln', '--configuration', 'Release']
# 2. Build the image, tagged with the short commit SHA (readable in the registry) - id: build name: gcr.io/cloud-builders/docker env: ['DOCKER_BUILDKIT=1'] args: - build - --tag=$_REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api:$SHORT_SHA - --file=Dockerfile - .
# 3. Push to Artifact Registry as sniplink-build - id: push name: gcr.io/cloud-builders/docker args: ['push', '$_REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api:$SHORT_SHA']
# 4. Resolve the immutable digest; tags can be moved, digests cannot - id: digest name: gcr.io/cloud-builders/docker entrypoint: bash args: - -c - | set -euo pipefail docker inspect --format='{{index .RepoDigests 0}}' \ $_REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api:$SHORT_SHA > /workspace/image-ref.txt echo "Deploying $$(cat /workspace/image-ref.txt)"
# 5. Deploy: the same Pulumi program you ran locally, now with CI's identity - id: deploy name: $_PULUMI_IMAGE dir: infra entrypoint: bash args: - -c - | set -euo pipefail pulumi login gs://$PROJECT_ID-pulumi-state pulumi up --yes --non-interactive --stack dev \ --config sniplink:image="$$(cat /workspace/image-ref.txt)" \ --config sniplink:revisionSuffix=$SHORT_SHA \ --config sniplink:commitSha=$COMMIT_SHA \ --config sniplink:buildId=$BUILD_ID
options: # Required when a build runs as a user-specified service account # (alternatively: a logs bucket you own). logging: CLOUD_LOGGING_ONLY
timeout: 1200sПройдімося ним один раз:
- Substitutions. Cloud Build замінює
$NAMEі${NAME}у файлі ще до запуску будь-чого.PROJECT_ID,BUILD_ID,COMMIT_SHAіSHORT_SHAвбудовані;COMMIT_SHAіSHORT_SHAзаповнюються лише для білдів, запущених тригером, а не для ручногоgcloud builds submit. Імена, що починаються з_, належать вам. Блокsubstitutions:задає значення за замовчуванням, аSubstitutionsтригера їх перевизначають, і саме так регіон з конфігу вашого stack потрапляє в білд. $$. Оскільки$належить Cloud Build, знак долара для bash треба писати як$$.$$(cat …)доходить до bash як$(cat …). Якщо забудете про це, Cloud Build відхилить файл через невідому substitution, і це принаймні гучна помилка.- Крок 1 запускає тести в тому самому SDK-образі, який використовує ваш Dockerfile. Теки
bin/іobj/, які він залишає в/workspace, не потрапляють у крок 2 завдяки.dockerignoreз модуля 2. - Кроки 2-3 збирають і пушать образ з тегом
$SHORT_SHA, щоб людина, яка дивиться в registry, могла зіставити образи з комітами.DOCKER_BUILDKIT=1важливий: образcloud-builders/dockerдосі за замовчуванням використовує старий builder, який не задає$BUILDPLATFORMі$TARGETARCH, тож Dockerfile з модуля 2 падає вже на першому рядку зfailed to parse platform. - Крок 4 резолвить digest.
RepoDigestsзаповнюється лише після push. Деплойapi@sha256:…замістьapi:abc1234означає, що ревізія запускає рівно ті байти, які пройшли тести, навіть якщо хтось пізніше запушить інший образ під тим самим тегом. - Крок 5 запускає Pulumi. І GCS state backend, і Google provider використовують Application Default Credentials, а в Cloud Build вони приходять від metadata-сервера як
sniplink-build. Налаштовувати нічого не треба.--configзаписує чотири значення конкретного білда в копіюPulumi.dev.yamlцього білда лише на цей запуск; ваш закомічений файл не змінюється. Трафік береться із закомічених ключів, тож білд робить з маршрутизацією саме те, що сказав змерджений PR.
Кроки 1 і 2 незалежні, тож їх можна запустити одночасно, поставивши waitFor: ['-'] на кожен і waitFor: ['test', 'build'] на push. У курсі я тримаю файл послідовним, бо так його легше читати в логах, а тест, що впав, тоді ще й економить час на збирання образу.
Пайплайн для pull request
Section titled “Пайплайн для pull request”PR-білд відповідає на два питання: чи проходять тести і що ця зміна зробить з продакшеном? Він ніколи не деплоїть.
# Runs on pull requests to main as sniplink-build. Never deploys.substitutions: _PULUMI_IMAGE: pulumi/pulumi-dotnet-10.0:3.267.0 # same pinned tag as cloudbuild.yaml
steps: - id: test name: mcr.microsoft.com/dotnet/sdk:10.0 entrypoint: dotnet args: ['test', 'Sniplink.sln', '--configuration', 'Release']
- id: preview name: $_PULUMI_IMAGE dir: infra entrypoint: bash args: - -c - | set -euo pipefail pulumi login gs://$PROJECT_ID-pulumi-state # Compare against what CI deployed, not against the committed config, # otherwise every preview shows the image and env vars as "changed". pulumi preview --non-interactive --diff --stack dev \ --config sniplink:image="$$(pulumi stack output deployedImage --stack dev)" \ --config sniplink:revisionSuffix="$$(pulumi stack output deployedRevisionSuffix --stack dev)" \ --config sniplink:commitSha="$$(pulumi stack output deployedCommitSha --stack dev)" \ --config sniplink:buildId="$$(pulumi stack output deployedBuildId --stack dev)"
options: logging: CLOUD_LOGGING_ONLYВивід preview у лозі білда рев’юер читає поруч із diff коду. Якщо PR, який «лише перейменовує змінну», показує заміну Cloud Run, ви дізнаєтесь про це до мерджу, а не після. Для PR з трафіком (скажімо, canaryPercent 0 → 10) preview показує зміну трафіку відносно ревізії, яка зараз live; після мерджу білд застосовує той самий розподіл трафіку, але вже з власною, щойно названою ревізією в canary-слоті.
Ваш ноутбук використовує той самий трюк. Щоб зробити preview локально після модуля 6, передавайте задеплоєні значення точно так, як це робить cloudbuild.pr.yaml.
Для обох тригерів я використовую один сервіс-акаунт, щоб модуль не розрісся. Суворіший варіант дає PR-білдам власний SA з ролями лише на читання (objectViewer на бакеті state, decrypter на KMS і viewer-ролі на ресурсах), тож шкідливий PR не зможе нічого записати навіть після необережного /gcbrun. Доступу на читання до бакета state достатньо, бо pulumi preview не бере lock на self-managed backend на кшталт GCS-бакета; pulumi up пише lock-файл і потребує доступу на запис.
Перший запуск
Section titled “Перший запуск”Закомітьте обидва YAML-файли і зміни в infra, запуште в main і стежте за білдом:
git add cloudbuild.yaml cloudbuild.pr.yaml infra/git commit -m "Deploy from Cloud Build"git push origin main
# Latest builds in your region, then stream one build's loggcloud builds list --region="$REGION" --limit=5gcloud builds log BUILD_ID --region="$REGION" --streamЛоги також є в Cloud Logging, завдяки CLOUD_LOGGING_ONLY, тож у консолі (лише для читання) шукайте Cloud Build → History для вашого регіону. Коли білд зелений:
curl -s "$(cd infra && pulumi stack output url)/_lab/info" | jq '{commitSha, buildId, revision, version}'commitSha має бути повним SHA вашого коміту, buildId має бути UUID, а revision має мати вигляд sniplink-api- і далі короткий SHA.
Той самий пайплайн на GitHub Actions
Section titled “Той самий пайплайн на GitHub Actions”GitHub Actions є тим CI, яким більшість .NET-команд уже користуються. Пайплайн той самий; змінюється те, як джоба стає sniplink-build. Тут GitHub підписує OIDC-токен для кожної джоби, а Google Cloud довіряє йому через Workload Identity Federation.
Більше нічого робити не треба. Тригер запускає cloudbuild.yaml від імені sniplink-build, а ідентичність приходить від metadata-сервера всередині вашого проєкту. Визначення білда лежить у вашому репозиторії, тригер і його ідентичність у вашому Pulumi stack, а логи в Cloud Logging вашого проєкту.
name: deployon: push: branches: [main]
permissions: contents: read id-token: write # lets the job request a GitHub OIDC token
env: REGION: europe-west1 PROJECT_ID: your-project-id IMAGE: europe-west1-docker.pkg.dev/your-project-id/sniplink/api
jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-dotnet@v6 with: dotnet-version: '10.0.x'
- run: dotnet test Sniplink.sln --configuration Release
# Exchange the GitHub OIDC token for short-lived Google credentials - uses: google-github-actions/auth@v3 with: workload_identity_provider: projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/github/providers/github-oidc service_account: sniplink-build@your-project-id.iam.gserviceaccount.com
- uses: google-github-actions/setup-gcloud@v3 - run: gcloud auth configure-docker ${REGION}-docker.pkg.dev --quiet
- name: Build and push run: | docker build --tag "$IMAGE:${GITHUB_SHA::7}" . docker push "$IMAGE:${GITHUB_SHA::7}" echo "IMAGE_REF=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE:${GITHUB_SHA::7}")" >> "$GITHUB_ENV"
- uses: pulumi/actions@v7 # installs the Pulumi CLI - name: Deploy working-directory: infra run: | pulumi login "gs://${PROJECT_ID}-pulumi-state" pulumi up --yes --non-interactive --stack dev \ --config sniplink:image="$IMAGE_REF" \ --config sniplink:revisionSuffix="${GITHUB_SHA::7}" \ --config sniplink:commitSha="$GITHUB_SHA" \ --config sniplink:buildId="$GITHUB_RUN_ID"GITHUB_RUN_ID є числом, а не UUID, тож цей варіант не проходить перевірку buildId у лабі. Лаба навмисно вимагає Cloud Build; використовуйте цю вкладку як патерн для основної роботи.
Бік ідентичності: pool, provider, прив’язка
Section titled “Бік ідентичності: pool, provider, прив’язка”Три ресурси роблять GitHub-токен прийнятним для Google Cloud:
- Workload identity pool: контейнер для зовнішніх ідентичностей у вашому проєкті.
- Provider у цьому pool: «довіряй токенам, виданим
https://token.actions.githubusercontent.com», плюс attribute mapping, який копіює claims з токена GitHub в атрибути Google, і attribute condition, яка відхиляє кожен токен не з вашого репозиторію. Без умови будь-який GitHub-репозиторій у світі міг би пред’явити вашому pool валідний токен; саме тому Google тепер вимагає умову для GitHub-провайдерів. - Прив’язка
roles/iam.workloadIdentityUserнаsniplink-buildдо principal set «усі ідентичності в цьому pool, у яких атрибутrepositoryдорівнюєowner/sniplink». Саме вона дозволяє джобі імперсонувати SA.
Обмін токенів виконує Security Token Service API (sts.googleapis.com). Його не було в bootstrap-списку, тож stack вмикає його сам.
using Pulumi;using Gcp = Pulumi.Gcp;
namespace Sniplink.Infra;
public static class GitHubActionsIdentity{ public static void Create(string githubRepo, Gcp.ServiceAccount.Account buildSa) { var sts = new Gcp.Projects.Service("sts", new() { ServiceName = "sts.googleapis.com", DisableOnDestroy = false, });
var pool = new Gcp.Iam.WorkloadIdentityPool("github", new() { WorkloadIdentityPoolId = "github", DisplayName = "GitHub Actions", }, new CustomResourceOptions { DependsOn = { sts } });
new Gcp.Iam.WorkloadIdentityPoolProvider("github-oidc", new() { WorkloadIdentityPoolId = pool.WorkloadIdentityPoolId, WorkloadIdentityPoolProviderId = "github-oidc", DisplayName = "GitHub OIDC", Oidc = new Gcp.Iam.Inputs.WorkloadIdentityPoolProviderOidcArgs { IssuerUri = "https://token.actions.githubusercontent.com", }, AttributeMapping = { ["google.subject"] = "assertion.sub", ["attribute.repository"] = "assertion.repository", ["attribute.ref"] = "assertion.ref", }, // Only tokens from this repository are accepted at all AttributeCondition = $"assertion.repository == '{githubRepo}'", });
// pool.Name = projects/NUMBER/locations/global/workloadIdentityPools/github new Gcp.ServiceAccount.IAMMember("github-impersonates-build", new() { ServiceAccountId = buildSa.Name, Role = "roles/iam.workloadIdentityUser", Member = pool.Name.Apply(n => $"principalSet://iam.googleapis.com/{n}/attribute.repository/{githubRepo}"), }); }}resource "google_project_service" "sts" { service = "sts.googleapis.com" disable_on_destroy = false}
resource "google_iam_workload_identity_pool" "github" { workload_identity_pool_id = "github" display_name = "GitHub Actions" depends_on = [google_project_service.sts]}
resource "google_iam_workload_identity_pool_provider" "github_oidc" { workload_identity_pool_id = google_iam_workload_identity_pool.github.workload_identity_pool_id workload_identity_pool_provider_id = "github-oidc" display_name = "GitHub OIDC"
oidc { issuer_uri = "https://token.actions.githubusercontent.com" }
attribute_mapping = { "google.subject" = "assertion.sub" "attribute.repository" = "assertion.repository" "attribute.ref" = "assertion.ref" }
attribute_condition = "assertion.repository == '${var.github_repo}'"}
resource "google_service_account_iam_member" "github_impersonates_build" { service_account_id = google_service_account.build.name role = "roles/iam.workloadIdentityUser" member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.github.name}/attribute.repository/${var.github_repo}"}Два покращення, які я використовую на роботі. Ставте умову на assertion.repository_id, а не на ім’я, бо видалений і створений наново (або перейменований) репозиторій може повторно використати ім’я, але ніколи не повторить числовий ID. А для деплоїв додайте до умови assertion.ref == 'refs/heads/main' (або зробіть окремий provider), щоб джоба на feature-гілці взагалі не могла імперсонувати deploy SA.
Cloud Build чи GitHub Actions?
Section titled “Cloud Build чи GitHub Actions?”| Cloud Build | GitHub Actions + WIF | |
|---|---|---|
| Ідентичність | SA, прикріплений до білда; федерувати нічого не треба | OIDC-федерація: pool, provider, умова, яку треба налаштувати правильно |
| Де що живе | Тригер та ідентичність живуть у вашому проєкті та вашому IaC; логи в Cloud Logging | Workflow у репозиторії; логи й історія запусків у GitHub |
| Екосистема | Будь-який контейнер як крок; маркетплейсу немає | Величезний маркетплейс actions, matrix builds, environments з апрувами |
| Досвід розробника | Логи і статус доходять до PR через GitHub app, але UI знаходиться в Cloud console | PR checks, анотації та перезапуски там, де розробники й так працюють |
| Приватна мережа | Private pools можуть дістатися ресурсів у вашому VPC | Для цього потрібні self-hosted runners |
| Free tier | Див. нижче | Безкоштовно на стандартних раннерах для публічних репозиторіїв; 2000 хвилин на місяць для приватних на GitHub Free |
Моя чесна думка: якщо ваша команда живе в GitHub і не потребує доступу до VPC з CI, Actions з WIF є нормальним вибором за замовчуванням, а додаткове налаштування ідентичності буде разовою ціною. Cloud Build виграє, коли ви хочете тримати ідентичність білда, його логи й audit trail усередині проєкту, коли потрібні private pools або нульова довіра до третіх сторін. Навичка, якої вчить цей модуль (ідентичність без ключів з коротким списком ролей з вузьким скоупом), однакова в обох випадках.
Вартість і логи білдів
Section titled “Вартість і логи білдів”Безкоштовний ліміт Cloud Build становить 2500 build-хвилин на місяць на білінг-акаунт, на типі машини за замовчуванням e2-standard-2 у default pool. Понад це та сама машина коштує 0,006 USD за build-хвилину. Google зазначає, що free tier є промо-пропозицією і може змінитися, тож перевірте сторінку з цінами, перш ніж на нього покладатися.
Білд Sniplink займає кілька хвилин: тести, збирання образу і dotnet build проєкту infra перед pulumi up. При приблизно шести хвилинах на push ви вкладаєтесь у free tier з запасом приблизно на 400 білдів на місяць, до чого курсовий проєкт і близько не дійде. Решта витрат цього модуля обмежується центами: ще один секрет (GitHub-токен) і образ в Artifact Registry на кожен коміт. Останнє тихо росте; cleanup policy в Artifact Registry, яка залишає останні N версій, варто додати, коли ви закінчите курс.
Логи йдуть лише в Cloud Logging (CLOUD_LOGGING_ONLY), у якого є власний безкоштовний ліміт на ingestion. Ставтеся до логів білдів так само, як мали б ставитися до логу джоби з інциденту: кожен, хто може їх читати, бачить усе, що виводить крок. Пайплайн вище ніколи не виводить секрети, а pulumi up маскує секретні значення конфігу у своєму виводі.
Мета: push у main вашого публічного GitHub-репозиторію тестується, збирається й деплоїться через Cloud Build від імені sniplink-build, а запущений сервіс може довести, з якого коміту і білда він походить.
Вимоги:
- Ваш репозиторій Sniplink публічний на GitHub і містить
cloudbuild.yamlтаcloudbuild.pr.yamlу корені. - Build SA, підключення, репозиторій і обидва тригери створені через Pulumi, з ролями з цього модуля і нічим ширшим.
- Поточну ревізію сервісу задеплоїв push-тригер з
trafficMode: promote, тож основний URL обслуговуєsniplink-api-<shortSha>,COMMIT_SHAмістить повний 40-символьний SHA, аBUILD_IDмістить UUID з Cloud Build. - У проєкті немає ключів сервіс-акаунтів, якими керує користувач.
Перевірте лабу
Що перевіряє кожна перевірка:
- Token і health: база з кожної лаби. Сервіс працює в Cloud Run у вашому проєкті.
- Commit SHA:
/_lab/infoповертаєcommitShaз 40 шістнадцяткових символів у нижньому регістрі. Самозвітна: сервіс повертає те, що каже його оточення. - Build ID:
buildIdє UUID, тобто має формат, який використовує Cloud Build. Теж самозвітна. - GitHub commit: платформа питає публічний API GitHub, чи існує цей коміт у репозиторії, який ви вказали, і чи існує
cloudbuild.yamlу цьому коміті. Це пов’язує запущену ревізію з реальним кодом у реальному репозиторії.
Чесно про обмеження: наполегливий студент міг би виставити ці змінні середовища вручну з ноутбука і пройти. Суворіша перевірка читала б історію Cloud Build та IAM сервісу напряму, і саме це згодом робитиме опційний рівень Verified. Самоперевірку нижче взагалі неможливо автоматизувати через URL, тож вона на вас.
Самоперевірка: жодних ключів. Виведіть ключі, якими керує користувач, для кожного сервіс-акаунта в проєкті. Ключі, якими керує Google, Google ротує сам, і з цим фільтром вони не показуються. Порожній вивід означає успіх.
for sa in $(gcloud iam service-accounts list --project="$PROJECT_ID" --format='value(email)'); do gcloud iam service-accounts keys list \ --iam-account="$sa" --managed-by=user --format='value(name)'doneЯкщо щось вивелося, видаліть це через gcloud iam service-accounts keys delete KEY_ID --iam-account=SA_EMAIL, а потім з’ясуйте, звідки воно взялося.
Прибирання
Section titled “Прибирання”Фінальний проєкт у модулі 7 є новим сервісом, але він використовує ті самі патерни: build SA, підключення до GitHub, тригер. У вас два варіанти.
Залишити все (рекомендую, якщо скоро продовжите). Підключення і тригери нічого не коштують, поки простоюють, а пайплайн модуля 7 може перевикористати підключення. Cloud Run масштабується до нуля. Єдині постійні витрати складають центи за версію KMS-ключа, секрети і збережені образи.
Знищити stack (якщо робите паузу). Запускайте зі свого ноутбука, бо права видаляти IAM-прив’язки і тригер є лише у вас:
cd infrapulumi destroyЦе видаляє сервіс, registry з образами, секрети, build SA, WIF pool (якщо ви його створювали), підключення та обидва тригери. pulumi destroy потребує того самого конфігу конкретного білда, що й preview, тож якщо він скаржиться на sniplink:image, передайте задеплоєні значення через --config, як у cloudbuild.pr.yaml. Дві речі залишаються, і прибрати їх треба вручну: встановлений застосунок Google Cloud Build на GitHub (Settings → Applications) і GitHub-токен, який варто відкликати в Settings → Developer settings. Видалені workload identity pools 30 днів перебувають у стані soft-delete і до того часу блокують той самий pool ID.
Що ви вивчили
Section titled “Що ви вивчили”- Ключ сервіс-акаунта є обліковими даними без терміну дії, які легко скопіювати; CI без ключів використовує прикріплену ідентичність (Cloud Build) або федерацію (Workload Identity Federation), а політика
iam.disableServiceAccountKeyCreationробить ключі неможливими за замовчуванням. - Окремий build SA отримує кожну роль на найвужчий ресурс: репозиторій, сервіс, runtime SA, бакет, ключ, секрет. Коли CI керує власним IAM, саме мінімальні привілеї перетворюють небезпечний коміт на білд, що впав, а окремий platform-stack дає чисте довгострокове розділення.
- 2nd-gen підключення до GitHub потребує одного встановлення GitHub app; секрет з токеном, підключення, репозиторій і тригери описуються кодом, а PR-білди з форків потребують гейту через коментар.
cloudbuild.yamlтестує, збирає, пушить і деплоїть за digest, називає кожну ревізію за її комітом і передаєCOMMIT_SHAтаBUILD_IDчерез конфіг Pulumi в сервіс, тоді як PR-білди лише роблятьpulumi preview. Трафік лишається в закоміченому конфігу, тож промоут і відкат робляться через pull request.- GitHub Actions робить ту саму роботу з OIDC pool, provider з attribute condition і прив’язкою
workloadIdentityUser, обмеженою одним репозиторієм.