diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa25671..ccffa2c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,9 +31,7 @@ jobs: - uses: actions/setup-go@v4.0.1 with: go-version-file: 'go.mod' - cache: true - - run: go mod download - - run: go build -v . + cache: false - name: Run linters uses: golangci/golangci-lint-action@v3.6.0 with: diff --git a/go.mod b/go.mod index 54e6a40..fc27690 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.20 require ( github.com/hashicorp/terraform-plugin-docs v0.16.0 github.com/hashicorp/terraform-plugin-framework v1.3.4 + github.com/hashicorp/terraform-plugin-framework-validators v0.11.0 github.com/hashicorp/terraform-plugin-go v0.18.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.4.0 @@ -15,7 +16,6 @@ require ( github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.1.1 // indirect github.com/Masterminds/sprig/v3 v3.2.2 // indirect - github.com/Microsoft/go-winio v0.6.0 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/agext/levenshtein v1.2.2 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect @@ -56,11 +56,9 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.0.0 // indirect github.com/posener/complete v1.2.3 // indirect - github.com/rogpeppe/go-internal v1.8.0 // indirect github.com/russross/blackfriday v1.6.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.5.0 // indirect - github.com/stretchr/testify v1.8.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect diff --git a/go.sum b/go.sum index aaccc8f..6942fd0 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,7 @@ github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0 github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= github.com/Masterminds/sprig/v3 v3.2.2 h1:17jRggJu518dr3QaafizSXOjKYp94wKfABxUmyxvxX8= github.com/Masterminds/sprig/v3 v3.2.2/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk= -github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= -github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= +github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/acomagu/bufpipe v1.0.4 h1:e3H4WUzM3npvo5uv95QuJM3cQspFNtFBzvJ2oNjKIDQ= @@ -85,6 +84,8 @@ github.com/hashicorp/terraform-plugin-docs v0.16.0 h1:UmxFr3AScl6Wged84jndJIfFcc github.com/hashicorp/terraform-plugin-docs v0.16.0/go.mod h1:M3ZrlKBJAbPMtNOPwHicGi1c+hZUh7/g0ifT/z7TVfA= github.com/hashicorp/terraform-plugin-framework v1.3.4 h1:dOTLsALgmQu+PawAvhfGQ04H0MeIz3EZmBw7OFvj7qs= github.com/hashicorp/terraform-plugin-framework v1.3.4/go.mod h1:2gGDpWiTI0irr9NSTLFAKlTi6KwGti3AoU19rFqU30o= +github.com/hashicorp/terraform-plugin-framework-validators v0.11.0 h1:DKb1bX7/EPZUTW6F5zdwJzS/EZ/ycVD6JAW5RYOj4f8= +github.com/hashicorp/terraform-plugin-framework-validators v0.11.0/go.mod h1:dzxOiHh7O9CAwc6p8N4mR1H++LtRkl+u+21YNiBVNno= github.com/hashicorp/terraform-plugin-go v0.18.0 h1:IwTkOS9cOW1ehLd/rG0y+u/TGLK9y6fGoBjXVUquzpE= github.com/hashicorp/terraform-plugin-go v0.18.0/go.mod h1:l7VK+2u5Kf2y+A+742GX0ouLut3gttudmvMgN0PA74Y= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= @@ -141,14 +142,12 @@ github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= @@ -160,15 +159,12 @@ github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2 h1:4jaiDzPyXQvSd7D0EjG45355tLlV3VOECpq10pLC+8s= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -215,7 +211,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= @@ -231,7 +226,6 @@ google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/client/api_account.go b/internal/client/api_account.go new file mode 100644 index 0000000..a66c5cc --- /dev/null +++ b/internal/client/api_account.go @@ -0,0 +1,185 @@ +package client + +import ( + "context" + "fmt" + "net/http" +) + +// CreateAccount struct. +type CreateAccount struct { + Name string `json:"name"` + ID string `json:"id"` + Password string `json:"password"` + FullName string `json:"fullName,omitempty"` + IsActive bool `json:"isActive,omitempty"` + IsAdmin bool `json:"isAdmin,omitempty"` + IsOrg bool `json:"isOrg,omitempty"` + SearchLDAP bool `json:"searchLDAP,omitempty"` +} + +// UpdateAccount struct. +type UpdateAccount struct { + FullName string `json:"fullName,omitempty"` + IsActive bool `json:"isActive,omitempty"` + IsAdmin bool `json:"isAdmin,omitempty"` +} + +// ResponseAccount struct. +type ResponseAccount struct { + Name string `json:"name"` + ID string `json:"id"` + FullName string `json:"fullName,omitempty"` + IsActive bool `json:"isActive"` + IsAdmin bool `json:"isAdmin"` + IsOrg bool `json:"isOrg"` + IsImported bool `json:"isImported"` + OnDemand bool `json:"onDemand"` + OtpEnabled bool `json:"otpEnabled"` + MembersCount int `json:"membersCount"` + TeamsCount int `json:"teamsCount"` +} + +// Account filters enum. +type AccountFilter string + +const ( + Users AccountFilter = "user" + Orgs AccountFilter = "orgs" + Admins AccountFilter = "admins" + NonAdmins AccountFilter = "non-admins" + ActiveUsers AccountFilter = "active-users" + InactiveUsers AccountFilter = "inactive-users" + URLTargetForAccounts = "accounts" +) + +// APIFormOfFilter is a string readable form of the AccountFilters enum. +func (accF AccountFilter) APIFormOfFilter() string { + filters := [...]string{"users", "orgs", "admins", "non-admins", "active-users"} + + x := string(accF) + for _, v := range filters { + if v == x { + return x + } + } + + return "all" +} + +// CreateAccount method - checking the MKE health endpoint. +func (c *Client) ApiCreateAccount(ctx context.Context, acc CreateAccount) (ResponseAccount, error) { + if (acc == CreateAccount{}) { + return ResponseAccount{}, fmt.Errorf("creating account failed. %w: %+v", ErrEmptyStruct, acc) + } + + req, err := c.RequestFromTargetAndJSONBody(ctx, http.MethodPost, URLTargetForAccounts, acc) + if err != nil { + return ResponseAccount{}, fmt.Errorf("creating account %s failed. %w: %s", acc.Name, ErrRequestCreation, err) + } + + resp, err := c.doAuthorizedRequest(req) + if err != nil { + return ResponseAccount{}, fmt.Errorf("creating account %s failed. %w", acc.Name, err) + } + + resAcc := ResponseAccount{} + if err := resp.JSONMarshallBody(&resAcc); err != nil { + return ResponseAccount{}, fmt.Errorf("creating account %s failed. %w: %s", acc.Name, ErrUnmarshaling, err) + } + + return resAcc, nil +} + +// DeleteAccount deletes a user from in Enzi. +func (c *Client) ApiDeleteAccount(ctx context.Context, id string) error { + url := fmt.Sprintf("%s/%s", URLTargetForAccounts, id) + req, err := c.RequestFromTargetAndBytesBody(ctx, http.MethodDelete, url, []byte{}) + + if err != nil { + return fmt.Errorf("deleting account %s failed. %w: %s", id, ErrRequestCreation, err) + } + + if _, err = c.doAuthorizedRequest(req); err != nil { + return fmt.Errorf("deleting account %s failed. %w", id, err) + } + return nil +} + +// ReadAccount method retrieves a user from the enzi endpoint. +func (c *Client) ApiReadAccount(ctx context.Context, id string) (ResponseAccount, error) { + url := fmt.Sprintf("%s/%s", URLTargetForAccounts, id) + req, err := c.RequestFromTargetAndBytesBody(ctx, http.MethodGet, url, []byte{}) + if err != nil { + return ResponseAccount{}, fmt.Errorf("reading account %s failed. %w: %s", id, ErrRequestCreation, err) + } + + resp, err := c.doAuthorizedRequest(req) + if err != nil { + return ResponseAccount{}, fmt.Errorf("reading account %s failed. %w", id, err) + } + + resAcc := ResponseAccount{} + if err := resp.JSONMarshallBody(&resAcc); err != nil { + return ResponseAccount{}, fmt.Errorf("reading account %s failed. %w: %s", id, ErrUnmarshaling, err) + } + return resAcc, nil +} + +// UpdateAccount updates a user in the enzi endpoint. +func (c *Client) ApiUpdateAccount(ctx context.Context, id string, acc UpdateAccount) (ResponseAccount, error) { + url := fmt.Sprintf("%s/%s", URLTargetForAccounts, id) + + req, err := c.RequestFromTargetAndJSONBody(ctx, http.MethodPatch, url, acc) + + if err != nil { + return ResponseAccount{}, fmt.Errorf("updating account %s failed. %w: %s", id, ErrRequestCreation, err) + } + + resp, err := c.doAuthorizedRequest(req) + if err != nil { + return ResponseAccount{}, fmt.Errorf("updating account %s failed. %w", id, err) + } + + resAcc := ResponseAccount{} + if err := resp.JSONMarshallBody(&resAcc); err != nil { + return ResponseAccount{}, fmt.Errorf("updating account %s failed. %w: %s", id, ErrUnmarshaling, err) + } + return resAcc, nil +} + +// ReadAccounts method retrieves all accounts depending on the filter passed from the enzi endpoint. +func (c *Client) ApiReadAccounts(ctx context.Context, accFilter AccountFilter) ([]ResponseAccount, error) { + // req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.createEnziUrl("accounts"), nil) + req, err := c.RequestFromTargetAndBytesBody(ctx, http.MethodGet, URLTargetForAccounts, []byte{}) + if err != nil { + return []ResponseAccount{}, fmt.Errorf("reading accounts in bulk '%s' failed. %w: %s", + accFilter.APIFormOfFilter(), ErrRequestCreation, err) + } + + q := req.URL.Query() + q.Add("filter", accFilter.APIFormOfFilter()) + req.URL.RawQuery = q.Encode() + + resp, err := c.doAuthorizedRequest(req) + if err != nil { + return []ResponseAccount{}, fmt.Errorf("reading accounts in bulk '%s' failed. %w", + accFilter.APIFormOfFilter(), err) + } + + accs := struct { + UsersCount int `json:"usersCount"` + OrgsCount int `json:"orgsCount"` + ResourceCount int `json:"resourceCount"` + NextPageStart string `json:"nextPageStart"` + + Accounts []ResponseAccount `json:"accounts"` + }{} + + if err := resp.JSONMarshallBody(&accs); err != nil { + return []ResponseAccount{}, fmt.Errorf("reading accounts in bulk '%s' failed. %w: %s", + accFilter.APIFormOfFilter(), ErrUnmarshaling, err) + } + + return accs.Accounts, nil +} diff --git a/internal/client/api_account_test.go b/internal/client/api_account_test.go new file mode 100644 index 0000000..fcb8086 --- /dev/null +++ b/internal/client/api_account_test.go @@ -0,0 +1,294 @@ +package client_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "github.com/Mirantis/terraform-provider-mke/internal/client" +) + +type testAccountStruct struct { + server *httptest.Server + expectedResponse client.ResponseAccount + expectedErr error +} + +func TestCreateValidAccount(t *testing.T) { + testResAcc := client.ResponseAccount{ + ID: "fake-test-id", + Name: "testuser", + FullName: "Test Name", + } + mAccount, err := json.Marshal(testResAcc) + if err != nil { + t.Error(err) + } + tc := testAccountStruct{ + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write(mAccount); err != nil { + t.Error(err) + return + } + })), + expectedResponse: testResAcc, + expectedErr: nil, + } + defer tc.server.Close() + testClient, err := client.NewUnsafeSSLClient(tc.server.URL, "fakeuser", "fakepass") + + if err != nil { + t.Error("couldn't create test client") + } + ctx := context.Background() + resp, err := testClient.ApiCreateAccount(ctx, client.CreateAccount{Name: testResAcc.Name}) + if !reflect.DeepEqual(tc.expectedResponse, resp) { + t.Errorf("expected (%v), got (%v)", tc.expectedResponse, resp) + } + if !errors.Is(err, tc.expectedErr) { + t.Errorf("expected (%v), got (%v)", tc.expectedErr, err) + } +} + +func TestCreateInvalidAccount(t *testing.T) { + testResAcc := client.ResponseAccount{} + mAccount, err := json.Marshal(testResAcc) + tUser := client.CreateAccount{Name: "testuser"} + if err != nil { + t.Fatal(err) + } + tc := testAccountStruct{ + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + if _, err := w.Write(mAccount); err != nil { + t.Error(err) + return + } + })), + expectedResponse: testResAcc, + expectedErr: client.ErrResponseError, + } + defer tc.server.Close() + testClient, err := client.NewUnsafeSSLClient(tc.server.URL, "fakeuser", "fakepass") + if err != nil { + t.Error("couldn't create test client") + } + ctx := context.Background() + + resp, err := testClient.ApiCreateAccount(ctx, tUser) + + if !reflect.DeepEqual(tc.expectedResponse, resp) { + t.Errorf("expected resp: (%+v),\n got (%+v)", tc.expectedResponse, resp) + } + if !errors.Is(err, tc.expectedErr) { + t.Errorf("expected error: (%v),\n got (%v)", tc.expectedErr, err) + } +} + +func TestCreateAccountEmpty(t *testing.T) { + tc := testAccountStruct{ + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + if _, err := w.Write(nil); err != nil { + t.Error(err) + return + } + })), + expectedResponse: client.ResponseAccount{}, + expectedErr: client.ErrEmptyStruct, + } + defer tc.server.Close() + testClient, err := client.NewUnsafeSSLClient(tc.server.URL, "fakeuser", "fakepass") + if err != nil { + t.Error("couldn't create test client") + } + ctx := context.Background() + + resp, err := testClient.ApiCreateAccount(ctx, client.CreateAccount{}) + + if !reflect.DeepEqual(tc.expectedResponse, resp) { + t.Errorf("expected resp: (%+v),\n got (%+v)", tc.expectedResponse, resp) + } + if !errors.Is(err, tc.expectedErr) { + t.Errorf("expected error: (%v),\n got (%v)", tc.expectedErr, err) + } +} + +func TestCreateAccountUnmarshalErr(t *testing.T) { + tc := testAccountStruct{ + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + if _, err := w.Write(nil); err != nil { + t.Error(err) + return + } + })), + expectedResponse: client.ResponseAccount{}, + expectedErr: client.ErrResponseError, + } + defer tc.server.Close() + testClient, err := client.NewUnsafeSSLClient(tc.server.URL, "fakeuser", "fakepass") + if err != nil { + t.Error("couldn't create test client") + } + ctx := context.Background() + + resp, err := testClient.ApiCreateAccount(ctx, client.CreateAccount{Name: "fake"}) + + if !reflect.DeepEqual(tc.expectedResponse, resp) { + t.Errorf("expected resp: (%+v),\n got (%+v)", tc.expectedResponse, resp) + } + if !errors.Is(err, tc.expectedErr) { + t.Errorf("expected error: (%v),\n got (%v)", tc.expectedErr, err) + } +} + +func TestDeleteAccountSuccess(t *testing.T) { + ctx := context.Background() + auth := client.Auth{ + Username: "myuser", + Password: "mypassword", + Token: "mytoken", + } + testAcc := "fakeid" + mockRequest := MockHandlerKey{ + Path: fmt.Sprintf("%s/%s", client.URLTargetForAccounts, testAcc), + Method: http.MethodDelete, + } + keysResp := errors.New("") + + svr := MockTestServer(&auth, MockHandlerMap{ + mockRequest: MockServerHandlerGeneratorReturnJson(keysResp), + }) + + u, _ := url.Parse(svr.URL) + c, err := client.NewClient(u, &auth, svr.Client()) + if err != nil { + t.Fatalf("Could not make a client: %s", err) + } + + err = c.ApiDeleteAccount(ctx, testAcc) + if err != nil { + t.Fatalf("delete account api call failed: %s", err) + } +} + +func TestReadAccountSuccess(t *testing.T) { + resAcc := client.ResponseAccount{ + Name: "fakeacc", + } + mResAcc, err := json.Marshal(resAcc) + if err != nil { + t.Fatal(err) + } + tc := testAccountStruct{ + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write(mResAcc); err != nil { + t.Error(err) + return + } + })), + expectedResponse: resAcc, + expectedErr: nil, + } + defer tc.server.Close() + testClient, err := client.NewUnsafeSSLClient(tc.server.URL, "fakeuser", "fakepass") + if err != nil { + t.Error("couldn't create test client") + } + ctx := context.Background() + resp, err := testClient.ApiReadAccount(ctx, "fakeid") + + if !reflect.DeepEqual(tc.expectedResponse, resp) { + t.Errorf("expected resp: (%+v),\n got (%+v)", tc.expectedResponse, resp) + } + if !errors.Is(err, tc.expectedErr) { + t.Errorf("expected error: (%v),\n got (%v)", tc.expectedErr, err) + } +} + +func TestUpdateAccountSuccess(t *testing.T) { + uAcc := client.ResponseAccount{ + Name: "fakeacc", + } + mUAcc, err := json.Marshal(uAcc) + if err != nil { + t.Fatal(err) + } + tc := testAccountStruct{ + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write(mUAcc); err != nil { + t.Error(err) + return + } + })), + expectedResponse: uAcc, + expectedErr: nil, + } + defer tc.server.Close() + testClient, err := client.NewUnsafeSSLClient(tc.server.URL, "fakeuser", "fakepass") + if err != nil { + t.Error("couldn't create test client") + } + ctx := context.Background() + resp, err := testClient.ApiUpdateAccount(ctx, "fakeid", client.UpdateAccount{FullName: "mock"}) + + if !reflect.DeepEqual(tc.expectedResponse, resp) { + t.Errorf("expected resp: (%+v),\n got (%+v)", tc.expectedResponse, resp) + } + if !errors.Is(err, tc.expectedErr) { + t.Errorf("expected error: (%v),\n got (%v)", tc.expectedErr, err) + } +} + +func TestReadAccountsSuccess(t *testing.T) { + resAccs := []client.ResponseAccount{} + resAccs = append(resAccs, + client.ResponseAccount{Name: "mock1"}, client.ResponseAccount{Name: "mock2"}) + + accs := struct { + UsersCount int `json:"usersCount"` + OrgsCount int `json:"orgsCount"` + ResourceCount int `json:"resourceCount"` + NextPageStart string `json:"nextPageStart"` + + Accounts []client.ResponseAccount `json:"accounts"` + }{Accounts: resAccs} + mResAccs, err := json.Marshal(accs) + if err != nil { + t.Fatal(err) + } + tc := testAccountStruct{ + server: httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write(mResAccs); err != nil { + t.Error(err) + return + } + })), + expectedErr: client.ErrUnmarshaling, + } + defer tc.server.Close() + testClient, err := client.NewUnsafeSSLClient(tc.server.URL, "fakeuser", "fakepass") + if err != nil { + t.Error("couldn't create test client") + } + ctx := context.Background() + resp, err := testClient.ApiReadAccounts(ctx, client.Users) + + if !reflect.DeepEqual(resAccs, resp) { + t.Errorf("expected resp: (%+v),\n got (%+v)", tc.expectedResponse, resp) + } + if errors.Is(err, tc.expectedErr) { + t.Errorf("expected error: (%v),\n got (%v)", tc.expectedErr, err) + } +} diff --git a/internal/client/do.go b/internal/client/do.go index 718dd61..de885dd 100644 --- a/internal/client/do.go +++ b/internal/client/do.go @@ -19,7 +19,7 @@ func (c *Client) doAuthorizedRequest(req *http.Request) (*Response, error) { return c.doRequest(req) } -// doRequest perform http request, catch http errors and return body as io.ReaderCloser. +// doRequest perform http request, catch http errors and return response as io.ReaderCloser. func (c *Client) doRequest(req *http.Request) (*Response, error) { apiRes, err := c.HTTPClient.Do(req) if err != nil { diff --git a/internal/client/util.go b/internal/client/util.go new file mode 100644 index 0000000..2a30286 --- /dev/null +++ b/internal/client/util.go @@ -0,0 +1,17 @@ +package client + +import ( + "math/rand" + "time" +) + +// GeneratePass creates a random password. +func GeneratePass() string { + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789!@#$") + rand.New(rand.NewSource(time.Now().UnixNano())) + b := make([]rune, 8) + for i := range b { + b[i] = letters[rand.Intn(len(letters))] + } + return string(b) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 171b25a..6e4b6bd 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -88,6 +88,7 @@ func (p *MKEProvider) Configure(ctx context.Context, req provider.ConfigureReque func (p *MKEProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewMKEClientBundleResource, + NewUserResource, } } diff --git a/internal/provider/user_resource.go b/internal/provider/user_resource.go new file mode 100644 index 0000000..95e70d2 --- /dev/null +++ b/internal/provider/user_resource.go @@ -0,0 +1,257 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/Mirantis/terraform-provider-mke/internal/client" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var _ resource.Resource = &UserResource{} + +type UserResourceModel struct { + Name types.String `tfsdk:"name"` + Password types.String `tfsdk:"password"` + FullName types.String `tfsdk:"full_name"` + IsAdmin types.Bool `tfsdk:"is_admin"` + Id types.String `tfsdk:"id"` +} + +type UserResource struct { + providerModel MKEProviderModel +} + +func NewUserResource() resource.Resource { + return &UserResource{} +} + +func (r *UserResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_user" +} + +func (r *UserResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + // This description is used by the documentation generator and the language server. + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Identifier", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "The name of the user", + Required: true, + Validators: []validator.String{stringvalidator.LengthBetween(3, 16)}, + }, + "password": schema.StringAttribute{ + MarkdownDescription: "The password of the user", + Required: true, + Sensitive: true, + Validators: []validator.String{stringvalidator.LengthBetween(8, 16)}, + }, + "full_name": schema.StringAttribute{ + MarkdownDescription: "The full name of the user", + Optional: true, + Computed: true, + Default: stringdefault.StaticString(""), + }, + "is_admin": schema.BoolAttribute{ + MarkdownDescription: "Is the user an admin", + Computed: true, + Default: booldefault.StaticBool(false), + Optional: true, + }, + }, + MarkdownDescription: "User resource", + } +} + +func (r *UserResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + lpm, ok := req.ProviderData.(MKEProviderModel) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *MKEProviderModel, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + tflog.Debug(ctx, "Successfully interpeted provider model", map[string]interface{}{}) + r.providerModel = lpm +} + +func (r *UserResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data *UserResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + pass := data.Password.ValueString() + if pass == "" { + pass = client.GeneratePass() + data.Password = basetypes.NewStringValue(pass) + } + + acc := client.CreateAccount{ + Name: data.Name.ValueString(), + Password: pass, + FullName: data.FullName.ValueString(), + IsAdmin: data.IsAdmin.ValueBool(), + IsOrg: false, + SearchLDAP: false, + } + + if resp.Diagnostics.HasError() { + return + } + + cl, err := r.providerModel.Client() + if err != nil { + resp.Diagnostics.AddError("MKE provider could not create a client", fmt.Sprintf("An error occurred creating the client: %s", err.Error())) + return + } + if r.providerModel.TestingMode() { + resp.Diagnostics.AddWarning("testing mode warning", "mke user resource handler is in testing mode, no creation will be run.") + data.Id = basetypes.NewStringValue(TestingVersion) + } else { + rAcc, err := cl.ApiCreateAccount(ctx, acc) + if err != nil { + resp.Diagnostics.AddError( + "Unexpected Create Account error", + err.Error(), + ) + return + } + + tflog.Trace(ctx, fmt.Sprintf("created User resource `%s`", data.Name.ValueString())) + + data.Id = basetypes.NewStringValue(rAcc.ID) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *UserResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + tflog.Debug(ctx, "Preparing to read user resource") + var data *UserResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + + cl, err := r.providerModel.Client() + if err != nil { + resp.Diagnostics.AddError("MKE provider could not create a client", fmt.Sprintf("An error occurred creating the client: %s", err.Error())) + return + } + if r.providerModel.TestingMode() { + resp.Diagnostics.AddWarning("testing mode warning", "mke user resource handler is in testing mode, no read will be run.") + data.Id = types.StringValue(TestingVersion) + } else { + rAcc, err := cl.ApiReadAccount(ctx, data.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + data.Id = types.StringValue(rAcc.ID) + data.Name = types.StringValue(rAcc.Name) + data.FullName = types.StringValue(rAcc.FullName) + data.IsAdmin = types.BoolValue(rAcc.IsAdmin) + } + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *UserResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + tflog.Debug(ctx, "Preparing to update user resource") + + var data *UserResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + + if resp.Diagnostics.HasError() { + return + } + cl, err := r.providerModel.Client() + if err != nil { + resp.Diagnostics.AddError("MKE provider could not create a client", fmt.Sprintf("An error occurred creating the client: %s", err.Error())) + return + } + if r.providerModel.TestingMode() { + resp.Diagnostics.AddWarning("testing mode warning", "mke user resource handler is in testing mode, no update will be run.") + data.Id = types.StringValue(TestingVersion) + } else { + user := client.UpdateAccount{ + FullName: data.FullName.ValueString(), + IsAdmin: data.IsAdmin.ValueBool(), + } + rAcc, err := cl.ApiUpdateAccount(ctx, data.Id.ValueString(), user) + tflog.Debug(ctx, fmt.Sprintf("The retuerned 'user' %+v", rAcc)) + + if err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + + // Overwrite user with refreshed state + data.Id = types.StringValue(rAcc.ID) + data.Name = types.StringValue(rAcc.Name) + data.FullName = types.StringValue(rAcc.FullName) + data.IsAdmin = types.BoolValue(rAcc.IsAdmin) + } + + // Set refreshed state + resp.Diagnostics.Append(resp.State.Set(ctx, data)...) + tflog.Debug(ctx, "Updated 'user' resource", map[string]any{"success": true}) +} + +func (r *UserResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data *UserResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + cl, err := r.providerModel.Client() + if err != nil { + resp.Diagnostics.AddError("MKE provider could not create a client", fmt.Sprintf("An error occurred creating the client: %s", err.Error())) + return + } + if r.providerModel.TestingMode() { + resp.Diagnostics.AddWarning("testing mode warning", "mke user resource handler is in testing mode, no deletion will be run.") + } else if err := cl.ApiDeleteAccount(ctx, data.Id.ValueString()); err != nil { + resp.Diagnostics.AddError("Client Error", err.Error()) + return + } + + tflog.Debug(ctx, "Deleted user resource", map[string]any{"success": true}) +} + +func (r *UserResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("name"), req, resp) +} diff --git a/internal/provider/user_resource_test.go b/internal/provider/user_resource_test.go new file mode 100644 index 0000000..414d0a8 --- /dev/null +++ b/internal/provider/user_resource_test.go @@ -0,0 +1,67 @@ +package provider_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +const ( + providerConfig = ` + provider "mke" { + endpoint = "test" + username = "test" + password = "test" + }` +) + +const ( + TestingVersion = "test" +) + +func TestUserResourceDefault(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: providerConfig + testUserResourceDefault(), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("mke_user.test", "name", TestingVersion), + ), + }, + // ImportState testing + { + ResourceName: "mke_user.test", + ImportState: true, + }, + // Update and Read testing + { + Config: providerConfig + ` + resource "mke_user" "test" { + name = "blah" + password = "blahblah" + full_name = "blah" + }`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("mke_user.test", "name", "blah"), + resource.TestCheckResourceAttr("mke_user.test", "password", "blahblah"), + resource.TestCheckResourceAttr("mke_user.test", "full_name", "blah"), + resource.TestCheckResourceAttr("mke_user.test", "is_admin", "false"), + resource.TestCheckResourceAttrSet("mke_user.test", "id"), + ), + }, + // Delete is called implicitly + }, + }) +} + +func testUserResourceDefault() string { + return ` + resource "mke_user" "test" { + name = "test" + password = "testtest" + full_name = "test" + }` +}