diff --git a/.changelog/1196.txt b/.changelog/1196.txt new file mode 100644 index 000000000..e6e1c3c47 --- /dev/null +++ b/.changelog/1196.txt @@ -0,0 +1,7 @@ +```release-note:feature +Add support for sync resource in HCP Vault Secrets +``` + +```release-note:improvement +Allow users to assign one or more syncs with an HCP Vault Secrets App +``` diff --git a/docs/resources/vault_secrets_app.md b/docs/resources/vault_secrets_app.md index 042356051..408e895d8 100644 --- a/docs/resources/vault_secrets_app.md +++ b/docs/resources/vault_secrets_app.md @@ -29,6 +29,7 @@ resource "hcp_vault_secrets_app" "example" { - `description` (String) The Vault Secrets app description - `project_id` (String) The ID of the HCP project where the HCP Vault Secrets app is located. +- `sync_names` (Set of String) Set of sync names to associate with this app. ### Read-Only diff --git a/docs/resources/vault_secrets_integration.md b/docs/resources/vault_secrets_integration.md index 59fa15b0b..3288b03a9 100644 --- a/docs/resources/vault_secrets_integration.md +++ b/docs/resources/vault_secrets_integration.md @@ -200,7 +200,7 @@ Read-Only: Required: -- `token` (String, Sensitive) Access token used to authenticate against the target GitLab account. +- `token` (String, Sensitive) Access token used to authenticate against the target GitLab account. This token must have privilege to create CI/CD variables. diff --git a/docs/resources/vault_secrets_sync.md b/docs/resources/vault_secrets_sync.md new file mode 100644 index 000000000..ea89f9b80 --- /dev/null +++ b/docs/resources/vault_secrets_sync.md @@ -0,0 +1,68 @@ +--- +page_title: "Resource hcp_vault_secrets_sync" +subcategory: "HCP Vault Secrets" +description: |- + The Vault Secrets sync resource manages an integration. +--- + +# hcp_vault_secrets_sync (Resource) + +The Vault Secrets sync resource manages an integration. + +## Example Usage + +```terraform +resource "hcp_vault_secrets_integration" "example_gitlab_integration" { + name = "gitlab-integration" + capabilities = ["SYNC"] + provider_type = "gitlab" + gitlab_access = { + token = "myaccesstoken" + } +} + +resource "hcp_vault_secrets_sync" "example_gitlab_project_sync" { + name = "gitlab-proj-sync" + integration_name = hcp_vault_secrets_integration.example_gitlab_integration.name + gitlab_config = { + scope = "PROJECT" + project_id = "123456" + } +} +``` + + +## Schema + +### Required + +- `integration_name` (String) The Vault Secrets integration name. +- `name` (String) The Vault Secrets Sync name. + +### Optional + +- `gitlab_config` (Attributes) Configuration parameters used to determine the sync destination. (see [below for nested schema](#nestedatt--gitlab_config)) +- `project_id` (String) HCP project ID that owns the HCP Vault Secrets integration. Inferred from the provider configuration if omitted. + +### Read-Only + +- `organization_id` (String) HCP organization ID that owns the HCP Vault Secrets integration. +- `resource_id` (String) Resource ID used to uniquely identify the sync on the HCP platform. + + +### Nested Schema for `gitlab_config` + +Optional: + +- `group_id` (String) ID of the group, if the scope is GROUP +- `project_id` (String) ID of the project, if the scope is PROJECT +- `scope` (String) The scope to which sync applies. Defaults to GROUP. The valid options are GROUP and PROJECT + +## Import + +Import is supported using the following syntax: + +```shell +# Vault Secrets Integration can be imported by specifying the name of the integration +terraform import hcp_vault_secrets_sync.example_gitlab_project_sync gitlab-proj-sync +``` diff --git a/examples/resources/hcp_vault_secrets_sync/import.sh b/examples/resources/hcp_vault_secrets_sync/import.sh new file mode 100644 index 000000000..6da504f8d --- /dev/null +++ b/examples/resources/hcp_vault_secrets_sync/import.sh @@ -0,0 +1,2 @@ +# Vault Secrets Integration can be imported by specifying the name of the integration +terraform import hcp_vault_secrets_sync.example_gitlab_project_sync gitlab-proj-sync diff --git a/examples/resources/hcp_vault_secrets_sync/resource.tf b/examples/resources/hcp_vault_secrets_sync/resource.tf new file mode 100644 index 000000000..807372f0c --- /dev/null +++ b/examples/resources/hcp_vault_secrets_sync/resource.tf @@ -0,0 +1,17 @@ +resource "hcp_vault_secrets_integration" "example_gitlab_integration" { + name = "gitlab-integration" + capabilities = ["SYNC"] + provider_type = "gitlab" + gitlab_access = { + token = "myaccesstoken" + } +} + +resource "hcp_vault_secrets_sync" "example_gitlab_project_sync" { + name = "gitlab-proj-sync" + integration_name = hcp_vault_secrets_integration.example_gitlab_integration.name + gitlab_config = { + scope = "PROJECT" + project_id = "123456" + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 735195a50..2b3c871d5 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -157,6 +157,7 @@ func (p *ProviderFramework) Resources(ctx context.Context) []func() resource.Res vaultsecrets.NewVaultSecretsIntegrationResource, vaultsecrets.NewVaultSecretsDynamicSecretResource, vaultsecrets.NewVaultSecretsRotatingSecretResource, + vaultsecrets.NewVaultSecretsSyncResource, // Vault Secrets Deprecated vaultsecrets.NewVaultSecretsIntegrationAWSResource, vaultsecrets.NewVaultSecretsIntegrationAzureResource, diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_app.go b/internal/provider/vaultsecrets/resource_vault_secrets_app.go index 794756997..83d29c375 100644 --- a/internal/provider/vaultsecrets/resource_vault_secrets_app.go +++ b/internal/provider/vaultsecrets/resource_vault_secrets_app.go @@ -10,7 +10,9 @@ import ( "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-11-28/client/secret_service" secretmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-11-28/models" + "github.com/hashicorp/terraform-plugin-framework-validators/setvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -30,6 +32,9 @@ type App struct { ProjectID types.String `tfsdk:"project_id"` OrganizationID types.String `tfsdk:"organization_id"` ResourceName types.String `tfsdk:"resource_name"` + SyncNames types.Set `tfsdk:"sync_names"` + + syncNames []string `tfsdk:"-"` } var _ resource.Resource = &resourceVaultSecretsApp{} @@ -90,7 +95,16 @@ func (r *resourceVaultSecretsApp) Schema(_ context.Context, _ resource.SchemaReq Computed: true, Description: "The app's resource name in the format secrets/project//app/.", }, - }, + "sync_names": schema.SetAttribute{ + Description: "Set of sync names to associate with this app.", + Optional: true, + ElementType: types.StringType, + Validators: []validator.Set{ + setvalidator.ValueStringsAre( + slugValidator, + ), + }, + }}, } } @@ -124,6 +138,7 @@ func (r *resourceVaultSecretsApp) Create(ctx context.Context, req resource.Creat Body: &secretmodels.SecretServiceCreateAppBody{ Name: app.AppName.ValueString(), Description: app.Description.ValueString(), + SyncNames: app.syncNames, }, OrganizationID: app.OrganizationID.ValueString(), ProjectID: app.ProjectID.ValueString(), @@ -170,6 +185,7 @@ func (r *resourceVaultSecretsApp) Update(ctx context.Context, req resource.Updat response, err := r.client.VaultSecrets.UpdateApp(&secret_service.UpdateAppParams{ Body: &secretmodels.SecretServiceUpdateAppBody{ Description: app.Description.ValueString(), + SyncNames: app.syncNames, }, Name: app.AppName.ValueString(), OrganizationID: app.OrganizationID.ValueString(), @@ -216,9 +232,11 @@ func (a *App) projectID() types.String { return a.ProjectID } -func (a *App) initModel(_ context.Context, orgID, projID string) diag.Diagnostics { +func (a *App) initModel(ctx context.Context, orgID, projID string) diag.Diagnostics { a.OrganizationID = types.StringValue(orgID) a.ProjectID = types.StringValue(projID) + a.syncNames = make([]string, 0, len(a.SyncNames.Elements())) + a.SyncNames.ElementsAs(ctx, &a.syncNames, false) return diag.Diagnostics{} } @@ -237,5 +255,17 @@ func (a *App) fromModel(_ context.Context, orgID, projID string, model any) diag a.ID = types.StringValue(appModel.ResourceID) a.ResourceName = types.StringValue(appModel.ResourceName) + var syncs []attr.Value + for _, c := range appModel.SyncNames { + syncs = append(syncs, types.StringValue(c)) + } + + if len(syncs) > 0 { + a.SyncNames, diags = types.SetValue(types.StringType, syncs) + if diags.HasError() { + return diags + } + } + return diags } diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_app_test.go b/internal/provider/vaultsecrets/resource_vault_secrets_app_test.go index bc8de8ae7..e93df1222 100644 --- a/internal/provider/vaultsecrets/resource_vault_secrets_app_test.go +++ b/internal/provider/vaultsecrets/resource_vault_secrets_app_test.go @@ -17,11 +17,16 @@ import ( ) func TestAccVaultSecretsResourceApp(t *testing.T) { - appName1 := generateRandomSlug() - appName2 := generateRandomSlug() - - description1 := "my description 1" - description2 := "my description 2" + var ( + integrationName1 = generateRandomSlug() + appName1 = generateRandomSlug() + appName2 = generateRandomSlug() + description1 = "my description 1" + description2 = "my description 2" + projSyncName = generateRandomSlug() + groupSyncName = generateRandomSlug() + gitLabToken = checkRequiredEnvVarOrFail(t, "VAULTSECRETS_GITLAB_ACCESS_TOKEN") + ) resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, @@ -30,21 +35,58 @@ func TestAccVaultSecretsResourceApp(t *testing.T) { { Config: appConfig(appName1, description1), Check: resource.ComposeTestCheckFunc( - appCheckFunc(appName1, description1)..., + appCheckFunc(appName1, description1, nil)..., ), }, // Changing an immutable field causes a recreation { Config: appConfig(appName2, description1), Check: resource.ComposeTestCheckFunc( - appCheckFunc(appName2, description1)..., + appCheckFunc(appName2, description1, nil)..., ), }, // Changing mutable fields causes an update { Config: appConfig(appName2, description2), Check: resource.ComposeTestCheckFunc( - appCheckFunc(appName2, description2)..., + appCheckFunc(appName2, description2, nil)..., + ), + }, + // Changing the sync_names causes an update + { + Config: fmt.Sprintf(` + resource "hcp_vault_secrets_integration" "acc_test" { + name = %q + capabilities = ["SYNC"] + provider_type = "gitlab" + gitlab_access = { + token = %q + } + } + resource "hcp_vault_secrets_sync" "gitlab_proj_sync" { + name = %q + integration_name = hcp_vault_secrets_integration.acc_test.name + gitlab_config = { + scope = "PROJECT" + project_id = "123456789" + } + } + resource "hcp_vault_secrets_sync" "gitlab_group_sync" { + name = %q + integration_name = hcp_vault_secrets_integration.acc_test.name + gitlab_config = { + scope = "GROUP" + group_id = "987654321" + } + } + resource "hcp_vault_secrets_app" "acc_test_app" { + app_name = %q + description = %q + sync_names = [hcp_vault_secrets_sync.gitlab_sync.name] + } + `, integrationName1, gitLabToken, projSyncName, groupSyncName, appName2, description2), + Check: resource.ComposeTestCheckFunc( + appCheckFunc(appName2, description2, []string{projSyncName, groupSyncName})..., ), }, // Deleting the app out of band causes a recreation @@ -63,7 +105,7 @@ func TestAccVaultSecretsResourceApp(t *testing.T) { }, Config: appConfig(appName2, description2), Check: resource.ComposeTestCheckFunc( - appCheckFunc(appName2, description2)..., + appCheckFunc(appName2, description2, nil)..., ), PlanOnly: true, ExpectNonEmptyPlan: true, @@ -87,7 +129,7 @@ func TestAccVaultSecretsResourceApp(t *testing.T) { }, Config: appConfig(appName2, description2), Check: resource.ComposeTestCheckFunc( - appCheckFunc(appName2, description2)..., + appCheckFunc(appName2, description2, nil)..., ), ResourceName: "hcp_vault_secrets_app.acc_test_app", ImportStateId: appName2, @@ -111,18 +153,26 @@ func appConfig(appName, description string) string { resource "hcp_vault_secrets_app" "acc_test_app" { app_name = %q description = %q + sync_names = [] }`, appName, description) } -func appCheckFunc(appName, description string) []resource.TestCheckFunc { - return []resource.TestCheckFunc{ +func appCheckFunc(appName, description string, syncNames []string) []resource.TestCheckFunc { + syncsTestFns := make([]resource.TestCheckFunc, 0, len(syncNames)) + for _, syncName := range syncNames { + syncsTestFns = append(syncsTestFns, resource.TestCheckTypeSetElemAttr("hcp_vault_secrets_app.acc_test_app", "sync_names.*", syncName)) + } + + testFns := []resource.TestCheckFunc{ resource.TestCheckResourceAttrSet("hcp_vault_secrets_app.acc_test_app", "organization_id"), resource.TestCheckResourceAttrSet("hcp_vault_secrets_app.acc_test_app", "id"), resource.TestCheckResourceAttrSet("hcp_vault_secrets_app.acc_test_app", "resource_name"), resource.TestCheckResourceAttr("hcp_vault_secrets_app.acc_test_app", "project_id", os.Getenv("HCP_PROJECT_ID")), resource.TestCheckResourceAttr("hcp_vault_secrets_app.acc_test_app", "app_name", appName), - resource.TestCheckResourceAttr("hcp_vault_secrets_app.acc_test_app", "description", description), - } + resource.TestCheckResourceAttr("hcp_vault_secrets_app.acc_test_app", "description", description)} + + testFns = append(testFns, syncsTestFns...) + return testFns } func appExists(t *testing.T, name string) bool { diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_integration.go b/internal/provider/vaultsecrets/resource_vault_secrets_integration.go index 9fdda766d..56f2b0649 100644 --- a/internal/provider/vaultsecrets/resource_vault_secrets_integration.go +++ b/internal/provider/vaultsecrets/resource_vault_secrets_integration.go @@ -291,11 +291,14 @@ func (r *resourceVaultSecretsIntegration) Schema(_ context.Context, _ resource.S Optional: true, Attributes: map[string]schema.Attribute{ "token": schema.StringAttribute{ - Description: "Access token used to authenticate against the target GitLab account.", + Description: "Access token used to authenticate against the target GitLab account. This token must have privilege to create CI/CD variables.", Required: true, Sensitive: true, }, }, + Validators: []validator.Object{ + exactlyOneIntegrationTypeFieldsValidator, + }, }, } diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_integration_test.go b/internal/provider/vaultsecrets/resource_vault_secrets_integration_test.go index 3a2555db3..a7883712a 100644 --- a/internal/provider/vaultsecrets/resource_vault_secrets_integration_test.go +++ b/internal/provider/vaultsecrets/resource_vault_secrets_integration_test.go @@ -17,7 +17,7 @@ import ( ) func TestAccVaultSecretsResourceIntegrationGitLab(t *testing.T) { - accessToken := checkRequiredEnvVarOrFail(t, "GITLAB_ACCESS_TOKEN") + accessToken := checkRequiredEnvVarOrFail(t, "VAULTSECRETS_GITLAB_ACCESS_TOKEN") integrationName1 := generateRandomSlug() integrationName2 := generateRandomSlug() diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_sync.go b/internal/provider/vaultsecrets/resource_vault_secrets_sync.go new file mode 100644 index 000000000..9e043ed20 --- /dev/null +++ b/internal/provider/vaultsecrets/resource_vault_secrets_sync.go @@ -0,0 +1,382 @@ +package vaultsecrets + +import ( + "context" + "fmt" + + "golang.org/x/exp/maps" + + "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-11-28/client/secret_service" + secretmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-vault-secrets/stable/2023-11-28/models" + "github.com/hashicorp/terraform-plugin-framework-validators/objectvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "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/planmodifier" + "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-provider-hcp/internal/clients" + "github.com/hashicorp/terraform-provider-hcp/internal/provider/modifiers" +) + +type Sync struct { + ResourceID types.String `tfsdk:"resource_id"` + Name types.String `tfsdk:"name"` + IntegrationName types.String `tfsdk:"integration_name"` + OrganizationID types.String `tfsdk:"organization_id"` + ProjectID types.String `tfsdk:"project_id"` + + // Destination-specific mutually exclusive fields + GitlabConfig types.Object `tfsdk:"gitlab_config"` + + // Inner API-compatible models derived from the Terraform fields + gitlabConfig *secretmodels.Secrets20231128SyncConfigGitlab `tfsdk:"-"` +} + +var _ resource.Resource = &resourceVaultSecretsSync{} +var _ resource.ResourceWithConfigure = &resourceVaultSecretsSync{} +var _ resource.ResourceWithModifyPlan = &resourceVaultSecretsSync{} +var _ resource.ResourceWithImportState = &resourceVaultSecretsSync{} + +func NewVaultSecretsSyncResource() resource.Resource { + return &resourceVaultSecretsSync{} +} + +type resourceVaultSecretsSync struct { + client *clients.Client +} + +func (r *resourceVaultSecretsSync) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_vault_secrets_sync" +} + +func (r *resourceVaultSecretsSync) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + attributes := map[string]schema.Attribute{ + "resource_id": schema.StringAttribute{ + Description: "Resource ID used to uniquely identify the sync on the HCP platform.", + Computed: true, + }, + "name": schema.StringAttribute{ + Description: "The Vault Secrets Sync name.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + slugValidator, + }, + }, + "integration_name": schema.StringAttribute{ + Description: "The Vault Secrets integration name.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: []validator.String{ + slugValidator, + }, + }, + "gitlab_config": schema.SingleNestedAttribute{ + Description: "Configuration parameters used to determine the sync destination.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "scope": schema.StringAttribute{ + Description: "The scope to which sync applies. Defaults to GROUP. The valid options are GROUP and PROJECT", + Optional: true, + Computed: true, + Validators: []validator.String{ + stringvalidator.OneOf("GROUP", "PROJECT"), + }, + }, + "group_id": schema.StringAttribute{ + Description: "ID of the group, if the scope is GROUP", + Optional: true, + Validators: []validator.String{ + stringvalidator.ConflictsWith( + path.MatchRelative().AtParent().AtName("project_id"), + ), + stringvalidator.AtLeastOneOf( + path.MatchRelative().AtParent().AtName("project_id"), + ), + }, + }, + "project_id": schema.StringAttribute{ + Description: "ID of the project, if the scope is PROJECT", + Optional: true, + }, + }, + Validators: []validator.Object{ + exactlyOneSyncConfigFieldsValidator, + }, + }, + } + + maps.Copy(attributes, locationAttributes) + + resp.Schema = schema.Schema{ + MarkdownDescription: "The Vault Secrets sync resource manages an integration.", + Attributes: attributes, + } +} + +func (r *resourceVaultSecretsSync) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.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 + } + r.client = client +} + +func (r *resourceVaultSecretsSync) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { + modifiers.ModifyPlanForDefaultProjectChange(ctx, r.client.Config.ProjectID, req.State, req.Config, req.Plan, resp) +} + +func (r *resourceVaultSecretsSync) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + resp.Diagnostics.Append(decorateOperation[*Sync](ctx, r.client, &resp.State, req.State.Get, "reading", func(i hvsResource) (any, error) { + sync, ok := i.(*Sync) + if !ok { + return nil, fmt.Errorf("invalid integration type, expected *Sync, got: %T, this is a bug on the provider", i) + } + + response, err := r.client.VaultSecrets.GetSync(secret_service.NewGetSyncParamsWithContext(ctx). + WithOrganizationID(sync.OrganizationID.ValueString()). + WithProjectID(sync.ProjectID.ValueString()). + WithName(sync.Name.ValueString()), nil) + + if err != nil && !clients.IsResponseCodeNotFound(err) { + return nil, err + } + + if response == nil || response.Payload == nil { + return nil, nil + } + return response.Payload.Sync, nil + })...) +} + +func (r *resourceVaultSecretsSync) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + resp.Diagnostics.Append(decorateOperation[*Sync](ctx, r.client, &resp.State, req.Plan.Get, "creating", func(i hvsResource) (any, error) { + sync, ok := i.(*Sync) + if !ok { + return nil, fmt.Errorf("invalid resource type, expected *Sync, got: %T, this is a bug on the provider", i) + } + + res, err := r.client.VaultSecrets.GetIntegration(secret_service.NewGetIntegrationParamsWithContext(ctx). + WithOrganizationID(sync.OrganizationID.ValueString()). + WithProjectID(sync.ProjectID.ValueString()). + WithName(sync.IntegrationName.ValueString()), nil) + + if err != nil { + return nil, err + } + + providerType := res.Payload.Integration.Provider + + response, err := r.client.VaultSecrets.CreateSync(&secret_service.CreateSyncParams{ + Body: &secretmodels.SecretServiceCreateSyncBody{ + Name: sync.Name.ValueString(), + IntegrationName: sync.IntegrationName.ValueString(), + Type: providerType, + SyncConfigGitlab: sync.gitlabConfig, + }, + OrganizationID: sync.OrganizationID.ValueString(), + ProjectID: sync.ProjectID.ValueString(), + }, nil) + if err != nil { + return nil, err + } + + return response.Payload.Sync, nil + })...) +} + +func (r *resourceVaultSecretsSync) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { +} + +func (r *resourceVaultSecretsSync) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + resp.Diagnostics.Append(decorateOperation[*Sync](ctx, r.client, &resp.State, req.State.Get, "deleting", func(i hvsResource) (any, error) { + sync, ok := i.(*Sync) + if !ok { + return nil, fmt.Errorf("invalid integration type, expected *Sync, got: %T, this is a bug on the provider", i) + } + + _, err := r.client.VaultSecrets.DeleteSync(secret_service.NewDeleteSyncParamsWithContext(ctx). + WithOrganizationID(sync.OrganizationID.ValueString()). + WithProjectID(sync.ProjectID.ValueString()). + WithName(sync.Name.ValueString()), nil) + if err != nil { + return nil, err + } + return nil, nil + })...) +} + +func (r *resourceVaultSecretsSync) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("organization_id"), r.client.Config.OrganizationID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("project_id"), r.client.Config.ProjectID)...) + resp.Diagnostics.Append(resp.State.SetAttribute(ctx, path.Root("name"), req.ID)...) +} + +var _ hvsResource = &Sync{} + +func (s *Sync) projectID() types.String { + return s.ProjectID +} + +func (s *Sync) initModel(ctx context.Context, orgID, projID string) diag.Diagnostics { + s.OrganizationID = types.StringValue(orgID) + s.ProjectID = types.StringValue(projID) + + if !s.GitlabConfig.IsNull() { + config := gitlabConfigParams{} + diags := s.GitlabConfig.As(ctx, &config, basetypes.ObjectAsOptions{}) + if diags.HasError() { + return diags + } + + scopeStr := config.Scope.ValueString() + if scopeStr == "" { + if !config.GroupID.IsNull() { + scopeStr = "GROUP" + } else if !config.ProjectID.IsNull() { + scopeStr = "PROJECT" + } else { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid GitLab Configuration", + "Either group_id or project_id must be specified", + ), + } + } + } + + scope := secretmodels.SyncConfigGitlabScope(scopeStr) + + var groupIDVal, projectIDVal string + if scope == secretmodels.SyncConfigGitlabScopeGROUP { + if config.GroupID.IsNull() { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid GitLab Configuration", + "group_id is required when scope is GROUP", + ), + } + } + groupIDVal = config.GroupID.ValueString() + projectIDVal = "" + } else if scope == secretmodels.SyncConfigGitlabScopePROJECT { + if config.ProjectID.IsNull() { + return diag.Diagnostics{ + diag.NewErrorDiagnostic( + "Invalid GitLab Configuration", + "project_id is required when scope is PROJECT", + ), + } + } + groupIDVal = "" + projectIDVal = config.ProjectID.ValueString() + } + + s.gitlabConfig = &secretmodels.Secrets20231128SyncConfigGitlab{ + Scope: &scope, + GroupID: groupIDVal, + ProjectID: projectIDVal, + Protected: false, + Raw: false, + } + } + + return diag.Diagnostics{} +} + +func (s *Sync) fromModel(ctx context.Context, orgID, projID string, model any) diag.Diagnostics { + diags := diag.Diagnostics{} + + syncModel, ok := model.(*secretmodels.Secrets20231128Sync) + if !ok { + diags.AddError( + "Invalid model type, this is a bug on the provider.", + fmt.Sprintf("Expected *secretmodels.Secrets20231128Sync, got: %T", model), + ) + return diags + } + + s.ResourceID = types.StringValue(syncModel.ResourceID) + s.Name = types.StringValue(syncModel.Name) + s.IntegrationName = types.StringValue(syncModel.IntegrationName) + s.OrganizationID = types.StringValue(orgID) + s.ProjectID = types.StringValue(projID) + + if syncModel.SyncConfigGitlab == nil { + s.GitlabConfig = types.ObjectNull(map[string]attr.Type{ + "scope": types.StringType, + "group_id": types.StringType, + "project_id": types.StringType, + }) + + return diags + } + + scopeVal := types.StringNull() + if syncModel.SyncConfigGitlab.Scope != nil { + scopeVal = types.StringValue(string(*syncModel.SyncConfigGitlab.Scope)) + } + + var groupIDVal types.String + if syncModel.SyncConfigGitlab.GroupID != "" { + groupIDVal = types.StringValue(syncModel.SyncConfigGitlab.GroupID) + } else { + groupIDVal = types.StringNull() + } + + var projectIDVal types.String + if syncModel.SyncConfigGitlab.ProjectID != "" { + projectIDVal = types.StringValue(syncModel.SyncConfigGitlab.ProjectID) + } else { + projectIDVal = types.StringNull() + } + + s.GitlabConfig, diags = types.ObjectValue( + map[string]attr.Type{ + "scope": types.StringType, + "group_id": types.StringType, + "project_id": types.StringType, + }, + map[string]attr.Value{ + "scope": scopeVal, + "group_id": groupIDVal, + "project_id": projectIDVal, + }, + ) + if diags.HasError() { + return diags + } + + return diags +} + +// Validations and types for sync destinations + +var exactlyOneSyncConfigFieldsValidator = objectvalidator.ExactlyOneOf( + path.Expressions{ + path.MatchRoot("gitlab_config"), + }..., +) + +type gitlabConfigParams struct { + Scope types.String `tfsdk:"scope"` + GroupID types.String `tfsdk:"group_id"` + ProjectID types.String `tfsdk:"project_id"` +} diff --git a/internal/provider/vaultsecrets/resource_vault_secrets_sync_test.go b/internal/provider/vaultsecrets/resource_vault_secrets_sync_test.go new file mode 100644 index 000000000..6e2eab6f1 --- /dev/null +++ b/internal/provider/vaultsecrets/resource_vault_secrets_sync_test.go @@ -0,0 +1,51 @@ +package vaultsecrets_test + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-provider-hcp/internal/provider/acctest" +) + +func TestAccVaultSecretsResourceSync(t *testing.T) { + syncName := generateRandomSlug() + integrationName := generateRandomSlug() + gitLabToken := checkRequiredEnvVarOrFail(t, "VAULTSECRETS_GITLAB_ACCESS_TOKEN") + + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: acctest.ProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: syncConfig(integrationName, syncName, gitLabToken), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("hcp_vault_secrets_sync.acc_test_gitlab_group", "organization_id"), + resource.TestCheckResourceAttr("hcp_vault_secrets_sync.acc_test_gitlab_group", "project_id", os.Getenv("HCP_PROJECT_ID")), + resource.TestCheckResourceAttr("hcp_vault_secrets_sync.acc_test_gitlab_group", "name", syncName), + resource.TestCheckResourceAttr("hcp_vault_secrets_sync.acc_test_gitlab_group", "integration_name", integrationName)), + }, + }, + }) +} + +func syncConfig(integrationName, syncName, accessToken string) string { + return fmt.Sprintf(` +resource "hcp_vault_secrets_integration" "acc_test" { + name = %q + capabilities = ["SYNC"] + provider_type = "gitlab" + gitlab_access = { + token = %q + } +} + +resource "hcp_vault_secrets_sync" "acc_test_gitlab_group" { + name = %q + integration_name = %q + gitlab_config = { + scope = "GROUP" + group_id = 123456 + } + }`, integrationName, accessToken, syncName, integrationName) +} diff --git a/templates/resources/vault_secrets_sync.md.tmpl b/templates/resources/vault_secrets_sync.md.tmpl new file mode 100644 index 000000000..025dcc1db --- /dev/null +++ b/templates/resources/vault_secrets_sync.md.tmpl @@ -0,0 +1,22 @@ +--- +page_title: "{{.Type}} {{.Name}}" +subcategory: "HCP Vault Secrets" +description: |- +{{ .Description | plainmarkdown | trimspace | prefixlines " " }} +--- + +# {{.Name}} ({{.Type}}) + +{{ .Description | trimspace }} + +## Example Usage + +{{ tffile "examples/resources/hcp_vault_secrets_sync/resource.tf" }} + +{{ .SchemaMarkdown | trimspace }} + +## Import + +Import is supported using the following syntax: + +{{ codefile "shell" "examples/resources/hcp_vault_secrets_sync/import.sh" }}