diff --git a/.changelog/807.txt b/.changelog/807.txt new file mode 100644 index 000000000..8a79a1151 --- /dev/null +++ b/.changelog/807.txt @@ -0,0 +1,4 @@ +```release-note:feature + New resource: Add `hcp_waypoint_add_on` resource for managing Waypoint Add-ons. + New data-source: Add `data.hcp_waypoint_add_on` data-source for Waypoint Add-ons. + ``` \ No newline at end of file diff --git a/docs/data-sources/waypoint_add_on.md b/docs/data-sources/waypoint_add_on.md new file mode 100644 index 000000000..c2f015cee --- /dev/null +++ b/docs/data-sources/waypoint_add_on.md @@ -0,0 +1,44 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "hcp_waypoint_add_on Data Source - terraform-provider-hcp" +subcategory: "" +description: |- + The Waypoint Add-on data source retrieves information on a given Add-on. +--- + +# hcp_waypoint_add_on (Data Source) + +The Waypoint Add-on data source retrieves information on a given Add-on. + + + + +## Schema + +### Optional + +- `id` (String) The ID of the Add-on. +- `name` (String) The name of the Add-on. + +### Read-Only + +- `application_id` (String) The ID of the Application that this Add-on is created for. +- `created_by` (String) The user who created the Add-on. +- `definition_id` (String) The ID of the Add-on Definition that this Add-on is created from. +- `description` (String) A longer description of the Add-on. +- `install_count` (Number) The number of installed Add-ons for the same Application that share the same Add-on Definition. +- `labels` (List of String) List of labels attached to this Add-on. +- `organization_id` (String) The ID of the HCP organization where the Waypoint AddOn is located. +- `project_id` (String) The ID of the HCP project where the Waypoint AddOn is located. +- `readme_markdown` (String) Instructions for using the Add-on (markdown format supported). +- `status` (Number) The status of the Terraform run for the Add-on. +- `summary` (String) A short summary of the Add-on. +- `terraform_no_code_module` (Attributes) Terraform Cloud no-code Module details. (see [below for nested schema](#nestedatt--terraform_no_code_module)) + + +### Nested Schema for `terraform_no_code_module` + +Read-Only: + +- `source` (String) Terraform Cloud no-code Module Source +- `version` (String) Terraform Cloud no-code Module Version diff --git a/docs/resources/waypoint_add_on.md b/docs/resources/waypoint_add_on.md new file mode 100644 index 000000000..8e0834181 --- /dev/null +++ b/docs/resources/waypoint_add_on.md @@ -0,0 +1,46 @@ +--- +page_title: "hcp_waypoint_add_on Resource - terraform-provider-hcp" +subcategory: "HCP Waypoint" +description: |- + Waypoint Add-on resource +--- + +# hcp_waypoint_add_on `Resource` + +-> **Note:** HCP Waypoint is currently in public beta. + +Waypoint Add-on resource + + +## Schema + +### Required + +- `application_id` (String) The ID of the Application that this Add-on is created for. +- `definition_id` (String) The ID of the Add-on Definition that this Add-on is created from. +- `name` (String) The name of the Add-on. + +### Optional + +- `project_id` (String) The ID of the HCP project where the Waypoint AddOn is located. + +### Read-Only + +- `created_by` (String) The user who created the Add-on. +- `description` (String) A longer description of the Add-on. +- `id` (String) The ID of the Add-on. +- `install_count` (Number) The number of installed Add-ons for the same Application that share the same Add-on Definition. +- `labels` (List of String) List of labels attached to this Add-on. +- `organization_id` (String) The ID of the HCP organization where the Waypoint AddOn is located. +- `readme_markdown` (String) The markdown for the Add-on README. +- `status` (Number) The status of the Terraform run for the Add-on. +- `summary` (String) A short summary of the Add-on. +- `terraform_no_code_module` (Attributes) Terraform Cloud no-code Module details. (see [below for nested schema](#nestedatt--terraform_no_code_module)) + + +### Nested Schema for `terraform_no_code_module` + +Read-Only: + +- `source` (String) Terraform Cloud no-code Module Source +- `version` (String) Terraform Cloud no-code Module Version \ No newline at end of file diff --git a/internal/clients/response.go b/internal/clients/response.go index 977fa9252..1db4efffb 100644 --- a/internal/clients/response.go +++ b/internal/clients/response.go @@ -22,3 +22,12 @@ func IsResponseCodeNotFound(err error) bool { return strings.Contains(err.Error(), fmt.Sprintf("[%d]", http.StatusNotFound)) } } + +func IsResponseCodeInternalError(erro error) bool { + var apiErr *runtime.APIError + if errors.As(erro, &apiErr) { + return apiErr.Code == http.StatusInternalServerError + } else { + return strings.Contains(erro.Error(), fmt.Sprintf("[%d]", http.StatusInternalServerError)) + } +} diff --git a/internal/clients/waypoint.go b/internal/clients/waypoint.go index b63af83c8..70f162c74 100644 --- a/internal/clients/waypoint.go +++ b/internal/clients/waypoint.go @@ -139,3 +139,41 @@ func GetApplicationByID(ctx context.Context, client *Client, loc *sharedmodels.H } return getResp.GetPayload().Application, nil } + +// GetAddOnByName will retrieve an add-on by name +func GetAddOnByName(ctx context.Context, client *Client, loc *sharedmodels.HashicorpCloudLocationLocation, defName string) (*waypoint_models.HashicorpCloudWaypointAddOn, error) { + ns, err := getNamespaceByLocation(ctx, client, loc) + if err != nil { + return nil, err + } + + params := &waypoint_service.WaypointServiceGetAddOn2Params{ + AddOnName: defName, + NamespaceID: ns.ID, + } + + getResp, err := client.Waypoint.WaypointServiceGetAddOn2(params, nil) + if err != nil { + return nil, err + } + return getResp.GetPayload().AddOn, nil +} + +// GetAddOnByID will retrieve an add-on by ID +func GetAddOnByID(ctx context.Context, client *Client, loc *sharedmodels.HashicorpCloudLocationLocation, defID string) (*waypoint_models.HashicorpCloudWaypointAddOn, error) { + ns, err := getNamespaceByLocation(ctx, client, loc) + if err != nil { + return nil, err + } + + params := &waypoint_service.WaypointServiceGetAddOnParams{ + AddOnID: defID, + NamespaceID: ns.ID, + } + + getResp, err := client.Waypoint.WaypointServiceGetAddOn(params, nil) + if err != nil { + return nil, err + } + return getResp.GetPayload().AddOn, nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f26382282..3c42096f6 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -153,6 +153,7 @@ func (p *ProviderFramework) Resources(ctx context.Context) []func() resource.Res // Waypoint waypoint.NewApplicationResource, waypoint.NewApplicationTemplateResource, + waypoint.NewAddOnResource, waypoint.NewAddOnDefinitionResource, waypoint.NewTfcConfigResource, }, packer.ResourceSchemaBuilders...) @@ -174,6 +175,7 @@ func (p *ProviderFramework) DataSources(ctx context.Context) []func() datasource // Waypoint waypoint.NewApplicationDataSource, waypoint.NewApplicationTemplateDataSource, + waypoint.NewAddOnDataSource, waypoint.NewAddOnDefinitionDataSource, }, packer.DataSourceSchemaBuilders...) } diff --git a/internal/provider/waypoint/data_source_waypoint_add_on.go b/internal/provider/waypoint/data_source_waypoint_add_on.go new file mode 100644 index 000000000..b8dd7281f --- /dev/null +++ b/internal/provider/waypoint/data_source_waypoint_add_on.go @@ -0,0 +1,275 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package waypoint + +import ( + "context" + "fmt" + "strconv" + + sharedmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-shared/v1/models" + waypoint_models "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/models" + "github.com/hashicorp/terraform-plugin-framework-validators/datasourcevalidator" + "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/hashicorp/terraform-provider-hcp/internal/clients" +) + +var _ datasource.DataSource = &DataSourceAddOn{} +var _ datasource.DataSourceWithConfigValidators = &DataSourceAddOn{} + +func (d DataSourceAddOn) ConfigValidators(ctx context.Context) []datasource.ConfigValidator { + return []datasource.ConfigValidator{ + datasourcevalidator.Conflicting( + path.MatchRoot("name"), + path.MatchRoot("id"), + ), + } +} + +type DataSourceAddOn struct { + client *clients.Client +} + +func NewAddOnDataSource() datasource.DataSource { + return &DataSourceAddOn{} +} + +func (d *DataSourceAddOn) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_waypoint_add_on" +} + +//TODO: Make sure this schema is correct (do we want to include count or output values?) + +func (d *DataSourceAddOn) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "The Waypoint Add-on data source retrieves information on a given Add-on.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Optional: true, + Description: "The ID of the Add-on.", + }, + "name": schema.StringAttribute{ + Description: "The name of the Add-on.", + Computed: true, + Optional: true, + }, + + "organization_id": schema.StringAttribute{ + Description: "The ID of the HCP organization where the Waypoint AddOn is located.", + Computed: true, + }, + "project_id": schema.StringAttribute{ + Description: "The ID of the HCP project where the Waypoint AddOn is located.", + Computed: true, + }, + "summary": schema.StringAttribute{ + Description: "A short summary of the Add-on.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "A longer description of the Add-on.", + Computed: true, + }, + "readme_markdown": schema.StringAttribute{ + Computed: true, + Description: "Instructions for using the Add-on (markdown format supported).", + }, + "labels": schema.ListAttribute{ + Computed: true, + Description: "List of labels attached to this Add-on.", + ElementType: types.StringType, + }, + "created_by": schema.StringAttribute{ + Description: "The user who created the Add-on.", + Computed: true, + }, + "install_count": schema.Int64Attribute{ + Description: "The number of installed Add-ons for the same Application that share the same " + + "Add-on Definition.", + Computed: true, + }, + "definition_id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the Add-on Definition that this Add-on is created from.", + }, + "application_id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the Application that this Add-on is created for.", + }, + "terraform_no_code_module": &schema.SingleNestedAttribute{ + Computed: true, + Description: "Terraform Cloud no-code Module details.", + Attributes: map[string]schema.Attribute{ + "source": &schema.StringAttribute{ + Computed: true, + Description: "Terraform Cloud no-code Module Source", + }, + "version": &schema.StringAttribute{ + Computed: true, + Description: "Terraform Cloud no-code Module Version", + }, + }, + }, + "status": schema.Int64Attribute{ + Computed: true, + Description: "The status of the Terraform run for the Add-on.", + }, + /*"output_values": schema.ListNestedAttribute{ + Computed: true, + Description: "The output values of the Terraform run for the Add-on, sensitive values have type " + + "and value omitted.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Computed: true, + Description: "The name of the output value.", + }, + "type": schema.StringAttribute{ + Computed: true, + Description: "The type of the output value.", + }, + "value": schema.StringAttribute{ + Computed: true, + Description: "The value of the output value.", + }, + "sensitive": schema.BoolAttribute{ + Computed: true, + Description: "Whether the output value is sensitive.", + }, + }, + }, + },*/ + }, + } +} + +func (d *DataSourceAddOn) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*clients.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected *clients.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + return + } + d.client = client +} + +// TODO: Output values? +func (d *DataSourceAddOn) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { + var state AddOnResourceModel + resp.Diagnostics.Append(req.Config.Get(ctx, &state)...) + + client := d.client + if d.client == nil { + resp.Diagnostics.AddError( + "Unconfigured HCP Client", + "Expected configured HCP client. Please report this issue to the provider developers.", + ) + return + } + + loc := &sharedmodels.HashicorpCloudLocationLocation{ + OrganizationID: client.Config.OrganizationID, + ProjectID: client.Config.ProjectID, + } + + var addOn *waypoint_models.HashicorpCloudWaypointAddOn + var err error + + if state.ID.IsNull() { + addOn, err = clients.GetAddOnByName(ctx, client, loc, state.Name.ValueString()) + if err != nil { + resp.Diagnostics.AddError(err.Error(), "failed to get add-on by name") + return + } + state.ID = types.StringValue(addOn.ID) + } else if state.Name.IsNull() { + addOn, err = clients.GetAddOnByID(ctx, client, loc, state.ID.ValueString()) + if err != nil { + resp.Diagnostics.AddError(err.Error(), "failed to get add-on by ID") + return + } + state.Name = types.StringValue(addOn.Name) + } + + state.Summary = types.StringValue(addOn.Summary) + + labels, diags := types.ListValueFrom(ctx, types.StringType, addOn.Labels) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + if len(labels.Elements()) == 0 { + labels = types.ListNull(types.StringType) + } + state.Labels = labels + + if addOn.TerraformNocodeModule != nil { + tfcNoCode := &tfcNoCodeModule{ + Source: types.StringValue(addOn.TerraformNocodeModule.Source), + Version: types.StringValue(addOn.TerraformNocodeModule.Version), + } + state.TerraformNoCodeModule, diags = types.ObjectValueFrom(ctx, tfcNoCode.attrTypes(), tfcNoCode) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + // set plan.description if it's not null or addOn.description is not empty + state.Description = types.StringValue(addOn.Description) + if addOn.Description == "" { + state.Description = types.StringNull() + } + state.ReadmeMarkdown = types.StringValue(addOn.ReadmeMarkdown.String()) + // set state.readme if it's not null or addOnDefinition.readme is not empty + if addOn.ReadmeMarkdown.String() == "" { + state.ReadmeMarkdown = types.StringNull() + } + + state.CreatedBy = types.StringValue(addOn.CreatedBy) + + // TODO: Add support for outputValues + + // If we can process status as an int64, add it to the plan + statusNum, err := strconv.ParseInt(addOn.Count, 10, 64) + if err != nil { + resp.Diagnostics.AddError("Error parsing installed Add-on status", err.Error()) + } else { + state.Status = types.Int64Value(statusNum) + } + + // If we can process count as an int64, add it to the plan + installedCount, err := strconv.ParseInt(addOn.Count, 10, 64) + if err != nil { + resp.Diagnostics.AddError("Error parsing installed Add-ons count", err.Error()) + } else { + state.Count = types.Int64Value(installedCount) + } + + // Display the reference to the Definition in the state + if addOn.Definition != nil { + if addOn.Definition.ID != "" { + state.DefinitionID = types.StringValue(addOn.Definition.ID) + } + } + + // Display the reference to the Application in the state + if addOn.Application != nil { + if addOn.Application.ID != "" { + state.ApplicationID = types.StringValue(addOn.Application.ID) + } + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} diff --git a/internal/provider/waypoint/data_source_waypoint_add_on_test.go b/internal/provider/waypoint/data_source_waypoint_add_on_test.go new file mode 100644 index 000000000..654dc2313 --- /dev/null +++ b/internal/provider/waypoint/data_source_waypoint_add_on_test.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package waypoint_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-hcp/internal/provider/acctest" + "github.com/hashicorp/terraform-provider-hcp/internal/provider/waypoint" +) + +func TestAccWaypointData_Add_On_basic(t *testing.T) { + // this is only used to verify the add-on gets cleaned up in the end + // of the test, and not used for any other purpose at this time + var addOnModel waypoint.AddOnResourceModel + resourceName := "hcp_waypoint_add_on.test" + dataSourceName := "data." + resourceName + addOnName := generateRandomName() + templateName := generateRandomName() + appName := generateRandomName() + defName := generateRandomName() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + CheckDestroy: testAccCheckWaypointAddOnDestroy(t, &addOnModel), + Steps: []resource.TestStep{ + { + // establish the base add-on + // note this reuses the config method from the add-on + // resource test + Config: testAddOnConfig(templateName, appName, defName, addOnName), + Check: resource.ComposeTestCheckFunc( + testAccCheckWaypointAddOnExists(t, resourceName, &addOnModel), + ), + }, + { + // add a data source config to read the add-on + Config: testDataAddOnConfig(templateName, appName, defName, addOnName), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(dataSourceName, "name", addOnName), + ), + }, + }, + }) +} + +func testDataAddOnConfig(templateName string, appName string, defName string, addOnName string) string { + return fmt.Sprintf(`%s +data "hcp_waypoint_add_on" "test" { + name = hcp_waypoint_add_on.test.name +}`, testAddOnConfig(templateName, appName, defName, addOnName)) +} diff --git a/internal/provider/waypoint/resource_waypoint_add_on.go b/internal/provider/waypoint/resource_waypoint_add_on.go new file mode 100644 index 000000000..faf5c8e76 --- /dev/null +++ b/internal/provider/waypoint/resource_waypoint_add_on.go @@ -0,0 +1,671 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package waypoint + +import ( + "context" + "fmt" + "strconv" + + sharedmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-shared/v1/models" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/client/waypoint_service" + waypointmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-waypoint-service/preview/2023-08-18/models" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/listplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-log/tflog" + "github.com/hashicorp/terraform-provider-hcp/internal/clients" +) + +// Ensure provider defined types fully satisfy framework interfaces. +var _ resource.Resource = &AddOnResource{} +var _ resource.ResourceWithImportState = &AddOnResource{} + +func NewAddOnResource() resource.Resource { + return &AddOnResource{} +} + +// AddOnResource defines the resource implementation. +type AddOnResource struct { + client *clients.Client +} + +// AddOnResourceModel describes the resource data model. +type AddOnResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ProjectID types.String `tfsdk:"project_id"` + OrgID types.String `tfsdk:"organization_id"` + Summary types.String `tfsdk:"summary"` + Labels types.List `tfsdk:"labels"` + Description types.String `tfsdk:"description"` + ReadmeMarkdown types.String `tfsdk:"readme_markdown"` + CreatedBy types.String `tfsdk:"created_by"` + Count types.Int64 `tfsdk:"install_count"` + Status types.Int64 `tfsdk:"status"` + ApplicationID types.String `tfsdk:"application_id"` + DefinitionID types.String `tfsdk:"definition_id"` + // OutputValues types.List `tfsdk:"output_values"` + + TerraformNoCodeModule types.Object `tfsdk:"terraform_no_code_module"` +} + +func (r tfcNoCodeModule) attrTypes() map[string]attr.Type { + return map[string]attr.Type{ + "source": types.StringType, + "version": types.StringType, + } +} + +func (r *AddOnResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_waypoint_add_on" +} + +// TODO: Make most of these computed because they are not used in the protos (Also add variables later) +func (r *AddOnResource) 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. + MarkdownDescription: "Waypoint Add-on resource", + + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + Description: "The ID of the Add-on.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the Add-on.", + Required: true, + }, + "organization_id": schema.StringAttribute{ + Description: "The ID of the HCP organization where the Waypoint AddOn is located.", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "project_id": schema.StringAttribute{ + Description: "The ID of the HCP project where the Waypoint AddOn is located.", + Optional: true, + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "summary": schema.StringAttribute{ + Description: "A short summary of the Add-on.", + Computed: true, + }, + "description": schema.StringAttribute{ + Description: "A longer description of the Add-on.", + Computed: true, + }, + "labels": schema.ListAttribute{ + Computed: true, + Description: "List of labels attached to this Add-on.", + ElementType: types.StringType, + PlanModifiers: []planmodifier.List{ + listplanmodifier.UseStateForUnknown(), + }, + }, + "readme_markdown": schema.StringAttribute{ + Description: "The markdown for the Add-on README.", + Computed: true, + }, + "created_by": schema.StringAttribute{ + Description: "The user who created the Add-on.", + Computed: true, + }, + "install_count": schema.Int64Attribute{ + Description: "The number of installed Add-ons for the same Application that share the same " + + "Add-on Definition.", + Computed: true, + }, + "terraform_no_code_module": &schema.SingleNestedAttribute{ + Computed: true, + Description: "Terraform Cloud no-code Module details.", + Attributes: map[string]schema.Attribute{ + "source": &schema.StringAttribute{ + Computed: true, + Description: "Terraform Cloud no-code Module Source", + }, + "version": &schema.StringAttribute{ + Computed: true, + Description: "Terraform Cloud no-code Module Version", + }, + }, + }, + "definition_id": schema.StringAttribute{ + Required: true, + Description: "The ID of the Add-on Definition that this Add-on is created from.", + }, + "application_id": schema.StringAttribute{ + Required: true, + Description: "The ID of the Application that this Add-on is created for.", + }, + "status": schema.Int64Attribute{ + Computed: true, + Description: "The status of the Terraform run for the Add-on.", + }, + /*"output_values": schema.ListNestedAttribute{ + Computed: true, + Description: "The output values of the Terraform run for the Add-on, sensitive values have type " + + "and value omitted.", + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Computed: true, + Description: "The name of the output value.", + }, + "type": schema.StringAttribute{ + Computed: true, + Description: "The type of the output value.", + }, + "value": schema.StringAttribute{ + Computed: true, + Description: "The value of the output value.", + }, + "sensitive": schema.BoolAttribute{ + Computed: true, + Description: "Whether the output value is sensitive.", + }, + }, + }, + },*/ + }, + } +} + +func (r *AddOnResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*clients.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *http.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +// TODO: Add support for new fields +func (r *AddOnResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan *AddOnResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + projectID := r.client.Config.ProjectID + if !plan.ProjectID.IsUnknown() { + projectID = plan.ProjectID.ValueString() + } + + orgID := r.client.Config.OrganizationID + loc := &sharedmodels.HashicorpCloudLocationLocation{ + OrganizationID: orgID, + ProjectID: projectID, + } + + client := r.client + ns, err := getNamespaceByLocation(ctx, client, loc) + if err != nil { + resp.Diagnostics.AddError( + "error getting namespace by location", + err.Error(), + ) + return + } + + stringLabels := []string{} + if !plan.Labels.IsNull() && !plan.Labels.IsUnknown() { + diagnostics := plan.Labels.ElementsAs(ctx, &stringLabels, false) + if diagnostics.HasError() { + resp.Diagnostics.AddError( + "error converting labels", + "The list of labels was incorrectly formated", + ) + return + } + } + + // An application ref can only have one of ID or Name set, + // we ask for ID, so we will set ID + applicationID := plan.ApplicationID.ValueString() + applicationRefModel := &waypointmodels.HashicorpCloudWaypointRefApplication{} + if applicationID != "" { + applicationRefModel.ID = applicationID + } else { + resp.Diagnostics.AddError( + "error reading application ID", + "The application ID was missing", + ) + return + } + + // Similarly, a definition ref can only have one of ID or Name set, + // we ask for ID, so we will set ID + definitionID := plan.DefinitionID.ValueString() + definitionRefModel := &waypointmodels.HashicorpCloudWaypointRefAddOnDefinition{} + if definitionID != "" { + definitionRefModel.ID = definitionID + } else { + resp.Diagnostics.AddError( + "error reading definition ID", + "The definition ID was missing", + ) + return + } + + modelBody := &waypointmodels.HashicorpCloudWaypointWaypointServiceCreateAddOnBody{ + Name: plan.Name.ValueString(), + Application: applicationRefModel, + Definition: definitionRefModel, + } + + params := &waypoint_service.WaypointServiceCreateAddOnParams{ + NamespaceID: ns.ID, + Body: modelBody, + } + responseAddOn, err := r.client.Waypoint.WaypointServiceCreateAddOn(params, nil) + if err != nil { + resp.Diagnostics.AddError("Error creating add-on", err.Error()) + return + } + + var addOn *waypointmodels.HashicorpCloudWaypointAddOn + if responseAddOn.Payload != nil { + addOn = responseAddOn.Payload.AddOn + } + if addOn == nil { + resp.Diagnostics.AddError("unknown error creating add-on", "empty add-on returned") + return + } + + plan.ID = types.StringValue(addOn.ID) + plan.Name = types.StringValue(addOn.Name) + plan.ProjectID = types.StringValue(projectID) + plan.OrgID = types.StringValue(orgID) + plan.Summary = types.StringValue(addOn.Summary) + + plan.Description = types.StringValue(addOn.Description) + // set plan.description if it's not null or addOn.description is not empty + if addOn.Description == "" { + plan.Description = types.StringNull() + } + plan.ReadmeMarkdown = types.StringValue(addOn.ReadmeMarkdown.String()) + // set plan.readme if it's not null or addOn.readme is not empty + if addOn.ReadmeMarkdown.String() == "" { + plan.ReadmeMarkdown = types.StringNull() + } + + labels, diags := types.ListValueFrom(ctx, types.StringType, addOn.Labels) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + plan.Labels = labels + + if addOn.TerraformNocodeModule != nil { + tfcNoCode := &tfcNoCodeModule{} + if addOn.TerraformNocodeModule.Source != "" { + tfcNoCode.Source = types.StringValue(addOn.TerraformNocodeModule.Source) + } + if addOn.TerraformNocodeModule.Version != "" { + tfcNoCode.Version = types.StringValue(addOn.TerraformNocodeModule.Version) + } + plan.TerraformNoCodeModule, diags = types.ObjectValueFrom(ctx, tfcNoCode.attrTypes(), tfcNoCode) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + // Display the reference to the Definition in the plan + if addOn.Definition != nil { + if addOn.Definition.ID != "" { + plan.DefinitionID = types.StringValue(addOn.Definition.ID) + } + } + + // Display the reference to the Application in the plan + if addOn.Application != nil { + if addOn.Application.ID != "" { + plan.ApplicationID = types.StringValue(addOn.Application.ID) + } + } + + plan.CreatedBy = types.StringValue(addOn.CreatedBy) + + // If we can process status as an int64, add it to the plan + statusNum, err := strconv.ParseInt(addOn.Count, 10, 64) + if err != nil { + resp.Diagnostics.AddError("Error parsing installed Add-on status", err.Error()) + } else { + plan.Status = types.Int64Value(statusNum) + } + + // If we can process count as an int64, add it to the plan + installedCount, err := strconv.ParseInt(addOn.Count, 10, 64) + if err != nil { + resp.Diagnostics.AddError("Error parsing installed Add-ons count", err.Error()) + } else { + plan.Count = types.Int64Value(installedCount) + } + + // TODO: Add support for output values + + // Write logs using the tflog package + // Documentation: https://terraform.io/plugin/log + tflog.Trace(ctx, "created add-on resource") + + // Save plan into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +// TODO: Add support for new fields +func (r *AddOnResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state *AddOnResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + projectID := r.client.Config.ProjectID + + orgID := r.client.Config.OrganizationID + loc := &sharedmodels.HashicorpCloudLocationLocation{ + OrganizationID: orgID, + ProjectID: projectID, + } + + client := r.client + + addOn, err := clients.GetAddOnByID(ctx, client, loc, state.ID.ValueString()) + if err != nil { + if clients.IsResponseCodeNotFound(err) { + tflog.Info(ctx, "Add-on not found for organization, removing from state.") + resp.State.RemoveResource(ctx) + return + } + resp.Diagnostics.AddError("Error reading Add-on", err.Error()) + return + } + + state.ID = types.StringValue(addOn.ID) + state.Name = types.StringValue(addOn.Name) + state.ProjectID = types.StringValue(projectID) + state.OrgID = types.StringValue(orgID) + state.Summary = types.StringValue(addOn.Summary) + + state.Description = types.StringValue(addOn.Description) + // set plan.description if it's not null or addOn.description is not empty + if addOn.Description == "" { + state.Description = types.StringNull() + } + state.ReadmeMarkdown = types.StringValue(addOn.ReadmeMarkdown.String()) + // set plan.readme if it's not null or addOn.readme is not empty + if addOn.ReadmeMarkdown.String() == "" { + state.ReadmeMarkdown = types.StringNull() + } + + labels, diags := types.ListValueFrom(ctx, types.StringType, addOn.Labels) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + state.Labels = labels + + if addOn.TerraformNocodeModule != nil { + tfcNoCode := &tfcNoCodeModule{ + Source: types.StringValue(addOn.TerraformNocodeModule.Source), + Version: types.StringValue(addOn.TerraformNocodeModule.Version), + } + state.TerraformNoCodeModule, diags = types.ObjectValueFrom(ctx, tfcNoCode.attrTypes(), tfcNoCode) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + state.CreatedBy = types.StringValue(addOn.CreatedBy) + + // TODO: Error out here on failure to convert? + + // If we can process status as an int64, add it to the plan + statusNum, err := strconv.ParseInt(addOn.Count, 10, 64) + if err != nil { + resp.Diagnostics.AddError("Error parsing installed Add-on status", err.Error()) + } else { + state.Status = types.Int64Value(statusNum) + } + + // If we can process count as an int64, add it to the plan + installedCount, err := strconv.ParseInt(addOn.Count, 10, 64) + if err != nil { + resp.Diagnostics.AddError("Error parsing installed Add-ons count", err.Error()) + } else { + state.Count = types.Int64Value(installedCount) + } + + // Display the reference to the Definition in the state + if addOn.Definition != nil { + if addOn.Definition.ID != "" { + state.DefinitionID = types.StringValue(addOn.Definition.ID) + } + } + + // Display the reference to the Application in the state + if addOn.Application != nil { + if addOn.Application.ID != "" { + state.ApplicationID = types.StringValue(addOn.Application.ID) + } + } + + // TODO: Add support for output values + + resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) +} + +// TODO: Add support for new fields +func (r *AddOnResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan *AddOnResourceModel + + // Read Terraform plan data into the model + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + + if resp.Diagnostics.HasError() { + return + } + + projectID := r.client.Config.ProjectID + + orgID := r.client.Config.OrganizationID + loc := &sharedmodels.HashicorpCloudLocationLocation{ + OrganizationID: orgID, + ProjectID: projectID, + } + + client := r.client + ns, err := getNamespaceByLocation(ctx, client, loc) + if err != nil { + resp.Diagnostics.AddError( + "error getting namespace by location", + err.Error(), + ) + return + } + + modelBody := &waypointmodels.HashicorpCloudWaypointWaypointServiceUpdateAddOnBody{ + Name: plan.Name.ValueString(), + } + + params := &waypoint_service.WaypointServiceUpdateAddOnParams{ + NamespaceID: ns.ID, + Body: modelBody, + ExistingAddOnID: plan.ID.ValueString(), + } + def, err := r.client.Waypoint.WaypointServiceUpdateAddOn(params, nil) + if err != nil { + resp.Diagnostics.AddError("Error updating Add-on", err.Error()) + return + } + + var addOn *waypointmodels.HashicorpCloudWaypointAddOn + if def.Payload != nil { + addOn = def.Payload.AddOn + } + if addOn == nil { + resp.Diagnostics.AddError("Unknown error updating Add-on", "Empty Add-on found") + return + } + + plan.ID = types.StringValue(addOn.ID) + plan.Name = types.StringValue(addOn.Name) + plan.ProjectID = types.StringValue(projectID) + plan.OrgID = types.StringValue(orgID) + plan.Summary = types.StringValue(addOn.Summary) + + plan.Description = types.StringValue(addOn.Description) + // set plan.description if it's not null or addOn.description is not empty + if addOn.Description == "" { + plan.Description = types.StringNull() + } + plan.ReadmeMarkdown = types.StringValue(addOn.ReadmeMarkdown.String()) + // set plan.readme if it's not null or addOn.readme is not empty + if addOn.ReadmeMarkdown.String() == "" { + plan.ReadmeMarkdown = types.StringNull() + } + + labels, diags := types.ListValueFrom(ctx, types.StringType, addOn.Labels) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + plan.Labels = labels + + if addOn.TerraformNocodeModule != nil { + tfcNoCode := &tfcNoCodeModule{ + Source: types.StringValue(addOn.TerraformNocodeModule.Source), + Version: types.StringValue(addOn.TerraformNocodeModule.Version), + } + plan.TerraformNoCodeModule, diags = types.ObjectValueFrom(ctx, tfcNoCode.attrTypes(), tfcNoCode) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + } + + // Display the reference to the Definition in the plan + if addOn.Definition != nil { + if addOn.Definition.ID != "" { + plan.DefinitionID = types.StringValue(addOn.Definition.ID) + } + } + + // Display the reference to the Application in the plan + if addOn.Application != nil { + if addOn.Application.ID != "" { + plan.ApplicationID = types.StringValue(addOn.Application.ID) + } + } + + plan.CreatedBy = types.StringValue(addOn.CreatedBy) + + // If we can process status as an int64, add it to the plan + statusNum, err := strconv.ParseInt(addOn.Count, 10, 64) + if err != nil { + resp.Diagnostics.AddError("Error parsing installed Add-on status", err.Error()) + } else { + plan.Status = types.Int64Value(statusNum) + } + + // If we can process count as an int64, add it to the plan + installedCount, err := strconv.ParseInt(addOn.Count, 10, 64) + if err != nil { + resp.Diagnostics.AddError("Error parsing installed Add-ons count", err.Error()) + } else { + plan.Count = types.Int64Value(installedCount) + } + + // TODO: Add support for output values + + // Write logs using the tflog package + // Documentation: https://terraform.io/plugin/log + tflog.Trace(ctx, "updated add-on resource") + + // Save updated data into Terraform state + resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...) +} + +func (r *AddOnResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state *AddOnResourceModel + + // Read Terraform prior state data into the model + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + + if resp.Diagnostics.HasError() { + return + } + + projectID := r.client.Config.ProjectID + + loc := &sharedmodels.HashicorpCloudLocationLocation{ + OrganizationID: r.client.Config.OrganizationID, + ProjectID: projectID, + } + + client := r.client + ns, err := getNamespaceByLocation(ctx, client, loc) + if err != nil { + resp.Diagnostics.AddError( + "Error Deleting TFC Config", + err.Error(), + ) + return + } + + params := &waypoint_service.WaypointServiceDestroyAddOnParams{ + NamespaceID: ns.ID, + AddOnID: state.ID.ValueString(), + } + + _, err = r.client.Waypoint.WaypointServiceDestroyAddOn(params, nil) + if err != nil { + if clients.IsResponseCodeNotFound(err) { + tflog.Info(ctx, "Add-on not found for organization during delete call, ignoring") + return + } + resp.Diagnostics.AddError( + "Error Deleting Add-on", + err.Error(), + ) + return + } + +} + +func (r *AddOnResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) +} diff --git a/internal/provider/waypoint/resource_waypoint_add_on_definition_test.go b/internal/provider/waypoint/resource_waypoint_add_on_definition_test.go index e39a6958b..03096bad0 100644 --- a/internal/provider/waypoint/resource_waypoint_add_on_definition_test.go +++ b/internal/provider/waypoint/resource_waypoint_add_on_definition_test.go @@ -19,7 +19,7 @@ import ( ) func TestAccWaypoint_Add_On_Definition_basic(t *testing.T) { - var appTemplateModel waypoint.AddOnDefinitionResourceModel + var addOnDefinitionModel waypoint.AddOnDefinitionResourceModel resourceName := "hcp_waypoint_add_on_definition.test" name := generateRandomName() updatedName := generateRandomName() @@ -27,21 +27,21 @@ func TestAccWaypoint_Add_On_Definition_basic(t *testing.T) { resource.Test(t, resource.TestCase{ PreCheck: func() { acctest.PreCheck(t) }, ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, - CheckDestroy: testAccCheckWaypointAddOnDefinitionDestroy(t, &appTemplateModel), + CheckDestroy: testAccCheckWaypointAddOnDefinitionDestroy(t, &addOnDefinitionModel), Steps: []resource.TestStep{ { Config: testAddOnDefinitionConfig(name), Check: resource.ComposeTestCheckFunc( - testAccCheckWaypointAddOnDefinitionExists(t, resourceName, &appTemplateModel), - testAccCheckWaypointAddOnDefinitionName(t, &appTemplateModel, name), + testAccCheckWaypointAddOnDefinitionExists(t, resourceName, &addOnDefinitionModel), + testAccCheckWaypointAddOnDefinitionName(t, &addOnDefinitionModel, name), resource.TestCheckResourceAttr(resourceName, "name", name), ), }, { Config: testAddOnDefinitionConfig(updatedName), Check: resource.ComposeTestCheckFunc( - testAccCheckWaypointAddOnDefinitionExists(t, resourceName, &appTemplateModel), - testAccCheckWaypointAddOnDefinitionName(t, &appTemplateModel, updatedName), + testAccCheckWaypointAddOnDefinitionExists(t, resourceName, &addOnDefinitionModel), + testAccCheckWaypointAddOnDefinitionName(t, &addOnDefinitionModel, updatedName), resource.TestCheckResourceAttr(resourceName, "name", updatedName), ), }, diff --git a/internal/provider/waypoint/resource_waypoint_add_on_test.go b/internal/provider/waypoint/resource_waypoint_add_on_test.go new file mode 100644 index 000000000..d80b5ff28 --- /dev/null +++ b/internal/provider/waypoint/resource_waypoint_add_on_test.go @@ -0,0 +1,165 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package waypoint_test + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" + "github.com/hashicorp/terraform-provider-hcp/internal/clients" + "github.com/hashicorp/terraform-provider-hcp/internal/provider/acctest" + "github.com/hashicorp/terraform-provider-hcp/internal/provider/waypoint" + + sharedmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-shared/v1/models" +) + +func TestAccWaypoint_Add_On_basic(t *testing.T) { + var addOnModel waypoint.AddOnResourceModel + resourceName := "hcp_waypoint_add_on.test" + addOnName := generateRandomName() + templateName := generateRandomName() + appName := generateRandomName() + defName := generateRandomName() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { acctest.PreCheck(t) }, + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + CheckDestroy: testAccCheckWaypointAddOnDestroy(t, &addOnModel), + Steps: []resource.TestStep{ + { + Config: testAddOnConfig(templateName, appName, defName, addOnName), + Check: resource.ComposeTestCheckFunc( + testAccCheckWaypointAddOnExists(t, resourceName, &addOnModel), + testAccCheckWaypointAddOnName(t, &addOnModel, addOnName), + resource.TestCheckResourceAttr(resourceName, "name", addOnName), + ), + }, + }, + }) +} + +// simple attribute check on the add-on definition received from the API +func testAccCheckWaypointAddOnName(t *testing.T, addOnModel *waypoint.AddOnResourceModel, nameValue string) resource.TestCheckFunc { + return func(s *terraform.State) error { + if addOnModel.Name.ValueString() != nameValue { + return fmt.Errorf("expected add-on name to be %s, but got %s", nameValue, addOnModel.Name.ValueString()) + } + return nil + } +} + +func testAccCheckWaypointAddOnExists(t *testing.T, resourceName string, addOnModel *waypoint.AddOnResourceModel) resource.TestCheckFunc { + return func(s *terraform.State) error { + // find the corresponding state object + rs, ok := s.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + client := acctest.HCPClients(t) + // Get the project ID and ID from state + projectID := rs.Primary.Attributes["project_id"] + addOnID := rs.Primary.Attributes["id"] + orgID := client.Config.OrganizationID + + loc := &sharedmodels.HashicorpCloudLocationLocation{ + OrganizationID: orgID, + ProjectID: projectID, + } + + // Fetch the add-on + addOn, err := clients.GetAddOnByID(context.Background(), client, loc, addOnID) + if err != nil { + return err + } + + // at this time we're only verifing existence and not checking all the + // values, so only set name and ID for now + addOnModel.Name = types.StringValue(addOn.Name) + addOnModel.ID = types.StringValue(addOn.ID) + + return nil + } +} + +func testAccCheckWaypointAddOnDestroy(t *testing.T, addOnModel *waypoint.AddOnResourceModel) resource.TestCheckFunc { + return func(_ *terraform.State) error { + client := acctest.HCPClients(t) + id := addOnModel.ID.ValueString() + projectID := client.Config.ProjectID + orgID := client.Config.OrganizationID + + loc := &sharedmodels.HashicorpCloudLocationLocation{ + OrganizationID: orgID, + ProjectID: projectID, + } + + addOn, err := clients.GetAddOnByID(context.Background(), client, loc, id) + if err != nil { + // expected (500 because the application is destroyed as well) + if clients.IsResponseCodeNotFound(err) { + return nil + } + return err + } + + // fall through, we expect a not found above but if we get this far then + // the test should fail + if addOn != nil { + return fmt.Errorf("expected add-on to be destroyed, but it still exists") + } + + return fmt.Errorf("Both add-on and error were nil in destroy check, this should not happen") + } +} + +// These are hardcoded project and no-code module values because they work. The +// automated tests do not run acceptance tests at this time, so these should be +// sufficient for now. +func testAddOnConfig(templateName string, appName string, defName string, addOnName string) string { + return fmt.Sprintf(` +resource "hcp_waypoint_application_template" "test" { + name = "%s" + summary = "some summary for fun" + readme_markdown_template = base64encode("# Some Readme") + terraform_no_code_module = { + source = "private/waypoint-tfc-testing/waypoint-template-starter/null" + version = "0.0.2" + } + terraform_cloud_workspace_details = { + name = "Default Project" + terraform_project_id = "prj-gfVyPJ2q2Aurn25o" + } + labels = ["one", "two"] +} + +resource "hcp_waypoint_application" "test" { + name = "%s" + application_template_id = hcp_waypoint_application_template.test.id +} + +resource "hcp_waypoint_add_on_definition" "test" { + name = "%s" + summary = "some summary for fun" + description = "some description for fun" + terraform_no_code_module = { + source = "private/waypoint-tfc-testing/waypoint-template-starter/null" + version = "0.0.2" + } + terraform_cloud_workspace_details = { + name = "Default Project" + terraform_project_id = "prj-gfVyPJ2q2Aurn25o" + } +} + +resource "hcp_waypoint_add_on" "test" { + name = "%s" + application_id = hcp_waypoint_application.test.id + definition_id = hcp_waypoint_add_on_definition.test.id +}`, templateName, appName, defName, addOnName) +} diff --git a/templates/resources/waypoint_add_on.md.tmpl b/templates/resources/waypoint_add_on.md.tmpl new file mode 100644 index 000000000..8da38ebea --- /dev/null +++ b/templates/resources/waypoint_add_on.md.tmpl @@ -0,0 +1,14 @@ +--- +page_title: "{{.Name}} {{.Type}} - {{.ProviderName}}" +subcategory: "HCP Waypoint" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} `{{.Type}}` + +-> **Note:** HCP Waypoint is currently in public beta. + +{{ .Description | trimspace }} + +{{ .SchemaMarkdown | trimspace }} \ No newline at end of file