internal/provider/resource_gitlab_group_variable.go (345 lines of code) (raw):

package provider import ( "context" "fmt" "log/slog" "regexp" "strings" "github.com/hashicorp/terraform-plugin-framework-validators/boolvalidator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "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/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "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-log/tflog" gitlab "gitlab.com/gitlab-org/api/client-go" "gitlab.com/gitlab-org/terraform-provider-gitlab/internal/provider/api" "gitlab.com/gitlab-org/terraform-provider-gitlab/internal/provider/utils" ) // Ensure provider defined types fully satisfy framework interfaces var ( _ resource.Resource = &gitlabGroupVariableResource{} _ resource.ResourceWithConfigure = &gitlabGroupVariableResource{} _ resource.ResourceWithImportState = &gitlabGroupVariableResource{} _ resource.ResourceWithValidateConfig = &gitlabGroupVariableResource{} ) var ( gitlabVariableTypeValues = []string{"env_var", "file"} stringToVariableTypelookup = map[string]gitlab.VariableTypeValue{"env_var": gitlab.EnvVariableType, "file": gitlab.FileVariableType} regexpGitlabVariableName = regexp.MustCompile(`^[a-zA-Z0-9_]+$`) invalidMaskedValueSummary = "Invalid value for a masked variable. Check the masked variable requirements: https://docs.gitlab.com/ci/variables/#mask-a-cicd-variable" ) func init() { registerResource(NewGitlabGroupGroupVariableResource) } // NewGitlabGroupGroupVariableResource is a helper function to simplify the provider implementation. func NewGitlabGroupGroupVariableResource() resource.Resource { return &gitlabGroupVariableResource{} } // gitlabGroupVariableResource defines the resource implementation. type gitlabGroupVariableResource struct { client *gitlab.Client } func (r *gitlabGroupVariableResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_group_variable" } // Struct for the schema type gitlabGroupVariableResourceModel struct { ID types.String `tfsdk:"id"` Group types.String `tfsdk:"group"` Key types.String `tfsdk:"key"` Value types.String `tfsdk:"value"` VariableType types.String `tfsdk:"variable_type"` Protected types.Bool `tfsdk:"protected"` Masked types.Bool `tfsdk:"masked"` Hidden types.Bool `tfsdk:"hidden"` EnvironmentScope types.String `tfsdk:"environment_scope"` Raw types.Bool `tfsdk:"raw"` Description types.String `tfsdk:"description"` } func (r *gitlabGroupVariableResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: `The ` + "`gitlab_group_variable`" + ` resource allows creating a GitLab group level variables. **Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/api/group_level_variables/)`, Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, MarkdownDescription: "The ID of this Terraform resource. In the format of `<group>:<key>:<environment_scope>`.", PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, }, "group": schema.StringAttribute{ MarkdownDescription: "The name or id of the group.", PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, Required: true, }, "key": schema.StringAttribute{ MarkdownDescription: "The name of the variable.", PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, Required: true, Validators: []validator.String{ stringvalidator.LengthBetween(1, 255), stringvalidator.RegexMatches(regexpGitlabVariableName, "is an invalid, only A-Z, a-z, 0-9, and _ are allowed"), }, }, "value": schema.StringAttribute{ MarkdownDescription: "The value of the variable.", Required: true, }, "variable_type": schema.StringAttribute{ MarkdownDescription: fmt.Sprintf("The type of a variable. Valid values are: %s.", utils.RenderValueListForDocs(gitlabVariableTypeValues)), Optional: true, Computed: true, Validators: []validator.String{stringvalidator.OneOf(gitlabVariableTypeValues...)}, }, "protected": schema.BoolAttribute{ MarkdownDescription: "If set to `true`, the variable will be passed only to pipelines running on protected branches and tags.", Optional: true, Computed: true, }, "masked": schema.BoolAttribute{ MarkdownDescription: "If set to `true`, the value of the variable will be masked in job logs. The value must meet the [masking requirements](https://docs.gitlab.com/ci/variables/#mask-a-cicd-variable).", Optional: true, Computed: true, }, "hidden": schema.BoolAttribute{ MarkdownDescription: "If set to `true`, the value of the variable will be hidden in the CI/CD User Interface. The value must meet the [hidden requirements](https://docs.gitlab.com/ci/variables/#hide-a-cicd-variable).", PlanModifiers: []planmodifier.Bool{boolplanmodifier.RequiresReplace()}, Optional: true, Computed: true, Validators: []validator.Bool{ boolvalidator.AlsoRequires(path.MatchRoot("masked")), }, }, "environment_scope": schema.StringAttribute{ MarkdownDescription: "The environment scope of the variable. Defaults to all environment (`*`). Note that in Community Editions of Gitlab, values other than `*` will cause inconsistent plans.", Optional: true, Computed: true, Default: stringdefault.StaticString("*"), }, "raw": schema.BoolAttribute{ MarkdownDescription: "Whether the variable is treated as a raw string. When true, variables in the value are not expanded.", Optional: true, Computed: true, }, "description": schema.StringAttribute{ Description: "The description of the variable.", Optional: true, Computed: true, }, }, } } // Configure adds the provider configured client to the resource. func (r *gitlabGroupVariableResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { // Prevent panic if the provider has not been configured. if req.ProviderData == nil { return } resourceData := req.ProviderData.(*GitLabResourceData) r.client = resourceData.Client } func (d *gitlabGroupVariableResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { var data gitlabGroupVariableResourceModel resp.Diagnostics.Append(req.Config.Get(ctx, &data)...) if !data.Hidden.IsNull() && !data.Hidden.IsUnknown() && data.Hidden.ValueBool() { if data.Masked.IsNull() || data.Masked.IsUnknown() || !data.Masked.ValueBool() { resp.Diagnostics.AddAttributeError(path.Root("hidden"), `Invalid value for a masked variable`, `A variable cannot be hidden without being masked. Please set the masked attribute to true`) return } } } // Create implements resource.Resource. func (r *gitlabGroupVariableResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data *gitlabGroupVariableResourceModel // Read Terraform plan data into the model resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } group := data.Group.ValueString() key := data.Key.ValueString() options := gitlab.CreateGroupVariableOptions{ Key: gitlab.Ptr(data.Key.ValueString()), Value: gitlab.Ptr(data.Value.ValueString()), Masked: gitlab.Ptr(data.Masked.ValueBool()), Raw: gitlab.Ptr(data.Raw.ValueBool()), Description: gitlab.Ptr(data.Description.ValueString()), // Technically optional, but set here since it has a default. EnvironmentScope: gitlab.Ptr(data.EnvironmentScope.ValueString()), } if !data.Protected.IsNull() && !data.Protected.IsUnknown() { options.Protected = gitlab.Ptr(data.Protected.ValueBool()) } if !data.VariableType.IsNull() && !data.VariableType.IsUnknown() { variableType, ok := stringToVariableTypelookup[data.VariableType.ValueString()] if !ok { resp.Diagnostics.AddError("Invalid variable type", fmt.Sprintf("The variable type '%s' is invalid", data.VariableType.ValueString())) return } options.VariableType = &variableType } if !data.Masked.IsNull() && !data.Masked.IsUnknown() { options.Masked = gitlab.Ptr(data.Masked.ValueBool()) } if !data.Hidden.IsNull() && !data.Hidden.IsUnknown() && data.Hidden.ValueBool() { if data.Masked.IsNull() || data.Masked.IsUnknown() || !data.Masked.ValueBool() { resp.Diagnostics.AddError("Invalid value for a masked variable", "Error: this is not expected to happen") return } options.Masked = gitlab.Ptr(false) options.MaskedAndHidden = gitlab.Ptr(data.Hidden.ValueBool()) } tflog.Debug(ctx, fmt.Sprintf("[DEBUG] create gitlab group variable %s/%s", group, key)) variable, _, err := r.client.GroupVariables.CreateVariable(group, &options, gitlab.WithContext(ctx)) if err != nil { if notOk, err := utils.AugmentVariableClientError(ctx, true, err); notOk { resp.Diagnostics.AddError(invalidMaskedValueSummary, err.Error()) return } resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to create group variable: %s", err.Error())) return } keyScope := fmt.Sprintf("%s:%s", key, data.EnvironmentScope.ValueString()) data.ID = types.StringValue(utils.BuildTwoPartID(&group, &keyScope)) data.groupVariableToStateModel(variable, group) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } // Read implements resource.Resource. func (r *gitlabGroupVariableResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data *gitlabGroupVariableResourceModel // Read Terraform prior state data into the model resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } group, key, err := utils.ParseTwoPartID(data.ID.ValueString()) if err != nil { resp.Diagnostics.AddError("Invalid resource ID format", fmt.Sprintf("The resource ID '%s' has an invalid format in Read. It should be '<group>:<key>:<environment_scope>'. Error: %s", data.ID.ValueString(), err.Error())) return } keyScope := strings.SplitN(key, ":", 2) scope := "*" if len(keyScope) == 2 { key = keyScope[0] scope = keyScope[1] } tflog.Debug(ctx, fmt.Sprintf("[DEBUG] read gitlab group variable %s/%s/%s", group, key, scope)) variable, _, err := r.client.GroupVariables.GetVariable( group, key, nil, gitlab.WithContext(ctx), utils.WithEnvironmentScopeFilter(ctx, scope), ) if err != nil { if api.Is404(err) { tflog.Debug(ctx, fmt.Sprintf("[DEBUG] gitlab group variable not found %s/%s, removing from state", group, key)) resp.State.RemoveResource(ctx) return } if notOk, err := utils.AugmentVariableClientError(ctx, true, err); notOk { resp.Diagnostics.AddError(invalidMaskedValueSummary, err.Error()) return } resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to read group variable: %s", err.Error())) return } data.groupVariableToStateModel(variable, group) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } // Update implements resource.Resource. func (r *gitlabGroupVariableResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data *gitlabGroupVariableResourceModel // Read Terraform plan data into the model resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } group := data.Group.ValueString() key := data.Key.ValueString() options := &gitlab.UpdateGroupVariableOptions{ Value: gitlab.Ptr(data.Value.ValueString()), Masked: gitlab.Ptr(data.Masked.ValueBool()), Raw: gitlab.Ptr(data.Raw.ValueBool()), Description: gitlab.Ptr(data.Description.ValueString()), // Technically optional, but set here since it has a default. EnvironmentScope: gitlab.Ptr(data.EnvironmentScope.ValueString()), } if !data.Protected.IsNull() && !data.Protected.IsUnknown() { options.Protected = gitlab.Ptr(data.Protected.ValueBool()) } if !data.VariableType.IsNull() && !data.VariableType.IsUnknown() { variableType, ok := stringToVariableTypelookup[data.VariableType.ValueString()] if !ok { resp.Diagnostics.AddError("Invalid variable type", fmt.Sprintf("The variable type '%s' is invalid", data.VariableType.ValueString())) return } options.VariableType = &variableType } if !data.Masked.IsNull() && !data.Masked.IsUnknown() { options.Masked = gitlab.Ptr(data.Masked.ValueBool()) } tflog.Debug(ctx, fmt.Sprintf("[DEBUG] update gitlab group variable %s/%s", group, key)) variable, _, err := r.client.GroupVariables.UpdateVariable( group, key, options, gitlab.WithContext(ctx), utils.WithEnvironmentScopeFilter(ctx, data.EnvironmentScope.ValueString()), ) if err != nil { if notOk, err := utils.AugmentVariableClientError(ctx, true, err); notOk { resp.Diagnostics.AddError(invalidMaskedValueSummary, err.Error()) return } resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to update group variable: %s", err.Error())) return } data.groupVariableToStateModel(variable, group) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } // Delete implements resource.Resource. func (r *gitlabGroupVariableResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data *gitlabGroupVariableResourceModel // Read Terraform prior state data into the model resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } group := data.Group.ValueString() key := data.Key.ValueString() environmentScope := data.EnvironmentScope.ValueString() tflog.Debug(ctx, fmt.Sprintf("[DEBUG] Delete gitlab group variable %s/%s/%s", group, key, environmentScope)) _, err := r.client.GroupVariables.RemoveVariable( group, key, &gitlab.RemoveGroupVariableOptions{ Filter: &gitlab.VariableFilter{ EnvironmentScope: environmentScope, }, }, gitlab.WithContext(ctx), ) if err != nil { if api.Is404(err) { slog.Debug("The variable was not found, assuming deleted. If the access token doesn't have permissions to view the resource, re-importing will be required to delete the resource.") return } if notOk, err := utils.AugmentVariableClientError(ctx, true, err); notOk { resp.Diagnostics.AddError(invalidMaskedValueSummary, err.Error()) return } resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to delete group variable: %s", err.Error())) return } } // ImportState imports the resource into the Terraform state. func (r *gitlabGroupVariableResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) } func (m *gitlabGroupVariableResourceModel) groupVariableToStateModel(variable *gitlab.GroupVariable, group string) { // attributes from api response keyScope := fmt.Sprintf("%s:%s", variable.Key, variable.EnvironmentScope) m.ID = types.StringValue(utils.BuildTwoPartID(&group, &keyScope)) m.Group = types.StringValue(group) m.Key = types.StringValue(variable.Key) if !variable.Hidden { // API response for hidden group variables is always null // this condition allows the value to be maintained in terraform state m.Value = types.StringValue(variable.Value) } m.VariableType = types.StringValue(string(variable.VariableType)) m.Protected = types.BoolValue(variable.Protected) m.Masked = types.BoolValue(variable.Masked) m.Hidden = types.BoolValue(variable.Hidden) m.EnvironmentScope = types.StringValue(variable.EnvironmentScope) m.Raw = types.BoolValue(variable.Raw) m.Description = types.StringValue(variable.Description) }