diff --git a/internal/btp/hana/auth.go b/internal/btp/hana/auth.go new file mode 100644 index 000000000..0efc1b782 --- /dev/null +++ b/internal/btp/hana/auth.go @@ -0,0 +1,21 @@ +package hana + +import ( + "encoding/json" + "github.com/kyma-project/cli.v3/internal/clierror" + "os" +) + +func ReadCredentialsFromFile(path string) (*HanaAdminCredentials, clierror.Error) { + bytes, err := os.ReadFile(path) + if err != nil { + return nil, clierror.Wrap(err, clierror.New("failed to read credentials file")) + } + credentials := &HanaAdminCredentials{} + + err = json.Unmarshal(bytes, credentials) + if err != nil { + return nil, clierror.Wrap(err, clierror.New("failed to parse credentials file")) + } + return credentials, nil +} diff --git a/internal/btp/hana/hana.go b/internal/btp/hana/hana.go new file mode 100644 index 000000000..e046d4f04 --- /dev/null +++ b/internal/btp/hana/hana.go @@ -0,0 +1,44 @@ +package hana + +import ( + "encoding/json" + "fmt" + "github.com/kyma-project/cli.v3/internal/clierror" + "io" + "net/http" +) + +func GetID(baseURL, token string) (string, clierror.Error) { + return getID(fmt.Sprintf("https://%s", baseURL), token) +} + +func getID(baseURL, token string) (string, clierror.Error) { + client := &http.Client{} + requestGet, err := http.NewRequest("GET", fmt.Sprintf("%s/inventory/v2/serviceInstances", baseURL), nil) + if err != nil { + return "", clierror.Wrap(err, clierror.New("failed to create a get Hana instances request")) + } + requestGet.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + + response, err := client.Do(requestGet) + if err != nil { + return "", clierror.Wrap(err, clierror.New("failed to get Hana instances")) + } + + if response.StatusCode != http.StatusOK { + return "", clierror.New(fmt.Sprintf("unexpected status code: %d", response.StatusCode)) + } + hanaInstanceBytes, err := io.ReadAll(response.Body) + if err != nil { + return "", clierror.Wrap(err, clierror.New("failed to read Hana instances from the response")) + } + hanaInstance := HanaInstance{} + err = json.Unmarshal(hanaInstanceBytes, &hanaInstance) + if err != nil { + return "", clierror.Wrap(err, clierror.New("failed to parse Hana instances from the response")) + } + if len(hanaInstance.Data) == 0 { + return "", clierror.New("no Hana instances found in the response") + } + return hanaInstance.Data[0].ID, nil +} diff --git a/internal/btp/hana/hana_test.go b/internal/btp/hana/hana_test.go new file mode 100644 index 000000000..2db64e189 --- /dev/null +++ b/internal/btp/hana/hana_test.go @@ -0,0 +1,76 @@ +package hana + +import ( + "fmt" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetID(t *testing.T) { + authToken := "test-token" + t.Run("get hana instance ID from server", func(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/inventory/v2/serviceInstances", r.URL.Path) + require.Equal(t, fmt.Sprintf("Bearer %s", authToken), r.Header.Get("Authorization")) + require.Equal(t, http.MethodGet, r.Method) + + _, err := w.Write([]byte(`{"data":[{"id":"test-id"}]}`)) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + id, err := getID(testServer.URL, authToken) + require.Nil(t, err) + require.Equal(t, "test-id", id) + }) + t.Run("response body empty", func(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/inventory/v2/serviceInstances", r.URL.Path) + require.Equal(t, fmt.Sprintf("Bearer %s", authToken), r.Header.Get("Authorization")) + require.Equal(t, http.MethodGet, r.Method) + _, err := w.Write([]byte(`{"data":[]}`)) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + id, err := getID(testServer.URL, authToken) + require.Contains(t, err.String(), "no Hana instances found in the response") + require.Empty(t, id) + }) + t.Run("can't create new GET request", func(t *testing.T) { + id, err := getID(": ", authToken) + require.Contains(t, err.String(), "failed to create a get Hana instances request") + require.Empty(t, id) + }) + t.Run("can't send GET request", func(t *testing.T) { + id, err := getID("https://localhost", authToken) + require.Contains(t, err.String(), "failed to get Hana instances") + require.Empty(t, id) + }) + t.Run("response status not OK", func(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer testServer.Close() + + id, err := getID(testServer.URL, authToken) + require.Contains(t, err.String(), "unexpected status code: 500") + require.Empty(t, id) + }) + t.Run("failed to unmarshal response", func(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, err := w.Write([]byte(`wrong format {`)) + require.NoError(t, err) + w.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + id, err := getID(testServer.URL, authToken) + require.Contains(t, err.String(), "failed to parse Hana instances from the response") + require.Empty(t, id) + }) +} diff --git a/internal/btp/hana/map.go b/internal/btp/hana/map.go new file mode 100644 index 000000000..ac3a4583f --- /dev/null +++ b/internal/btp/hana/map.go @@ -0,0 +1,45 @@ +package hana + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/kyma-project/cli.v3/internal/clierror" + "net/http" +) + +func MapInstance(baseURL, clusterID, hanaID, token string) clierror.Error { + return mapInstance(fmt.Sprintf("https://%s", baseURL), clusterID, hanaID, token) +} + +func mapInstance(baseURL, clusterID, hanaID, token string) clierror.Error { + client := &http.Client{} + + requestData := HanaMapping{ + Platform: "kubernetes", + PrimaryID: clusterID, + } + + requestString, err := json.Marshal(requestData) + if err != nil { + return clierror.Wrap(err, clierror.New("failed to marshal mapping request")) + } + + request, err := http.NewRequest("POST", fmt.Sprintf("%s/inventory/v2/serviceInstances/%s/instanceMappings", baseURL, hanaID), bytes.NewBuffer(requestString)) + if err != nil { + return clierror.Wrap(err, clierror.New("failed to create mapping request")) + } + request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token)) + + resp, err := client.Do(request) + if err != nil { + return clierror.Wrap(err, clierror.New("failed to post mapping request")) + } + + // server sends status Created when mapping is created, and 200 if it already exists + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return clierror.Wrap(fmt.Errorf("status code: %d", resp.StatusCode), clierror.New("unexpected status code")) + } + + return nil +} diff --git a/internal/btp/hana/map_test.go b/internal/btp/hana/map_test.go new file mode 100644 index 000000000..38077d15c --- /dev/null +++ b/internal/btp/hana/map_test.go @@ -0,0 +1,44 @@ +package hana + +import ( + "fmt" + "github.com/stretchr/testify/require" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMapInstance(t *testing.T) { + t.Run("map hana instance", func(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/inventory/v2/serviceInstances/test-id/instanceMappings", r.URL.Path) + require.Equal(t, fmt.Sprintf("Bearer %s", "test-token"), r.Header.Get("Authorization")) + require.Equal(t, http.MethodPost, r.Method) + + w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + err := mapInstance(testServer.URL, "test-cluster-id", "test-id", "test-token") + require.Nil(t, err) + }) + t.Run("failed to create mapping request", func(t *testing.T) { + err := mapInstance(": ", "test-cluster-id", "test-id", "test-token") + require.Contains(t, err.String(), "failed to create mapping request") + }) + t.Run("failed to post mapping request", func(t *testing.T) { + + err := mapInstance("https://localhost", "test-cluster-id", "test-id", "test-token") + require.Contains(t, err.String(), "failed to post mapping request") + }) + t.Run("unexpected status code", func(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer testServer.Close() + + err := mapInstance(testServer.URL, "test-cluster-id", "test-id", "test-token") + require.Contains(t, err.String(), "unexpected status code") + }) +} diff --git a/internal/btp/hana/types.go b/internal/btp/hana/types.go new file mode 100644 index 000000000..c89a47dbf --- /dev/null +++ b/internal/btp/hana/types.go @@ -0,0 +1,23 @@ +package hana + +import "github.com/kyma-project/cli.v3/internal/btp/auth" + +type HanaInstance struct { + Data []HanaInstanceData `json:"data"` +} + +type HanaInstanceData struct { + ID string `json:"id"` + Name string `json:"name"` + ServicePlanName string `json:"servicePlanName"` +} + +type HanaAdminCredentials struct { + BaseURL string `json:"baseurl"` + UAA auth.UAA `json:"uaa"` +} + +type HanaMapping struct { + Platform string `json:"platform"` + PrimaryID string `json:"primaryID"` +} diff --git a/internal/cmd/alpha/alpha.go b/internal/cmd/alpha/alpha.go index c2c05d0da..648a9aace 100644 --- a/internal/cmd/alpha/alpha.go +++ b/internal/cmd/alpha/alpha.go @@ -4,6 +4,7 @@ import ( "github.com/kyma-project/cli.v3/internal/clierror" "github.com/kyma-project/cli.v3/internal/cmd/alpha/access" "github.com/kyma-project/cli.v3/internal/cmd/alpha/add" + "github.com/kyma-project/cli.v3/internal/cmd/alpha/hana" "github.com/kyma-project/cli.v3/internal/cmd/alpha/modules" "github.com/kyma-project/cli.v3/internal/cmd/alpha/oidc" "github.com/kyma-project/cli.v3/internal/cmd/alpha/provision" @@ -29,6 +30,7 @@ func NewAlphaCMD() (*cobra.Command, clierror.Error) { return nil, err } + cmd.AddCommand(hana.NewHanaCMD(kymaConfig)) cmd.AddCommand(provision.NewProvisionCMD()) cmd.AddCommand(referenceinstance.NewReferenceInstanceCMD(kymaConfig)) cmd.AddCommand(access.NewAccessCMD(kymaConfig)) diff --git a/internal/cmd/alpha/hana/hana.go b/internal/cmd/alpha/hana/hana.go new file mode 100644 index 000000000..fdfac6158 --- /dev/null +++ b/internal/cmd/alpha/hana/hana.go @@ -0,0 +1,19 @@ +package hana + +import ( + "github.com/kyma-project/cli.v3/internal/cmdcommon" + "github.com/spf13/cobra" +) + +func NewHanaCMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command { + cmd := &cobra.Command{ + Use: "hana", + Short: "Manage a Hana instance on the Kyma platform.", + Long: `Use this command to manage a Hana instance on the SAP Kyma platform.`, + DisableFlagsInUseLine: true, + } + + cmd.AddCommand(NewMapHanaCMD(kymaConfig)) + + return cmd +} diff --git a/internal/cmd/alpha/hana/map.go b/internal/cmd/alpha/hana/map.go new file mode 100644 index 000000000..873922960 --- /dev/null +++ b/internal/cmd/alpha/hana/map.go @@ -0,0 +1,90 @@ +package hana + +import ( + "context" + "github.com/kyma-project/cli.v3/internal/btp/auth" + "github.com/kyma-project/cli.v3/internal/btp/hana" + "github.com/kyma-project/cli.v3/internal/clierror" + "github.com/kyma-project/cli.v3/internal/cmdcommon" + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type hanaMapConfig struct { + *cmdcommon.KymaConfig + + hanaID string + credentialsPath string +} + +func NewMapHanaCMD(kymaConfig *cmdcommon.KymaConfig) *cobra.Command { + config := hanaMapConfig{ + KymaConfig: kymaConfig, + } + + cmd := &cobra.Command{ + Use: "map", + Short: "Map the Hana instance to the Kyma cluster.", + Long: "Use this command to map the Hana instance to the Kyma cluster.", + Run: func(_ *cobra.Command, _ []string) { + clierror.Check(runHanaMap(&config)) + }, + } + + cmd.Flags().StringVar(&config.credentialsPath, "credentials-path", "", "Path to the credentials json file.") + cmd.Flags().StringVar(&config.hanaID, "hana-id", "", "Hana instance ID.") + _ = cmd.MarkFlagRequired("credentials-path") + + return cmd +} + +func runHanaMap(config *hanaMapConfig) clierror.Error { + client, err := config.GetKubeClientWithClierr() + if err != nil { + return err + } + + clusterID, err := getClusterID(config.Ctx, client.Static()) + if err != nil { + return clierror.WrapE(err, clierror.New("while getting cluster ID")) + } + + credentials, err := hana.ReadCredentialsFromFile(config.credentialsPath) + if err != nil { + return clierror.WrapE(err, clierror.New("while reading Hana credentials from file")) + } + + token, err := auth.GetOAuthToken("client_credentials", credentials.UAA.URL, credentials.UAA.ClientID, credentials.UAA.ClientSecret) + if err != nil { + return clierror.WrapE(err, clierror.New("while getting OAuth token")) + } + + // get Hana ID if not provided by the user + hanaID := config.hanaID + if hanaID == "" { + hanaID, err = hana.GetID(credentials.BaseURL, token.AccessToken) + if err != nil { + return clierror.WrapE(err, clierror.New("while getting hana ID")) + + } + } + err = hana.MapInstance(credentials.BaseURL, clusterID, hanaID, token.AccessToken) + if err != nil { + return clierror.WrapE(err, clierror.New("while mapping Hana instance")) + } + return nil +} + +func getClusterID(ctx context.Context, client kubernetes.Interface) (string, clierror.Error) { + secret, geterr := client.CoreV1().Secrets("kyma-system").Get(ctx, "sap-btp-manager", metav1.GetOptions{}) + if geterr != nil { + return "", clierror.Wrap(geterr, clierror.New("failed to get secret kyma-system/sap-btp-manager")) + } + + if secret.Data["cluster_id"] == nil { + return "", clierror.New("cluster_id not found in the secret kyma-system/sap-btp-manager") + } + + return string(secret.Data["cluster_id"]), nil +}