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
32 changes: 16 additions & 16 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,9 @@ License URL: https://github.com/cloudflare/circl/blob/v1.6.3/LICENSE

----------
Module: github.com/codesphere-cloud/cs-go
Version: v0.21.1
Version: v0.22.0
License: Apache-2.0
License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.21.1/LICENSE
License URL: https://github.com/codesphere-cloud/cs-go/blob/v0.22.0/LICENSE

----------
Module: github.com/codesphere-cloud/oms/internal/tmpl
Expand Down Expand Up @@ -797,9 +797,9 @@ License URL: https://github.com/open-telemetry/opentelemetry-proto-go/blob/otlp/

----------
Module: go.yaml.in/yaml/v2
Version: v2.4.3
Version: v2.4.4
License: Apache-2.0
License URL: https://github.com/yaml/go-yaml/blob/v2.4.3/LICENSE
License URL: https://github.com/yaml/go-yaml/blob/v2.4.4/LICENSE

----------
Module: go.yaml.in/yaml/v3
Expand Down Expand Up @@ -929,9 +929,9 @@ License URL: https://github.com/helm/helm/blob/v4.1.3/LICENSE

----------
Module: k8s.io/api
Version: v0.35.2
Version: v0.35.3
License: Apache-2.0
License URL: https://github.com/kubernetes/api/blob/v0.35.2/LICENSE
License URL: https://github.com/kubernetes/api/blob/v0.35.3/LICENSE

----------
Module: k8s.io/apiextensions-apiserver/pkg/apis/apiextensions
Expand All @@ -941,15 +941,15 @@ License URL: https://github.com/kubernetes/apiextensions-apiserver/blob/v0.35.1/

----------
Module: k8s.io/apimachinery/pkg
Version: v0.35.2
Version: v0.35.3
License: Apache-2.0
License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.2/LICENSE
License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.3/LICENSE

----------
Module: k8s.io/apimachinery/third_party/forked/golang
Version: v0.35.2
Version: v0.35.3
License: BSD-3-Clause
License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.2/third_party/forked/golang/LICENSE
License URL: https://github.com/kubernetes/apimachinery/blob/v0.35.3/third_party/forked/golang/LICENSE

----------
Module: k8s.io/apiserver/pkg/endpoints/deprecation
Expand All @@ -959,21 +959,21 @@ License URL: https://github.com/kubernetes/apiserver/blob/v0.35.1/LICENSE

----------
Module: k8s.io/cli-runtime/pkg
Version: v0.35.2
Version: v0.35.3
License: Apache-2.0
License URL: https://github.com/kubernetes/cli-runtime/blob/v0.35.2/LICENSE
License URL: https://github.com/kubernetes/cli-runtime/blob/v0.35.3/LICENSE

----------
Module: k8s.io/client-go
Version: v0.35.2
Version: v0.35.3
License: Apache-2.0
License URL: https://github.com/kubernetes/client-go/blob/v0.35.2/LICENSE
License URL: https://github.com/kubernetes/client-go/blob/v0.35.3/LICENSE

----------
Module: k8s.io/client-go/third_party/forked/golang/template
Version: v0.35.2
Version: v0.35.3
License: BSD-3-Clause
License URL: https://github.com/kubernetes/client-go/blob/v0.35.2/third_party/forked/golang/LICENSE
License URL: https://github.com/kubernetes/client-go/blob/v0.35.3/third_party/forked/golang/LICENSE

----------
Module: k8s.io/component-base/version
Expand Down
24 changes: 18 additions & 6 deletions internal/bootstrap/gcp/gcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,21 +485,33 @@ func (b *GCPBootstrapper) EnsureProject() error {
parent = fmt.Sprintf("folders/%s", b.Env.FolderID)
}

deleteProjectAfter, err := calculateProjectExpiryLabel(b.Env.ProjectTTL)
if err != nil {
return fmt.Errorf("failed to calculate project expiry label: %w", err)
}

labels := map[string]string{
OMSManagedLabel: "true",
DeleteAfterLabel: deleteProjectAfter,
}

existingProject, err := b.GCPClient.GetProjectByName(b.Env.FolderID, b.Env.ProjectName)
if err == nil {
b.Env.ProjectID = existingProject.ProjectId
b.Env.ProjectName = existingProject.Name

err := b.GCPClient.UpdateProject(existingProject.ProjectId, labels)
if err != nil {
return fmt.Errorf("failed to update project: %w", err)
}

return nil
}

if err.Error() == fmt.Sprintf("project not found: %s", b.Env.ProjectName) {
projectId := b.GCPClient.CreateProjectID(b.Env.ProjectName)

projectTTL, err := time.ParseDuration(b.Env.ProjectTTL)
if err != nil {
return fmt.Errorf("invalid project TTL format: %w", err)
}

_, err = b.GCPClient.CreateProject(parent, projectId, b.Env.ProjectName, projectTTL)
_, err = b.GCPClient.CreateProject(parent, projectId, b.Env.ProjectName, labels)
if err != nil {
return fmt.Errorf("failed to create project: %w", err)
}
Expand Down
48 changes: 36 additions & 12 deletions internal/bootstrap/gcp/gcp_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"fmt"
"strings"
"sync"
"time"

"slices"

Expand All @@ -30,6 +29,7 @@ import (
"google.golang.org/api/iterator"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/fieldmaskpb"
)

// Interface for high-level GCP operations
Expand All @@ -38,7 +38,8 @@ import (
type GCPClientManager interface {
GetProjectByName(folderID string, displayName string) (*resourcemanagerpb.Project, error)
CreateProjectID(projectName string) string
CreateProject(parent, projectName, displayName string, ttl time.Duration) (string, error)
CreateProject(parent, projectName, displayName string, labels map[string]string) (string, error)
UpdateProject(projectID string, labels map[string]string) error
DeleteProject(projectID string) error
IsOMSManagedProject(projectID string) (bool, error)
GetBillingInfo(projectID string) (*cloudbilling.ProjectBillingInfo, error)
Expand Down Expand Up @@ -117,26 +118,18 @@ func (c *GCPClient) CreateProjectID(projectName string) string {

// CreateProject creates a new GCP project under the specified parent (folder or organization).
// It returns the project ID of the newly created project.
// The project is labeled with 'oms-managed=true' to identify it as created by OMS.
func (c *GCPClient) CreateProject(parent, projectID, displayName string, projectTTL time.Duration) (string, error) {
func (c *GCPClient) CreateProject(parent, projectID, displayName string, labels map[string]string) (string, error) {
client, err := resourcemanager.NewProjectsClient(c.ctx)
if err != nil {
return "", err
}
defer util.IgnoreError(client.Close)

gcpLabelLayout := "2006-01-02_15-04-05"
deleteProjectAfter := time.Now().UTC().Add(projectTTL).Format(gcpLabelLayout)
deleteProjectAfter = fmt.Sprintf("%s_utc", deleteProjectAfter) // GCP Labels are very limited. This is the only way to add TZ info.

project := &resourcemanagerpb.Project{
ProjectId: projectID,
DisplayName: displayName,
Parent: parent,
Labels: map[string]string{
OMSManagedLabel: "true",
DeleteAfterLabel: deleteProjectAfter,
},
Labels: labels,
}

op, err := client.CreateProject(c.ctx, &resourcemanagerpb.CreateProjectRequest{Project: project})
Expand All @@ -152,6 +145,37 @@ func (c *GCPClient) CreateProject(parent, projectID, displayName string, project
return resp.ProjectId, nil
}

// UpdateProject updates the project's labels of an existing GCP project.
// Returns an error if the update operation fails or if the project does not exist.
func (c *GCPClient) UpdateProject(projectID string, labels map[string]string) error {
client, err := resourcemanager.NewProjectsClient(c.ctx)
if err != nil {
return err
}
defer util.IgnoreError(client.Close)

project := &resourcemanagerpb.Project{
Name: getProjectResourceName(projectID),
Labels: labels,
}

op, err := client.UpdateProject(c.ctx, &resourcemanagerpb.UpdateProjectRequest{
Project: project,
UpdateMask: &fieldmaskpb.FieldMask{
Paths: []string{"labels"},
},
})
if err != nil {
return fmt.Errorf("failed to update project %s with config %v: %w", projectID, project, err)
}

if _, err = op.Wait(c.ctx); err != nil {
return fmt.Errorf("failed to wait for project update: %w", err)
}

return nil
}

// DeleteProject deletes the specified GCP project.
func (c *GCPClient) DeleteProject(projectID string) error {
client, err := resourcemanager.NewProjectsClient(c.ctx)
Expand Down
17 changes: 13 additions & 4 deletions internal/bootstrap/gcp/gcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"context"
"fmt"
"strings"
"time"

"os"

Expand Down Expand Up @@ -157,7 +156,7 @@ var _ = Describe("GCP Bootstrapper", func() {
// 3. EnsureProject
gc.EXPECT().GetProjectByName(mock.Anything, "test-project").Return(nil, fmt.Errorf("project not found: test-project"))
gc.EXPECT().CreateProjectID("test-project").Return(projectId)
gc.EXPECT().CreateProject(mock.Anything, mock.Anything, "test-project", time.Hour).Return(mock.Anything, nil)
gc.EXPECT().CreateProject(mock.Anything, mock.Anything, "test-project", mock.Anything).Return(mock.Anything, nil)

// 4. EnsureBilling
gc.EXPECT().GetBillingInfo(projectId).Return(&cloudbilling.ProjectBillingInfo{BillingEnabled: false}, nil)
Expand Down Expand Up @@ -537,6 +536,7 @@ var _ = Describe("GCP Bootstrapper", func() {
Describe("Valid EnsureProject", func() {
It("uses existing project", func() {
gc.EXPECT().GetProjectByName(csEnv.FolderID, csEnv.ProjectName).Return(&resourcemanagerpb.Project{ProjectId: "existing-id", Name: "existing-proj"}, nil)
gc.EXPECT().UpdateProject("existing-id", mock.Anything).Return(nil)

err := bs.EnsureProject()
Expect(err).NotTo(HaveOccurred())
Expand All @@ -546,7 +546,7 @@ var _ = Describe("GCP Bootstrapper", func() {
It("creates project when missing", func() {
gc.EXPECT().GetProjectByName(csEnv.FolderID, csEnv.ProjectName).Return(nil, fmt.Errorf("project not found: %s", csEnv.ProjectName))
gc.EXPECT().CreateProjectID(csEnv.ProjectName).Return("new-proj-id")
gc.EXPECT().CreateProject(csEnv.FolderID, "new-proj-id", csEnv.ProjectName, time.Hour).Return("", nil)
gc.EXPECT().CreateProject(csEnv.FolderID, "new-proj-id", csEnv.ProjectName, mock.Anything).Return("", nil)

err := bs.EnsureProject()
Expect(err).NotTo(HaveOccurred())
Expand All @@ -566,13 +566,22 @@ var _ = Describe("GCP Bootstrapper", func() {
It("returns error when CreateProject fails", func() {
gc.EXPECT().GetProjectByName("", csEnv.ProjectName).Return(nil, fmt.Errorf("project not found: %s", csEnv.ProjectName))
gc.EXPECT().CreateProjectID(csEnv.ProjectName).Return("fake-id")
gc.EXPECT().CreateProject("", "fake-id", csEnv.ProjectName, time.Hour).Return("", fmt.Errorf("create error"))
gc.EXPECT().CreateProject("", "fake-id", csEnv.ProjectName, mock.Anything).Return("", fmt.Errorf("create error"))

err := bs.EnsureProject()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to create project"))
Expect(err.Error()).To(ContainSubstring("create error"))
})

It("returns an error when UpdateProject fails", func() {
gc.EXPECT().GetProjectByName(csEnv.FolderID, csEnv.ProjectName).Return(&resourcemanagerpb.Project{ProjectId: "existing-id", Name: "existing-proj"}, nil)
gc.EXPECT().UpdateProject("existing-id", mock.Anything).Return(fmt.Errorf("failed to update project"))

err := bs.EnsureProject()
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to update project"))
})
})
})

Expand Down
26 changes: 26 additions & 0 deletions internal/bootstrap/gcp/helper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright (c) Codesphere Inc.
// SPDX-License-Identifier: Apache-2.0

package gcp

import (
"fmt"
"time"
)

// calculateProjectExpiryLabel takes a TTL string (e.g. "24h") and
// returns a formatted UTC timestamp string that is usable as a GCP project label for automatic deletion.
func calculateProjectExpiryLabel(projectTTLStr string) (string, error) {
projectTTL, err := time.ParseDuration(projectTTLStr)
if err != nil {
return "", fmt.Errorf("invalid project TTL format: %w", err)
}

// prepare label for gcp project deletion in custom UTC time format.
// GCP Labels are very limited. This is an easy way to add date and TZ info in one label.
gcpExpiryLabelLayout := "2006-01-02_15-04-05"
deleteProjectAfter := time.Now().UTC().Add(projectTTL).Format(gcpExpiryLabelLayout)
deleteProjectAfter = fmt.Sprintf("%s_utc", deleteProjectAfter)

return deleteProjectAfter, nil
}
57 changes: 57 additions & 0 deletions internal/bootstrap/gcp/helper_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) Codesphere Inc.
// SPDX-License-Identifier: Apache-2.0

package gcp

import (
"time"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("calculateProjectExpiryLabel", func() {
const customDateFormat string = "2006-01-02_15-04-05_utc"
const customDateFormatRegex string = `^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_utc$`

type validTestCase struct {
inputTTL string
expectedDuration time.Duration
}

DescribeTable("calculating the expiry label from string durations",
func(tc validTestCase) {
label, err := calculateProjectExpiryLabel(tc.inputTTL)
Expect(err).NotTo(HaveOccurred())
Expect(label).To(MatchRegexp(customDateFormatRegex))

parsedTime, err := time.Parse(customDateFormat, label)
Expect(err).NotTo(HaveOccurred())

expectedTime := time.Now().UTC().Add(tc.expectedDuration)

Expect(parsedTime).To(BeTemporally("~", expectedTime, 10*time.Second))
},

// Define test scenarios
Entry("1 Minute", validTestCase{
inputTTL: "1m",
expectedDuration: 1 * time.Minute,
}),
Entry("1 hour", validTestCase{
inputTTL: "1h",
expectedDuration: 1 * time.Hour,
}),
Entry("1 Day", validTestCase{
inputTTL: "24h",
expectedDuration: 24 * time.Hour,
}),
)

It("returns an error for invalid duration strings", func() {
label, err := calculateProjectExpiryLabel("1d") // 'd' is not a valid time unit in Go's duration parsing
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("invalid project TTL format"))
Expect(label).To(BeEmpty())
})
})
Loading
Loading