internal/provider/resource_gitlab_user_runner.go (401 lines of code) (raw):
package provider
import (
"context"
"fmt"
"strconv"
"github.com/hashicorp/terraform-plugin-framework-validators/int64validator"
"github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
"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/boolplanmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/int64planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier"
"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 = &gitlabUserRunnerResource{}
_ resource.ResourceWithConfigure = &gitlabUserRunnerResource{}
_ resource.ResourceWithImportState = &gitlabUserRunnerResource{}
_ resource.ResourceWithValidateConfig = &gitlabUserRunnerResource{}
_ resource.ResourceWithModifyPlan = &gitlabUserRunnerResource{}
)
func init() {
registerResource(NewGitLabUserRunnerResource)
}
func NewGitLabUserRunnerResource() resource.Resource {
return &gitlabUserRunnerResource{}
}
type gitlabUserRunnerResource struct {
client *gitlab.Client
}
// Metadata returns the resource name
func (d *gitlabUserRunnerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
resp.TypeName = req.ProviderTypeName + "_user_runner"
}
// Struct for the schema
type gitlabUserRunnerModel struct {
ID types.String `tfsdk:"id"`
RunnerType types.String `tfsdk:"runner_type"`
GroupID types.Int64 `tfsdk:"group_id"`
ProjectID types.Int64 `tfsdk:"project_id"`
Description types.String `tfsdk:"description"`
Paused types.Bool `tfsdk:"paused"`
Locked types.Bool `tfsdk:"locked"`
Untagged types.Bool `tfsdk:"untagged"`
TagList types.Set `tfsdk:"tag_list"`
AccessLevel types.String `tfsdk:"access_level"`
MaximumTimeout types.Int64 `tfsdk:"maximum_timeout"`
Token types.String `tfsdk:"token"`
MaintenanceNote types.String `tfsdk:"maintenance_note"`
}
func (d *gitlabUserRunnerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
// Valid values for the schema:
var validRunnerTypes = []string{"instance_type", "group_type", "project_type"}
var validAccessLevels = []string{"not_protected", "ref_protected"}
resp.Schema = schema.Schema{
MarkdownDescription: `The ` + "`gitlab_user_runner`" + ` resource allows creating a GitLab runner using the new [GitLab Runner Registration Flow](https://docs.gitlab.com/ci/runners/new_creation_workflow/).
**Upstream API**: [GitLab REST API docs](https://docs.gitlab.com/api/users/#create-a-runner)`,
Attributes: map[string]schema.Attribute{
"id": schema.StringAttribute{
Computed: true,
MarkdownDescription: "The ID of the gitlab runner.",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"runner_type": schema.StringAttribute{
Required: true,
MarkdownDescription: fmt.Sprintf("The scope of the runner. Valid values are: %s.", utils.RenderValueListForDocs(validRunnerTypes)),
Validators: []validator.String{
stringvalidator.OneOfCaseInsensitive(validRunnerTypes...),
},
PlanModifiers: []planmodifier.String{stringplanmodifier.RequiresReplace()},
},
"group_id": schema.Int64Attribute{
Optional: true,
MarkdownDescription: "The ID of the group that the runner is created in. Required if runner_type is group_type.",
PlanModifiers: []planmodifier.Int64{int64planmodifier.RequiresReplace(), int64planmodifier.UseStateForUnknown()},
},
"project_id": schema.Int64Attribute{
Optional: true,
MarkdownDescription: "The ID of the project that the runner is created in. Required if runner_type is project_type.",
PlanModifiers: []planmodifier.Int64{int64planmodifier.RequiresReplace(), int64planmodifier.UseStateForUnknown()},
},
"description": schema.StringAttribute{
Optional: true,
Computed: true,
MarkdownDescription: "Description of the runner.",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"paused": schema.BoolAttribute{
Optional: true,
Computed: true,
MarkdownDescription: "Specifies if the runner should ignore new jobs.",
PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()},
},
"locked": schema.BoolAttribute{
Optional: true,
Computed: true,
MarkdownDescription: "Specifies if the runner should be locked for the current project.",
PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()},
},
"untagged": schema.BoolAttribute{
Optional: true,
Computed: true,
MarkdownDescription: "Specifies if the runner should handle untagged jobs.",
PlanModifiers: []planmodifier.Bool{boolplanmodifier.UseStateForUnknown()},
},
"tag_list": schema.SetAttribute{
ElementType: types.StringType,
Optional: true,
Computed: true,
MarkdownDescription: "A list of runner tags.",
PlanModifiers: []planmodifier.Set{setplanmodifier.UseStateForUnknown()},
},
"access_level": schema.StringAttribute{
Optional: true,
Computed: true,
MarkdownDescription: fmt.Sprintf("The access level of the runner. Valid values are: %s.", utils.RenderValueListForDocs(validAccessLevels)),
Validators: []validator.String{
stringvalidator.OneOfCaseInsensitive(validAccessLevels...),
},
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"maximum_timeout": schema.Int64Attribute{
Optional: true,
Computed: true,
MarkdownDescription: "Maximum timeout that limits the amount of time (in seconds) that runners can run jobs. Must be at least 600 (10 minutes).",
Validators: []validator.Int64{
int64validator.AtLeast(600), // api enforced {maximum_timeout: [needs to be at least 10 minutes]}}
},
PlanModifiers: []planmodifier.Int64{int64planmodifier.UseStateForUnknown()},
},
"token": schema.StringAttribute{
Computed: true,
Sensitive: true,
MarkdownDescription: "The authentication token to use when setting up a new runner with this configuration. This value cannot be imported.",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
},
"maintenance_note": schema.StringAttribute{
Optional: true,
Computed: true,
MarkdownDescription: "Free-form maintenance notes for the runner (1024 characters) ",
PlanModifiers: []planmodifier.String{stringplanmodifier.UseStateForUnknown()},
Validators: []validator.String{
stringvalidator.UTF8LengthAtMost(1024),
},
},
},
}
}
// ModifyPlan is used to ignore and updates to `Token` outside the create.
func (d *gitlabUserRunnerResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
var stateData, planData *gitlabUserRunnerModel
resp.Diagnostics.Append(req.State.Get(ctx, &stateData)...)
resp.Diagnostics.Append(req.Plan.Get(ctx, &planData)...)
if resp.Diagnostics.HasError() {
return
}
// If the `ID` is nil or unknown in state, we're doing a "Create" operation, otherwise we're doing an "Update" operation.
// If we're doing an update operation, then we need to set Token to `null` if it's currently `null` in state
// to prevent an "Unknown" error when using it after import.
if stateData == nil || stateData.ID.IsNull() || stateData.ID.IsUnknown() {
// We're doing a "Create" operation
return
}
// We're performing an update, so check if the token is null or unknown in state, and set it to null in the plan
// if it is (because we're updating, or importing, etc)
if (stateData.Token.IsNull() || stateData.Token.IsUnknown()) && planData != nil {
planData.Token = types.StringNull()
}
resp.Diagnostics.Append(resp.Plan.Set(ctx, planData)...)
}
// Configure adds the provider configured client to the resource.
func (r *gitlabUserRunnerResource) 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
}
// The `create` method of this resource is special, because it doesn't use the `Runners` client, it uses the
// `users` client instead since it needs to create a user-authorized runner. Other functions will use normal
// runner client interactions.
func (r *gitlabUserRunnerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
var data gitlabUserRunnerModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
options := &gitlab.CreateUserRunnerOptions{
RunnerType: data.RunnerType.ValueStringPointer(),
}
if !data.GroupID.IsNull() {
options.GroupID = gitlab.Ptr(int(data.GroupID.ValueInt64()))
}
if !data.ProjectID.IsNull() {
options.ProjectID = gitlab.Ptr(int(data.ProjectID.ValueInt64()))
}
if !data.Description.IsNull() && !data.Description.IsUnknown() {
options.Description = gitlab.Ptr(data.Description.ValueString())
}
if !data.Paused.IsNull() && !data.Paused.IsUnknown() {
options.Paused = data.Paused.ValueBoolPointer()
}
if !data.Locked.IsNull() && !data.Locked.IsUnknown() {
options.Locked = data.Locked.ValueBoolPointer()
}
if !data.Untagged.IsNull() && !data.Untagged.IsUnknown() {
options.RunUntagged = data.Untagged.ValueBoolPointer()
}
if !data.TagList.IsNull() && !data.TagList.IsUnknown() {
// convert the Set to a []string and pass it in
var tags []string
data.TagList.ElementsAs(ctx, &tags, true)
options.TagList = &tags
}
if !data.AccessLevel.IsNull() && !data.AccessLevel.IsUnknown() {
options.AccessLevel = data.AccessLevel.ValueStringPointer()
}
// Attempting to create with a timeout of 0 causes an error, so we validate that the value is
// greater than 0 before including it within create.
if !data.MaximumTimeout.IsNull() && !data.MaximumTimeout.IsUnknown() && data.MaximumTimeout.ValueInt64() > 0 {
options.MaximumTimeout = gitlab.Ptr(int(data.MaximumTimeout.ValueInt64()))
}
if !data.MaintenanceNote.IsNull() && !data.MaintenanceNote.IsUnknown() {
options.MaintenanceNote = gitlab.Ptr(data.MaintenanceNote.ValueString())
}
tflog.Debug(ctx, "Creating new GitLab Runner", map[string]any{
"options": options,
})
userRunner, _, err := r.client.Users.CreateUserRunner(options)
if err != nil {
resp.Diagnostics.Append(diag.NewErrorDiagnostic("Error creating new GitLab Runner", fmt.Sprintf("couldn't create new GitLab Runner: %v", err)))
return
}
// Set the ID
data.ID = types.StringValue(strconv.Itoa(userRunner.ID))
// Save the token, since that is only available from the `create` function
data.Token = types.StringValue(userRunner.Token)
// Save this data to state so it's not lost if we fail to read the runner for whatever reason
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
// call "getRunner" so we get a Runner Details model from our user runner response. This
// is required because the userRunner response doesn't have all the attribute values, it only has the ID and token info.
runner, _, err := r.client.Runners.GetRunnerDetails(userRunner.ID)
if err != nil {
resp.Diagnostics.Append(diag.NewErrorDiagnostic("Error reading new GitLab runner after creation", fmt.Sprintf("Error reading new GitLab runner after creation: %v", err)))
return
}
resp.Diagnostics.Append(data.modelToStateModel(runner, ctx)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *gitlabUserRunnerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
var data gitlabUserRunnerModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
runnerId := data.ID.ValueString()
runner, _, err := r.client.Runners.GetRunnerDetails(runnerId)
if err != nil {
if api.Is404(err) {
tflog.Debug(ctx, "[DEBUG] gitlab runner not found", map[string]any{
"runnerId": runnerId,
})
resp.State.RemoveResource(ctx)
return
}
resp.Diagnostics.Append(diag.NewErrorDiagnostic(
"couldn't read runner details due to API error",
fmt.Sprintf("couldn't read runner details due to API error: %v", err),
))
return
}
resp.Diagnostics.Append(data.modelToStateModel(runner, ctx)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *gitlabUserRunnerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
var data gitlabUserRunnerModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
runnerId := data.ID.ValueString()
options := &gitlab.UpdateRunnerDetailsOptions{}
if !data.Description.IsNull() && !data.Description.IsUnknown() {
options.Description = gitlab.Ptr(data.Description.ValueString())
}
if !data.Paused.IsNull() && !data.Paused.IsUnknown() {
options.Paused = data.Paused.ValueBoolPointer()
}
if !data.Locked.IsNull() && !data.Locked.IsUnknown() {
options.Locked = data.Locked.ValueBoolPointer()
}
if !data.Untagged.IsNull() && !data.Untagged.IsUnknown() {
options.RunUntagged = data.Untagged.ValueBoolPointer()
}
if !data.TagList.IsNull() && !data.TagList.IsUnknown() {
// convert the Set to a []string and pass it in
var tags []string
data.TagList.ElementsAs(ctx, &tags, true)
options.TagList = &tags
}
if !data.AccessLevel.IsNull() && !data.AccessLevel.IsUnknown() {
options.AccessLevel = data.AccessLevel.ValueStringPointer()
}
if !data.MaximumTimeout.IsNull() && !data.MaximumTimeout.IsUnknown() && data.MaximumTimeout.ValueInt64() > 0 {
options.MaximumTimeout = gitlab.Ptr(int(data.MaximumTimeout.ValueInt64()))
}
if !data.MaintenanceNote.IsNull() && !data.MaintenanceNote.IsUnknown() {
options.MaintenanceNote = gitlab.Ptr(data.MaintenanceNote.ValueString())
}
tflog.Debug(ctx, "Updating GitLab Runner ID", map[string]any{
"runnerId": runnerId,
"options": options,
})
runner, _, err := r.client.Runners.UpdateRunnerDetails(runnerId, options)
if err != nil {
resp.Diagnostics.Append(diag.NewErrorDiagnostic("Error updating GitLab runner", fmt.Sprintf("Error updating GitLab runner %s: %v", runnerId, err)))
return
}
resp.Diagnostics.Append(data.modelToStateModel(runner, ctx)...)
resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
}
func (r *gitlabUserRunnerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
var data gitlabUserRunnerModel
resp.Diagnostics.Append(req.State.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
runnerId, err := strconv.Atoi(data.ID.ValueString())
if err != nil {
tflog.Debug(ctx, "[DEBUG] gitlab runner ID in state is not a number.", map[string]any{
"id": data.ID.ValueString(),
})
resp.Diagnostics.Append(diag.NewErrorDiagnostic(
"Attempted to delete a runner with an invalid non-integer ID",
"The terraform provider attempted to run `delete` on a GitLab runner, but the ID was a non-integer value. This can be caused by importing an invalid ID. You may need to manually remove the runner from state.",
))
return
}
tflog.Debug(ctx, "Deleting GitLab Runner by ID", map[string]any{
"runnerId": runnerId,
})
if _, err = r.client.Runners.DeleteRegisteredRunnerByID(runnerId); err != nil {
resp.Diagnostics.AddError(
"GitLab API Error occurred",
fmt.Sprintf("Unable to delete runner: %s", err.Error()),
)
}
}
func (r *gitlabUserRunnerResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp)
}
func (r *gitlabUserRunnerResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data gitlabUserRunnerModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() {
return
}
// get the runner type so we can validate that proper attributes are present based on the runner type
runnerType := data.RunnerType.ValueString()
if runnerType == "group_type" {
// GroupId must be provided
if data.GroupID.IsNull() {
resp.Diagnostics.AddAttributeError(path.Root("group_id"),
`Group ID not provided when Runner Type is set to "group_type".`,
`When creating a Group Runner, a Group ID must be provided. The Group ID was not provided, but the Runner Type value was set to "group_type". Please provide a Group ID!`,
)
}
// ProjectID must NOT be provided
if !data.ProjectID.IsNull() {
resp.Diagnostics.AddAttributeError(path.Root("project_id"),
`Project ID must not be provided when Runner Type is set to "group_type".`,
`When creating a Group Runner, "project_id" must not be provided.`,
)
}
}
if runnerType == "project_type" {
// ProjectID must be provided
if data.ProjectID.IsNull() {
resp.Diagnostics.AddAttributeError(path.Root("project_id"),
`Project ID not provided when Runner Type is set to "project_id".`,
`When creating a Project Runner, a Project ID must be provided. The Project ID was not provided, but the Runner Type value was set to "project_id". Please provide a Project ID!`,
)
}
// GroupID must NOT be provided
if !data.GroupID.IsNull() {
resp.Diagnostics.AddAttributeError(path.Root("group_id"),
`Group ID must not be provided when Runner Type is set to "project_type".`,
`When creating a Project Runner, "group_id" must not be provided.`,
)
}
}
if runnerType == "instance_type" && (!data.ProjectID.IsNull() || !data.GroupID.IsNull()) {
resp.Diagnostics.AddAttributeError(path.Root("runner_type"),
`Runner Type was set to "instance_type", but a project or group ID was provided`,
`When creating an Instance Runner, a Project ID or Group ID was provided. Those attributes are invalid for an Instance Runner, and should be removed.`,
)
}
if !data.MaximumTimeout.IsNull() && !data.MaximumTimeout.IsUnknown() && data.MaximumTimeout.ValueInt64() == 0 {
resp.Diagnostics.AddAttributeError(path.Root("maximum_timeout"),
`"maximum_timeout" cannot have a value of 0 configured. Please configure a value greater than 0.`,
`"maximum_timeout" cannot have a value of 0 configured. Please configure a value greater than 0.`,
)
}
}
func (d *gitlabUserRunnerModel) modelToStateModel(r *gitlab.RunnerDetails, ctx context.Context) diag.Diagnostics {
d.RunnerType = types.StringValue(r.RunnerType)
d.Description = types.StringValue(r.Description)
d.Paused = types.BoolValue(r.Paused)
d.Locked = types.BoolValue(r.Locked)
d.Untagged = types.BoolValue(r.RunUntagged)
d.AccessLevel = types.StringValue(r.AccessLevel)
d.MaximumTimeout = types.Int64Value(int64(r.MaximumTimeout))
d.MaintenanceNote = types.StringValue(r.MaintenanceNote)
// uses a []string, so doesn't require a `types` package constructor.
list, diag := types.SetValueFrom(ctx, types.StringType, r.TagList)
if diag != nil {
return diag
}
d.TagList = list
return nil
}