From 0258c8e64272161fd5a66f89097d18b781a5c071 Mon Sep 17 00:00:00 2001 From: Mitchell Nielsen Date: Tue, 11 Jun 2024 16:47:19 -0500 Subject: [PATCH] feat(blocks): add Block data source (#209) Adds block data source along with docs and tests. Closes https://github.com/PrefectHQ/terraform-provider-prefect/issues/175 --- README.md | 2 +- docs/data-sources/block.md | 59 ++++++ .../data-sources/prefect_block/data-source.tf | 15 ++ internal/api/block_documents.go | 1 + internal/client/block_documents.go | 34 +++ internal/provider/datasources/block.go | 198 ++++++++++++++++++ internal/provider/datasources/block_test.go | 85 ++++++++ internal/provider/provider.go | 1 + 8 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 docs/data-sources/block.md create mode 100644 examples/data-sources/prefect_block/data-source.tf create mode 100644 internal/provider/datasources/block.go create mode 100644 internal/provider/datasources/block_test.go diff --git a/README.md b/README.md index bc727212..f00fd0e5 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Check back with us to see new additions and improvements - and please don't hesi | Account Member | ✓ | | | ✓ | | Account Role | ✓ | | | ✓ | | Account | ✓ | ✓ | ✓ | ✓ | -| Block | | ✓ | ✓ | | +| Block | ✓ | ✓ | ✓ | | | Service Account | ✓ | ✓ | ✓ | ✓ | | Team | ✓ | | | ✓ | | Variable | ✓ | ✓ | ✓ | | diff --git a/docs/data-sources/block.md b/docs/data-sources/block.md new file mode 100644 index 00000000..6d2f3a63 --- /dev/null +++ b/docs/data-sources/block.md @@ -0,0 +1,59 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "prefect_block Data Source - prefect" +subcategory: "" +description: |- + Get information about an existing Block by either: + ID, orblock type name and block name + + If the ID is provided, then the block type name and block name will be ignored. + + Use this data source to obtain Block-specific attributes, such as the data. +--- + +# prefect_block (Data Source) + +Get information about an existing Block by either: +- ID, or +- block type name and block name +
+If the ID is provided, then the block type name and block name will be ignored. +
+Use this data source to obtain Block-specific attributes, such as the data. + +## Example Usage + +```terraform +# Get block by ID using Terraform ID reference. +data "prefect_block" "existing_by_id" { + id = prefect_block.my_existing_block.id +} + +# Get block by ID string. +data "prefect_block" "existing_by_id_string" { + id = "00000000-0000-0000-0000-000000000000" +} + +# Get block by type slug and name. +data "prefect_block" "existing_by_id_string" { + name = "my_existing_block" + type_slug = "secret" +} +``` + + +## Schema + +### Optional + +- `account_id` (String) Account ID (UUID), defaults to the account set in the provider +- `id` (String) Block ID (UUID) +- `name` (String) Name of the block +- `type_slug` (String) Block type slug +- `workspace_id` (String) Workspace ID (UUID), defaults to the workspace set in the provider + +### Read-Only + +- `created` (String) Timestamp of when the resource was created (RFC3339) +- `data` (String, Sensitive) The user-inputted Block payload, as a JSON string. The value's schema will depend on the selected `type` slug. Use `prefect block types inspect ` to view the data schema for a given Block type. +- `updated` (String) Timestamp of when the resource was updated (RFC3339) diff --git a/examples/data-sources/prefect_block/data-source.tf b/examples/data-sources/prefect_block/data-source.tf new file mode 100644 index 00000000..fa72a9bc --- /dev/null +++ b/examples/data-sources/prefect_block/data-source.tf @@ -0,0 +1,15 @@ +# Get block by ID using Terraform ID reference. +data "prefect_block" "existing_by_id" { + id = prefect_block.my_existing_block.id +} + +# Get block by ID string. +data "prefect_block" "existing_by_id_string" { + id = "00000000-0000-0000-0000-000000000000" +} + +# Get block by type slug and name. +data "prefect_block" "existing_by_id_string" { + name = "my_existing_block" + type_slug = "secret" +} diff --git a/internal/api/block_documents.go b/internal/api/block_documents.go index 6e7a35fc..3b218107 100644 --- a/internal/api/block_documents.go +++ b/internal/api/block_documents.go @@ -8,6 +8,7 @@ import ( type BlockDocumentClient interface { Get(ctx context.Context, id uuid.UUID) (*BlockDocument, error) + GetByName(ctx context.Context, typeSlug, name string) (*BlockDocument, error) Create(ctx context.Context, payload BlockDocumentCreate) (*BlockDocument, error) Update(ctx context.Context, id uuid.UUID, payload BlockDocumentUpdate) error Delete(ctx context.Context, id uuid.UUID) error diff --git a/internal/client/block_documents.go b/internal/client/block_documents.go index d27a7239..d52f7ba5 100644 --- a/internal/client/block_documents.go +++ b/internal/client/block_documents.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "github.com/google/uuid" "github.com/prefecthq/terraform-provider-prefect/internal/api" @@ -75,6 +76,39 @@ func (c *BlockDocumentClient) Get(ctx context.Context, id uuid.UUID) (*api.Block return &blockDocument, nil } +func (c *BlockDocumentClient) GetByName(ctx context.Context, typeSlug, name string) (*api.BlockDocument, error) { + // This URL is a little different, as it starts with 'block_types' instead of 'block_documents'. + newRoutePrefix := fmt.Sprintf("block_types/slug/%s/block_documents/name/%s", typeSlug, name) + reqURL := strings.ReplaceAll(c.routePrefix, "block_documents", newRoutePrefix) + reqURL = fmt.Sprintf("%s?include_secrets=true", reqURL) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + setDefaultHeaders(req, c.apiKey) + + resp, err := c.hc.Do(req) + if err != nil { + return nil, fmt.Errorf("http error: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + errorBody, _ := io.ReadAll(resp.Body) + + return nil, fmt.Errorf("status code %s, error=%s", resp.Status, errorBody) + } + + var blockDocument api.BlockDocument + if err := json.NewDecoder(resp.Body).Decode(&blockDocument); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &blockDocument, nil +} + func (c *BlockDocumentClient) Create(ctx context.Context, payload api.BlockDocumentCreate) (*api.BlockDocument, error) { var buf bytes.Buffer if err := json.NewEncoder(&buf).Encode(&payload); err != nil { diff --git a/internal/provider/datasources/block.go b/internal/provider/datasources/block.go new file mode 100644 index 00000000..fd16bb2d --- /dev/null +++ b/internal/provider/datasources/block.go @@ -0,0 +1,198 @@ +package datasources + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform-plugin-framework-jsontypes/jsontypes" + "github.com/hashicorp/terraform-plugin-framework/datasource" + "github.com/hashicorp/terraform-plugin-framework/datasource/schema" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/prefecthq/terraform-provider-prefect/internal/api" + "github.com/prefecthq/terraform-provider-prefect/internal/provider/customtypes" + "github.com/prefecthq/terraform-provider-prefect/internal/provider/helpers" +) + +// Ensure the implementation satisfies the expected interfaces. +var ( + _ datasource.DataSource = &blockDataSource{} + _ datasource.DataSourceWithConfigure = &blockDataSource{} +) + +// blockDataSource is the data source implementation. +type blockDataSource struct { + client api.PrefectClient +} + +// BlockDataSourceModel defines the Terraform data source model. +type BlockDataSourceModel struct { + ID customtypes.UUIDValue `tfsdk:"id"` + Created customtypes.TimestampValue `tfsdk:"created"` + Updated customtypes.TimestampValue `tfsdk:"updated"` + AccountID customtypes.UUIDValue `tfsdk:"account_id"` + WorkspaceID customtypes.UUIDValue `tfsdk:"workspace_id"` + + Name types.String `tfsdk:"name"` + Data jsontypes.Normalized `tfsdk:"data"` + TypeSlug types.String `tfsdk:"type_slug"` +} + +// NewBlockDataSource is a helper function to simplify the provider implementation. +// +//nolint:ireturn // required by Terraform API +func NewBlockDataSource() datasource.DataSource { + return &blockDataSource{} +} + +// Metadata returns the data source type name. +func (d *blockDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_block" +} + +// Schema defines the scema for the data source. +func (d *blockDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: ` +Get information about an existing Block by either: +- ID, or +- block type name and block name +
+If the ID is provided, then the block type name and block name will be ignored. +
+Use this data source to obtain Block-specific attributes, such as the data. +`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + CustomType: customtypes.UUIDType{}, + Description: "Block ID (UUID)", + Optional: true, + }, + "created": schema.StringAttribute{ + Computed: true, + CustomType: customtypes.TimestampType{}, + Description: "Timestamp of when the resource was created (RFC3339)", + }, + "updated": schema.StringAttribute{ + Computed: true, + CustomType: customtypes.TimestampType{}, + Description: "Timestamp of when the resource was updated (RFC3339)", + }, + "account_id": schema.StringAttribute{ + CustomType: customtypes.UUIDType{}, + Description: "Account ID (UUID), defaults to the account set in the provider", + Optional: true, + }, + "workspace_id": schema.StringAttribute{ + CustomType: customtypes.UUIDType{}, + Description: "Workspace ID (UUID), defaults to the workspace set in the provider", + Optional: true, + }, + "name": schema.StringAttribute{ + Computed: true, + Description: "Name of the block", + Optional: true, + }, + "data": schema.StringAttribute{ + Computed: true, + Sensitive: true, + CustomType: jsontypes.NormalizedType{}, + Description: "The user-inputted Block payload, as a JSON string. The value's schema will depend on the selected `type` slug. Use `prefect block types inspect ` to view the data schema for a given Block type.", + }, + "type_slug": schema.StringAttribute{ + Computed: true, + Description: "Block type slug", + Optional: true, + }, + }, + } +} + +// Read refreshes the Terraform state with the latest data. +func (d *blockDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var state BlockDataSourceModel + + diag := req.Config.Get(ctx, &state) + resp.Diagnostics.Append(diag...) + if resp.Diagnostics.HasError() { + return + } + + client, err := d.client.BlockDocuments(state.AccountID.ValueUUID(), state.WorkspaceID.ValueUUID()) + if err != nil { + resp.Diagnostics.Append(helpers.CreateClientErrorDiagnostic("Block", err)) + + return + } + + var block *api.BlockDocument + + switch { + case !state.ID.IsNull(): + block, err = client.Get(ctx, state.ID.ValueUUID()) + case !state.Name.IsNull() && !state.TypeSlug.IsNull(): + block, err = client.GetByName(ctx, state.TypeSlug.ValueString(), state.Name.ValueString()) + default: + resp.Diagnostics.AddError( + "Insufficient search criteria provided", + "Provide either the ID, or the block type name and block name.", + ) + + return + } + + if err != nil { + resp.Diagnostics.AddError( + "Error refreshing block state", + fmt.Sprintf("Could not read block, unexpected error: %s", err.Error()), + ) + + return + } + + state.ID = customtypes.NewUUIDValue(block.ID) + state.Created = customtypes.NewTimestampPointerValue(block.Created) + state.Updated = customtypes.NewTimestampPointerValue(block.Updated) + + state.Name = types.StringValue(block.Name) + state.TypeSlug = types.StringValue(block.BlockType.Slug) + + byteSlice, err := json.Marshal(block.Data) + if err != nil { + resp.Diagnostics.AddAttributeError( + path.Root("data"), + "Failed to serialize Block Data", + fmt.Sprintf("Could not serialize Block Data as JSON string: %s", err.Error()), + ) + + return + } + + state.Data = jsontypes.NewNormalizedValue(string(byteSlice)) + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } +} + +// Configure initializes runtime state for the data source. +func (d *blockDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(api.PrefectClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected api.PrefectClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + d.client = client +} diff --git a/internal/provider/datasources/block_test.go b/internal/provider/datasources/block_test.go new file mode 100644 index 00000000..7d8289da --- /dev/null +++ b/internal/provider/datasources/block_test.go @@ -0,0 +1,85 @@ +package datasources_test + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/prefecthq/terraform-provider-prefect/internal/testutils" +) + +func fixtureAccBlockByName(name string) string { + aID := os.Getenv("PREFECT_CLOUD_ACCOUNT_ID") + + return fmt.Sprintf(` +data "prefect_workspace" "evergreen" { + handle = "github-ci-tests" +} + +resource "prefect_block" "%s" { + name = "%s" + type_slug = "secret" + + data = jsonencode({ + "someKey" : "someValue" + }) + + account_id = "%s" + workspace_id = data.prefect_workspace.evergreen.id +} + +data "prefect_block" "my_existing_secret_by_id" { + id = prefect_block.%s.id + + account_id = "%s" + workspace_id = data.prefect_workspace.evergreen.id + + depends_on = [prefect_block.%s] +} + +data "prefect_block" "my_existing_secret_by_name" { + name = "%s" + type_slug = "secret" + + account_id = "%s" + workspace_id = data.prefect_workspace.evergreen.id + + depends_on = [prefect_block.%s] +} +`, name, name, aID, name, aID, name, name, aID, name) +} + +//nolint:paralleltest // we use the resource.ParallelTest helper instead +func TestAccDatasource_block(t *testing.T) { + datasourceNameByID := "data.prefect_block.my_existing_secret_by_id" + datasourceNameByName := "data.prefect_block.my_existing_secret_by_name" + + blockName := "my-block" + blockValue := "{\"someKey\":\"someValue\"}" + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: testutils.TestAccProtoV6ProviderFactories, + PreCheck: func() { testutils.AccTestPreCheck(t) }, + Steps: []resource.TestStep{ + { + // Test block datasource by ID. + Config: fixtureAccBlockByName(blockName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(datasourceNameByID, "id"), + resource.TestCheckResourceAttr(datasourceNameByID, "name", blockName), + resource.TestCheckResourceAttr(datasourceNameByID, "data", blockValue), + ), + }, + { + // Test block datasource by name. + Config: fixtureAccBlockByName(blockName), + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttrSet(datasourceNameByName, "id"), + resource.TestCheckResourceAttr(datasourceNameByName, "name", blockName), + resource.TestCheckResourceAttr(datasourceNameByName, "data", blockValue), + ), + }, + }, + }) +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0aed2c16..8d311493 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -220,6 +220,7 @@ func (p *PrefectProvider) DataSources(_ context.Context) []func() datasource.Dat datasources.NewAccountMemberDataSource, datasources.NewAccountMembersDataSource, datasources.NewAccountRoleDataSource, + datasources.NewBlockDataSource, datasources.NewServiceAccountDataSource, datasources.NewTeamDataSource, datasources.NewTeamsDataSource,