Give manual image builds a source bucket cb-image can read

The first `make build` failed before any step ran:

  INVALID_ARGUMENT: could not resolve source: cb-image@... does not
  have storage.objects.get access to ... gitea-496920_cloudbuild/source/...

`gcloud builds submit` uploads the source tarball to <project>_cloudbuild
and the build, running as the user-specified cb-image@, must read it
back. Nothing grants that. Binding on that bucket is not an option: gcloud
creates it on the first submit, after `pulumi up` has already run.
Project-wide objectViewer would also open the backup, config and state
buckets.

Pulumi now owns <project>-gitea-build-source, readable by cb-image@ and
nothing else, with a 7-day delete rule since each tarball is read once.
`make build` stages there via --gcs-source-staging-dir. Triggered builds
fetch source through the GitHub connection and are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 04:04:18 -05:00
committed by Jason Ross
co-authored by Claude Opus 5.5
parent d48f2f5e0c
commit a1669d6be1
4 changed files with 49 additions and 2 deletions
+5
View File
@@ -44,6 +44,10 @@ up: check ## Apply the infrastructure
# to cb-image@, not to whatever default Cloud Build account this project
# happens to have -- and on newer projects the legacy default does not exist.
# Without this the images push fine and the rollout step fails.
# --gcs-source-staging-dir: running as cb-image@, the build must be able to read
# the uploaded source. Pulumi grants that on this bucket only; the default
# <project>_cloudbuild bucket is unreadable to it and the build fails at
# "could not resolve source".
# SHORT_SHA: Cloud Build fills it in only for triggered builds. For `builds
# submit` it is empty, and image.yaml's `--tag <image>:$SHORT_SHA` becomes an
# invalid reference that fails the build.
@@ -52,6 +56,7 @@ build: ## Build and roll out the container images via Cloud Build
gcloud builds submit --config cloudbuild/image.yaml --project $(PROJECT) \
--region=$(REGION) \
--service-account=projects/$(PROJECT)/serviceAccounts/cb-image@$(PROJECT).iam.gserviceaccount.com \
--gcs-source-staging-dir=gs://$(PROJECT)-gitea-build-source/source \
--substitutions=_REGION=$(REGION),_ZONE=$(ZONE),SHORT_SHA=$(shell git rev-parse --short=7 HEAD)
.PHONY: rollout
+4
View File
@@ -74,6 +74,9 @@ func main() {
if err := iam.GrantBuckets(ctx, accounts, buckets.Config, buckets.Backup); err != nil {
return err
}
if err := iam.GrantBuildSource(ctx, accounts, buckets.BuildSource); err != nil {
return err
}
// The zone already exists and is delegated; this only adds the A record
// and the zone-scoped permission Caddy needs for DNS-01.
@@ -100,6 +103,7 @@ func main() {
ctx.Export("registry", pulumi.Sprintf("%s/%s/%s", cfg.ARHost(), cfg.Project, registry.RepoID))
ctx.Export("configBucket", buckets.Config.Name)
ctx.Export("backupBucket", buckets.Backup.Name)
ctx.Export("buildSourceBucket", buckets.BuildSource.Name)
ctx.Export("configHash", pulumi.String(buckets.ConfigHash))
ctx.Export("vmServiceAccount", accounts.VM.Email)
ctx.Export("imageServiceAccount", accounts.Image.Email)
+11
View File
@@ -152,6 +152,17 @@ func GrantSecretRead(ctx *pulumi.Context, cfg *config.Config, a *Accounts, name
return err
}
// GrantBuildSource lets cb-image@ read the tarballs `make build` stages, and
// nothing else in storage. See storage.New for why the bucket exists.
func GrantBuildSource(ctx *pulumi.Context, a *Accounts, sourceBucket *storage.Bucket) error {
_, err := storage.NewBucketIAMMember(ctx, "img-build-source-reader", &storage.BucketIAMMemberArgs{
Bucket: sourceBucket.Name,
Role: pulumi.String("roles/storage.objectViewer"),
Member: pulumi.Sprintf("serviceAccount:%s", a.Image.Email),
})
return err
}
// GrantBuckets: read-only on config, write-only on backups. The VM can create a
// backup but cannot read or delete existing ones, which limits what ransomware
// on the box could do to the backup history.
+29 -2
View File
@@ -1,4 +1,4 @@
// Package storage holds the two buckets and, importantly, uploads the vm/ tree
// Package storage holds the buckets and, importantly, uploads the vm/ tree
// as Pulumi-managed objects.
//
// Uploading the VM configuration through Pulumi (rather than a `gcloud storage
@@ -24,6 +24,8 @@ import (
type Buckets struct {
Config *storage.Bucket
Backup *storage.Bucket
// BuildSource stages the source tarball for `make build`. See New.
BuildSource *storage.Bucket
// ConfigHash changes whenever any file under vm/ changes. It is written into
// instance metadata so a config change is visible from `describe`, and so
// there is something to compare against when debugging drift.
@@ -72,12 +74,37 @@ func New(ctx *pulumi.Context, cfg *config.Config, vmDir string, deps []pulumi.Re
return nil, err
}
// Where `gcloud builds submit` stages its source tarball. Builds run as
// cb-image@, which needs storage.objects.get on that tarball. The default
// staging bucket is <project>_cloudbuild, created by gcloud on the first
// submit -- after `pulumi up`, so there is nothing to bind to in advance --
// and project-wide objectViewer would also open the backup and state
// buckets. A dedicated bucket keeps the grant exact. Triggered builds fetch
// source through the GitHub connection and never touch it.
buildSourceBucket, err := storage.NewBucket(ctx, "gitea-build-source", &storage.BucketArgs{
Name: pulumi.Sprintf("%s-gitea-build-source", cfg.Project),
Location: pulumi.String(strings.ToUpper(cfg.Region)),
UniformBucketLevelAccess: pulumi.Bool(true),
PublicAccessPrevention: pulumi.String("enforced"),
// Tarballs are only read once, by the build they were uploaded for.
LifecycleRules: storage.BucketLifecycleRuleArray{
&storage.BucketLifecycleRuleArgs{
Action: &storage.BucketLifecycleRuleActionArgs{Type: pulumi.String("Delete")},
Condition: &storage.BucketLifecycleRuleConditionArgs{Age: pulumi.Int(7)},
},
},
ForceDestroy: pulumi.Bool(true),
}, opts)
if err != nil {
return nil, err
}
hash, err := uploadTree(ctx, configBucket, vmDir)
if err != nil {
return nil, err
}
return &Buckets{Config: configBucket, Backup: backupBucket, ConfigHash: hash}, nil
return &Buckets{Config: configBucket, Backup: backupBucket, BuildSource: buildSourceBucket, ConfigHash: hash}, nil
}
// uploadTree mirrors vmDir into gs://<bucket>/vm/ and returns a content hash of