Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add function that starts NEG controller per Project #2729

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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
4 changes: 4 additions & 0 deletions cmd/glbc/app/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ func NewGCEClient(logger klog.Logger) *gce.Cloud {
configReader = func() io.Reader { return nil }
}

return GCEClientForConfigReader(configReader, logger)
}

func GCEClientForConfigReader(configReader func() io.Reader, logger klog.Logger) *gce.Cloud {
// Creating the cloud interface involves resolving the metadata server to get
// an oauth token. If this fails, the token provider assumes it's not on GCE.
// No errors are thrown. So we need to keep retrying till it works because
Expand Down
134 changes: 134 additions & 0 deletions pkg/multiproject/gce/gce.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package gce

import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"regexp"

gcfg "gopkg.in/gcfg.v1"
cloudgce "k8s.io/cloud-provider-gcp/providers/gce"
"k8s.io/ingress-gce/cmd/glbc/app"
"k8s.io/ingress-gce/pkg/flags"
"k8s.io/ingress-gce/pkg/multiproject/projectcrd"
"k8s.io/klog/v2"
)

// NewGCEForProject returns a new GCE client for the given project.
// If the projectCRD is nil, it returns the default cloud, associated with the cluster-project.
func NewGCEForProject(defaultConfigFile cloudgce.ConfigFile, projectCRD *projectcrd.Project, logger klog.Logger) (*cloudgce.Cloud, error) {
configFile, err := generateConfigFileForProject(defaultConfigFile, projectCRD)
if err != nil {
return nil, fmt.Errorf("failed to generate config file for project %s: %v", projectCRD.ProjectName(), err)
}

// convert file struct to bytes
configBytes, err := json.Marshal(configFile)
if err != nil {
return nil, fmt.Errorf("failed to marshal config file: %v", err)
}

configReader := func() io.Reader {
return bytes.NewReader(configBytes)
}

return app.GCEClientForConfigReader(configReader, logger), nil
}

// generateConfigFileForProject generates a new GCE config file for the given project.
// It returns the default config file if the project name is not set, which is considered as the cluster-project.
// Otherwise, it replaces needed fields in the config file with the values from the project CRD.
func generateConfigFileForProject(defaultConfigFile cloudgce.ConfigFile, projectCRD *projectcrd.Project) (*cloudgce.ConfigFile, error) {
if projectCRD == nil {
return &defaultConfigFile, nil
}

configFile := &defaultConfigFile

configFile.Global.ProjectID = projectCRD.ProjectID()
projectNumber := projectCRD.ProjectNumber()
configFile.Global.TokenURL = replaceTokenURLProjectNumber(configFile.Global.TokenURL, fmt.Sprintf("%d", projectNumber))

// Update the TokenBody with the new project number
newTokenBody, err := replaceTokenBodyProjectNumber(configFile.Global.TokenBody, projectNumber)
if err != nil {
return nil, fmt.Errorf("failed to replace project number in TokenBody: %v", err)
}
configFile.Global.TokenBody = newTokenBody

configFile.Global.NetworkName = projectCRD.NetworkURL()
configFile.Global.SubnetworkName = projectCRD.SubnetworkURL()

return configFile, nil
}

// replaceTokenURLProjectNumber replaces the project number in the token URL.
// Original token URL is expected to be in the format:
// https://gkeauth.googleapis.com/v1/projects/{PROJECT_NUMBER}/locations/{LOCATION}/clusters/{CLUSTER_NAME}:generateToken
// This function replaces the {PROJECT_NUMBER} with the new project number while keeping the rest of the URL unchanged.
func replaceTokenURLProjectNumber(tokenURL string, projectNumber string) string {
re := regexp.MustCompile(`(/projects/)([^/]*)(/)`)
newTokenURL := re.ReplaceAllString(tokenURL, "${1}"+projectNumber+"${3}")
return newTokenURL
}

// replaceTokenBodyProjectNumber replaces the project number in the token body.
// Original token body is expected to be in the format:
//
// {
// "projectNumber": "1234567890"
// ... some other fields ...
// }
//
// This function replaces the project number with the new project number while keeping the rest of the body unchanged.
func replaceTokenBodyProjectNumber(tokenBody string, projectNumber int64) (string, error) {
var bodyMap map[string]interface{}

// Unmarshal the JSON string into a map
err := json.Unmarshal([]byte(tokenBody), &bodyMap)
if err != nil {
return "", fmt.Errorf("error unmarshaling TokenBody: %v", err)
}

// Replace the projectNumber with the new value
bodyMap["projectNumber"] = projectNumber

// Marshal the map back into a JSON string
newTokenBodyBytes, err := json.Marshal(bodyMap)
if err != nil {
return "", fmt.Errorf("error marshaling TokenBody: %v", err)
}

newTokenBody := string(newTokenBodyBytes)
return newTokenBody, nil
}

// DefaultGCEConfigFile returns the default GCE config file.
// It reads config from the file and then parses it into a cloudgce.ConfigFile struct.
func DefaultGCEConfigFile(logger klog.Logger) *cloudgce.ConfigFile {
if flags.F.ConfigFilePath == "" {
return nil
}

logger.Info("Reading config from the specified path", "path", flags.F.ConfigFilePath)
config, err := os.Open(flags.F.ConfigFilePath)
if err != nil {
klog.Fatalf("%v", err)
}
defer config.Close()

allConfig, err := io.ReadAll(config)
if err != nil {
klog.Fatalf("Error while reading config (%q): %v", flags.F.ConfigFilePath, err)
}
logger.V(4).Info("Cloudprovider config file", "config", string(allConfig))

cfg := &cloudgce.ConfigFile{}
if err := gcfg.FatalOnly(gcfg.ReadInto(cfg, bytes.NewReader(allConfig))); err != nil {
klog.Fatalf("Error while reading config (%q): %v", flags.F.ConfigFilePath, err)
}

return cfg
}
221 changes: 221 additions & 0 deletions pkg/multiproject/gce/gce_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package gce

import (
"encoding/json"
"testing"

"github.com/google/go-cmp/cmp"
cloudgce "k8s.io/cloud-provider-gcp/providers/gce"
"k8s.io/ingress-gce/pkg/multiproject/projectcrd"
)

func TestReplaceTokenURLProjectNumber(t *testing.T) {
testCases := []struct {
name string
tokenURL string
projectNumber string
expectedResult string
}{
{
name: "Replace project number in URL",
tokenURL: "https://gkeauth.googleapis.com/v1/projects/12345/locations/us-central1/clusters/example-cluster:generateToken",
projectNumber: "654321",
expectedResult: "https://gkeauth.googleapis.com/v1/projects/654321/locations/us-central1/clusters/example-cluster:generateToken",
},
{
name: "URL with non-numeric project number",
tokenURL: "https://gkeauth.googleapis.com/v1/projects/abcde/locations/us-central1/clusters/example-cluster:generateToken",
projectNumber: "654321",
expectedResult: "https://gkeauth.googleapis.com/v1/projects/654321/locations/us-central1/clusters/example-cluster:generateToken",
},
{
name: "URL without project number",
tokenURL: "https://gkeauth.googleapis.com/v1/projects//locations/us-central1/clusters/example-cluster:generateToken",
projectNumber: "654321",
expectedResult: "https://gkeauth.googleapis.com/v1/projects/654321/locations/us-central1/clusters/example-cluster:generateToken",
},
{
name: "Empty token URL",
tokenURL: "",
projectNumber: "654321",
expectedResult: "",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := replaceTokenURLProjectNumber(tc.tokenURL, tc.projectNumber)
if result != tc.expectedResult {
t.Errorf("replaceTokenURLProjectNumber(%q, %q) = %q; want %q", tc.tokenURL, tc.projectNumber, result, tc.expectedResult)
}
})
}
}

func TestReplaceTokenBodyProjectNumber(t *testing.T) {
testCases := []struct {
name string
tokenBody string
projectNumber int64
expectedResult string
expectError bool
}{
{
name: "Valid token body",
tokenBody: `{"projectNumber":12345,"clusterId":"example-cluster"}`,
projectNumber: 654321,
expectedResult: `{"clusterId":"example-cluster","projectNumber":654321}`,
expectError: false,
},
{
name: "Valid token body with extra fields",
tokenBody: `{"projectNumber":"oldNumber","clusterId":"example-cluster","isActive":true,"count":10}`,
projectNumber: 654321,
expectedResult: `{"clusterId":"example-cluster","count":10,"isActive":true,"projectNumber":654321}`,
expectError: false,
},
{
name: "Invalid JSON format",
tokenBody: `{"projectNumber":12345,"clusterId":"example-cluster"`,
projectNumber: 654321,
expectedResult: "",
expectError: true,
},
{
name: "Empty token body",
tokenBody: "",
projectNumber: 654321,
expectedResult: "",
expectError: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := replaceTokenBodyProjectNumber(tc.tokenBody, tc.projectNumber)
if (err != nil) != tc.expectError {
t.Errorf("Expected error: %v, got: %v", tc.expectError, err)
return
}
if !tc.expectError {
// Normalize JSON strings for comparison
expectedJSON := normalizeJSON(t, tc.expectedResult)
resultJSON := normalizeJSON(t, result)

if expectedJSON != resultJSON {
t.Errorf("Expected result: %s, got: %s", expectedJSON, resultJSON)
}
}
})
}
}

// normalizeJSON helps to compare JSON strings without considering field order
func normalizeJSON(t *testing.T, jsonString string) string {
var temp interface{}
err := json.Unmarshal([]byte(jsonString), &temp)
if err != nil {
t.Fatalf("Invalid JSON string: %v", err)
}
normalizedBytes, err := json.Marshal(temp)
if err != nil {
t.Fatalf("Error marshaling JSON: %v", err)
}
return string(normalizedBytes)
}

func TestGenerateConfigFileForProject(t *testing.T) {
testCases := []struct {
name string
defaultConfig cloudgce.ConfigFile
projectCRD *projectcrd.Project
expectedConfig *cloudgce.ConfigFile
expectError bool
}{
{
name: "Nil projectCRD returns default config",
defaultConfig: cloudgce.ConfigFile{
Global: cloudgce.ConfigGlobal{
ProjectID: "default-project-id",
TokenURL: "default-token-url",
TokenBody: "default-token-body",
NetworkName: "default-network",
SubnetworkName: "default-subnetwork",
},
},
projectCRD: nil,
expectedConfig: &cloudgce.ConfigFile{
Global: cloudgce.ConfigGlobal{
ProjectID: "default-project-id",
TokenURL: "default-token-url",
TokenBody: "default-token-body",
NetworkName: "default-network",
SubnetworkName: "default-subnetwork",
},
},
expectError: false,
},
{
name: "Valid projectCRD replaces fields",
defaultConfig: cloudgce.ConfigFile{
Global: cloudgce.ConfigGlobal{
ProjectID: "default-project-id",
TokenURL: "https://gkeauth.googleapis.com/v1/projects/12345/locations/us-central1/clusters/example-cluster:generateToken",
TokenBody: `{"projectNumber":12345,"clusterId":"example-cluster"}`,
NetworkName: "default-network",
SubnetworkName: "default-subnetwork",
},
},
projectCRD: &projectcrd.Project{
Spec: projectcrd.ProjectSpec{
ProjectID: "project-crd-project-id",
ProjectNumber: 654321,
NetworkConfig: projectcrd.NetworkConfig{
Network: "project-crd-network-url",
DefaultSubnetwork: "project-crd-subnetwork-url",
},
},
},
expectedConfig: &cloudgce.ConfigFile{
Global: cloudgce.ConfigGlobal{
ProjectID: "project-crd-project-id",
TokenURL: "https://gkeauth.googleapis.com/v1/projects/654321/locations/us-central1/clusters/example-cluster:generateToken",
TokenBody: `{"clusterId":"example-cluster","projectNumber":654321}`,
NetworkName: "project-crd-network-url",
SubnetworkName: "project-crd-subnetwork-url",
},
},
expectError: false,
},
{
name: "Error replacing TokenBody",
defaultConfig: cloudgce.ConfigFile{
Global: cloudgce.ConfigGlobal{
TokenBody: `{"projectNumber":12345,"clusterId":"example-cluster"`, // Invalid JSON
},
},
projectCRD: &projectcrd.Project{
Spec: projectcrd.ProjectSpec{
ProjectID: "project-crd-project-id",
ProjectNumber: 654321,
},
},
expectedConfig: nil,
expectError: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config, err := generateConfigFileForProject(tc.defaultConfig, tc.projectCRD)
if (err != nil) != tc.expectError {
t.Errorf("Expected error: %v, got: %v", tc.expectError, err)
}
if !tc.expectError {
if diff := cmp.Diff(config, tc.expectedConfig); diff != "" {
t.Errorf("generateConfigFileForProject() mismatch (-want +got):\n%s", diff)
}
}
})
}
}
Loading