diff --git a/cmd/cluster_upgrade_to_next_kubernetes_version.go b/cmd/cluster_upgrade_to_next_kubernetes_version.go index 3f9cd94a..d579335a 100644 --- a/cmd/cluster_upgrade_to_next_kubernetes_version.go +++ b/cmd/cluster_upgrade_to_next_kubernetes_version.go @@ -109,7 +109,7 @@ var clusterUpgradeCmd = &cobra.Command{ utils.PrintlnError(err) } - if utils.IsTerminalClusterState(*status.Status) { + if utils.IsTerminalClusterState(status.Status) { break } diff --git a/go.mod b/go.mod index 66873a3f..44fef392 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/posthog/posthog-go v1.12.5 github.com/pterm/pterm v0.12.83 - github.com/qovery/qovery-client-go v0.0.0-20260512064301-eef39158fba8 + github.com/qovery/qovery-client-go v0.0.0-20260609072636-f548ebe903f2 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index b9d6371f..e17d13f8 100644 --- a/go.sum +++ b/go.sum @@ -189,8 +189,8 @@ github.com/posthog/posthog-go v1.12.5 h1:l/x3mpqisXJ0sTOyyRutsTQAgiWYuJT1uhN4cQr github.com/posthog/posthog-go v1.12.5/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= -github.com/qovery/qovery-client-go v0.0.0-20260512064301-eef39158fba8 h1:jk/MDhyGR91u46XOoxDFnu2pL7mB3dLCpU9vLy62vjE= -github.com/qovery/qovery-client-go v0.0.0-20260512064301-eef39158fba8/go.mod h1:mcXeQtxR4AIGIBaWLhy52S16UwL8/1fcDywDuSK1BZ4= +github.com/qovery/qovery-client-go v0.0.0-20260609072636-f548ebe903f2 h1:R6lG3dFH9/N7k7Hz+MMMCY1y3c0N9rmY6NAAjy1dkos= +github.com/qovery/qovery-client-go v0.0.0-20260609072636-f548ebe903f2/go.mod h1:mcXeQtxR4AIGIBaWLhy52S16UwL8/1fcDywDuSK1BZ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= diff --git a/pkg/admin_cluster_services.go b/pkg/admin_cluster_services.go index 2edd6d9d..ec996cef 100644 --- a/pkg/admin_cluster_services.go +++ b/pkg/admin_cluster_services.go @@ -559,7 +559,7 @@ func (service AdminClusterBatchDeployServiceImpl) Deploy(clusters []ClusterDetai } // Trigger a deployment only when the target status is in terminal state - if utils.IsTerminalClusterState(*clusterStatus.Status) { + if utils.IsTerminalClusterState(clusterStatus.Status) { utils.Println(fmt.Sprintf("[Organization '%s' - Cluster '%s'] - Starting deployment - https://console.qovery.com/organization/%s/cluster/%s/cluster-logs", cluster.OrganizationName, cluster.ClusterName, cluster.OrganizationId, cluster.ClusterId)) var err error if service.UpgradeClusterNewK8sVersion != nil { @@ -573,7 +573,7 @@ func (service AdminClusterBatchDeployServiceImpl) Deploy(clusters []ClusterDetai cluster.CurrentStatus = "DEPLOYING" currentDeployingClustersByClusterId[cluster.ClusterId] = cluster } else { - status := fmt.Sprintf("%v", *clusterStatus.Status) // only solution to get the underlying enum's string value + status := fmt.Sprintf("%v", clusterStatus.Status) // only solution to get the underlying enum's string value utils.Println(fmt.Sprintf("[Organization '%s' - Cluster '%s'] - Cluster's state is '%s' (not a terminal state), sending it to waiting queue to be processed later", cluster.OrganizationName, cluster.ClusterName, status)) pendingClusters = append(pendingClusters, cluster) } @@ -607,11 +607,11 @@ func (service AdminClusterBatchDeployServiceImpl) Deploy(clusters []ClusterDetai } // set cluster status - status := fmt.Sprintf("%v", *clusterStatus.Status) // only solution to get the underlying enum's string value + status := fmt.Sprintf("%v", clusterStatus.Status) // only solution to get the underlying enum's string value cluster.CurrentStatus = status // Mark the deployment as finished only if terminal state OR status is "INTERNAL_ERROR" (specific case) - if utils.IsTerminalClusterState(*clusterStatus.Status) || cluster.CurrentStatus == "INTERNAL_ERROR" { - utils.Println(fmt.Sprintf("[Organization '%s' - Cluster '%s'] - Cluster deployed with '%s' status ", cluster.OrganizationName, cluster.ClusterName, *clusterStatus.Status)) + if utils.IsTerminalClusterState(clusterStatus.Status) || cluster.CurrentStatus == "INTERNAL_ERROR" { + utils.Println(fmt.Sprintf("[Organization '%s' - Cluster '%s'] - Cluster deployed with '%s' status ", cluster.OrganizationName, cluster.ClusterName, clusterStatus.Status)) processedClusters = append(processedClusters, cluster) clustersToRemoveFromMap = append(clustersToRemoveFromMap, clusterId) diff --git a/pkg/admin_load_credentials.go b/pkg/admin_load_credentials.go index d4e52b6e..8756eb25 100644 --- a/pkg/admin_load_credentials.go +++ b/pkg/admin_load_credentials.go @@ -7,6 +7,7 @@ import ( "net/http" "os" "os/exec" + "strings" "github.com/go-jose/go-jose/v4/json" log "github.com/sirupsen/logrus" @@ -57,9 +58,18 @@ func LoadCredentials(clusterId string, doNotConnectToBastion bool) error { if err := os.Setenv("KUBECONFIG", filePath); err != nil { return fmt.Errorf("failed to set KUBECONFIG: %w", err) } + if kubeconfigRequiresQoveryCommand(kubeconfig) { + if _, err := exec.LookPath("qovery"); err != nil { + utils.PrintlnInfo(fmt.Sprintf("KUBECONFIG uses qovery as an exec credential command, but qovery was not found in PATH: %v", err)) + } + } return StartChildShell() } +func kubeconfigRequiresQoveryCommand(kubeconfig string) bool { + return strings.Contains(kubeconfig, "command: qovery") +} + func StartChildShell() error { // Get the user's default shell shell := os.Getenv("SHELL") @@ -137,7 +147,12 @@ func getClusterCredentials(clusterId string) []utils.Var { log.Fatal(err) } + return clusterCredentialsFromPayload(clusterId, payload) +} + +func clusterCredentialsFromPayload(clusterId string, payload map[string]string) []utils.Var { var clusterCreds []utils.Var + isGcpPayload := isGcpCredentialsPayload(payload) for key, value := range payload { switch key { case "access_key_id": @@ -147,7 +162,13 @@ func getClusterCredentials(clusterId string) []utils.Var { case "aws_session_token": clusterCreds = append(clusterCreds, utils.Var{Key: "AWS_SESSION_TOKEN", Value: value}) case "region": - clusterCreds = append(clusterCreds, utils.Var{Key: "AWS_DEFAULT_REGION", Value: value}) + if isGcpPayload { + if _, hasGcpRegion := payload["gcp_region"]; !hasGcpRegion { + clusterCreds = appendGcpRegionVars(clusterCreds, value) + } + } else { + clusterCreds = append(clusterCreds, utils.Var{Key: "AWS_DEFAULT_REGION", Value: value}) + } case "scaleway_access_key": clusterCreds = append(clusterCreds, utils.Var{Key: "SCW_ACCESS_KEY", Value: value}) case "scaleway_secret_key": @@ -161,7 +182,50 @@ func getClusterCredentials(clusterId string) []utils.Var { clusterCreds = append(clusterCreds, utils.Var{Key: "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE", Value: filepath}) clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_CREDENTIALS", Value: value}) + case "gcp_access_token": + filepath := utils.WriteInFile(clusterId, "google_access_token", []byte(value)) + + clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_OAUTH_ACCESS_TOKEN", Value: value}) + clusterCreds = append(clusterCreds, utils.Var{Key: "CLOUDSDK_AUTH_ACCESS_TOKEN_FILE", Value: filepath}) + case "gcp_project_id": + clusterCreds = appendGcpProjectVars(clusterCreds, value) + case "gcp_region": + clusterCreds = appendGcpRegionVars(clusterCreds, value) + case "gcp_access_token_expiration": + clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_OAUTH_ACCESS_TOKEN_EXPIRATION", Value: value}) + case "gcp_credentials_type": + clusterCreds = append(clusterCreds, utils.Var{Key: "GCP_CREDENTIALS_TYPE", Value: value}) } } return clusterCreds } + +func appendGcpProjectVars(clusterCreds []utils.Var, projectId string) []utils.Var { + clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_PROJECT", Value: projectId}) + clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_CLOUD_PROJECT", Value: projectId}) + clusterCreds = append(clusterCreds, utils.Var{Key: "CLOUDSDK_CORE_PROJECT", Value: projectId}) + return clusterCreds +} + +func appendGcpRegionVars(clusterCreds []utils.Var, region string) []utils.Var { + clusterCreds = append(clusterCreds, utils.Var{Key: "GOOGLE_REGION", Value: region}) + clusterCreds = append(clusterCreds, utils.Var{Key: "CLOUDSDK_COMPUTE_REGION", Value: region}) + return clusterCreds +} + +func isGcpCredentialsPayload(payload map[string]string) bool { + gcpKeys := []string{ + "json_credentials", + "gcp_access_token", + "gcp_project_id", + "gcp_region", + "gcp_access_token_expiration", + "gcp_credentials_type", + } + for _, key := range gcpKeys { + if _, ok := payload[key]; ok { + return true + } + } + return false +} diff --git a/pkg/admin_load_credentials_test.go b/pkg/admin_load_credentials_test.go new file mode 100644 index 00000000..2aa2f886 --- /dev/null +++ b/pkg/admin_load_credentials_test.go @@ -0,0 +1,84 @@ +package pkg + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/qovery/qovery-cli/utils" +) + +func TestClusterCredentialsFromPayload(t *testing.T) { + t.Run("Should map legacy GCP json credentials", func(t *testing.T) { + // given + clusterId := "test-legacy-gcp" + payload := map[string]string{ + "json_credentials": "base64-json", + } + + // when + credentials := clusterCredentialsFromPayload(clusterId, payload) + + // then + assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_CREDENTIALS", Value: "base64-json"}) + assert.Contains(t, credentials, utils.Var{Key: "CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE", Value: "/tmp/qovery_test-legacy-gcp/google_creds.json"}) + }) + + t.Run("Should map GCP workload identity federation access token credentials", func(t *testing.T) { + // given + payload := map[string]string{ + "gcp_access_token": "access-token", + "gcp_project_id": "project-id", + "gcp_region": "europe-west1", + "gcp_access_token_expiration": "2026-06-08T17:00:00Z", + "gcp_credentials_type": "WORKLOAD_IDENTITY_FEDERATION", + } + + // when + credentials := clusterCredentialsFromPayload("test-wif-gcp", payload) + + // then + assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_OAUTH_ACCESS_TOKEN", Value: "access-token"}) + assert.Contains(t, credentials, utils.Var{Key: "CLOUDSDK_AUTH_ACCESS_TOKEN_FILE", Value: "/tmp/qovery_test-wif-gcp/google_access_token"}) + assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_PROJECT", Value: "project-id"}) + assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_CLOUD_PROJECT", Value: "project-id"}) + assert.Contains(t, credentials, utils.Var{Key: "CLOUDSDK_CORE_PROJECT", Value: "project-id"}) + assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_REGION", Value: "europe-west1"}) + assert.Contains(t, credentials, utils.Var{Key: "CLOUDSDK_COMPUTE_REGION", Value: "europe-west1"}) + assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_OAUTH_ACCESS_TOKEN_EXPIRATION", Value: "2026-06-08T17:00:00Z"}) + assert.Contains(t, credentials, utils.Var{Key: "GCP_CREDENTIALS_TYPE", Value: "WORKLOAD_IDENTITY_FEDERATION"}) + }) + + t.Run("Should not map generic region to AWS_DEFAULT_REGION for GCP credentials", func(t *testing.T) { + // given + payload := map[string]string{ + "gcp_access_token": "access-token", + "gcp_project_id": "project-id", + "gcp_credentials_type": "WORKLOAD_IDENTITY_FEDERATION", + "region": "europe-west9", + } + + // when + credentials := clusterCredentialsFromPayload("test-wif-gcp-region", payload) + + // then + assert.Contains(t, credentials, utils.Var{Key: "GOOGLE_REGION", Value: "europe-west9"}) + assert.Contains(t, credentials, utils.Var{Key: "CLOUDSDK_COMPUTE_REGION", Value: "europe-west9"}) + assert.NotContains(t, credentials, utils.Var{Key: "AWS_DEFAULT_REGION", Value: "europe-west9"}) + }) + + t.Run("Should keep mapping generic region to AWS_DEFAULT_REGION for AWS credentials", func(t *testing.T) { + // given + payload := map[string]string{ + "access_key_id": "access-key", + "secret_access_key": "secret-key", + "region": "eu-west-3", + } + + // when + credentials := clusterCredentialsFromPayload("test-aws", payload) + + // then + assert.Contains(t, credentials, utils.Var{Key: "AWS_DEFAULT_REGION", Value: "eu-west-3"}) + }) +} diff --git a/pkg/cluster/cluster_mock.go b/pkg/cluster/cluster_mock.go index 8bc92bc6..59d8e28b 100644 --- a/pkg/cluster/cluster_mock.go +++ b/pkg/cluster/cluster_mock.go @@ -33,8 +33,9 @@ func MockListClusters(organization *qovery.Organization, clusters []qovery.Clust func MockDeployCluster(organization *qovery.Organization, cluster *qovery.Cluster, clusterState *qovery.ClusterStateEnum) { var clusterStatus = qovery.ClusterStatus{ - ClusterId: &cluster.Id, - Status: clusterState, + ClusterId: cluster.Id, + Status: clusterStateOrDefault(clusterState), + Reason: qovery.DEPLOYMENTINFRAREASON_UNSPECIFIED, } var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster/", cluster.Id, "/deploy") httpmock.RegisterResponder("POST", url, @@ -49,8 +50,9 @@ func MockDeployCluster(organization *qovery.Organization, cluster *qovery.Cluste func MockStopCluster(organization *qovery.Organization, cluster *qovery.Cluster, clusterState *qovery.ClusterStateEnum) { var clusterStatus = qovery.ClusterStatus{ - ClusterId: &cluster.Id, - Status: clusterState, + ClusterId: cluster.Id, + Status: clusterStateOrDefault(clusterState), + Reason: qovery.DEPLOYMENTINFRAREASON_UNSPECIFIED, } var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster/", cluster.Id, "/stop") httpmock.RegisterResponder("POST", url, @@ -65,8 +67,9 @@ func MockStopCluster(organization *qovery.Organization, cluster *qovery.Cluster, func MockGetClusterStatus(organization *qovery.Organization, cluster *qovery.Cluster, clusterState *qovery.ClusterStateEnum) { var clusterStatus = qovery.ClusterStatus{ - ClusterId: &cluster.Id, - Status: clusterState, + ClusterId: cluster.Id, + Status: clusterStateOrDefault(clusterState), + Reason: qovery.DEPLOYMENTINFRAREASON_UNSPECIFIED, } var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster/", cluster.Id, "/status") httpmock.RegisterResponder("GET", url, @@ -79,6 +82,13 @@ func MockGetClusterStatus(organization *qovery.Organization, cluster *qovery.Clu }) } +func clusterStateOrDefault(clusterState *qovery.ClusterStateEnum) qovery.ClusterStateEnum { + if clusterState == nil { + return qovery.CLUSTERSTATEENUM_DEPLOYED + } + return *clusterState +} + func MockCreateCluster(organization *qovery.Organization) { var url = fmt.Sprint("https://api.qovery.com/organization/", organization.Id, "/cluster") httpmock.RegisterResponder("POST", url, diff --git a/pkg/cluster/cluster_service.go b/pkg/cluster/cluster_service.go index 1d84b347..35aa54d2 100644 --- a/pkg/cluster/cluster_service.go +++ b/pkg/cluster/cluster_service.go @@ -74,7 +74,7 @@ func (service *ClusterServiceImpl) DeployCluster(organizationName string, cluste return err } - if utils.IsTerminalClusterState(*status.Status) { + if utils.IsTerminalClusterState(status.Status) { break } @@ -124,7 +124,7 @@ func (service *ClusterServiceImpl) StopCluster(organizationName string, clusterN return err } - if utils.IsTerminalClusterState(*status.Status) { + if utils.IsTerminalClusterState(status.Status) { break } diff --git a/pkg/cluster/credentials/cluster_credentials_service.go b/pkg/cluster/credentials/cluster_credentials_service.go index beddb3c0..630d5642 100644 --- a/pkg/cluster/credentials/cluster_credentials_service.go +++ b/pkg/cluster/credentials/cluster_credentials_service.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "net/http" "github.com/fatih/color" "github.com/qovery/qovery-client-go" @@ -12,6 +13,11 @@ import ( "github.com/qovery/qovery-cli/utils" ) +const ( + gcpCredentialsTypeWif = "Workload Identity Federation" + gcpCredentialsTypeServiceAccount = "Service Account JSON Key" +) + type ClusterCredentialsService interface { ListClusterCredentials(organizationID string, cloudProviderType qovery.CloudProviderEnum) (*qovery.ClusterCredentialsResponseList, error) AskToCreateCredentials(organizationID string, cloudProviderType qovery.CloudProviderEnum) (*qovery.ClusterCredentials, error) @@ -77,9 +83,8 @@ func (service *ClusterCredentialsServiceImpl) AskToCreateCredentials( creds, resp, err := service.client.CloudProviderCredentialsAPI.CreateOnPremiseCredentials(context.Background(), organizationID).OnPremiseCredentialsRequest(qovery.OnPremiseCredentialsRequest{ Name: "on-premise", }).Execute() - if err != nil || resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("%s: %v\n%s", color.RedString("Error"), string(body), err) + if apiErr := formatCloudProviderCredentialsApiError(resp, err); apiErr != nil { + return nil, apiErr } return creds, nil } @@ -122,9 +127,8 @@ func (service *ClusterCredentialsServiceImpl) AskToCreateCredentials( SecretAccessKey: secretKey, }, }).Execute() - if err != nil || resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("%s: %v\n%s", color.RedString("Error"), string(body), err) + if apiErr := formatCloudProviderCredentialsApiError(resp, err); apiErr != nil { + return nil, apiErr } return creds, nil @@ -169,30 +173,79 @@ func (service *ClusterCredentialsServiceImpl) AskToCreateCredentials( ScalewayProjectId: projectId, ScalewayOrganizationId: organizationId, }).Execute() - if err != nil || resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("%s: %v\n%s", color.RedString("Error"), string(body), err) + if apiErr := formatCloudProviderCredentialsApiError(resp, err); apiErr != nil { + return nil, apiErr } return creds, nil case qovery.CLOUDPROVIDERENUM_GCP: - gcpJsonCredentials, err := service.promptUiFactory.RunPrompt("Enter your GCP JSON credentials (*base64* encoded)", "") + _, gcpCredentialsType, err := service.promptUiFactory.RunSelect("Which GCP credentials type do you want to use?", []string{ + gcpCredentialsTypeWif, + gcpCredentialsTypeServiceAccount, + }) if err != nil { return nil, err } - if utils.IsEmptyOrBlank(gcpJsonCredentials) { - return nil, fmt.Errorf("please enter a non-empty gcp json credentials") - } - creds, resp, err := service.client.CloudProviderCredentialsAPI.CreateGcpCredentials(context.Background(), organizationID).GcpCredentialsRequest(qovery.GcpCredentialsRequest{ - Name: credentialsName, - GcpCredentials: gcpJsonCredentials, - }).Execute() - if err != nil || resp.StatusCode >= 400 { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("%s: %v\n%s", color.RedString("Error"), string(body), err) + + var gcpCredentialsRequest qovery.GcpCredentialsRequest + switch gcpCredentialsType { + case gcpCredentialsTypeWif: + serviceAccountEmail, err := service.promptUiFactory.RunPrompt("Enter your GCP service account email", "") + if err != nil { + return nil, err + } + workloadIdentityProviderResource, err := service.promptUiFactory.RunPrompt("Enter your GCP Workload Identity provider resource", "") + if err != nil { + return nil, err + } + if utils.IsEmptyOrBlank(serviceAccountEmail) { + return nil, fmt.Errorf("please enter a non-empty gcp service account email") + } + if utils.IsEmptyOrBlank(workloadIdentityProviderResource) { + return nil, fmt.Errorf("please enter a non-empty gcp workload identity provider resource") + } + + gcpCredentialsRequest = qovery.GcpWorkloadIdentityFederationCredentialsRequestAsGcpCredentialsRequest( + qovery.NewGcpWorkloadIdentityFederationCredentialsRequest(credentialsName, serviceAccountEmail, workloadIdentityProviderResource), + ) + + case gcpCredentialsTypeServiceAccount: + gcpJsonCredentials, err := service.promptUiFactory.RunPrompt("Enter your GCP JSON credentials (*base64* encoded)", "") + if err != nil { + return nil, err + } + if utils.IsEmptyOrBlank(gcpJsonCredentials) { + return nil, fmt.Errorf("please enter a non-empty gcp json credentials") + } + + gcpServiceAccountKeyRequest := qovery.NewGcpServiceAccountKeyCredentialsRequest(credentialsName, gcpJsonCredentials) + gcpCredentialsRequest = qovery.GcpServiceAccountKeyCredentialsRequestAsGcpCredentialsRequest(gcpServiceAccountKeyRequest) + + default: + return nil, fmt.Errorf("unhandled gcp credentials type during credentials creation: %s", gcpCredentialsType) + } + + creds, resp, err := service.client.CloudProviderCredentialsAPI.CreateGcpCredentials(context.Background(), organizationID).GcpCredentialsRequest(gcpCredentialsRequest).Execute() + if apiErr := formatCloudProviderCredentialsApiError(resp, err); apiErr != nil { + return nil, apiErr } return creds, nil } return nil, fmt.Errorf("unhandled cloud provider type during credentials creation: %s", cloudProviderType) } + +func formatCloudProviderCredentialsApiError(resp *http.Response, err error) error { + if err == nil && (resp == nil || resp.StatusCode < http.StatusBadRequest) { + return nil + } + + if resp != nil && resp.Body != nil { + body, _ := io.ReadAll(resp.Body) + if len(body) > 0 { + return fmt.Errorf("%s: %v\n%s", color.RedString("Error"), string(body), err) + } + } + + return fmt.Errorf("%s: %v", color.RedString("Error"), err) +} diff --git a/pkg/cluster/credentials/cluster_credentials_service_test.go b/pkg/cluster/credentials/cluster_credentials_service_test.go index 6d41e136..3c63355e 100644 --- a/pkg/cluster/credentials/cluster_credentials_service_test.go +++ b/pkg/cluster/credentials/cluster_credentials_service_test.go @@ -1,6 +1,7 @@ package credentials import ( + "errors" "github.com/google/uuid" "github.com/jarcoal/httpmock" "github.com/qovery/qovery-client-go" @@ -70,6 +71,17 @@ func TestCredentialsNameOnCreateCredentials(t *testing.T) { }) } +func TestFormatCloudProviderCredentialsApiError(t *testing.T) { + t.Run("Should return transport error when response is nil", func(t *testing.T) { + // when + err := formatCloudProviderCredentialsApiError(nil, errors.New("connection refused")) + + // then + assert.NotNil(t, err) + assert.Contains(t, err.Error(), "connection refused") + }) +} + func TestAwsCredentials(t *testing.T) { t.Run("Should succeed to create AWS credentials according to prompt user inputs", func(t *testing.T) { httpmock.Activate() @@ -350,7 +362,7 @@ func TestScalewayCredentials(t *testing.T) { } func TestGcpCredentials(t *testing.T) { - t.Run("Should succeed to create GCP credentials according to prompt user inputs", func(t *testing.T) { + t.Run("Should succeed to create GCP service account JSON key credentials according to prompt user inputs", func(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -363,6 +375,7 @@ func TestGcpCredentials(t *testing.T) { utils.GetQoveryClient("Fake token type", "Fake token"), promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{ "Give a name to your credentials": "gcp-credentials", + "Which GCP credentials type do you want to use?": gcpCredentialsTypeServiceAccount, "Enter your GCP JSON credentials (*base64* encoded)": "gcp-creds-json", }), ) @@ -374,8 +387,40 @@ func TestGcpCredentials(t *testing.T) { assert.Nil(t, err) assert.NotNil(t, credentials) var createdCredentials = allCredentialsById[credentials.GenericClusterCredentials.Id].(qovery.GcpCredentialsRequest) - assert.Equal(t, "gcp-credentials", createdCredentials.Name) - assert.Equal(t, "gcp-creds-json", createdCredentials.GcpCredentials) + assert.NotNil(t, createdCredentials.GcpServiceAccountKeyCredentialsRequest) + assert.Equal(t, "gcp-credentials", createdCredentials.GcpServiceAccountKeyCredentialsRequest.Name) + assert.Equal(t, "gcp-creds-json", createdCredentials.GcpServiceAccountKeyCredentialsRequest.GcpCredentials) + }) + t.Run("Should succeed to create GCP workload identity federation credentials according to prompt user inputs", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + // mock + var organization = organization.CreateTestOrganization() + MockCreateGcpCredentials(organization) + + // given + var service = NewClusterCredentialsService( + utils.GetQoveryClient("Fake token type", "Fake token"), + promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{ + "Give a name to your credentials": "gcp-wif-credentials", + "Which GCP credentials type do you want to use?": gcpCredentialsTypeWif, + "Enter your GCP service account email": "svc@example.iam.gserviceaccount.com", + "Enter your GCP Workload Identity provider resource": "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", + }), + ) + + // when + var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_GCP) + + // then + assert.Nil(t, err) + assert.NotNil(t, credentials) + var createdCredentials = allCredentialsById[credentials.GenericClusterCredentials.Id].(qovery.GcpCredentialsRequest) + assert.NotNil(t, createdCredentials.GcpWorkloadIdentityFederationCredentialsRequest) + assert.Equal(t, "gcp-wif-credentials", createdCredentials.GcpWorkloadIdentityFederationCredentialsRequest.Name) + assert.Equal(t, "svc@example.iam.gserviceaccount.com", createdCredentials.GcpWorkloadIdentityFederationCredentialsRequest.ServiceAccountEmail) + assert.Equal(t, "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", createdCredentials.GcpWorkloadIdentityFederationCredentialsRequest.WorkloadIdentityProviderResource) }) t.Run("Should fail to create GCP credentials if json is empty", func(t *testing.T) { httpmock.Activate() @@ -390,6 +435,7 @@ func TestGcpCredentials(t *testing.T) { utils.GetQoveryClient("Fake token type", "Fake token"), promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{ "Give a name to your credentials": "gcp-credentials", + "Which GCP credentials type do you want to use?": gcpCredentialsTypeServiceAccount, "Enter your GCP JSON credentials (*base64* encoded)": "", }), ) @@ -402,6 +448,60 @@ func TestGcpCredentials(t *testing.T) { assert.NotNil(t, err) assert.Equal(t, "please enter a non-empty gcp json credentials", err.Error()) }) + t.Run("Should fail to create GCP workload identity federation credentials if service account email is empty", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + // mock + var organization = organization.CreateTestOrganization() + MockCreateGcpCredentials(organization) + + // given + var service = NewClusterCredentialsService( + utils.GetQoveryClient("Fake token type", "Fake token"), + promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{ + "Give a name to your credentials": "gcp-wif-credentials", + "Which GCP credentials type do you want to use?": gcpCredentialsTypeWif, + "Enter your GCP service account email": "", + "Enter your GCP Workload Identity provider resource": "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider", + }), + ) + + // when + var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_GCP) + + // then + assert.Nil(t, credentials) + assert.NotNil(t, err) + assert.Equal(t, "please enter a non-empty gcp service account email", err.Error()) + }) + t.Run("Should fail to create GCP workload identity federation credentials if provider resource is empty", func(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + // mock + var organization = organization.CreateTestOrganization() + MockCreateGcpCredentials(organization) + + // given + var service = NewClusterCredentialsService( + utils.GetQoveryClient("Fake token type", "Fake token"), + promptuifactory.NewPromptUiFactoryMock(map[string]bool{}, map[string]string{ + "Give a name to your credentials": "gcp-wif-credentials", + "Which GCP credentials type do you want to use?": gcpCredentialsTypeWif, + "Enter your GCP service account email": "svc@example.iam.gserviceaccount.com", + "Enter your GCP Workload Identity provider resource": "", + }), + ) + + // when + var credentials, err = service.AskToCreateCredentials(organization.Id, qovery.CLOUDPROVIDERENUM_GCP) + + // then + assert.Nil(t, credentials) + assert.NotNil(t, err) + assert.Equal(t, "please enter a non-empty gcp workload identity provider resource", err.Error()) + }) t.Run("Should list GCP credentials", func(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() diff --git a/pkg/cluster/selfmanaged/self_managed_cluster_service.go b/pkg/cluster/selfmanaged/self_managed_cluster_service.go index 0c176472..b9507d0b 100644 --- a/pkg/cluster/selfmanaged/self_managed_cluster_service.go +++ b/pkg/cluster/selfmanaged/self_managed_cluster_service.go @@ -222,6 +222,10 @@ func getName(creds *qovery.ClusterCredentials) (string, error) { return castedCreds.GetName(), nil case *qovery.ScalewayClusterCredentials: return castedCreds.GetName(), nil + case *qovery.GcpStaticClusterCredentials: + return castedCreds.GetName(), nil + case *qovery.GcpWorkloadIdentityFederationClusterCredentials: + return castedCreds.GetName(), nil case *qovery.GenericClusterCredentials: return castedCreds.GetName(), nil default: @@ -237,6 +241,10 @@ func getId(creds *qovery.ClusterCredentials) (string, error) { return castedCreds.GetId(), nil case *qovery.ScalewayClusterCredentials: return castedCreds.GetId(), nil + case *qovery.GcpStaticClusterCredentials: + return castedCreds.GetId(), nil + case *qovery.GcpWorkloadIdentityFederationClusterCredentials: + return castedCreds.GetId(), nil case *qovery.GenericClusterCredentials: return castedCreds.GetId(), nil default: