diff --git a/.changes/unreleased/Feature-20241022-094830.yaml b/.changes/unreleased/Feature-20241022-094830.yaml new file mode 100644 index 00000000..b4c52cfe --- /dev/null +++ b/.changes/unreleased/Feature-20241022-094830.yaml @@ -0,0 +1,4 @@ +kind: Feature +body: Add `opslevel_alias` resource for managing a set of aliases on an "aliasable" + resource in OpsLevel +time: 2024-10-22T09:48:30.49587-05:00 diff --git a/examples/resources/opslevel_alias/resource.tf b/examples/resources/opslevel_alias/resource.tf new file mode 100644 index 00000000..b406f07f --- /dev/null +++ b/examples/resources/opslevel_alias/resource.tf @@ -0,0 +1,41 @@ +resource "opslevel_alias" "service" { + resource_type = "service" + resource_identifier = "example_alias" + + aliases = ["example_alias_2", "example_alias_3"] +} + +resource "opslevel_alias" "team" { + resource_type = "team" + resource_identifier = "example_alias" + + aliases = ["example_alias_2", "example_alias_3"] +} + +resource "opslevel_alias" "domain" { + resource_type = "domain" + resource_identifier = "example_alias" + + aliases = ["example_alias_2", "example_alias_3"] +} + +resource "opslevel_alias" "system" { + resource_type = "system" + resource_identifier = "example_alias" + + aliases = ["example_alias_2", "example_alias_3"] +} + +resource "opslevel_alias" "infra" { + resource_type = "infrastructure_resource" + resource_identifier = "example_alias" + + aliases = ["example_alias_2", "example_alias_3"] +} + +resource "opslevel_alias" "scorecard" { + resource_type = "scorecard" + resource_identifier = "example_alias" + + aliases = ["example_alias_2", "example_alias_3"] +} \ No newline at end of file diff --git a/opslevel/helpers.go b/opslevel/helpers.go new file mode 100644 index 00000000..aec9bc9c --- /dev/null +++ b/opslevel/helpers.go @@ -0,0 +1,17 @@ +package opslevel + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-framework/diag" +) + +type TerraformSource interface { + Get(ctx context.Context, target interface{}) diag.Diagnostics +} + +func read[T any](ctx context.Context, d *diag.Diagnostics, state TerraformSource) T { + var data T + d.Append(state.Get(ctx, &data)...) + return data +} diff --git a/opslevel/provider.go b/opslevel/provider.go index 40905f50..009cf9cd 100644 --- a/opslevel/provider.go +++ b/opslevel/provider.go @@ -173,6 +173,7 @@ func (p *OpslevelProvider) Configure(ctx context.Context, req provider.Configure func (p *OpslevelProvider) Resources(context.Context) []func() resource.Resource { return []func() resource.Resource{ + NewAliasResource, NewCheckAlertSourceUsageResource, NewCheckCustomEventResource, NewCheckGitBranchProtectionResource, diff --git a/opslevel/resource_opslevel_alias.go b/opslevel/resource_opslevel_alias.go new file mode 100644 index 00000000..74f0b8bf --- /dev/null +++ b/opslevel/resource_opslevel_alias.go @@ -0,0 +1,247 @@ +package opslevel + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/hashicorp/terraform-plugin-framework/diag" + + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "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/opslevel/opslevel-go/v2024" +) + +var _ resource.ResourceWithConfigure = &AliasResource{} + +func NewAliasResource() resource.Resource { + return &AliasResource{} +} + +// AliasResource defines the resource implementation for managing aliases. +type AliasResource struct { + CommonResourceClient +} + +func (r *AliasResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_alias" +} + +type AliasResourceModel struct { + ResourceType types.String `tfsdk:"resource_type"` + ResourceIdentifier types.String `tfsdk:"resource_identifier"` + Aliases types.Set `tfsdk:"aliases"` + + Id types.String `tfsdk:"id"` +} + +func (s AliasResourceModel) GetResource(d *diag.Diagnostics, client *opslevel.Client) opslevel.AliasableResourceInterface { + resourceType := opslevel.AliasOwnerTypeEnum(s.ResourceType.ValueString()) + resourceIdentifier := s.ResourceIdentifier.ValueString() + output, err := client.GetAliasableResource(resourceType, resourceIdentifier) + if err != nil { + d.AddError( + "opslevel client error", + fmt.Sprintf("Failed to find aliasable resource, %s", err), + ) + } + return output +} + +func (s AliasResourceModel) GetAliases(ctx context.Context, d *diag.Diagnostics) []string { + var output []string + if !s.Aliases.IsNull() { + d.Append(s.Aliases.ElementsAs(ctx, &output, true)...) + } + return output +} + +func (r *AliasResource) 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: "Alias Resource", + + Attributes: map[string]schema.Attribute{ + "resource_identifier": schema.StringAttribute{ + Description: "The id or human-friendly, unique identifier of the resource this alias belongs to.", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "resource_type": schema.StringAttribute{ + Description: fmt.Sprintf( + "The resource type that the alias applies to. One of `%s`", + strings.Join(opslevel.AllAliasOwnerTypeEnum, "`, `"), + ), + Required: true, + Validators: []validator.String{ + stringvalidator.OneOf(opslevel.AllAliasOwnerTypeEnum...), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "aliases": schema.SetAttribute{ + ElementType: types.StringType, + Description: "The unique set of aliases to ensure exist on the resource.", + Required: true, + }, + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "The id of the resource, maybe be duplicative of the 'resource_identifier' but in the case where that is an alias itself this is the identifier of what it found during lookup.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *AliasResource) createAlias(d *diag.Diagnostics, alias string, aliasable opslevel.AliasableResourceInterface) { + input := opslevel.AliasCreateInput{ + Alias: alias, + OwnerId: aliasable.ResourceId(), + } + + if _, err := r.client.CreateAlias(input); err != nil { + d.AddError( + "opslevel client error", + fmt.Sprintf("Failed to create alias '%s', %s", alias, err), + ) + } +} + +func (r *AliasResource) deleteAlias(d *diag.Diagnostics, alias string, aliasable opslevel.AliasableResourceInterface) { + input := opslevel.AliasDeleteInput{ + Alias: alias, + OwnerType: aliasable.AliasableType(), + } + if err := r.client.DeleteAlias(input); err != nil { + // This allows locked slugs to be added and not cause a failure upon delete + if strings.Contains(err.Error(), "slug is locked, it cannot be deleted") { + return + } + d.AddError( + "opslevel client error", + fmt.Sprintf("Failed to delete alias '%s', %s", alias, err), + ) + } +} + +func (r *AliasResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + planModel := read[AliasResourceModel](ctx, &resp.Diagnostics, req.Plan) + if resp.Diagnostics.HasError() { + return + } + + desiredAliases := planModel.GetAliases(ctx, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + aliasable := planModel.GetResource(&resp.Diagnostics, r.client) + if resp.Diagnostics.HasError() { + return + } + + planModel.Id = types.StringValue(string(aliasable.ResourceId())) + + currentAliases := aliasable.GetAliases() + for _, alias := range desiredAliases { + if slices.Contains(currentAliases, alias) { + continue + } + r.createAlias(&resp.Diagnostics, alias, aliasable) + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &planModel)...) +} + +func (r *AliasResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + planModel := read[AliasResourceModel](ctx, &resp.Diagnostics, req.State) + + aliasable := planModel.GetResource(&resp.Diagnostics, r.client) + if resp.Diagnostics.HasError() { + return + } + + planModel.Id = types.StringValue(string(aliasable.ResourceId())) + + resp.Diagnostics.Append(resp.State.Set(ctx, &planModel)...) +} + +func (r *AliasResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + planModel := read[AliasResourceModel](ctx, &resp.Diagnostics, req.Plan) + stateModel := read[AliasResourceModel](ctx, &resp.Diagnostics, req.State) + if resp.Diagnostics.HasError() { + return + } + + desiredAliases := planModel.GetAliases(ctx, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + managedAliases := stateModel.GetAliases(ctx, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + aliasable := planModel.GetResource(&resp.Diagnostics, r.client) + if resp.Diagnostics.HasError() { + return + } + currentAliases := aliasable.GetAliases() + + for _, alias := range managedAliases { + if slices.Contains(desiredAliases, alias) { + continue + } + if slices.Contains(currentAliases, alias) { + r.deleteAlias(&resp.Diagnostics, alias, aliasable) + } + } + + for _, alias := range desiredAliases { + if slices.Contains(currentAliases, alias) { + continue + } + r.createAlias(&resp.Diagnostics, alias, aliasable) + } + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, &planModel)...) +} + +func (r *AliasResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + stateModel := read[AliasResourceModel](ctx, &resp.Diagnostics, req.State) + + managedAliases := stateModel.GetAliases(ctx, &resp.Diagnostics) + if resp.Diagnostics.HasError() { + return + } + + aliasable := stateModel.GetResource(&resp.Diagnostics, r.client) + if resp.Diagnostics.HasError() { + return + } + currentAliases := aliasable.GetAliases() + + for _, alias := range managedAliases { + if slices.Contains(currentAliases, alias) { + r.deleteAlias(&resp.Diagnostics, alias, aliasable) + } + } +} diff --git a/submodules/opslevel-go b/submodules/opslevel-go index 68ee5fbe..16937315 160000 --- a/submodules/opslevel-go +++ b/submodules/opslevel-go @@ -1 +1 @@ -Subproject commit 68ee5fbe2470b119c5e8616543862341babf7e6e +Subproject commit 169373152f2cd48496256e2087710d0d82be5c66 diff --git a/tests/aliases.tftest.hcl b/tests/aliases.tftest.hcl new file mode 100644 index 00000000..051f03a3 --- /dev/null +++ b/tests/aliases.tftest.hcl @@ -0,0 +1,100 @@ +variables { + resource_name = "opslevel_alias" + # required fields + # optional fields +} + +run "from_data_module" { + command = plan + plan_options { + target = [ + data.opslevel_domains.all + ] + } + + module { + source = "./data" + } +} + +run "resource_create_aliases" { + variables { + resource_type = "domain" + resource_identifier = run.from_data_module.first_domain.id + aliases = toset(["one", "two", "three"]) + } + + module { + source = "./opslevel_modules/modules/aliases" + } + + assert { + condition = alltrue([ + can(opslevel_alias.this.aliases), + can(opslevel_alias.this.id), + ]) + error_message = replace(var.error_unexpected_resource_fields, "TYPE", var.resource_name) + } + + assert { + condition = opslevel_alias.this.aliases == var.aliases + error_message = format( + "expected '%v' but got '%v'", + var.aliases, + opslevel_alias.this.aliases, + ) + } +} + +run "resource_modify_managed_aliases" { + variables { + resource_type = "domain" + resource_identifier = run.from_data_module.first_domain.id + aliases = toset(["one", "four", "three"]) + } + + module { + source = "./opslevel_modules/modules/aliases" + } + + assert { + condition = opslevel_alias.this.aliases == var.aliases + error_message = format( + "expected '%v' but got '%v'", + var.aliases, + opslevel_alias.this.aliases, + ) + } +} + +run "delete_delete_alias_outside_of_terraform" { + + variables { + command = "delete alias -t domain four" + } + + module { + source = "./cli" + } +} + +run "resource_ensure_managed_aliases" { + variables { + resource_type = "domain" + resource_identifier = run.from_data_module.first_domain.id + aliases = toset(["one", "four", "three"]) + } + + module { + source = "./opslevel_modules/modules/aliases" + } + + assert { + condition = opslevel_alias.this.aliases == var.aliases + error_message = format( + "expected '%v' but got '%v'", + var.aliases, + opslevel_alias.this.aliases, + ) + } +} \ No newline at end of file diff --git a/tests/opslevel_modules b/tests/opslevel_modules index dc7d7b07..b09eb50a 160000 --- a/tests/opslevel_modules +++ b/tests/opslevel_modules @@ -1 +1 @@ -Subproject commit dc7d7b072428535c68cc76e194c8cb3274046065 +Subproject commit b09eb50a478f7ed0ed795464a81e12fc96652945