From da5e752dc27cd257a0cf6fcd6cf6fbc3feabdeda Mon Sep 17 00:00:00 2001 From: Aristides Neto Date: Wed, 23 Oct 2024 15:40:13 -0300 Subject: [PATCH] Implementing tsuru token feature (#60) * Add support for tsuru token resource * Add acceptance tests for tsuru token * Add documentation for tsuru token * Added sensitive field for token --- examples/resources/tsuru_token/import.sh | 4 + examples/resources/tsuru_token/resource.tf | 6 + internal/provider/provider.go | 1 + internal/provider/resource_tsuru_token.go | 265 ++++++++++++++++++ .../provider/resource_tsuru_token_test.go | 177 ++++++++++++ 5 files changed, 453 insertions(+) create mode 100644 examples/resources/tsuru_token/import.sh create mode 100644 examples/resources/tsuru_token/resource.tf create mode 100644 internal/provider/resource_tsuru_token.go create mode 100644 internal/provider/resource_tsuru_token_test.go diff --git a/examples/resources/tsuru_token/import.sh b/examples/resources/tsuru_token/import.sh new file mode 100644 index 0000000..94d8890 --- /dev/null +++ b/examples/resources/tsuru_token/import.sh @@ -0,0 +1,4 @@ +terraform import tsuru_token.resource_name "token_id" + +# example +terraform import tsuru_token.simple_token "my-simple-token" diff --git a/examples/resources/tsuru_token/resource.tf b/examples/resources/tsuru_token/resource.tf new file mode 100644 index 0000000..25754af --- /dev/null +++ b/examples/resources/tsuru_token/resource.tf @@ -0,0 +1,6 @@ +resource "tsuru_token" "simple_token" { + token_id = "my-simple-token" + description = "My description" + team = "team-dev" + expires = "24h" +} \ No newline at end of file diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 3936fa4..adf5c08 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -81,6 +81,7 @@ func Provider() *schema.Provider { "tsuru_pool": resourceTsuruPool(), "tsuru_cluster_pool": resourceTsuruClusterPool(), "tsuru_cluster": resourceTsuruCluster(), + "tsuru_token": resourceTsuruToken(), }, DataSourcesMap: map[string]*schema.Resource{ "tsuru_app": dataSourceTsuruApp(), diff --git a/internal/provider/resource_tsuru_token.go b/internal/provider/resource_tsuru_token.go new file mode 100644 index 0000000..bb56952 --- /dev/null +++ b/internal/provider/resource_tsuru_token.go @@ -0,0 +1,265 @@ +// Copyright 2024 tsuru authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package provider + +import ( + "context" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/pkg/errors" + tsuru_client "github.com/tsuru/go-tsuruclient/pkg/tsuru" +) + +func resourceTsuruToken() *schema.Resource { + return &schema.Resource{ + Description: "Tsuru Token", + CreateContext: resourceTsuruTokenCreate, + ReadContext: resourceTsuruTokenRead, + UpdateContext: resourceTsuruTokenUpdate, + DeleteContext: resourceTsuruTokenDelete, + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(60 * time.Minute), + Update: schema.DefaultTimeout(60 * time.Minute), + Delete: schema.DefaultTimeout(60 * time.Minute), + }, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + Schema: map[string]*schema.Schema{ + "team": { + Type: schema.TypeString, + Description: "The team name responsible for this token", + Required: true, + }, + "token_id": { + Type: schema.TypeString, + Description: "Token name, must be a unique identifier, if empty it will be generated automatically", + Optional: true, + ForceNew: true, + }, + "description": { + Type: schema.TypeString, + Description: "Token description", + Optional: true, + }, + "expires": { + Type: schema.TypeString, + Description: "Token expiration with suffix (s for seconds, m for minutos, h for hours, ...) 0 or unset means it never expires", + Optional: true, + Default: "0s", + }, + "regenerate_on_update": { + Type: schema.TypeBool, + Description: "Setting regenerate will change de value of the token, invalidating the previous value", + Optional: true, + Default: false, + }, + "token": { + Type: schema.TypeString, + Description: "Tsuru token", + Computed: true, + Sensitive: true, + }, + "created_at": { + Type: schema.TypeString, + Description: "Token creation date", + Computed: true, + }, + "expires_at": { + Type: schema.TypeString, + Description: "Token expiration date", + Computed: true, + }, + "last_access": { + Type: schema.TypeString, + Description: "Token last access date", + Computed: true, + }, + "creator_email": { + Type: schema.TypeString, + Description: "Token creator email", + Computed: true, + }, + "roles": { + Type: schema.TypeList, + Description: "Tsuru token roles", + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + }, + "context_value": { + Type: schema.TypeString, + Computed: true, + }, + }, + }, + }, + }, + } +} + +func resourceTsuruTokenCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + provider := meta.(*tsuruProvider) + + teamToken := tsuru_client.TeamTokenCreateArgs{ + Team: d.Get("team").(string), + } + + if tokenId, ok := d.GetOk("token_id"); ok { + teamToken.TokenId = tokenId.(string) + } + + if desc, ok := d.GetOk("description"); ok { + teamToken.Description = desc.(string) + } + + if expires, ok := d.GetOk("expires"); ok { + duration, err := time.ParseDuration(expires.(string)) + if err != nil { + return diag.FromErr(err) + } + teamToken.ExpiresIn = int64(duration.Seconds()) + } + + err := resource.RetryContext(ctx, d.Timeout(schema.TimeoutCreate), func() *resource.RetryError { + token, _, err := provider.TsuruClient.AuthApi.TeamTokenCreate(ctx, teamToken) + if err != nil { + var apiError tsuru_client.GenericOpenAPIError + if errors.As(err, &apiError) { + if isRetryableError(apiError.Body()) { + return resource.RetryableError(err) + } + } + return resource.NonRetryableError(err) + } + d.SetId(token.TokenId) + return nil + }) + + if err != nil { + return diag.FromErr(err) + } + + return resourceTsuruTokenRead(ctx, d, meta) +} + +func resourceTsuruTokenRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + provider := meta.(*tsuruProvider) + tokenId := d.Id() + + teamToken, _, err := provider.TsuruClient.AuthApi.TeamTokenInfo(ctx, tokenId) + if err != nil { + if isNotFoundError(err) { + d.SetId("") + return nil + } + return diag.Errorf("unable to read token %s: %v", tokenId, err) + } + + d.Set("token", teamToken.Token) + d.Set("created_at", formatDate(teamToken.CreatedAt)) + d.Set("expires_at", formatDate(teamToken.ExpiresAt)) + d.Set("last_access", formatDate(teamToken.LastAccess)) + d.Set("creator_email", teamToken.CreatorEmail) + d.Set("team", teamToken.Team) + d.Set("description", teamToken.Description) + d.Set("roles", flattenRoles(teamToken.Roles)) + + return nil +} + +func resourceTsuruTokenUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + provider := meta.(*tsuruProvider) + tokenId := d.Id() + + teamToken := tsuru_client.TeamTokenUpdateArgs{} + + if regenerate, ok := d.GetOk("regenerate_on_update"); ok { + teamToken.Regenerate = regenerate.(bool) + } + + if desc, ok := d.GetOk("description"); ok { + teamToken.Description = desc.(string) + } + + if expires, ok := d.GetOk("expires"); ok { + duration, err := time.ParseDuration(expires.(string)) + if err != nil { + return diag.FromErr(err) + } + teamToken.ExpiresIn = int64(duration.Seconds()) + } + + err := resource.RetryContext(ctx, d.Timeout(schema.TimeoutUpdate), func() *resource.RetryError { + _, _, err := provider.TsuruClient.AuthApi.TeamTokenUpdate(ctx, tokenId, teamToken) + if err != nil { + var apiError tsuru_client.GenericOpenAPIError + if errors.As(err, &apiError) { + if isRetryableError(apiError.Body()) { + return resource.RetryableError(err) + } + return resource.NonRetryableError(err) + } + } + return nil + }) + + if err != nil { + return diag.Errorf("unable to update token %s: %v", tokenId, err) + } + + return resourceTsuruTokenRead(ctx, d, meta) +} + +func resourceTsuruTokenDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { + provider := meta.(*tsuruProvider) + tokenId := d.Id() + + err := resource.RetryContext(ctx, d.Timeout(schema.TimeoutDelete), func() *resource.RetryError { + _, err := provider.TsuruClient.AuthApi.TeamTokenDelete(ctx, tokenId) + if err != nil { + var apiError tsuru_client.GenericOpenAPIError + if errors.As(err, &apiError) { + if isRetryableError(apiError.Body()) { + return resource.RetryableError(err) + } + } + return resource.NonRetryableError(err) + } + return nil + }) + + if err != nil { + return diag.Errorf("unable to delete token %s: %v", tokenId, err) + } + + return nil +} + +func flattenRoles(roles []tsuru_client.RoleInstance) []interface{} { + result := []interface{}{} + + for _, role := range roles { + result = append(result, map[string]interface{}{ + "name": role.Name, + "context_value": role.Contextvalue, + }) + } + + return result +} + +func formatDate(date time.Time) string { + if date.IsZero() { + return "-" + } + return date.In(time.Local).Format(time.RFC822) +} diff --git a/internal/provider/resource_tsuru_token_test.go b/internal/provider/resource_tsuru_token_test.go new file mode 100644 index 0000000..52d7b66 --- /dev/null +++ b/internal/provider/resource_tsuru_token_test.go @@ -0,0 +1,177 @@ +package provider + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + echo "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tsuru/go-tsuruclient/pkg/tsuru" +) + +func TestAccResourceTsuruToken(t *testing.T) { + fakeServer := echo.New() + + iterationCount := 0 + + fakeServer.POST("/1.6/tokens", func(c echo.Context) error { + teamToken := tsuru.TeamTokenCreateArgs{} + err := c.Bind(&teamToken) + require.NoError(t, err) + + if iterationCount == 0 { + assert.Equal(t, "my-simple-token", teamToken.TokenId) + assert.Equal(t, "My description", teamToken.Description) + assert.Equal(t, "team-dev", teamToken.Team) + assert.Equal(t, int64(86400), teamToken.ExpiresIn) + } + + if iterationCount == 1 { + assert.Equal(t, "my-simple-token", teamToken.TokenId) + assert.Equal(t, "My new description", teamToken.Description) + assert.Equal(t, "team-dev", teamToken.Team) + assert.Equal(t, int64(43200), teamToken.ExpiresIn) + } + + iterationCount++ + return c.JSON(http.StatusOK, map[string]interface{}{ + "status": "success", + "token_id": teamToken.TokenId, + }) + }) + + fakeServer.GET("/1.7/tokens/:token", func(c echo.Context) error { + token := c.Param("token") + if iterationCount == 1 { + if token != "my-simple-token" { + return nil + } + + teamToken := tsuru.TeamToken{ + Token: "string-token", + TokenId: token, + Description: "My description", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour * 24), + LastAccess: time.Now().Add(-time.Hour * 1), + CreatorEmail: "creator@example.com", + Team: "team-dev", + Roles: []tsuru.RoleInstance{ + { + Name: "role-name", + Contextvalue: "role-context", + }, + }, + } + return c.JSON(http.StatusOK, teamToken) + } + + if iterationCount == 2 { + if token != "my-simple-token" { + return nil + } + + teamToken := tsuru.TeamToken{ + Token: "string-token", + TokenId: token, + Description: "My new description", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(time.Hour * 24), + LastAccess: time.Now().Add(-time.Hour * 1), + CreatorEmail: "creator@example.com", + Team: "team-dev", + Roles: []tsuru.RoleInstance{ + { + Name: "role-name", + Contextvalue: "role-context", + }, + }, + } + return c.JSON(http.StatusOK, teamToken) + } + + return c.JSON(http.StatusNotFound, nil) + }) + + fakeServer.PUT("/1.6/tokens/:token", func(c echo.Context) error { + token := c.Param("token") + teamTokem := tsuru.TeamToken{} + c.Bind(&teamTokem) + assert.Equal(t, "my-simple-token", token) + assert.Equal(t, "My new description", teamTokem.Description) + iterationCount++ + return c.JSON(http.StatusOK, nil) + }) + + fakeServer.DELETE("/1.6/tokens/:token", func(c echo.Context) error { + token := c.Param("token") + assert.Equal(t, "my-simple-token", token) + return c.NoContent(http.StatusNoContent) + }) + + fakeServer.HTTPErrorHandler = func(err error, c echo.Context) { + t.Errorf("methods=%s, path=%s, err=%s", c.Request().Method, c.Path(), err.Error()) + } + + server := httptest.NewServer(fakeServer) + os.Setenv("TSURU_TARGET", server.URL) + + resourceName := "tsuru_token.team_token" + resource.Test(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + ProviderFactories: testAccProviderFactories, + CheckDestroy: nil, + Steps: []resource.TestStep{ + { + Config: testAccResourceTsuruToken_basic(), + Check: resource.ComposeAggregateTestCheckFunc( + testAccResourceExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "token_id", "my-simple-token"), + resource.TestCheckResourceAttr(resourceName, "description", "My description"), + resource.TestCheckResourceAttr(resourceName, "team", "team-dev"), + resource.TestCheckResourceAttr(resourceName, "expires", "24h"), + ), + }, + { + Config: testAccResourceTsuruToken_complete(), + Check: resource.ComposeAggregateTestCheckFunc( + testAccResourceExists(resourceName), + resource.TestCheckResourceAttr(resourceName, "token_id", "my-simple-token"), + resource.TestCheckResourceAttr(resourceName, "description", "My new description"), + resource.TestCheckResourceAttr(resourceName, "team", "team-dev"), + resource.TestCheckResourceAttr(resourceName, "expires", "12h"), + resource.TestCheckResourceAttr(resourceName, "regenerate_on_update", "true"), + ), + }, + }, + }) + +} + +func testAccResourceTsuruToken_basic() string { + return ` + resource "tsuru_token" "team_token" { + token_id = "my-simple-token" + description = "My description" + team = "team-dev" + expires = "24h" + } +` +} + +func testAccResourceTsuruToken_complete() string { + return ` + resource "tsuru_token" "team_token" { + token_id = "my-simple-token" + description = "My new description" + team = "team-dev" + expires = "12h" + regenerate_on_update = "true" + } +` +}