diff --git a/Makefile b/Makefile index aeb5f2d..f694631 100644 --- a/Makefile +++ b/Makefile @@ -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 +# _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 :$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 diff --git a/infra/main.go b/infra/main.go index a2166f9..b13f2f1 100644 --- a/infra/main.go +++ b/infra/main.go @@ -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) diff --git a/infra/pkg/iam/iam.go b/infra/pkg/iam/iam.go index 8157247..f58f3c9 100644 --- a/infra/pkg/iam/iam.go +++ b/infra/pkg/iam/iam.go @@ -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. diff --git a/infra/pkg/storage/storage.go b/infra/pkg/storage/storage.go index 6e562d1..ed6671a 100644 --- a/infra/pkg/storage/storage.go +++ b/infra/pkg/storage/storage.go @@ -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 _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:///vm/ and returns a content hash of