diff --git a/provider/challenge/hint_subdata_source.go b/provider/challenge/hint_subdata_source.go deleted file mode 100644 index d883178..0000000 --- a/provider/challenge/hint_subdata_source.go +++ /dev/null @@ -1,24 +0,0 @@ -package challenge - -import ( - "github.com/hashicorp/terraform-plugin-framework/datasource/schema" - "github.com/hashicorp/terraform-plugin-framework/types" -) - -func HintSubdatasourceAttributes() map[string]schema.Attribute { - return map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - }, - "content": schema.StringAttribute{ - Computed: true, - }, - "cost": schema.Int64Attribute{ - Computed: true, - }, - "requirements": schema.ListAttribute{ - ElementType: types.StringType, - Computed: true, - }, - } -} diff --git a/provider/challenge/hint_subresource.go b/provider/challenge/hint_subresource.go deleted file mode 100644 index 4ce34fb..0000000 --- a/provider/challenge/hint_subresource.go +++ /dev/null @@ -1,126 +0,0 @@ -package challenge - -import ( - "context" - "fmt" - "strconv" - - "github.com/ctfer-io/go-ctfd/api" - "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" - "github.com/hashicorp/terraform-plugin-framework/attr" - "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/hashicorp/terraform-plugin-framework/resource/schema" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" - "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" - "github.com/hashicorp/terraform-plugin-framework/types" - "github.com/hashicorp/terraform-plugin-framework/types/basetypes" - "github.com/hashicorp/terraform-plugin-log/tflog" -) - -type HintSubresourceModel struct { - ID types.String `tfsdk:"id"` - Content types.String `tfsdk:"content"` - Cost types.Int64 `tfsdk:"cost"` - Requirements types.List `tfsdk:"requirements"` -} - -func HintSubresourceAttributes() map[string]schema.Attribute { - return map[string]schema.Attribute{ - "id": schema.StringAttribute{ - Computed: true, - MarkdownDescription: "Identifier of the hint, used internally to handle the CTFd corresponding object.", - PlanModifiers: []planmodifier.String{ - stringplanmodifier.UseStateForUnknown(), - }, - }, - "content": schema.StringAttribute{ - MarkdownDescription: "Content of the hint as displayed to the end-user.", - Required: true, - }, - "cost": schema.Int64Attribute{ - MarkdownDescription: "Cost of the hint, and if any specified, the end-user will consume its own (or team) points to get it.", - Optional: true, - Computed: true, - Default: int64default.StaticInt64(0), - }, - "requirements": schema.ListAttribute{ - MarkdownDescription: "Other hints required to be consumed before getting this one. Useful for cost-increasing hint strategies with more and more help.", - ElementType: types.StringType, - Computed: true, - Optional: true, - Default: listdefault.StaticValue(basetypes.NewListValueMust(types.StringType, []attr.Value{})), - }, - } -} - -func (data *HintSubresourceModel) Create(ctx context.Context, diags diag.Diagnostics, client *api.Client, challengeID int) { - preq := make([]int, 0, len(data.Requirements.Elements())) - for _, req := range data.Requirements.Elements() { - // TODO use strconv.Atoi and handle error properly - reqid := utils.Atoi(req.(types.String).ValueString()) - preq = append(preq, reqid) - } - - res, err := client.PostHints(&api.PostHintsParams{ - ChallengeID: challengeID, - Content: data.Content.ValueString(), - Cost: int(data.Cost.ValueInt64()), - Requirements: api.Requirements{ - Prerequisites: preq, - }, - }, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to create hint, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "created a hint") - - data.ID = types.StringValue(strconv.Itoa(res.ID)) -} - -func (data *HintSubresourceModel) Update(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - preq := make([]int, 0, len(data.Requirements.Elements())) - for _, req := range data.Requirements.Elements() { - // TODO use strconv.Atoi and handle error properly - reqid := utils.Atoi(req.(types.String).ValueString()) - preq = append(preq, reqid) - } - - res, err := client.PatchHint(data.ID.ValueString(), &api.PatchHintsParams{ - Content: data.Content.ValueString(), - Cost: int(data.Cost.ValueInt64()), - Requirements: api.Requirements{ - Prerequisites: preq, - }, - }, api.WithContext(ctx)) - if err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to update hint, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "updated a hint") - - data.Content = types.StringValue(*res.Content) - data.Cost = types.Int64Value(int64(res.Cost)) -} - -func (data *HintSubresourceModel) Delete(ctx context.Context, diags diag.Diagnostics, client *api.Client) { - if err := client.DeleteHint(data.ID.ValueString(), api.WithContext(ctx)); err != nil { - diags.AddError( - "Client Error", - fmt.Sprintf("Unable to delete hint, got error: %s", err), - ) - return - } - - tflog.Trace(ctx, "deleted a hint") -} diff --git a/provider/hint_resource.go b/provider/hint_resource.go new file mode 100644 index 0000000..cf35216 --- /dev/null +++ b/provider/hint_resource.go @@ -0,0 +1,243 @@ +package provider + +import ( + "context" + "fmt" + "strconv" + + "github.com/ctfer-io/go-ctfd/api" + "github.com/ctfer-io/terraform-provider-ctfd/provider/utils" + "github.com/hashicorp/terraform-plugin-framework/attr" + "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/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/listdefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "github.com/hashicorp/terraform-plugin-log/tflog" +) + +var ( + _ resource.Resource = (*hintResource)(nil) + _ resource.ResourceWithConfigure = (*hintResource)(nil) + _ resource.ResourceWithImportState = (*hintResource)(nil) +) + +func NewHintResource() resource.Resource { + return &hintResource{} +} + +type hintResource struct { + client *api.Client +} + +type hintResourceModel struct { + ID types.String `tfsdk:"id"` + ChallengeID types.String `tfsdk:"challenge_id"` + Content types.String `tfsdk:"content"` + Cost types.Int64 `tfsdk:"cost"` + Requirements []types.String `tfsdk:"requirements"` +} + +func (r *hintResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_hint" +} + +func (r *hintResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + MarkdownDescription: "A hint for a challenge to help players solve it.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + MarkdownDescription: "Identifier of the hint, used internally to handle the CTFd corresponding object.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "challenge_id": schema.StringAttribute{ + MarkdownDescription: "Challenge of the hint.", + Required: true, + }, + "content": schema.StringAttribute{ + MarkdownDescription: "Content of the hint as displayed to the end-user.", + Required: true, + }, + "cost": schema.Int64Attribute{ + MarkdownDescription: "Cost of the hint, and if any specified, the end-user will consume its own (or team) points to get it.", + Computed: true, + Optional: true, + Default: int64default.StaticInt64(0), + }, + "requirements": schema.ListAttribute{ + MarkdownDescription: "List of the other hints it depends on.", + ElementType: types.StringType, + Computed: true, + Optional: true, + Default: listdefault.StaticValue(basetypes.NewListValueMust(types.StringType, []attr.Value{})), + }, + }, + } +} + +func (r *hintResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Prevent panic if the provider has not been configured. + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(*api.Client) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *github.com/ctfer-io/go-ctfd/api.Client, got: %T. Please open an issue at https://github.com/ctfer-io/terraform-provider-ctfd", req.ProviderData), + ) + return + } + + r.client = client +} + +func (r *hintResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var data hintResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Create hint + reqs := make([]int, 0, len(data.Requirements)) + for _, preq := range data.Requirements { + id, _ := strconv.Atoi(preq.ValueString()) + reqs = append(reqs, id) + } + res, err := r.client.PostHints(&api.PostHintsParams{ + ChallengeID: utils.Atoi(data.ChallengeID.ValueString()), + Content: data.Content.ValueString(), + Cost: int(data.Cost.ValueInt64()), + Requirements: api.Requirements{ + Prerequisites: reqs, + }, + }, api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to create hint, got error: %s", err), + ) + return + } + + tflog.Trace(ctx, "created a hint") + + // Save computed attributes in state + data.ID = types.StringValue(strconv.Itoa(res.ID)) + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *hintResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var data hintResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Retrieve hint + h, err := r.client.GetHint(data.ID.ValueString(), api.WithContext(ctx)) + if err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to update hint %s, got error: %s", data.ID.ValueString(), err), + ) + return + } + // Forced to pass by all hints as CTFd does not return content for direct query + hints, err := r.client.GetChallengeHints(h.ChallengeID, api.WithContext(ctx)) + hint := (*api.Hint)(nil) + for _, h := range hints { + if h.ID == utils.Atoi(data.ID.ValueString()) { + hint = h + break + } + } + if hint == nil { + resp.Diagnostics.AddError( + "CTFd Error", + fmt.Sprintf("Unable to get hint %s of challenge %s, got error: %s", data.ID.ValueString(), data.ChallengeID.ValueString(), err), + ) + return + } + + // Upsert values + data.ChallengeID = types.StringValue(strconv.Itoa(h.ChallengeID)) + data.Content = types.StringValue(*hint.Content) + data.Cost = types.Int64Value(int64(hint.Cost)) + reqs := make([]basetypes.StringValue, 0, len(hint.Requirements.Prerequisites)) + for _, preq := range hint.Requirements.Prerequisites { + reqs = append(reqs, types.StringValue(strconv.Itoa(preq))) + } + data.Requirements = reqs + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *hintResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var data hintResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + // Update hint + preqs := make([]int, 0, len(data.Requirements)) + for _, preq := range data.Requirements { + id, _ := strconv.Atoi(preq.ValueString()) + preqs = append(preqs, id) + } + if _, err := r.client.PatchHint(data.ID.ValueString(), &api.PatchHintsParams{ + ChallengeID: utils.Atoi(data.ChallengeID.ValueString()), + Content: data.Content.ValueString(), + Cost: int(data.Cost.ValueInt64()), + Requirements: api.Requirements{ + Prerequisites: preqs, + }, + }, api.WithContext(ctx)); err != nil { + resp.Diagnostics.AddError( + "Client Error", + fmt.Sprintf("Unable to update hint %s, got error: %s", data.ID.ValueString(), err), + ) + return + } + + if resp.Diagnostics.HasError() { + return + } + resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) +} + +func (r *hintResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var data hintResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &data)...) + if resp.Diagnostics.HasError() { + return + } + + if err := r.client.DeleteHint(data.ID.ValueString(), api.WithContext(ctx)); err != nil { + resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete hint %s, got error: %s", data.ID.ValueString(), err)) + return + } +} + +func (r *hintResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { + resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) + + // Automatically call r.Read +} diff --git a/provider/hint_resource_test.go b/provider/hint_resource_test.go new file mode 100644 index 0000000..968dbcb --- /dev/null +++ b/provider/hint_resource_test.go @@ -0,0 +1,71 @@ +package provider_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAcc_Hint_Lifecycle(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + // Create and Read testing + { + Config: providerConfig + ` +resource "ctfd_challenge" "example" { + name = "Example challenge" + category = "test" + description = "Example challenge description..." + value = 500 +} + +resource "ctfd_hint" "first" { + challenge_id = ctfd_challenge.example.id + content = "This is a first hint" + cost = 1 +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + // Verify dynamic values have any value set in the state. + resource.TestCheckResourceAttr("ctfd_hint.first", "requirements.#", "0"), + ), + }, + // ImportState testing + { + ResourceName: "ctfd_hint.first", + ImportState: true, + ImportStateVerify: true, + }, + // Update and Read testing + { + Config: providerConfig + ` +resource "ctfd_challenge" "example" { + name = "Example challenge" + category = "test" + description = "Example challenge description..." + value = 500 +} + +resource "ctfd_hint" "first" { + challenge_id = ctfd_challenge.example.id + content = "This is a first hint" + cost = 1 +} + +resource "ctfd_hint" "second" { + challenge_id = ctfd_challenge.example.id + content = "This is a second hint" + cost = 2 + requirements = [ctfd_hint.first.id] +} +`, + Check: resource.ComposeAggregateTestCheckFunc( + resource.TestCheckResourceAttr("ctfd_hint.first", "requirements.#", "0"), + resource.TestCheckResourceAttr("ctfd_hint.second", "requirements.#", "1"), + ), + }, + // Delete testing automatically occurs in TestCase + }, + }) +} diff --git a/provider/provider.go b/provider/provider.go index d1be7a5..9fe5529 100644 --- a/provider/provider.go +++ b/provider/provider.go @@ -188,6 +188,7 @@ func (p *CTFdProvider) Configure(ctx context.Context, req provider.ConfigureRequ func (p *CTFdProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewChallengeResource, + NewHintResource, NewFlagResource, NewFileResource, NewUserResource,