internal/provider/resource_gitlab_project_variable.go (368 lines of code) (raw):

package provider import ( "context" "fmt" "log/slog" "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" ) var ( _ resource.Resource = &gitlabProjectVariableResource{} _ resource.ResourceWithConfigure = &gitlabProjectVariableResource{} _ resource.ResourceWithImportState = &gitlabProjectVariableResource{} _ resource.ResourceWithUpgradeState = &gitlabProjectVariableResource{} _ resource.ResourceWithValidateConfig = &gitlabProjectVariableResource{} ) func init() { registerResource(NewGitlabProjectVariableResource) } func NewGitlabProjectVariableResource() resource.Resource { return &gitlabProjectVariableResource{} } type gitlabProjectVariableResource struct { client *gitlab.Client } func (r *gitlabProjectVariableResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_project_variable" } type gitlabProjectVariableResourceModel struct { ID types.String `tfsdk:"id"` Project types.String `tfsdk:"project"` 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 *gitlabProjectVariableResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { schema := *r.getProjectVariableSchema() schema.Version = 1 resp.Schema = schema } // Configure adds the provider configured client to the resource. func (r *gitlabProjectVariableResource) 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 *gitlabProjectVariableResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) { var data gitlabProjectVariableResourceModel 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 *gitlabProjectVariableResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data *gitlabProjectVariableResourceModel // Read Terraform plan data into the model resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } project := data.Project.ValueString() key := data.Key.ValueString() options := gitlab.CreateProjectVariableOptions{ 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 configuration", "A variable cannot be hidden without being masked. Please set the masked attribute to true when setting hidden to true.") return } options.Masked = gitlab.Ptr(false) options.MaskedAndHidden = gitlab.Ptr(data.Hidden.ValueBool()) } tflog.Debug(ctx, fmt.Sprintf("[DEBUG] create gitlab project variable %s/%s", project, key)) variable, _, err := r.client.ProjectVariables.CreateVariable(project, &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 project variable: %s", err.Error())) return } keyScope := fmt.Sprintf("%s:%s", key, data.EnvironmentScope.ValueString()) data.ID = types.StringValue(utils.BuildTwoPartID(&project, &keyScope)) data.projectVariableToStateModel(variable, project) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } // Read implements resource.Resource. func (r *gitlabProjectVariableResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data *gitlabProjectVariableResourceModel // Read Terraform prior state data into the model resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } project, 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 '<project>:<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 project variable %s/%s/%s", project, key, scope)) variable, _, err := r.client.ProjectVariables.GetVariable( project, key, nil, gitlab.WithContext(ctx), utils.WithEnvironmentScopeFilter(ctx, scope), ) if err != nil { if api.Is404(err) { tflog.Debug(ctx, fmt.Sprintf("[DEBUG] gitlab project variable not found %s/%s, removing from state", project, 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 project variable: %s", err.Error())) return } data.projectVariableToStateModel(variable, project) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } // Update implements resource.Resource. func (r *gitlabProjectVariableResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data *gitlabProjectVariableResourceModel // Read Terraform plan data into the model resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } project := data.Project.ValueString() key := data.Key.ValueString() options := &gitlab.UpdateProjectVariableOptions{ 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 project variable %s/%s", project, key)) variable, _, err := r.client.ProjectVariables.UpdateVariable( project, 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, fmt.Sprintf("%s:%v", invalidMaskedValueSummary, err.Error())) return } resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to update project variable: %s", err.Error())) return } data.projectVariableToStateModel(variable, project) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } // Delete implements resource.Resource. func (r *gitlabProjectVariableResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data *gitlabProjectVariableResourceModel // Read Terraform prior state data into the model resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } project := data.Project.ValueString() key := data.Key.ValueString() environmentScope := data.EnvironmentScope.ValueString() tflog.Debug(ctx, fmt.Sprintf("[DEBUG] Delete gitlab project variable %s/%s/%s", project, key, environmentScope)) _, err := r.client.ProjectVariables.RemoveVariable( project, key, &gitlab.RemoveProjectVariableOptions{ Filter: &gitlab.VariableFilter{ EnvironmentScope: environmentScope, // This will always be populated because we use a default }, }, 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 project variable: %s", err.Error())) return } } // ImportState imports the resource into the Terraform state. func (r *gitlabProjectVariableResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) } func (r *gitlabProjectVariableResource) UpgradeState(context.Context) map[int64]resource.StateUpgrader { return map[int64]resource.StateUpgrader{ 0: { PriorSchema: r.getProjectVariableSchema(), StateUpgrader: func(ctx context.Context, req resource.UpgradeStateRequest, resp *resource.UpgradeStateResponse) { var data *gitlabProjectVariableResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } // Update to add `environment_scope` if only 2 parts of the ID are present parts := strings.Split(data.ID.ValueString(), ":") // We only need to migrate the ID if the length is 2. If the length is 3, the current logic handles it appropriately. if len(parts) == 2 { project := parts[0] key := parts[1] environmentScope := data.EnvironmentScope // Build new ID with environment_scope newID := fmt.Sprintf("%s:%s:%s", project, key, environmentScope) data.ID = types.StringValue(newID) } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) }, }, } } func (m *gitlabProjectVariableResourceModel) projectVariableToStateModel(variable *gitlab.ProjectVariable, project string) { // attributes from api response keyScope := fmt.Sprintf("%s:%s", variable.Key, variable.EnvironmentScope) m.ID = types.StringValue(utils.BuildTwoPartID(&project, &keyScope)) m.Project = types.StringValue(project) m.Key = types.StringValue(variable.Key) 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) if !variable.Hidden { // API response for hidden project variables is always null // this condition allows the value to be maintained in terraform state m.Value = types.StringValue(variable.Value) } } func (r *gitlabProjectVariableResource) getProjectVariableSchema() *schema.Schema { return &schema.Schema{ MarkdownDescription: `The ` + "`gitlab_project_variable`" + ` resource allows creating and managing a GitLab project level variable. **Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/api/project_level_variables/)`, Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, MarkdownDescription: "The ID of this Terraform resource. In the format of `<project>:<key>:<environment_scope>`.", PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()}, }, "project": schema.StringAttribute{ MarkdownDescription: "The name or id of the project.", 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/ee/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.", PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, 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, }, }, } }