diff --git a/cmd/common/helpers.go b/cmd/common/helpers.go index 534f2abb1..fdab63133 100644 --- a/cmd/common/helpers.go +++ b/cmd/common/helpers.go @@ -5,6 +5,7 @@ import ( "fmt" "io" + ocmsdk "github.com/openshift-online/ocm-sdk-go" bplogin "github.com/openshift/backplane-cli/cmd/ocm-backplane/login" bpconfig "github.com/openshift/backplane-cli/pkg/cli/config" "github.com/openshift/osdctl/pkg/utils" @@ -76,3 +77,31 @@ func GetKubeConfigAndClient(clusterID string, elevationReasons ...string) (clien } return kubeCli, kubeconfig, clientset, err } + +// If some elevationReasons are provided, then the config will be elevated with user backplane-cluster-admin +// Using provided OCM sdk connection for config values. +func GetKubeConfigAndClientWithConn(clusterID string, ocm *ocmsdk.Connection, elevationReasons ...string) (client.Client, *rest.Config, *kubernetes.Clientset, error) { + bp, err := bpconfig.GetBackplaneConfigurationWithConn(ocm) + if err != nil { + return nil, nil, nil, fmt.Errorf("failed to load backplane-cli config: %v", err) + } + var kubeconfig *rest.Config + if len(elevationReasons) == 0 { + kubeconfig, err = bplogin.GetRestConfigWithConn(bp, ocm, clusterID) + } else { + kubeconfig, err = bplogin.GetRestConfigAsUserWithConn(bp, ocm, clusterID, "backplane-cluster-admin", elevationReasons...) + } + if err != nil { + return nil, nil, nil, err + } + // create the clientset + clientset, err := kubernetes.NewForConfig(kubeconfig) + if err != nil { + return nil, nil, nil, err + } + kubeCli, err := client.New(kubeconfig, client.Options{}) + if err != nil { + return nil, nil, nil, err + } + return kubeCli, kubeconfig, clientset, nil +} diff --git a/cmd/common/helpers_test.go b/cmd/common/helpers_test.go new file mode 100644 index 000000000..a9e1b1bad --- /dev/null +++ b/cmd/common/helpers_test.go @@ -0,0 +1,70 @@ +package common + +import ( + "testing" + + sdk "github.com/openshift-online/ocm-sdk-go" +) + +// TestGetKubeConfigAndClientWithConn tests the GetKubeConfigAndClientWithConn function +// which creates a Kubernetes client, REST config, and clientset using a provided OCM SDK +// connection. This function supports both regular and elevated (backplane-cluster-admin) +// access based on the presence of elevation reasons. +func TestGetKubeConfigAndClientWithConn(t *testing.T) { + tests := []struct { + name string + clusterID string + ocmConn *sdk.Connection + elevationReasons []string + wantErr bool + }{ + { + // Test that passing a nil OCM connection without elevation returns an error + name: "nil OCM connection", + clusterID: "test-cluster-id", + ocmConn: nil, + elevationReasons: nil, + wantErr: true, + }, + { + // Test that passing a nil OCM connection with elevation reasons also returns an error + name: "nil OCM connection with elevation reasons", + clusterID: "test-cluster-id", + ocmConn: nil, + elevationReasons: []string{"testing"}, + wantErr: true, + }, + { + // Test that passing an empty cluster ID with nil connection returns an error + name: "empty cluster ID with nil connection", + clusterID: "", + ocmConn: nil, + elevationReasons: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + kubeCli, kubeconfig, clientset, err := GetKubeConfigAndClientWithConn(tt.clusterID, tt.ocmConn, tt.elevationReasons...) + if tt.wantErr { + if err == nil { + t.Errorf("GetKubeConfigAndClientWithConn() expected error but got none") + } + } else { + if err != nil { + t.Errorf("GetKubeConfigAndClientWithConn() unexpected error = %v", err) + } + if kubeCli == nil { + t.Errorf("GetKubeConfigAndClientWithConn() returned nil kubeCli") + } + if kubeconfig == nil { + t.Errorf("GetKubeConfigAndClientWithConn() returned nil kubeconfig") + } + if clientset == nil { + t.Errorf("GetKubeConfigAndClientWithConn() returned nil clientset") + } + } + }) + } +} diff --git a/cmd/hive/cmd.go b/cmd/hive/cmd.go index 6a8c9226e..e1a758fb2 100644 --- a/cmd/hive/cmd.go +++ b/cmd/hive/cmd.go @@ -2,6 +2,7 @@ package hive import ( "fmt" + cd "github.com/openshift/osdctl/cmd/hive/clusterdeployment" "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/genericclioptions" diff --git a/docs/osdctl_hive_login-tests.md b/docs/osdctl_hive_login-tests.md new file mode 100644 index 000000000..c736f65ac --- /dev/null +++ b/docs/osdctl_hive_login-tests.md @@ -0,0 +1,59 @@ +## osdctl hive login-tests + +Test utility to exercise OSDCTL client connections for both Target Cluster and it's Hive Cluster. + +### Synopsis + + +This test utility attempts to exercise and validate OSDCTL's functions related to +OCM and backplane client connections. + +This test utility can be run against an OSD/Rosa Classic target cluster. This utility +will attempt to discover the Hive cluster, and create both +OCM and kube client connections, and perform basic requests for each to connection in +order to validate functionality of the related OSDCTL utility functions. + +This test utility allows for the target cluster to exist in a separate OCM +environment (ie integration, staging) from the hive cluster (ie production). + +The default OCM environment vars should be set for the target cluster. +If the target cluster exists outside of the OCM 'production' environment, the user +has the option to provide the production OCM config (with valid token set), +or provide the production OCM API url as a command argument, or set the value in the osdctl +config yaml file (ie: "hive_ocm_url: https://api.openshift.com" or "hive_ocm_url: production" ). +For testing purposes comment out 'hive_ocm_url' from osdctl's config if testing an empty value. + + +``` +osdctl hive login-tests [flags] +``` + +### Options + +``` + -C, --cluster-id string Cluster ID + -h, --help help for login-tests + --hive-ocm-config string OCM config for hive if different than Cluster + --hive-ocm-url string OCM URL for hive, this will fallback to reading from the osdctl config value: 'hive_ocm_url' if left empty + --verbose Verbose output +``` + +### Options inherited from parent commands + +``` + --as string Username to impersonate for the operation. User could be a regular user or a service account in a namespace. + --cluster string The name of the kubeconfig cluster to use + --context string The name of the kubeconfig context to use + --insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure + --kubeconfig string Path to the kubeconfig file to use for CLI requests. + -o, --output string Valid formats are ['', 'json', 'yaml', 'env'] + --request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0") + -s, --server string The address and port of the Kubernetes API server + --skip-aws-proxy-check aws_proxy Don't use the configured aws_proxy value + -S, --skip-version-check skip checking to see if this is the most recent release +``` + +### SEE ALSO + +* [osdctl hive](osdctl_hive.md) - hive related utilities + diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go index ee9640c29..32d8d4f5d 100644 --- a/pkg/k8s/client.go +++ b/pkg/k8s/client.go @@ -5,6 +5,7 @@ import ( "fmt" "io" + sdk "github.com/openshift-online/ocm-sdk-go" bplogin "github.com/openshift/backplane-cli/cmd/ocm-backplane/login" bpconfig "github.com/openshift/backplane-cli/pkg/cli/config" bputils "github.com/openshift/backplane-cli/pkg/utils" @@ -162,6 +163,26 @@ func NewRestConfig(clusterID string) (*rest.Config, error) { return cfg, nil } +// Create Backplane connection to a provided cluster, using a provided ocm sdk connection +// This is intended to allow backplane connections to multiple clusters which exist in different +// ocm environments by allowing the caller to provide an ocm connection to the function. +func NewWithConn(clusterID string, options client.Options, ocmConn *sdk.Connection) (client.Client, error) { + if ocmConn == nil { + return nil, fmt.Errorf("nil OCM sdk connection provided to NewWithConn()") + } + bp, err := bpconfig.GetBackplaneConfigurationWithConn(ocmConn) + if err != nil { + return nil, fmt.Errorf("failed to load backplane-cli config: %v", err) + } + + cfg, err := bplogin.GetRestConfigWithConn(bp, ocmConn, clusterID) + if err != nil { + return nil, err + } + setRuntimeLoggerDiscard() + return client.New(cfg, options) +} + func NewAsBackplaneClusterAdmin(clusterID string, options client.Options, elevationReasons ...string) (client.Client, error) { bp, err := bpconfig.GetBackplaneConfiguration() if err != nil { @@ -176,6 +197,26 @@ func NewAsBackplaneClusterAdmin(clusterID string, options client.Options, elevat return client.New(cfg, options) } +// Create Backplane connection as cluster admin to a provided cluster, using a provided ocm sdk connection +// This is intended to allow backplane connections to multiple clusters which exist in different +// ocm environments by allowing the caller to provide an ocm connection to the function. +func NewAsBackplaneClusterAdminWithConn(clusterID string, options client.Options, ocmConn *sdk.Connection, elevationReasons ...string) (client.Client, error) { + if ocmConn == nil { + return nil, fmt.Errorf("nil OCM sdk connection provided to NewAsBackplaneClusterAdminWithConn()") + } + bp, err := bpconfig.GetBackplaneConfigurationWithConn(ocmConn) + if err != nil { + return nil, fmt.Errorf("failed to load backplane-cli config: %v", err) + } + + cfg, err := bplogin.GetRestConfigAsUserWithConn(bp, ocmConn, clusterID, "backplane-cluster-admin", elevationReasons...) + if err != nil { + return nil, err + } + setRuntimeLoggerDiscard() + return client.New(cfg, options) +} + func setRuntimeLoggerDiscard() { // To avoid warnings/backtrace, if k8s controller-runtime logger has not already been set, do it now... if !log.Log.Enabled() { diff --git a/pkg/k8s/client_test.go b/pkg/k8s/client_test.go new file mode 100644 index 000000000..16816ffec --- /dev/null +++ b/pkg/k8s/client_test.go @@ -0,0 +1,117 @@ +package k8s + +import ( + "testing" + + sdk "github.com/openshift-online/ocm-sdk-go" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// TestNewWithConn tests the NewWithConn function which creates a Kubernetes client +// using backplane with a provided OCM SDK connection. This allows connecting to clusters +// in different OCM environments by providing a custom OCM connection. +func TestNewWithConn(t *testing.T) { + tests := []struct { + name string + clusterID string + options client.Options + ocmConn *sdk.Connection + wantErr bool + }{ + { + // Test that passing a nil OCM connection returns an error + name: "nil OCM connection", + clusterID: "test-cluster-id", + options: client.Options{}, + ocmConn: nil, + wantErr: true, + }, + { + // Test that passing an empty cluster ID with nil connection returns an error + name: "empty cluster ID with nil connection", + clusterID: "", + options: client.Options{}, + ocmConn: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewWithConn(tt.clusterID, tt.options, tt.ocmConn) + if tt.wantErr { + if err == nil { + t.Errorf("NewWithConn() expected error but got none") + } + } else { + if err != nil { + t.Errorf("NewWithConn() unexpected error = %v", err) + } + if client == nil { + t.Errorf("NewWithConn() returned nil client") + } + } + }) + } +} + +// TestNewAsBackplaneClusterAdminWithConn tests the NewAsBackplaneClusterAdminWithConn function +// which creates an elevated Kubernetes client (backplane-cluster-admin) using a provided OCM +// SDK connection. This function allows connecting to clusters in different OCM environments +// with elevated permissions. +func TestNewAsBackplaneClusterAdminWithConn(t *testing.T) { + tests := []struct { + name string + clusterID string + options client.Options + ocmConn *sdk.Connection + elevationReasons []string + wantErr bool + }{ + { + // Test that passing a nil OCM connection with elevation reasons returns an error + name: "nil OCM connection", + clusterID: "test-cluster-id", + options: client.Options{}, + ocmConn: nil, + elevationReasons: []string{"testing"}, + wantErr: true, + }, + { + // Test that passing a nil OCM connection without elevation reasons also returns an error + name: "nil OCM connection with no elevation reasons", + clusterID: "test-cluster-id", + options: client.Options{}, + ocmConn: nil, + elevationReasons: nil, + wantErr: true, + }, + { + // Test that passing an empty cluster ID with nil connection returns an error + name: "empty cluster ID with nil connection", + clusterID: "", + options: client.Options{}, + ocmConn: nil, + elevationReasons: []string{"testing"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewAsBackplaneClusterAdminWithConn(tt.clusterID, tt.options, tt.ocmConn, tt.elevationReasons...) + if tt.wantErr { + if err == nil { + t.Errorf("NewAsBackplaneClusterAdminWithConn() expected error but got none") + } + } else { + if err != nil { + t.Errorf("NewAsBackplaneClusterAdminWithConn() unexpected error = %v", err) + } + if client == nil { + t.Errorf("NewAsBackplaneClusterAdminWithConn() returned nil client") + } + } + }) + } +} diff --git a/pkg/utils/ocm.go b/pkg/utils/ocm.go index 32cd9b091..1ce008477 100644 --- a/pkg/utils/ocm.go +++ b/pkg/utils/ocm.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "path/filepath" "regexp" "strings" @@ -13,6 +14,9 @@ import ( sdk "github.com/openshift-online/ocm-sdk-go" amsv1 "github.com/openshift-online/ocm-sdk-go/accountsmgmt/v1" cmv1 "github.com/openshift-online/ocm-sdk-go/clustersmgmt/v1" + "github.com/openshift/osdctl/pkg/k8s" + "github.com/spf13/viper" + "sigs.k8s.io/controller-runtime/pkg/client" ocmConfig "github.com/openshift-online/ocm-common/pkg/ocm/config" ocmConnBuilder "github.com/openshift-online/ocm-common/pkg/ocm/connection-builder" @@ -171,6 +175,84 @@ func GenerateQuery(clusterIdentifier string) string { } } +// Finds the OCM Configuration file and returns the path to it. +// ( Taken wholesale from openshift-online/ocm-cli ) +func getOCMConfigLocation() (string, error) { + if ocmconfig := os.Getenv("OCM_CONFIG"); ocmconfig != "" { + return ocmconfig, nil + } + + // Determine home directory to use for the legacy file path + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + + path := filepath.Join(home, ".ocm.json") + + _, err = os.Stat(path) + if os.IsNotExist(err) { + // Determine standard config directory + configDir, err := os.UserConfigDir() + if err != nil { + return path, err + } + + // Use standard config directory + path = filepath.Join(configDir, "/ocm/ocm.json") + } + + return path, nil +} + +// Exported function fetch and return OCM config +func GetOCMConfigFromEnv() (*ocmConfig.Config, error) { + return loadOCMConfig() +} + +// Loads the OCM Configuration file +// Taken wholesale from openshift-online/ocm-cli +func loadOCMConfig() (*ocmConfig.Config, error) { + var err error + + file, err := getOCMConfigLocation() + if err != nil { + return nil, err + } + + _, err = os.Stat(file) + if os.IsNotExist(err) { + cfg := &ocmConfig.Config{} + err = nil + return cfg, err + } + + if err != nil { + err = fmt.Errorf("can't check if config file '%s' exists: %v", file, err) + return nil, err + } + + data, err := os.ReadFile(file) + if err != nil { + err = fmt.Errorf("can't read config file '%s': %v", file, err) + return nil, err + } + + if len(data) == 0 { + return nil, nil + } + + cfg := &ocmConfig.Config{} + err = json.Unmarshal(data, cfg) + + if err != nil { + err = fmt.Errorf("can't parse config file '%s': %v", file, err) + return cfg, err + } + + return cfg, nil +} + // Creates a connection to OCM func CreateConnection() (*sdk.Connection, error) { urlEnv := os.Getenv("OCM_URL") @@ -202,6 +284,33 @@ func CreateConnection() (*sdk.Connection, error) { return connBuilder.Build() } +// Creates a connection to OCM +func CreateConnectionWithUrl(OcmUrl string) (*sdk.Connection, error) { + if len(OcmUrl) <= 0 { + return nil, fmt.Errorf("CreateConnectionWithUrl provided empty OCM URL") + } + // First we need to validate URL in the case where it may be an alias + ocmApiUrl, ok := urlAliases[OcmUrl] + if !ok { + return nil, fmt.Errorf("invalid OCM_URL found: %s\nValid URL aliases are: 'production', 'staging', 'integration'", OcmUrl) + } + config, err := ocmConfig.Load() + if err != nil { + return nil, fmt.Errorf("unable to load OCM config. %w", err) + } + + agentString := fmt.Sprintf("osdctl-%s", Version) + + connBuilder := ocmConnBuilder.NewConnection().Config(config).AsAgent(agentString) + + if connBuilder == nil { + return nil, fmt.Errorf("CreateConnectionWithUrl, ocm connection builder returned nil builder") + } + connBuilder.WithApiUrl(ocmApiUrl) + + return connBuilder.Build() +} + func GetSupportRoleArnForCluster(ocmClient *sdk.Connection, clusterID string) (string, error) { clusterResponse, err := ocmClient.ClustersMgmt().V1().Clusters().Cluster(clusterID).Get().Send() @@ -300,6 +409,36 @@ func GetHiveShard(clusterID string) (string, error) { return shard, nil } +// Returns the hive shard corresponding to a cluster using provided OCM connection +// e.g. https://api..byo5.p1.openshiftapps.com:6443 +func GetHiveShardWithConn(clusterID string, conn *sdk.Connection) (string, error) { + if conn == nil { + return "", fmt.Errorf("nil OCM sdk connection provided to GetHiveShardWithConn()") + } + + shardPath, err := conn.ClustersMgmt().V1().Clusters(). + Cluster(clusterID). + ProvisionShard(). + Get(). + Send() + + if err != nil { + return "", err + } + + var shard string + + if shardPath != nil { + shard = shardPath.Body().HiveConfig().Server() + } + + if shard == "" { + return "", fmt.Errorf("unable to retrieve shard for cluster %s", clusterID) + } + + return shard, nil +} + func GetHiveCluster(clusterId string) (*cmv1.Cluster, error) { conn, err := CreateConnection() if err != nil { @@ -335,6 +474,91 @@ func GetHiveCluster(clusterId string) (*cmv1.Cluster, error) { return resp.Items().Get(0), nil } +func GetHiveBPClientForCluster(clusterID string, options client.Options, elevationReason string, hiveOCMURL string) (client.Client, error) { + var hiveOCMConn *sdk.Connection + var err error + if len(clusterID) <= 0 { + return nil, fmt.Errorf("GetHiveBPClientForCluster provided empty target cluster ID") + } + if len(hiveOCMURL) <= 0 { + hiveOCMURL = viper.GetString("hive_ocm_url") + } + if len(hiveOCMURL) > 0 { + hiveOCMConn, err = CreateConnectionWithUrl(hiveOCMURL) + if err != nil { + return nil, fmt.Errorf("unable to create hive OCM connection with URL:'%s'. Err: %w", hiveOCMURL, err) + } + defer hiveOCMConn.Close() + hiveCluster, err := GetHiveClusterWithConn(clusterID, nil, hiveOCMConn) + if err != nil { + return nil, fmt.Errorf("failed to fetch hive cluster for cluster:'%s', ocmURL:'%s', Err:'%v'", clusterID, hiveOCMURL, err) + } + if len(elevationReason) > 0 { + return k8s.NewAsBackplaneClusterAdminWithConn(hiveCluster.ID(), options, hiveOCMConn, elevationReason) + } + return k8s.NewWithConn(hiveCluster.ID(), options, hiveOCMConn) + } else { + hiveCluster, err := GetHiveCluster(clusterID) + if err != nil { + return nil, fmt.Errorf("failed to fetch hive cluster for cluster:'%s', err:'%v'", clusterID, err) + } + if len(elevationReason) > 0 { + return k8s.NewAsBackplaneClusterAdmin(hiveCluster.ID(), options, elevationReason) + } + return k8s.New(hiveCluster.ID(), options) + } +} + +// Fetch Hive Cluster with provided OCM connections. +// In the case that the target cluster(stage, integration, etc does not reside +// in the same OCM env as Hive (prod), separate OCM SDK connections +// can be provided for accessing each. If nil is provided a temporary connection using +// the default OCM env vars will be made. +func GetHiveClusterWithConn(clusterId string, clusterOCM *sdk.Connection, hiveOCM *sdk.Connection) (*cmv1.Cluster, error) { + var err error + if clusterOCM == nil { + clusterOCM, err = CreateConnection() + if err != nil { + return nil, err + } + // If provided by caller do not close, only close if connection created here. + defer clusterOCM.Close() + } + if hiveOCM == nil { + hiveOCM = clusterOCM + } + provisionShard, err := clusterOCM.ClustersMgmt().V1().Clusters(). + Cluster(clusterId). + ProvisionShard(). + Get(). + Send() + if err != nil { + fmt.Printf("Failed to get provisionShard for cluster:'%s', err:'%v'", clusterId, err) + return nil, err + } + + hiveApiUrl, ok := provisionShard.Body().HiveConfig().GetServer() + if !ok { + fmt.Printf("No provisionShard found for cluster:'%s'", clusterId) + return nil, fmt.Errorf("no provision shard url found for %s", clusterId) + } + resp, err := hiveOCM.ClustersMgmt().V1().Clusters().List(). + Parameter("search", fmt.Sprintf("api.url='%s'", hiveApiUrl)). + Send() + if err != nil { + fmt.Printf("Error listing clusters with hiveApiUrl:'%s'", hiveApiUrl) + return nil, err + } + + if resp.Items().Empty() { + fmt.Printf("Failed to find hive cluster from hiveApiURL:'%s'", hiveApiUrl) + return nil, fmt.Errorf("failed to find cluster with api.url=%s", hiveApiUrl) + } + + return resp.Items().Get(0), nil + +} + // GetManagementCluster returns the OCM Cluster object for a provided clusterId func GetManagementCluster(clusterId string) (*cmv1.Cluster, error) { conn, err := CreateConnection() @@ -515,3 +739,63 @@ func SendRequest(request *sdk.Request) (*sdk.Response, error) { } return response, nil } + +// Creates an OCM Config object from values read at provided filePath +// utils has a local 'copy' of the config struct +// rather than vendor from "github.com/openshift-online/ocm-cli/pkg/config" +func GetOcmConfigFromFilePath(filePath string) (*ocmConfig.Config, error) { + data, err := os.ReadFile(filePath) + if err != nil { + err = fmt.Errorf("can't read config file '%s': %v", filePath, err) + return nil, err + } + + if len(data) == 0 { + return nil, fmt.Errorf("empty config file:'%s'", filePath) + } + cfg := &ocmConfig.Config{} + err = json.Unmarshal(data, cfg) + if err != nil { + err = fmt.Errorf("can't parse config file '%s': %v", filePath, err) + return nil, err + } + return cfg, nil +} + +func GetOCMSdkConnBuilderFromConfig(ocmCfg *ocmConfig.Config) (*sdk.ConnectionBuilder, error) { + if ocmCfg == nil { + return nil, fmt.Errorf("nil OCM config provided to OCMSdkConnBuilderFromConfig()") + } + // Can use the sdk.connection builder or alternatively omc cli's connection builder wrappers here. + // Each returns an ocm-sdk connection builder. + ocmSdkConnBuilder := sdk.NewConnectionBuilder() + ocmSdkConnBuilder.URL(ocmCfg.URL) + ocmSdkConnBuilder.Tokens(ocmCfg.AccessToken, ocmCfg.RefreshToken) + ocmSdkConnBuilder.Client(ocmCfg.ClientID, ocmCfg.ClientSecret) + return ocmSdkConnBuilder, nil +} + +// Returns an ocmSdkConnBuilder with initial values read from provided configFilePath. +func GetOCMSdkConnBuilderFromFilePath(configFilePath string) (*sdk.ConnectionBuilder, error) { + // Now get the backplane url and access token from OCM... + ocmConfig, err := GetOcmConfigFromFilePath(configFilePath) + if err != nil { + return nil, err + } + return GetOCMSdkConnBuilderFromConfig(ocmConfig) + +} + +// Returns an OCM SDK connection using values read from provided configFilePath. +func GetOCMSdkConnFromFilePath(configFilePath string) (*sdk.Connection, error) { + ocmSdkConnBuilder, err := GetOCMSdkConnBuilderFromFilePath(configFilePath) + if err != nil { + return nil, err + } + ocmSdkConn, err := ocmSdkConnBuilder.Build() + + if err != nil { + return nil, err + } + return ocmSdkConn, nil +} diff --git a/pkg/utils/ocm_test.go b/pkg/utils/ocm_test.go index 32c27a730..0bd221552 100644 --- a/pkg/utils/ocm_test.go +++ b/pkg/utils/ocm_test.go @@ -1,8 +1,14 @@ package utils import ( + "encoding/json" "os" + "path/filepath" "testing" + + ocmConfig "github.com/openshift-online/ocm-common/pkg/ocm/config" + sdk "github.com/openshift-online/ocm-sdk-go" + "sigs.k8s.io/controller-runtime/pkg/client" ) func resetEnvVars(t *testing.T) { @@ -57,3 +63,599 @@ func TestGenerateQuery(t *testing.T) { }) } } + +// TestGetOcmConfigFromFilePath tests the GetOcmConfigFromFilePath function which loads +// OCM configuration from a JSON file at the provided path. It validates that the function +// correctly handles valid config files, non-existent files, empty files, and malformed JSON. +func TestGetOcmConfigFromFilePath(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T) string + wantErr bool + errContains string + checkConfig func(*testing.T, *ocmConfig.Config) + }{ + { + // Test that a valid OCM config file is successfully parsed and loaded with correct values + name: "valid config file", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "ocm.json") + config := ocmConfig.Config{ + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + URL: "https://api.openshift.com", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + } + data, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configFile, data, 0600); err != nil { + t.Skipf("failed to write config file (insufficient permissions?): %v", err) + } + return configFile + }, + wantErr: false, + checkConfig: func(t *testing.T, cfg *ocmConfig.Config) { + if cfg == nil { + t.Error("expected non-nil config") + return + } + if cfg.AccessToken != "test-access-token" { + t.Errorf("expected AccessToken 'test-access-token', got '%s'", cfg.AccessToken) + } + if cfg.RefreshToken != "test-refresh-token" { + t.Errorf("expected RefreshToken 'test-refresh-token', got '%s'", cfg.RefreshToken) + } + if cfg.URL != "https://api.openshift.com" { + t.Errorf("expected URL 'https://api.openshift.com', got '%s'", cfg.URL) + } + if cfg.ClientID != "test-client-id" { + t.Errorf("expected ClientID 'test-client-id', got '%s'", cfg.ClientID) + } + if cfg.ClientSecret != "test-client-secret" { + t.Errorf("expected ClientSecret 'test-client-secret', got '%s'", cfg.ClientSecret) + } + }, + }, + { + // Test that attempting to load a non-existent file returns an appropriate error + name: "non-existent file", + setupFunc: func(t *testing.T) string { + return "/nonexistent/path/ocm.json" + }, + wantErr: true, + errContains: "can't read config file", + }, + { + // Test that an empty config file returns an error + name: "empty config file", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "ocm.json") + if err := os.WriteFile(configFile, []byte(""), 0600); err != nil { + t.Skipf("failed to write empty file (insufficient permissions?): %v", err) + } + return configFile + }, + wantErr: true, + errContains: "empty config file", + }, + { + // Test that a file with invalid JSON syntax returns a parse error + name: "invalid json", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "ocm.json") + if err := os.WriteFile(configFile, []byte("{invalid json}"), 0600); err != nil { + t.Skipf("failed to write invalid json (insufficient permissions?): %v", err) + } + return configFile + }, + wantErr: true, + errContains: "can't parse config file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filePath := tt.setupFunc(t) + cfg, err := GetOcmConfigFromFilePath(filePath) + if tt.wantErr { + if err == nil { + t.Errorf("GetOcmConfigFromFilePath() expected error but got none") + } else if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("GetOcmConfigFromFilePath() error = %v, want error containing %v", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("GetOcmConfigFromFilePath() unexpected error = %v", err) + } + if cfg == nil { + t.Errorf("GetOcmConfigFromFilePath() returned nil config") + } + if tt.checkConfig != nil { + tt.checkConfig(t, cfg) + } + } + }) + } +} + +// TestGetOCMSdkConnBuilderFromConfig tests the GetOCMSdkConnBuilderFromConfig function +// which creates an OCM SDK connection builder from a provided OCM config object. +// It validates nil config handling and successful builder creation with valid config. +func TestGetOCMSdkConnBuilderFromConfig(t *testing.T) { + tests := []struct { + name string + config *ocmConfig.Config + wantErr bool + }{ + { + // Test that a valid OCM config successfully creates a connection builder + name: "valid config", + config: &ocmConfig.Config{ + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + URL: "https://api.openshift.com", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + }, + wantErr: false, + }, + { + // Test that passing a nil config returns an error + name: "nil config", + config: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder, err := GetOCMSdkConnBuilderFromConfig(tt.config) + if tt.wantErr { + if err == nil { + t.Errorf("GetOCMSdkConnBuilderFromConfig() expected error but got none") + } + } else { + if err != nil { + t.Errorf("GetOCMSdkConnBuilderFromConfig() unexpected error = %v", err) + } + if builder == nil { + t.Errorf("GetOCMSdkConnBuilderFromConfig() returned nil builder") + } + } + }) + } +} + +// TestGetOCMSdkConnBuilderFromFilePath tests the GetOCMSdkConnBuilderFromFilePath function +// which reads an OCM config file and creates an SDK connection builder from it. +// It validates both successful builder creation and error handling for invalid file paths. +func TestGetOCMSdkConnBuilderFromFilePath(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T) string + wantErr bool + errContains string + }{ + { + // Test that a valid config file successfully creates a connection builder + name: "valid config file", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "ocm.json") + config := ocmConfig.Config{ + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + URL: "https://api.openshift.com", + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + } + data, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configFile, data, 0600); err != nil { + t.Skipf("failed to write config file (insufficient permissions?): %v", err) + } + return configFile + }, + wantErr: false, + }, + { + // Test that attempting to load from a non-existent file returns an error + name: "non-existent file", + setupFunc: func(t *testing.T) string { + return "/nonexistent/path/ocm.json" + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filePath := tt.setupFunc(t) + builder, err := GetOCMSdkConnBuilderFromFilePath(filePath) + if tt.wantErr { + if err == nil { + t.Errorf("GetOCMSdkConnBuilderFromFilePath() expected error but got none") + } + } else { + if err != nil { + t.Errorf("GetOCMSdkConnBuilderFromFilePath() unexpected error = %v", err) + } + if builder == nil { + t.Errorf("GetOCMSdkConnBuilderFromFilePath() returned nil builder") + } + } + }) + } +} + +// TestGetOCMSdkConnFromFilePath tests the GetOCMSdkConnFromFilePath function which +// reads an OCM config file and creates a fully initialized OCM SDK connection from it. +// It validates error handling for non-existent files and empty config files. +func TestGetOCMSdkConnFromFilePath(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T) string + wantErr bool + }{ + { + // Test that attempting to create a connection from a non-existent file returns an error + name: "non-existent file", + setupFunc: func(t *testing.T) string { + return "/nonexistent/path/ocm.json" + }, + wantErr: true, + }, + { + // Test that an empty config file returns an error when trying to build a connection + name: "empty config file", + setupFunc: func(t *testing.T) string { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "ocm.json") + if err := os.WriteFile(configFile, []byte(""), 0600); err != nil { + t.Skipf("failed to write empty file (insufficient permissions?): %v", err) + } + return configFile + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filePath := tt.setupFunc(t) + conn, err := GetOCMSdkConnFromFilePath(filePath) + if tt.wantErr { + if err == nil { + t.Errorf("GetOCMSdkConnFromFilePath() expected error but got none") + } + } else { + if err != nil { + t.Errorf("GetOCMSdkConnFromFilePath() unexpected error = %v", err) + } + if conn != nil { + defer conn.Close() + } + } + }) + } +} + +// TestGetHiveShardWithConn tests the GetHiveShardWithConn function which retrieves +// the hive shard URL for a cluster using a provided OCM SDK connection. +// It validates that the function properly handles nil connection inputs. +func TestGetHiveShardWithConn(t *testing.T) { + tests := []struct { + name string + clusterID string + conn *sdk.Connection + wantErr bool + }{ + { + // Test that passing a nil OCM connection returns an error + name: "nil connection", + clusterID: "test-cluster-id", + conn: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := GetHiveShardWithConn(tt.clusterID, tt.conn) + if tt.wantErr { + if err == nil { + t.Errorf("GetHiveShardWithConn() expected error but got none") + } + } else { + if err != nil { + t.Errorf("GetHiveShardWithConn() unexpected error = %v", err) + } + } + }) + } +} + +// TestGetHiveClusterWithConn tests the GetHiveClusterWithConn function which fetches +// the hive cluster information using separate OCM connections for the target cluster +// and hive cluster. It validates the function's ability to create temporary connections +// when nil connections are provided. +func TestGetHiveClusterWithConn(t *testing.T) { + tests := []struct { + name string + clusterID string + clusterOCM *sdk.Connection + hiveOCM *sdk.Connection + wantErr bool + }{ + { + // Test that when both connections are nil, the function attempts to create a temporary connection + // This will fail without proper OCM environment variables set + name: "both connections nil - should create temporary connection", + clusterID: "test-cluster-id", + clusterOCM: nil, + hiveOCM: nil, + wantErr: true, // will fail when trying to create connection without proper env vars + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := GetHiveClusterWithConn(tt.clusterID, tt.clusterOCM, tt.hiveOCM) + if tt.wantErr { + if err == nil { + t.Errorf("GetHiveClusterWithConn() expected error but got none") + } + } else { + if err != nil { + t.Errorf("GetHiveClusterWithConn() unexpected error = %v", err) + } + } + }) + } +} + +// TestGetOCMConfigFromEnv tests the GetOCMConfigFromEnv function which loads +// OCM configuration from environment variables and default file locations. +// It validates the function's ability to handle different config file locations +// including OCM_CONFIG env var, ~/.ocm.json, and $XDG_CONFIG_HOME/ocm/ocm.json. +func TestGetOCMConfigFromEnv(t *testing.T) { + tests := []struct { + name string + setupFunc func(t *testing.T) func() + wantErr bool + errContains string + checkConfig func(*testing.T, *ocmConfig.Config) + }{ + { + // Test loading config from a custom OCM_CONFIG environment variable path + name: "with OCM_CONFIG env var set to valid file", + setupFunc: func(t *testing.T) func() { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "custom-ocm.json") + config := ocmConfig.Config{ + AccessToken: "test-token-from-env", + RefreshToken: "test-refresh", + URL: "https://api.openshift.com", + } + data, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configFile, data, 0600); err != nil { + t.Skipf("failed to write config file (insufficient permissions?): %v", err) + } + oldVal := os.Getenv("OCM_CONFIG") + os.Setenv("OCM_CONFIG", configFile) + return func() { + os.Setenv("OCM_CONFIG", oldVal) + } + }, + wantErr: false, + checkConfig: func(t *testing.T, cfg *ocmConfig.Config) { + if cfg == nil { + t.Error("expected non-nil config") + return + } + if cfg.AccessToken != "test-token-from-env" { + t.Errorf("expected AccessToken 'test-token-from-env', got '%s'", cfg.AccessToken) + } + }, + }, + { + // Test that when no config file exists, an empty config is returned without error + name: "no config file exists - returns empty config", + setupFunc: func(t *testing.T) func() { + // Set OCM_CONFIG to a non-existent path + oldVal := os.Getenv("OCM_CONFIG") + os.Setenv("OCM_CONFIG", "/nonexistent/path/ocm.json") + return func() { + os.Setenv("OCM_CONFIG", oldVal) + } + }, + wantErr: false, + checkConfig: func(t *testing.T, cfg *ocmConfig.Config) { + if cfg == nil { + t.Error("expected non-nil empty config") + } + }, + }, + { + // Test that a malformed config file returns a parse error + name: "invalid json in config file", + setupFunc: func(t *testing.T) func() { + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, "invalid-ocm.json") + if err := os.WriteFile(configFile, []byte("{invalid json}"), 0600); err != nil { + t.Skipf("failed to write invalid json (insufficient permissions?): %v", err) + } + oldVal := os.Getenv("OCM_CONFIG") + os.Setenv("OCM_CONFIG", configFile) + return func() { + os.Setenv("OCM_CONFIG", oldVal) + } + }, + wantErr: true, + errContains: "can't parse config file", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cleanup := tt.setupFunc(t) + defer cleanup() + + cfg, err := GetOCMConfigFromEnv() + if tt.wantErr { + if err == nil { + t.Errorf("GetOCMConfigFromEnv() expected error but got none") + } else if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("GetOCMConfigFromEnv() error = %v, want error containing %v", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("GetOCMConfigFromEnv() unexpected error = %v", err) + } + if tt.checkConfig != nil { + tt.checkConfig(t, cfg) + } + } + }) + } +} + +// TestCreateConnectionWithUrl tests the CreateConnectionWithUrl function which creates +// an OCM SDK connection with a specified URL. It validates URL alias handling for +// 'production', 'staging', and 'integration' environments. +// Note: Successful connection creation requires valid OCM credentials and is tested +// in integration tests or the hive-login test command. +func TestCreateConnectionWithUrl(t *testing.T) { + tests := []struct { + name string + ocmUrl string + wantErr bool + errContains string + }{ + { + // Test that an empty URL returns an error + name: "empty URL", + ocmUrl: "", + wantErr: true, + errContains: "empty OCM URL", + }, + { + // Test that an invalid alias returns an error with valid aliases listed + name: "invalid URL alias", + ocmUrl: "invalid-alias", + wantErr: true, + errContains: "invalid OCM_URL found", + }, + { + // Test that 'production' alias doesn't fail with "invalid alias" error + // Will fail with credentials error if not logged in, which is expected + name: "production alias recognized", + ocmUrl: "production", + wantErr: true, // Will fail without credentials, but not with "invalid alias" error + }, + { + // Test that 'staging' alias doesn't fail with "invalid alias" error + // Will fail with credentials error if not logged in, which is expected + name: "staging alias recognized", + ocmUrl: "staging", + wantErr: true, // Will fail without credentials, but not with "invalid alias" error + }, + { + // Test that 'integration' alias doesn't fail with "invalid alias" error + // Will fail with credentials error if not logged in, which is expected + name: "integration alias recognized", + ocmUrl: "integration", + wantErr: true, // Will fail without credentials, but not with "invalid alias" error + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conn, err := CreateConnectionWithUrl(tt.ocmUrl) + if tt.wantErr { + if err == nil { + t.Errorf("CreateConnectionWithUrl() expected error but got none") + } else if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("CreateConnectionWithUrl() error = %v, want error containing %v", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("CreateConnectionWithUrl() unexpected error = %v", err) + } + if conn != nil { + defer conn.Close() + } + } + }) + } +} + +// TestGetHiveBPClientForCluster tests the GetHiveBPClientForCluster function which creates a +// backplane client connection to a hive cluster. It validates input validation and +// error handling for empty cluster IDs. +// Note: Successful connection creation requires valid cluster ID, OCM credentials, +// and accessible hive cluster. These scenarios are tested in integration tests or +// the hive-login test command. +func TestGetHiveBPClientForCluster(t *testing.T) { + tests := []struct { + name string + clusterID string + elevationReason string + hiveOCMURL string + wantErr bool + errContains string + }{ + { + // Test that an empty cluster ID returns an error + name: "empty cluster ID", + clusterID: "", + elevationReason: "", + hiveOCMURL: "", + wantErr: true, + errContains: "empty target cluster ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := GetHiveBPClientForCluster(tt.clusterID, client.Options{}, tt.elevationReason, tt.hiveOCMURL) + if tt.wantErr { + if err == nil { + t.Errorf("GetHiveBPClientForCluster() expected error but got none") + } else if tt.errContains != "" && !contains(err.Error(), tt.errContains) { + t.Errorf("GetHiveBPClientForCluster() error = %v, want error containing %v", err, tt.errContains) + } + } else { + if err != nil { + t.Errorf("GetHiveBPClientForCluster() unexpected error = %v", err) + } + } + }) + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && stringContains(s, substr))) +} + +func stringContains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +}