Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/bootstrap/gcp/gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,13 @@ type CodesphereEnvironment struct {
CreateTestUser bool `json:"-"`
OmsWorkdir string `json:"-"`
RootDiskSize int64 `json:"root_disk_size"`

// OpenFGA database backups. The bucket lives in the project and is removed
// together with the project on cleanup. Access key/secret are populated only
// when a new HMAC key is created.
OpenfgaBackupBucket string `json:"openfga_backup_bucket"`
OpenfgaBackupAccessKeyID string `json:"-"`
OpenfgaBackupSecret string `json:"-"`
}

func NewGCPBootstrapper(
Expand Down Expand Up @@ -272,6 +279,11 @@ func (b *GCPBootstrapper) Bootstrap() error {
return fmt.Errorf("failed to ensure IAM roles: %w", err)
}

err = b.stlog.Step("Ensure openfga backup bucket", b.EnsureOpenfgaBackupBucket)
if err != nil {
return fmt.Errorf("failed to ensure openfga backup bucket: %w", err)
}

err = b.stlog.Step("Ensure VPC", b.EnsureVPC)
if err != nil {
return fmt.Errorf("failed to ensure VPC: %w", err)
Expand Down
49 changes: 49 additions & 0 deletions internal/bootstrap/gcp/gcp_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ import (
"github.com/lithammer/shortuuid"
"google.golang.org/api/cloudbilling/v1"
"google.golang.org/api/dns/v1"
"google.golang.org/api/googleapi"
"google.golang.org/api/iam/v1"
"google.golang.org/api/iterator"
publicca "google.golang.org/api/publicca/v1"
storage "google.golang.org/api/storage/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/fieldmaskpb"
Expand Down Expand Up @@ -63,6 +65,8 @@ type GCPClientManager interface {
EnsureDNSRecordSets(projectID, zoneName string, records []*dns.ResourceRecordSet) error
DeleteDNSRecordSets(projectID, zoneName, baseDomain string) error
CreatePublicCAExternalAccountKey(projectID string) (keyID, b64MacKey string, err error)
EnsureStorageBucket(projectID, bucketName, location string) error
CreateHMACKey(projectID, serviceAccountEmail string) (accessID, secret string, err error)
}

// Concrete implementation
Expand Down Expand Up @@ -874,6 +878,51 @@ func (c *GCPClient) CreatePublicCAExternalAccountKey(projectID string) (string,
return key.KeyId, key.B64MacKey, nil
}

// EnsureStorageBucket creates a Cloud Storage bucket in the given project and
// location. It is idempotent: an already-existing bucket owned by the project is
// treated as success. The bucket lives in the project so it is removed together
// with the project on cleanup.
func (c *GCPClient) EnsureStorageBucket(projectID, bucketName, location string) error {
svc, err := storage.NewService(c.ctx)
if err != nil {
return fmt.Errorf("failed to create storage client: %w", err)
}

bucket := &storage.Bucket{
Name: bucketName,
Location: location,
}
_, err = svc.Buckets.Insert(projectID, bucket).Context(c.ctx).Do()
if err != nil {
if apiErr, ok := err.(*googleapi.Error); ok && apiErr.Code == 409 {
// Bucket already exists (owned by this project on re-runs).
return nil
}
return fmt.Errorf("failed to create storage bucket %s: %w", bucketName, err)
}
return nil
}

// CreateHMACKey creates an HMAC key for the given service account, used for
// S3-compatible access to Cloud Storage. The secret is only returned at creation
// time, so callers must persist it. HMAC keys are removed together with the
// project on cleanup.
func (c *GCPClient) CreateHMACKey(projectID, serviceAccountEmail string) (string, string, error) {
svc, err := storage.NewService(c.ctx)
if err != nil {
return "", "", fmt.Errorf("failed to create storage client: %w", err)
}

key, err := svc.Projects.HmacKeys.Create(projectID, serviceAccountEmail).Context(c.ctx).Do()
if err != nil {
return "", "", fmt.Errorf("failed to create HMAC key: %w", err)
}
if key.Metadata == nil {
return "", "", fmt.Errorf("HMAC key response missing metadata")
}
return key.Metadata.AccessId, key.Secret, nil
}

// Helper functions
func protoString(s string) *string { return &s }
func protoBool(b bool) *bool { return &b }
Expand Down
6 changes: 6 additions & 0 deletions internal/bootstrap/gcp/gcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,12 @@ var _ = Describe("GCP Bootstrapper", func() {
gc.EXPECT().AssignIAMRole(projectId, "cloud-controller", projectId, []string{"roles/compute.admin"}).Return(nil)
gc.EXPECT().AssignIAMRole(csEnv.DNSProjectID, "cloud-controller", projectId, []string{"roles/dns.admin"}).Return(nil)

// EnsureOpenfgaBackupBucket
gc.EXPECT().EnsureStorageBucket(projectId, projectId+"-openfga-backup", "us-central1").Return(nil)
gc.EXPECT().CreateServiceAccount(projectId, "openfga-backup", "openfga-backup").Return("openfga-backup@p.iam.gserviceaccount.com", true, nil)
gc.EXPECT().AssignIAMRole(projectId, "openfga-backup", projectId, []string{"roles/storage.objectAdmin"}).Return(nil)
gc.EXPECT().CreateHMACKey(projectId, "openfga-backup@p.iam.gserviceaccount.com").Return("fake-access-id", "fake-secret", nil)

// EnsureVPC
gc.EXPECT().CreateVPC(projectId, "us-central1", projectId+"-vpc", projectId+"-us-central1-subnet", projectId+"-router", projectId+"-nat-gateway").Return(nil)

Expand Down
1 change: 1 addition & 0 deletions internal/bootstrap/gcp/iam_admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ func (b *GCPBootstrapper) EnsureAPIsEnabled() error {
"serviceusage.googleapis.com",
"artifactregistry.googleapis.com",
"dns.googleapis.com",
"storage.googleapis.com",
}
if b.Env.GoogleACMEIssuer {
apis = append(apis, "publicca.googleapis.com")
Expand Down
1 change: 1 addition & 0 deletions internal/bootstrap/gcp/iam_admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ var _ = Describe("IAM & Admin", func() {
"serviceusage.googleapis.com",
"artifactregistry.googleapis.com",
"dns.googleapis.com",
"storage.googleapis.com",
}).Return(nil)

err := bs.EnsureAPIsEnabled()
Expand Down
67 changes: 67 additions & 0 deletions internal/bootstrap/gcp/install_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@ func (b *GCPBootstrapper) UpdateInstallConfig() error {
}
b.applyExternalLokiConfig()
b.applyPrometheusRemoteWriteConfig()
b.applyOpenfgaBackupConfig()

if !b.Env.ExistingConfigUsed {
err := b.icg.GenerateSecrets()
Expand Down Expand Up @@ -450,6 +451,72 @@ func (b *GCPBootstrapper) applySshProxyConfig() {
})
}

// openfgaBackupSAName is the service account whose HMAC key authenticates OpenFGA
// database backups against the S3-compatible Cloud Storage endpoint.
const openfgaBackupSAName = "openfga-backup"

// EnsureOpenfgaBackupBucket creates the Cloud Storage bucket, dedicated service
// account and HMAC key used for OpenFGA database backups. The bucket and HMAC key
// live in the project so they are removed together with the project on cleanup.
//
// The HMAC secret is only returned at creation time, so it is persisted to the
// vault by applyOpenfgaBackupConfig. Creation is skipped when a real secret is
// already present in the vault (e.g. on re-runs or recovered configs).
func (b *GCPBootstrapper) EnsureOpenfgaBackupBucket() error {
bucketName := fmt.Sprintf("%s-openfga-backup", b.Env.ProjectID)

if err := b.GCPClient.EnsureStorageBucket(b.Env.ProjectID, bucketName, b.Env.Region); err != nil {
return fmt.Errorf("failed to ensure openfga backup bucket: %w", err)
}
b.Env.OpenfgaBackupBucket = bucketName

saEmail, _, err := b.GCPClient.CreateServiceAccount(b.Env.ProjectID, openfgaBackupSAName, openfgaBackupSAName)
if err != nil {
return fmt.Errorf("failed to ensure openfga backup service account: %w", err)
}
if err := b.GCPClient.AssignIAMRole(b.Env.ProjectID, openfgaBackupSAName, b.Env.ProjectID, []string{"roles/storage.objectAdmin"}); err != nil {
return fmt.Errorf("failed to assign storage role to openfga backup service account: %w", err)
}

// The HMAC secret cannot be retrieved after creation, so only create a new key
// when we don't already have a real one persisted in the vault.
if existing := b.icg.GetVault().GetSecret(files.SecretOpenfgaDbBackupSecretAccessKey); existing != nil &&
existing.Fields != nil && existing.Fields.Password != "" && existing.Fields.Password != "dummy" {
return nil
}

accessID, secret, err := b.GCPClient.CreateHMACKey(b.Env.ProjectID, saEmail)
if err != nil {
return fmt.Errorf("failed to create openfga backup HMAC key: %w", err)
}
b.Env.OpenfgaBackupAccessKeyID = accessID
b.Env.OpenfgaBackupSecret = secret

return nil
}

// applyOpenfgaBackupConfig wires the bucket created by EnsureOpenfgaBackupBucket
// into the install config and persists the HMAC credentials to the vault. It is a
// no-op when no bucket was provisioned.
func (b *GCPBootstrapper) applyOpenfgaBackupConfig() {
if b.Env.OpenfgaBackupBucket == "" {
return
}

b.Env.InstallConfig.Codesphere.OpenfgaBackups = &files.OpenfgaBackupsConfig{
Enabled: true,
DestinationPath: "s3://" + b.Env.OpenfgaBackupBucket,
EndpointURL: "https://storage.googleapis.com",
}

// Only overwrite when a new HMAC key was created this run; otherwise the
// existing secret loaded from the vault is kept.
if b.Env.OpenfgaBackupAccessKeyID != "" {
b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretOpenfgaDbBackupAccessKeyId, Fields: &files.SecretFields{Password: b.Env.OpenfgaBackupAccessKeyID}})
b.icg.GetVault().SetSecret(files.SecretEntry{Name: files.SecretOpenfgaDbBackupSecretAccessKey, Fields: &files.SecretFields{Password: b.Env.OpenfgaBackupSecret}})
}
}

func (b *GCPBootstrapper) applyExternalLokiConfig() {
if b.Env.ExternalLokiEndpoint == "" {
return
Expand Down
75 changes: 75 additions & 0 deletions internal/bootstrap/gcp/install_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,51 @@ var _ = Describe("Installconfig & Secrets", func() {
})
})

Describe("EnsureOpenfgaBackupBucket", func() {
It("creates the bucket, service account and HMAC key", func() {
vault := &files.InstallVault{}
icg.EXPECT().GetVault().Return(vault)

gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(nil)
gc.EXPECT().CreateServiceAccount("pid", "openfga-backup", "openfga-backup").Return("openfga-backup@pid.iam.gserviceaccount.com", true, nil)
gc.EXPECT().AssignIAMRole("pid", "openfga-backup", "pid", []string{"roles/storage.objectAdmin"}).Return(nil)
gc.EXPECT().CreateHMACKey("pid", "openfga-backup@pid.iam.gserviceaccount.com").Return("access-id", "secret-key", nil)

err := bs.EnsureOpenfgaBackupBucket()
Expect(err).NotTo(HaveOccurred())
Expect(bs.Env.OpenfgaBackupBucket).To(Equal("pid-openfga-backup"))
Expect(bs.Env.OpenfgaBackupAccessKeyID).To(Equal("access-id"))
Expect(bs.Env.OpenfgaBackupSecret).To(Equal("secret-key"))
})

It("does not create a new HMAC key when a real secret already exists", func() {
vault := &files.InstallVault{
Secrets: []files.SecretEntry{
{Name: files.SecretOpenfgaDbBackupSecretAccessKey, Fields: &files.SecretFields{Password: "existing-secret"}},
},
}
icg.EXPECT().GetVault().Return(vault)

gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(nil)
gc.EXPECT().CreateServiceAccount("pid", "openfga-backup", "openfga-backup").Return("openfga-backup@pid.iam.gserviceaccount.com", false, nil)
gc.EXPECT().AssignIAMRole("pid", "openfga-backup", "pid", []string{"roles/storage.objectAdmin"}).Return(nil)
// CreateHMACKey must not be called.

err := bs.EnsureOpenfgaBackupBucket()
Expect(err).NotTo(HaveOccurred())
Expect(bs.Env.OpenfgaBackupBucket).To(Equal("pid-openfga-backup"))
Expect(bs.Env.OpenfgaBackupAccessKeyID).To(BeEmpty())
})

It("returns an error when bucket creation fails", func() {
gc.EXPECT().EnsureStorageBucket("pid", "pid-openfga-backup", "us-central1").Return(fmt.Errorf("bucket error"))

err := bs.EnsureOpenfgaBackupBucket()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to ensure openfga backup bucket"))
})
})

Describe("UpdateInstallConfig", func() {
var vault *files.InstallVault
BeforeEach(func() {
Expand All @@ -331,6 +376,9 @@ var _ = Describe("Installconfig & Secrets", func() {
err := bs.UpdateInstallConfig()
Expect(err).NotTo(HaveOccurred())

// No openfga backup bucket provisioned → config stays unset.
Expect(bs.Env.InstallConfig.Codesphere.OpenfgaBackups).To(BeNil())

applications := bs.Env.InstallConfig.PcApps["applications"].(map[string]interface{})
sshProxy := applications["ssh-workspace-proxy"].(map[string]interface{})
Expect(sshProxy["enabled"]).To(Equal(true))
Expand Down Expand Up @@ -381,6 +429,33 @@ var _ = Describe("Installconfig & Secrets", func() {

Expect(bs.Env.InstallConfig.Datacenter.Name).To(Equal("staging"))
})
It("wires the openfga backup config and secrets when a bucket was provisioned", func() {
csEnv.OpenfgaBackupBucket = "pid-openfga-backup"
csEnv.OpenfgaBackupAccessKeyID = "access-id"
csEnv.OpenfgaBackupSecret = "secret-key"

icg.EXPECT().GenerateSecrets().Return(nil)
icg.EXPECT().WriteInstallConfig("fake-config-file", true).Return(nil)
icg.EXPECT().WriteVault("fake-secret", true).Return(nil)

nodeClient.EXPECT().CopyFile(mock.Anything, mock.Anything, mock.Anything).Return(nil).Twice()

err := bs.UpdateInstallConfig()
Expect(err).NotTo(HaveOccurred())

ob := bs.Env.InstallConfig.Codesphere.OpenfgaBackups
Expect(ob).NotTo(BeNil())
Expect(ob.Enabled).To(BeTrue())
Expect(ob.DestinationPath).To(Equal("s3://pid-openfga-backup"))
Expect(ob.EndpointURL).To(Equal("https://storage.googleapis.com"))

accessKey := vault.GetSecret(files.SecretOpenfgaDbBackupAccessKeyId)
Expect(accessKey).NotTo(BeNil())
Expect(accessKey.Fields.Password).To(Equal("access-id"))
secretKey := vault.GetSecret(files.SecretOpenfgaDbBackupSecretAccessKey)
Expect(secretKey).NotTo(BeNil())
Expect(secretKey.Fields.Password).To(Equal("secret-key"))
})
Context("When internal flags are set in CodesphereEnvironment", func() {
BeforeEach(func() {
csEnv.InternalFlags = []string{"fake-exp1", "fake-exp2"}
Expand Down
Loading
Loading