internal/provider/resource_gitlab_release.go (582 lines of code) (raw):

package provider import ( "context" "fmt" "time" "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-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 = &gitlabReleaseResource{} _ resource.ResourceWithConfigure = &gitlabReleaseResource{} _ resource.ResourceWithImportState = &gitlabReleaseResource{} ) func init() { registerResource(NewGitLabReleaseResource) } // NewGitLabReleaseResource is a helper function to simplify the provider implementation. func NewGitLabReleaseResource() resource.Resource { return &gitlabReleaseResource{} } func (r *gitlabReleaseResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_release" } // gitlabReleaseResource defines the resource implementation. type gitlabReleaseResource struct { client *gitlab.Client } // gitlabReleaseResourceModel describes the resource data model type gitlabReleaseResourceModel struct { ID types.String `tfsdk:"id"` Project types.String `tfsdk:"project"` Name types.String `tfsdk:"name"` TagName types.String `tfsdk:"tag_name"` TagMessage types.String `tfsdk:"tag_message"` TagPath types.String `tfsdk:"tag_path"` Description types.String `tfsdk:"description"` DescriptionHTML types.String `tfsdk:"description_html"` Ref types.String `tfsdk:"ref"` Milestones types.Set `tfsdk:"milestones"` CreatedAt types.String `tfsdk:"created_at"` ReleasedAt types.String `tfsdk:"released_at"` Author types.Object `tfsdk:"author"` Commit types.Object `tfsdk:"commit"` UpcomingRelease types.Bool `tfsdk:"upcoming_release"` CommitPath types.String `tfsdk:"commit_path"` Assets types.Object `tfsdk:"assets"` Links types.Object `tfsdk:"links"` } type gitlabReleaseAuthor struct { ID types.Int64 `tfsdk:"id"` Name types.String `tfsdk:"name"` Username types.String `tfsdk:"username"` State types.String `tfsdk:"state"` AvatarURL types.String `tfsdk:"avatar_url"` WebURL types.String `tfsdk:"web_url"` } type gitlabReleaseCommit struct { ID types.String `tfsdk:"id"` ShortID types.String `tfsdk:"short_id"` Title types.String `tfsdk:"title"` CreatedAt types.String `tfsdk:"created_at"` ParentIDs types.Set `tfsdk:"parent_ids"` Message types.String `tfsdk:"message"` AuthorName types.String `tfsdk:"author_name"` AuthorEmail types.String `tfsdk:"author_email"` AuthoredDate types.String `tfsdk:"authored_date"` CommitterName types.String `tfsdk:"committer_name"` CommitterEmail types.String `tfsdk:"committer_email"` CommittedDate types.String `tfsdk:"committed_date"` } type gitlabReleaseAsset struct { Count types.Int64 `tfsdk:"count"` } type gitlabReleaseLinks struct { ClosedIssuesURL types.String `tfsdk:"closed_issues_url"` ClosedMergeRequestsURL types.String `tfsdk:"closed_merge_requests_url"` EditURL types.String `tfsdk:"edit_url"` MergedMergeRequestsURL types.String `tfsdk:"merged_merge_requests_url"` OpenedIssuesURL types.String `tfsdk:"opened_issues_url"` OpenedMergeRequestsURL types.String `tfsdk:"opened_merge_requests_url"` Self types.String `tfsdk:"self"` } func (r *gitlabReleaseResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: fmt.Sprintf(`The ` + "`gitlab_release`" + ` resource allows to manage the lifecycle of releases in gitlab. **Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/api/releases/)`), Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "The ID of this Terraform resource. In the format of `<project_id:tag_name>`.", Computed: true, }, "project": schema.StringAttribute{ MarkdownDescription: "The ID or full path of the project.", Required: true, PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, }, "name": schema.StringAttribute{ MarkdownDescription: "The name of the release.", Optional: true, Computed: true, }, "tag_name": schema.StringAttribute{ MarkdownDescription: "The tag where the release is created from.", Required: true, PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()}, Validators: []validator.String{stringvalidator.LengthAtLeast(1)}, }, "tag_message": schema.StringAttribute{ MarkdownDescription: "Message to use if creating a new annotated tag.", Optional: true, }, "tag_path": schema.StringAttribute{ MarkdownDescription: "The path to the tag.", Computed: true, }, "description": schema.StringAttribute{ MarkdownDescription: "The description of the release. You can use Markdown.", Optional: true, Computed: true, }, "description_html": schema.StringAttribute{ MarkdownDescription: "HTML rendered Markdown of the release description.", Computed: true, }, "ref": schema.StringAttribute{ MarkdownDescription: "If a tag specified in tag_name doesn't exist, the release is created from ref and tagged with tag_name. It can be a commit SHA, another tag name, or a branch name.", Optional: true, Computed: true, }, "milestones": schema.SetAttribute{ MarkdownDescription: "The title of each milestone the release is associated with. GitLab Premium customers can specify group milestones.", Optional: true, ElementType: types.StringType, }, "created_at": schema.StringAttribute{ MarkdownDescription: "Date and time the release was created. In ISO 8601 format (2019-03-15T08:00:00Z).", Computed: true, }, "released_at": schema.StringAttribute{ MarkdownDescription: "Date and time for the release. Defaults to the current time. Expected in ISO 8601 format (2019-03-15T08:00:00Z). Only provide this field if creating an upcoming or historical release.", Optional: true, Computed: true, }, "author": schema.SingleNestedAttribute{ MarkdownDescription: "The author of the release.", Computed: true, Attributes: map[string]schema.Attribute{ "id": schema.Int64Attribute{ MarkdownDescription: "The ID of the author's user.", Computed: true, }, "name": schema.StringAttribute{ MarkdownDescription: "The name of the author.", Computed: true, }, "username": schema.StringAttribute{ MarkdownDescription: "The username of the author.", Computed: true, }, "state": schema.StringAttribute{ MarkdownDescription: "The state of the author's user.", Computed: true, }, "avatar_url": schema.StringAttribute{ MarkdownDescription: "The url of the author's' user avatar.", Computed: true, }, "web_url": schema.StringAttribute{ MarkdownDescription: "The url to the author's user profile.", Computed: true, }, }, }, "commit": schema.SingleNestedAttribute{ MarkdownDescription: "The release commit.", Computed: true, Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "The git commit full SHA", Computed: true, }, "short_id": schema.StringAttribute{ MarkdownDescription: "The git commit short SHA.", Computed: true, }, "title": schema.StringAttribute{ MarkdownDescription: "The title of the commit.", Computed: true, }, "created_at": schema.StringAttribute{ MarkdownDescription: "The date and time the commit was created. In ISO 8601 format (2019-03-15T08:00:00Z).", Computed: true, }, "parent_ids": schema.SetAttribute{ MarkdownDescription: "The full SHA of any parent commits.", Computed: true, ElementType: types.StringType, }, "message": schema.StringAttribute{ MarkdownDescription: "The commit message.", Computed: true, }, "author_name": schema.StringAttribute{ MarkdownDescription: "The name of the commit author.", Computed: true, }, "author_email": schema.StringAttribute{ MarkdownDescription: "The email address of the commit author.", Computed: true, }, "authored_date": schema.StringAttribute{ MarkdownDescription: "The date and time the commit was authored. In ISO 8601 format (2019-03-15T08:00:00Z).", Computed: true, }, "committer_name": schema.StringAttribute{ MarkdownDescription: "The name of the committer.", Computed: true, }, "committer_email": schema.StringAttribute{ MarkdownDescription: "The email address of the committer.", Computed: true, }, "committed_date": schema.StringAttribute{ MarkdownDescription: "The date and time the commit was made. In ISO 8601 format (2019-03-15T08:00:00Z).", Computed: true, }, }, }, "upcoming_release": schema.BoolAttribute{ MarkdownDescription: "Whether the release_at attribute is set to a future date.", Computed: true, }, "commit_path": schema.StringAttribute{ MarkdownDescription: "The path to the commit", Computed: true, }, "assets": schema.SingleNestedAttribute{ MarkdownDescription: "The release assets.", Optional: true, Computed: true, Attributes: map[string]schema.Attribute{ "count": schema.Int64Attribute{ MarkdownDescription: "The total count of assets in this release.", Computed: true, }, }, }, "links": schema.SingleNestedAttribute{ MarkdownDescription: "Links of the release", Computed: true, Attributes: map[string]schema.Attribute{ "closed_issues_url": schema.StringAttribute{ MarkdownDescription: "URL of the release's closed issues.", Computed: true, }, "closed_merge_requests_url": schema.StringAttribute{ MarkdownDescription: "URL of the release's closed merge requests.", Computed: true, }, "edit_url": schema.StringAttribute{ MarkdownDescription: "URL of the release's edit page.", Computed: true, }, "merged_merge_requests_url": schema.StringAttribute{ MarkdownDescription: "URL of the release's merged merge requests.", Computed: true, }, "opened_issues_url": schema.StringAttribute{ MarkdownDescription: "URL of the release's open issues.", Computed: true, }, "opened_merge_requests_url": schema.StringAttribute{ MarkdownDescription: "URL of the release's open merge requests.", Computed: true, }, "self": schema.StringAttribute{ MarkdownDescription: "URL of the release.", Computed: true, }, }, }, }, } } // Configure adds the provider configured client to the resource. func (r *gitlabReleaseResource) 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 } // Create creates a new upstream resource and adds it into the Terraform state. func (r *gitlabReleaseResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data gitlabReleaseResourceModel // Read Terraform plan data into the model resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } projectID := data.Project.ValueString() options := &gitlab.CreateReleaseOptions{ TagName: gitlab.Ptr(data.TagName.ValueString()), } if !data.Name.IsNull() && !data.Name.IsUnknown() { options.Name = data.Name.ValueStringPointer() } if !data.TagMessage.IsNull() && !data.TagMessage.IsUnknown() { options.TagMessage = data.TagMessage.ValueStringPointer() } if !data.Description.IsNull() && !data.Description.IsUnknown() { options.Description = data.Description.ValueStringPointer() } if !data.Ref.IsNull() && !data.Ref.IsUnknown() { options.Ref = data.Ref.ValueStringPointer() } if !data.ReleasedAt.IsNull() && len(data.ReleasedAt.ValueString()) > 0 { releasedAt := data.ReleasedAt.ValueString() releasedAtTime, err := time.Parse(api.Iso8601, releasedAt) if err != nil { resp.Diagnostics.AddError( "Error parsing released at date", fmt.Sprintf("Could not parse released at date %q: %s", releasedAt, err), ) return } options.ReleasedAt = &releasedAtTime } milestones := make([]string, 0, len(data.Milestones.Elements())) for _, milestone := range data.Milestones.Elements() { milestones = append(milestones, milestone.String()) } options.Milestones = &milestones release, _, err := r.client.Releases.CreateRelease(projectID, options, gitlab.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to create a release: %s", err.Error())) return } data.ID = types.StringValue(utils.BuildTwoPartID(&projectID, data.TagName.ValueStringPointer())) resp.Diagnostics.Append(data.releaseModelToState(release, ctx)...) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) // Log the creation of the resource tflog.Debug(ctx, "created a release", map[string]any{ "name": data.Name.ValueString(), "id": data.ID.ValueString(), }) } func (r *gitlabReleaseResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data gitlabReleaseResourceModel // Read Terraform plan data into the model resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } projectID, tagName, err := utils.ParseTwoPartID(data.ID.ValueString()) if err != nil { resp.Diagnostics.AddError( "Invalid resource ID format Read", fmt.Sprintf("The resource ID '%s' has an invalid format. It should be '<project-id>:<tag-name>'. Error: %s", data.ID.ValueString(), err.Error()), ) return } release, _, err := r.client.Releases.GetRelease(projectID, tagName, gitlab.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to get a release: %s", err.Error())) return } data.Project = types.StringValue(projectID) data.TagName = types.StringValue(tagName) resp.Diagnostics.Append(data.releaseModelToState(release, ctx)...) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *gitlabReleaseResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data gitlabReleaseResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } projectID := data.Project.ValueString() tagName := data.TagName.ValueString() options := &gitlab.UpdateReleaseOptions{} if !data.Name.IsNull() && !data.Name.IsUnknown() { options.Name = gitlab.Ptr(data.Name.ValueString()) } if !data.Description.IsNull() && !data.Description.IsUnknown() { options.Description = gitlab.Ptr(data.Description.ValueString()) } if !data.Milestones.IsNull() && !data.Milestones.IsUnknown() { var milestones []string data.Milestones.ElementsAs(ctx, &milestones, true) options.Milestones = &milestones } if !data.ReleasedAt.IsNull() && !data.ReleasedAt.IsUnknown() { releasedAt, err := time.Parse(time.RFC3339, data.ReleasedAt.ValueString()) if err != nil { resp.Diagnostics.AddError("Invalid released_at date format", fmt.Sprintf("Error parsing released_at value to RFC3339 format: %v", err)) return } options.ReleasedAt = gitlab.Ptr(releasedAt) } release, _, err := r.client.Releases.UpdateRelease(projectID, tagName, options, gitlab.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to update a release: %s", err.Error())) return } data.ID = types.StringValue(utils.BuildTwoPartID(&projectID, &tagName)) resp.Diagnostics.Append(data.releaseModelToState(release, ctx)...) resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *gitlabReleaseResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data gitlabReleaseResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } projectID, tagName, err := utils.ParseTwoPartID(data.ID.ValueString()) if err != nil { resp.Diagnostics.AddError( "Invalid resource ID format Delete", fmt.Sprintf("The resource ID '%s' has an invalid format. It should be '<project-id>:<tag-name>'. Error: %s", data.ID.ValueString(), err.Error()), ) return } _, _, err = r.client.Releases.DeleteRelease(projectID, tagName, gitlab.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError("GitLab API error occurred", fmt.Sprintf("Unable to delete a release: %s", err.Error())) return } } func (r *gitlabReleaseResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) } func (r *gitlabReleaseResourceModel) releaseModelToState(release *gitlab.Release, ctx context.Context) diag.Diagnostics { r.Name = types.StringValue(release.Name) r.TagName = types.StringValue(release.TagName) r.TagPath = types.StringValue(release.TagPath) r.Description = types.StringValue(release.Description) r.DescriptionHTML = types.StringValue(release.DescriptionHTML) r.CreatedAt = types.StringValue(release.CreatedAt.Format(time.RFC3339)) r.ReleasedAt = types.StringValue(release.ReleasedAt.Format(time.RFC3339)) author, diag := (&gitlabReleaseAuthor{ ID: types.Int64Value(int64(release.Author.ID)), Name: types.StringValue(release.Author.Name), Username: types.StringValue(release.Author.Username), State: types.StringValue(release.Author.State), AvatarURL: types.StringValue(release.Author.AvatarURL), WebURL: types.StringValue(release.Author.WebURL), }).toObjectType() r.Author = author if diag != nil { return diag } parentIDs, diag := types.SetValueFrom(ctx, types.StringType, release.Commit.ParentIDs) if diag != nil { return diag } commit, diag := (&gitlabReleaseCommit{ ID: types.StringValue(release.Commit.ID), ShortID: types.StringValue(release.Commit.ShortID), Title: types.StringValue(release.Commit.Title), CreatedAt: types.StringValue(release.Commit.CreatedAt.Format(time.RFC3339)), ParentIDs: parentIDs, AuthorName: types.StringValue(release.Commit.AuthorName), AuthorEmail: types.StringValue(release.Commit.AuthorEmail), AuthoredDate: types.StringValue(release.Commit.AuthoredDate.Format(time.RFC3339)), CommitterName: types.StringValue(release.Commit.CommitterName), CommitterEmail: types.StringValue(release.Commit.CommitterEmail), CommittedDate: types.StringValue(release.Commit.CommittedDate.Format(time.RFC3339)), Message: types.StringValue(release.Commit.Message), }).toObjectType() if diag != nil { return diag } r.Commit = commit r.UpcomingRelease = types.BoolValue(release.UpcomingRelease) r.CommitPath = types.StringValue(release.CommitPath) assets, diag := (&gitlabReleaseAsset{ Count: types.Int64Value(int64(release.Assets.Count)), }).toObjectType() if diag != nil { return diag } r.Assets = assets links, diag := (&gitlabReleaseLinks{ ClosedIssuesURL: types.StringValue(release.Links.ClosedIssueURL), ClosedMergeRequestsURL: types.StringValue(release.Links.ClosedMergeRequest), EditURL: types.StringValue(release.Links.EditURL), MergedMergeRequestsURL: types.StringValue(release.Links.MergedMergeRequest), OpenedIssuesURL: types.StringValue(release.Links.OpenedIssues), OpenedMergeRequestsURL: types.StringValue(release.Links.OpenedMergeRequest), Self: types.StringValue(release.Links.Self), }).toObjectType() if diag != nil { return diag } r.Links = links return nil } // toObjectType converts the model struct into a `types.Object` type, making it support // types.Unknown and types.Null values. This appears to be required since every // attribute in the model object is Computed, causing initial computation of the state // to mark this as `Unknown`, causing errors if we use the native model object. func (author *gitlabReleaseAuthor) toObjectType() (types.Object, diag.Diagnostics) { attributeTypes := map[string]attr.Type{ "id": types.Int64Type, "name": types.StringType, "username": types.StringType, "state": types.StringType, "avatar_url": types.StringType, "web_url": types.StringType, } attributes := map[string]attr.Value{ "id": author.ID, "name": author.Name, "username": author.Username, "state": author.State, "avatar_url": author.AvatarURL, "web_url": author.WebURL, } return types.ObjectValue(attributeTypes, attributes) } // toObjectType converts the model struct into a `types.Object` type, making it support // types.Unknown and types.Null values. This appears to be required since every // attribute in the model object is Computed, causing initial computation of the state // to mark this as `Unknown`, causing errors if we use the native model object. func (commit *gitlabReleaseCommit) toObjectType() (types.Object, diag.Diagnostics) { attributeTypes := map[string]attr.Type{ "id": types.StringType, "short_id": types.StringType, "title": types.StringType, "created_at": types.StringType, "parent_ids": types.SetType{ElemType: types.StringType}, "message": types.StringType, "author_name": types.StringType, "author_email": types.StringType, "authored_date": types.StringType, "committer_name": types.StringType, "committer_email": types.StringType, "committed_date": types.StringType, } attributes := map[string]attr.Value{ "id": commit.ID, "short_id": commit.ShortID, "title": commit.Title, "created_at": commit.CreatedAt, "parent_ids": commit.ParentIDs, "message": commit.Message, "author_name": commit.AuthorName, "author_email": commit.AuthorEmail, "authored_date": commit.AuthoredDate, "committer_name": commit.CommitterName, "committer_email": commit.CommitterEmail, "committed_date": commit.CommittedDate, } return types.ObjectValue(attributeTypes, attributes) } // toObjectType converts the model struct into a `types.Object` type, making it support // types.Unknown and types.Null values. This appears to be required since every // attribute in the model object is Computed, causing initial computation of the state // to mark this as `Unknown`, causing errors if we use the native model object. func (asset *gitlabReleaseAsset) toObjectType() (types.Object, diag.Diagnostics) { attributeTypes := map[string]attr.Type{ "count": types.Int64Type, } attributes := map[string]attr.Value{ "count": asset.Count, } return types.ObjectValue(attributeTypes, attributes) } // toObjectType converts the model struct into a `types.Object` type, making it support // types.Unknown and types.Null values. This appears to be required since every // attribute in the model object is Computed, causing initial computation of the state // to mark this as `Unknown`, causing errors if we use the native model object. func (links *gitlabReleaseLinks) toObjectType() (types.Object, diag.Diagnostics) { attributeTypes := map[string]attr.Type{ "closed_issues_url": types.StringType, "closed_merge_requests_url": types.StringType, "edit_url": types.StringType, "merged_merge_requests_url": types.StringType, "opened_issues_url": types.StringType, "opened_merge_requests_url": types.StringType, "self": types.StringType, } attributes := map[string]attr.Value{ "closed_issues_url": links.ClosedIssuesURL, "closed_merge_requests_url": links.ClosedMergeRequestsURL, "edit_url": links.EditURL, "merged_merge_requests_url": links.MergedMergeRequestsURL, "opened_issues_url": links.OpenedIssuesURL, "opened_merge_requests_url": links.OpenedMergeRequestsURL, "self": links.Self, } return types.ObjectValue(attributeTypes, attributes) }