diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b0b403..f609508 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,13 +19,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Unshallow run: git fetch --prune --unshallow - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: - go-version: 1.14 + go-version-file: 'go.mod' - name: Import GPG key id: import_gpg uses: paultyng/ghaction-import-gpg@v2.1.0 @@ -33,10 +33,10 @@ jobs: GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} PASSPHRASE: ${{ secrets.PASSPHRASE }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v2 + uses: goreleaser/goreleaser-action@v5 with: version: latest - args: release --rm-dist + args: release --clean env: GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..0db13c0 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,76 @@ +name: Tests + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + +jobs: + build: + name: Build + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + - run: go mod download + - run: go build -v . + + lint: + name: Lint + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + - name: Run gofmt + run: | + if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then + echo "Please run 'gofmt -s -w .' to format your code" + gofmt -s -l . + exit 1 + fi + - name: Run go vet + run: go vet ./... + + test: + name: Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + - run: go mod download + - run: go test -v -short -timeout 120s ./... + + acceptance: + name: Acceptance Tests + runs-on: ubuntu-latest + timeout-minutes: 60 + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + env: + VO_API_ID: ${{ secrets.VO_API_ID }} + VO_API_KEY: ${{ secrets.VO_API_KEY }} + VO_BASE_URL: ${{ secrets.VO_BASE_URL }} + VO_REPLACEMENT_USERNAME: ${{ secrets.VO_REPLACEMENT_USERNAME }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + - run: go mod download + - name: Run acceptance tests + if: env.VO_API_ID != '' + run: go test -v -timeout 120m ./victorops/... + diff --git a/GNUmakefile b/GNUmakefile index fcbe53f..0c1620c 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -9,6 +9,9 @@ TEST_COUNT?=1 default: build build: fmtcheck + go build -o terraform-provider-victorops + +install: build go install sweep: @@ -19,60 +22,49 @@ test: fmtcheck go test $(TEST) $(TESTARGS) -short -timeout=120s -parallel=4 testacc: fmtcheck + @echo "==> Running acceptance tests..." + @echo "Note: Set VO_API_ID, VO_API_KEY, VO_BASE_URL, and VO_REPLACEMENT_USERNAME for acceptance tests" go test $(TEST) -v -count $(TEST_COUNT) -parallel 20 $(TESTARGS) -timeout 120m fmt: @echo "==> Fixing source code with gofmt..." gofmt -s -w ./$(PKG_NAME) -# Currently required by tf-deploy compile fmtcheck: - @sh -c "'$(CURDIR)/scripts/gofmtcheck.sh'" + @echo "==> Checking source code formatting..." + @test -z "$$(gofmt -s -l ./$(PKG_NAME) | tee /dev/stderr)" || \ + (echo; echo "Please run 'make fmt' to fix formatting"; exit 1) + +vet: + @echo "==> Running go vet..." + @go vet ./... depscheck: @echo "==> Checking source code with go mod tidy..." @go mod tidy @git diff --exit-code -- go.mod go.sum || \ (echo; echo "Unexpected difference in go.mod/go.sum files. Run 'go mod tidy' command or revert any go.mod/go.sum changes and commit."; exit 1) - @echo "==> Checking source code with go mod vendor..." - @go mod vendor - @git diff --compact-summary --exit-code -- vendor || \ - (echo; echo "Unexpected difference in vendor/ directory. Run 'go mod vendor' command or revert any go.mod/go.sum/vendor changes and commit."; exit 1) - -docscheck: - @tfproviderdocs check - @misspell -error -source text CHANGELOG.md -lint: +lint: fmtcheck vet @echo "==> Checking source code against linters..." - @golangci-lint run ./$(PKG_NAME)/... - @tfproviderlint \ - -c 1 \ - -S001 \ - -S002 \ - -S003 \ - -S004 \ - -S005 \ - -S007 \ - -S008 \ - -S009 \ - -S010 \ - -S011 \ - -S012 \ - -S013 \ - -S014 \ - -S015 \ - -S016 \ - -S017 \ - -S019 \ - ./$(PKG_NAME) + @if command -v golangci-lint >/dev/null 2>&1; then \ + golangci-lint run ./$(PKG_NAME)/...; \ + else \ + echo "golangci-lint not installed, skipping..."; \ + fi + +docs: + @echo "==> Generating documentation..." + @if command -v tfplugindocs >/dev/null 2>&1; then \ + tfplugindocs generate; \ + else \ + echo "tfplugindocs not installed. Install with: go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@latest"; \ + fi tools: - GO111MODULE=on go install github.com/bflad/tfproviderlint/cmd/tfproviderlint - GO111MODULE=on go install github.com/bflad/tfproviderdocs - GO111MODULE=on go install github.com/client9/misspell/cmd/misspell - GO111MODULE=on go install github.com/golangci/golangci-lint/cmd/golangci-lint - GO111MODULE=on go install github.com/katbyte/terrafmt + @echo "==> Installing development tools..." + go install github.com/hashicorp/terraform-plugin-docs/cmd/tfplugindocs@latest + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest test-compile: @if [ "$(TEST)" = "./..." ]; then \ @@ -82,5 +74,7 @@ test-compile: fi go test -c $(TEST) $(TESTARGS) -.PHONY: build sweep test testacc fmt fmtcheck lint tools test-compile depscheck docscheck +clean: + rm -f terraform-provider-victorops +.PHONY: build install sweep test testacc fmt fmtcheck vet lint tools test-compile depscheck docs clean diff --git a/README.md b/README.md index 2428237..bba4262 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -Terraform `VictorOps` Provider +Terraform `VictorOps/Splunk OnCall` Provider ========================= - Website: https://www.terraform.io @@ -10,9 +10,9 @@ Terraform `VictorOps` Provider Requirements ------------ -- A VictorOps account that you want to manage alongwith API key and token. -- [Terraform](https://www.terraform.io/downloads.html) 0.10.x or higher -- [Go](https://golang.org/doc/install) 1.14 (to build the provider plugin) +- A VictorOps/Splunk OnCall account with API access (API key and ID). +- [Terraform](https://www.terraform.io/downloads.html) 0.13.x or higher +- [Go](https://golang.org/doc/install) 1.21+ (to build the provider plugin) Building The Provider --------------------- @@ -27,69 +27,112 @@ Enter the provider directory and build the provider ```sh $ cd $GOPATH/src/github.com/splunk/terraform-provider-victorops - -# MacOC -$ go build - -# Ubuntu -$ make build -$ cp $GOPATH/bin/terraform-provider-victorops . +$ go build -o terraform-provider-victorops ``` Features ------------ -Using this VictorOps Terraform provider, you can manage the following VictorOps resources. -1. User -2. Team -3. User-Team assignment -4. Escalation Policy -5. Routing Key +Using this VictorOps/Splunk OnCall Terraform provider, you can manage the following resources: + +### Resources + +| Resource | Description | +|----------|-------------| +| `victorops_user` | Manage users | +| `victorops_team` | Manage teams | +| `victorops_team_membership` | Manage user-team assignments | +| `victorops_escalation_policy` | Manage escalation policies | +| `victorops_routing_key` | Manage routing keys | +| `victorops_user_contact_email` | Manage user email contact methods | +| `victorops_user_contact_phone` | Manage user phone contact methods | +| `victorops_alert_rule` | Manage alert routing rules | +| `victorops_maintenance_mode` | Manage maintenance windows | +| `victorops_scheduled_override` | Manage scheduled on-call overrides | +| `victorops_user_paging_policy` | Manage user paging policies | + +### Data Sources + +| Data Source | Description | +|-------------|-------------| +| `victorops_users` | List users (with optional email filter) | +| `victorops_routing_keys` | List routing keys | +| `victorops_team_admins` | List team administrators | +| `victorops_user_devices` | List user contact devices (read-only) | +| `victorops_rotations` | List team rotations | +| `victorops_team_oncall_schedule` | Get team on-call schedule | Usage ------------ -``` + +### Provider Configuration + +```hcl terraform { - required_providers { - victorops = { - source = "splunk/victorops" - version = "0.1.1" - } - } + required_providers { + victorops = { + source = "splunk/victorops" + version = "~> 0.2.0" + } + } } provider "victorops" { - api_id = "6d700de8" // An API id tied to an admin user - api_key = "" // An API key tied to an admin user + api_id = var.victorops_api_id # Or set VO_API_ID env var + api_key = var.victorops_api_key # Or set VO_API_KEY env var } +``` + +### Example: Managing Users and Teams -// Define the first tf-configured user, 'John Dane' -resource "victorops_user" "jdane_tf" { +```hcl +# Create a user +resource "victorops_user" "jdane" { first_name = "John" last_name = "Dane" user_name = "jdane" - email = "jdane51@victorops.com" - is_admin = false // deprecated - We no longer support creating admin users through TF/public APIs. The value in this field is ignored. - replacement_user = "myDefaultVOUser" // optional - // Specify this with the default username to replace all users when deleting users using TF + email = "jdane@example.com" + is_admin = false # deprecated - We no longer support creating admin users through TF/public APIs. The value in this field is ignored. + replacement_user = "default_user" # optional - Specify the default username to replace all users when deleting users using TF } -// Create a new team -resource "victorops_team" "team_vikings" { - name = "VO-Vikings" +# Create a team +resource "victorops_team" "platform" { + name = "Platform-Team" } -// Assigning an existing user to a team -resource "victorops_team_membership" "jdane_membership" { - team_id = victorops_team.team_vikings.id - user_name = victorops_user.jdane_tf.user_name +# Add user to team +resource "victorops_team_membership" "jdane_platform" { + team_id = victorops_team.platform.id + user_name = victorops_user.jdane.user_name } +``` + +### Example: Contact Methods -// Create escalation policies for existing VO rotation (created using portal) -// Note: You need to fetch an existing Rotation Group Slug for using the Escalation Policy +```hcl +# Add email contact method +resource "victorops_user_contact_email" "jdane_work" { + username = victorops_user.jdane.user_name + email = "jdane-alerts@example.com" + label = "Work Email" +} + +# Add phone contact method +resource "victorops_user_contact_phone" "jdane_mobile" { + username = victorops_user.jdane.user_name + phone = "+1-555-123-4567" + label = "Mobile" +} +``` + +### Example: Escalation Policy and Routing + +```hcl +# Create escalation policy resource "victorops_escalation_policy" "high_severity" { name = "High Severity" - team_id = victorops_team.team_vikings.id + team_id = victorops_team.platform.id step { timeout = 60 entries = [ @@ -98,37 +141,133 @@ resource "victorops_escalation_policy" "high_severity" { slug = "rtg-wvvhXshpvaRdn7jM" } ] - } + } } -// Create routing keys to push alerts to our escalation policies -resource "victorops_routing_key" "viking_high_severity" { - name = "viking-high-severity" +# Create routing key +resource "victorops_routing_key" "platform_alerts" { + name = "platform-alerts" targets = [victorops_escalation_policy.high_severity.id] } ``` +### Example: Alert Rules + +```hcl +# Create alert routing rule +resource "victorops_alert_rule" "database_alerts" { + alert_field = "host_name" + alert_value_match = "db-*" + match_type = "WILDCARD" + routing_key = victorops_routing_key.platform_alerts.name + stop_flag = false + notes = "Route database alerts to platform team" +} +``` + +### Example: Maintenance Mode + +```hcl +# Start maintenance mode for specific routing keys +resource "victorops_maintenance_mode" "deploy" { + routing_keys = [victorops_routing_key.platform_alerts.name] + purpose = "Scheduled deployment" +} + +# Global maintenance mode (all routing keys) +resource "victorops_maintenance_mode" "global" { + purpose = "Infrastructure maintenance" +} +``` + +### Example: Scheduled Override + +```hcl +# Schedule an on-call override +resource "victorops_scheduled_override" "vacation" { + username = "jdane" + timezone = "America/New_York" + start = "2024-12-20T09:00:00Z" + end = "2024-12-27T09:00:00Z" +} +``` + +### Example: Data Sources + +```hcl +# List all users +data "victorops_users" "all" {} + +# List users by email +data "victorops_users" "by_email" { + email = "jdane@example.com" +} + +# List routing keys +data "victorops_routing_keys" "all" {} + +# Get team on-call schedule +data "victorops_team_oncall_schedule" "platform" { + team_id = victorops_team.platform.id + days_forward = 14 +} + +# List team rotations +data "victorops_rotations" "platform" { + team_id = victorops_team.platform.id +} + +# List team admins +data "victorops_team_admins" "platform" { + team_id = victorops_team.platform.id +} + +# List user devices (read-only) +data "victorops_user_devices" "jdane" { + username = "jdane" +} +``` + Developing the Provider --------------------------- -If you wish to work on the provider, you'll first need [Go](http://www.golang.org) installed on your machine (version 1.14+ is *required*). You'll also need to correctly setup a [GOPATH](http://golang.org/doc/code.html#GOPATH), as well as adding `$GOPATH/bin` to your `$PATH`. +If you wish to work on the provider, you'll first need [Go](http://www.golang.org) installed on your machine (version 1.21+ is *required*). -To compile the provider, build the provider as mentioned above. In order to test the provider, you can simply run `make test`. +To compile the provider: ```sh -$ make test +$ go build -o terraform-provider-victorops ``` -In order to run the full suite of Acceptance tests, run `make testacc`. +To run unit tests: -Acceptance tests require the following environment variables to be set. +```sh +$ go test -short ./victorops/... +``` + +To run acceptance tests, set the following environment variables: -- VO_API_ID -- VO_API_KEY -- VO_BASE_URL -- VO_REPLACEMENT_USERNAME - - the default username to replace all users when removed +| Variable | Description | +|----------|-------------| +| `VO_API_ID` | VictorOps API ID | +| `VO_API_KEY` | VictorOps API Key | +| `VO_BASE_URL` | API base URL (default: `https://api.victorops.com`) | +| `VO_REPLACEMENT_USERNAME` | Default username to replace deleted users | +| `VO_OVERRIDE_USERNAME` | (Optional) Username in an escalation policy for override tests | ```sh -$ make testacc +$ export VO_API_ID="your-api-id" +$ export VO_API_KEY="your-api-key" +$ export VO_REPLACEMENT_USERNAME="existing-user" +$ TF_ACC=1 go test -v ./victorops/... -timeout 120m ``` + +API Rate Limiting +----------------- + +The VictorOps API has a rate limit of approximately 2 requests per second. This provider implements automatic retry with exponential backoff for rate-limited requests (HTTP 429) and transient server errors (HTTP 500, 502, 503, 504). + +License +------- + +See [LICENSE](LICENSE) file. diff --git a/go.mod b/go.mod index 22be805..143f76c 100644 --- a/go.mod +++ b/go.mod @@ -1,37 +1,64 @@ module github.com/terraform-providers/terraform-provider-victorops -go 1.14 +go 1.22 require ( - cloud.google.com/go v0.55.0 // indirect + github.com/bxcodec/faker/v3 v3.8.1 + github.com/hashicorp/terraform-plugin-sdk/v2 v2.31.0 + github.com/stretchr/testify v1.8.4 + github.com/victorops/go-victorops v1.0.1 + golang.org/x/time v0.5.0 +) + +require ( + github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect github.com/agext/levenshtein v1.2.3 // indirect - github.com/aws/aws-sdk-go v1.29.31 // indirect - github.com/bxcodec/faker/v3 v3.5.0 - github.com/fatih/color v1.9.0 // indirect - github.com/hashicorp/go-getter v1.4.1 // indirect - github.com/hashicorp/go-hclog v0.12.1 // indirect - github.com/hashicorp/go-plugin v1.2.0 // indirect - github.com/hashicorp/go-uuid v1.0.2 // indirect - github.com/hashicorp/hcl v1.0.0 // indirect - github.com/hashicorp/hcl/v2 v2.3.0 // indirect - github.com/hashicorp/terraform-plugin-sdk v1.8.0 - github.com/hashicorp/terraform-svchost v0.0.0-20191119180714-d2e4933b9136 // indirect - github.com/hashicorp/yamux v0.0.0-20190923154419-df201c70410d // indirect - github.com/jmespath/go-jmespath v0.3.0 // indirect - github.com/mattn/go-colorable v0.1.6 // indirect - github.com/mitchellh/go-testing-interface v1.14.0 // indirect - github.com/mitchellh/mapstructure v1.2.2 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/cloudflare/circl v1.3.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/google/go-cmp v0.6.0 // 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.4.1-0.20200414143053-d3edf31b6320 // indirect + github.com/hashicorp/go-hclog v1.6.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.6.0 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.6.0 // indirect + github.com/hashicorp/hc-install v0.6.2 // indirect + github.com/hashicorp/hcl/v2 v2.19.1 // indirect + github.com/hashicorp/logutils v1.0.0 // indirect + github.com/hashicorp/terraform-exec v0.19.0 // indirect + github.com/hashicorp/terraform-json v0.18.0 // indirect + github.com/hashicorp/terraform-plugin-go v0.20.0 // indirect + github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect + github.com/hashicorp/terraform-registry-address v0.2.3 // indirect + github.com/hashicorp/terraform-svchost v0.1.1 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // 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.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.1.0 // indirect - github.com/posener/complete v1.2.3 // indirect - github.com/stretchr/testify v1.5.1 - github.com/ulikunitz/xz v0.5.7 // indirect - github.com/victorops/go-victorops v1.0.1 + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect - github.com/zclconf/go-cty v1.3.1 // indirect - golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 // indirect - golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e // indirect - golang.org/x/tools v0.0.0-20200324201824-1fc30e1f4ccc // indirect - google.golang.org/genproto v0.0.0-20200324203455-a04cca1dde73 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect - gopkg.in/yaml.v2 v2.2.7 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + github.com/zclconf/go-cty v1.14.1 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/mod v0.14.0 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/appengine v1.6.8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0 // indirect + google.golang.org/grpc v1.60.1 // indirect + google.golang.org/protobuf v1.32.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 711f7f0..e7c7c92 100644 --- a/go.sum +++ b/go.sum @@ -1,489 +1,233 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.55.0 h1:eoz/lYxKSL4CNAiaUJ0ZfD1J3bfMYbU5B3rwM1C1EIU= -cloud.google.com/go v0.55.0/go.mod h1:ZHmoY+/lIMNkN2+fBmuTiqZ4inFhvQad8ft7MT8IV5Y= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0 h1:xE3CPsOgttP4ACBePh79zTKALtXwn/Edhcr16R5hMWU= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0 h1:/May9ojXjRkPBNVrq+oWLqmWCkr4OU5uRY29bu0mRyQ= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0 h1:Lpy6hKgdcl7a3WGSfJIFmxmcdjSpP6OmBEfcOv1Y680= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0 h1:UDpwYIwla4jHGzZJaEJYx1tOejbgSoNqsAfHAUYe2r8= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= -github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +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 v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCvIsutKu5zLMgWtgh9YxGCNAw8Ad8hjwfYg= +github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= -github.com/agl/ed25519 v0.0.0-20170116200512-5312a6153412/go.mod h1:WPjqKcmVOxf0XSf3YxCJs6N6AOSrOx3obionmG7T0y0= -github.com/apparentlymart/go-cidr v1.0.1 h1:NmIwLZ/KdsjIUlhf+/Np40atNXm/+lZ5txfTJ/SpF+U= -github.com/apparentlymart/go-cidr v1.0.1/go.mod h1:EBcsNrHc3zQeuaeCeCtQruQm+n9/YjEn/vI25Lg7Gwc= -github.com/apparentlymart/go-dump v0.0.0-20180507223929-23540a00eaa3/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= -github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0 h1:MzVXffFUye+ZcSR6opIgz9Co7WcDx6ZcY+RjfFHoA0I= -github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0/go.mod h1:oL81AME2rN47vu18xqj1S1jPIPuN7afo62yKTNn3XMM= -github.com/apparentlymart/go-textseg v1.0.0 h1:rRmlIsPEEhUTIKQb7T++Nz/A5Q6C9IuX2wFoYVvnCs0= -github.com/apparentlymart/go-textseg v1.0.0/go.mod h1:z96Txxhf3xSFMPmb5X/1W05FF/Nj9VFpLOpjS5yuumk= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -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/aws/aws-sdk-go v1.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM= -github.com/aws/aws-sdk-go v1.25.3/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= -github.com/aws/aws-sdk-go v1.29.31 h1:y4lIvJf88grxomd/caxacLrNFIz2U3jld9vHElK/FhA= -github.com/aws/aws-sdk-go v1.29.31/go.mod h1:1KvfttTE3SPKMpo8g2c6jL3ZKfXtFvKscTgahTma5Xg= -github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= -github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= -github.com/bgentry/speakeasy v0.1.0 h1:ByYyxL9InA1OWqxJqqp2A5pYHUrCiAL6K3J+LKSsQkY= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bxcodec/faker/v3 v3.5.0 h1:Rahy6dwbd6up0wbwbV7dFyQb+jmdC51kpATuUdnzfMg= -github.com/bxcodec/faker/v3 v3.5.0/go.mod h1:gF31YgnMSMKgkvl+fyEo1xuSMbEuieyqfeslGYFjneM= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +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/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/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/bxcodec/faker/v3 v3.8.1 h1:qO/Xq19V6uHt2xujwpaetgKhraGCapqY2CRWGD/SqcM= +github.com/bxcodec/faker/v3 v3.8.1/go.mod h1:DdSDccxF5msjFo5aO4vrobRQ8nIApg8kq3QWPEQD6+o= +github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= -github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +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/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.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= +github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-git/v5 v5.10.1 h1:tu8/D8i+TWxgKpzQ3Vc43e+kkhXqtsZCKI/egajKnxk= +github.com/go-git/go-git/v5 v5.10.1/go.mod h1:uEuHjxkHap8kAl//V5F/nNWwqIYtP/402ddd05mp0wg= 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/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3 h1:GV+pQPG/EUUbkh47niozDcADz6go/dUwhVzdUQHIVRw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +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.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +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.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-getter v1.4.0/go.mod h1:7qxyCd8rBfcShwsvxgIguu4KbS3l8bUCwg2Umn7RjeY= -github.com/hashicorp/go-getter v1.4.1 h1:3A2Mh8smGFcf5M+gmcv898mZdrxpseik45IpcyISLsA= -github.com/hashicorp/go-getter v1.4.1/go.mod h1:7qxyCd8rBfcShwsvxgIguu4KbS3l8bUCwg2Umn7RjeY= -github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI= -github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= -github.com/hashicorp/go-hclog v0.12.1 h1:99niEVkDqsEv3/jINwoOUgGE9L41LHXM4k3jTkV+DdA= -github.com/hashicorp/go-hclog v0.12.1/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= -github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY= -github.com/hashicorp/go-plugin v1.2.0 h1:CUfYokW0EJNDcGecVrHZK//Cp1GFlHwoqtcUIEiU6BY= -github.com/hashicorp/go-plugin v1.2.0/go.mod h1:F9eH4LrE/ZsRdbwhfjs9k9HoDUwAHnYtXdgmf1AVNs0= -github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= -github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE= -github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E= -github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v0.0.0-20170504190234-a4b07c25de5f/go.mod h1:oZtUIOe8dh44I2q6ScRibXws4Ajl+d+nod3AaR9vL5w= -github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/hcl/v2 v2.0.0/go.mod h1:oVVDG71tEinNGYCxinCYadcmKU9bglqW9pV3txagJ90= -github.com/hashicorp/hcl/v2 v2.3.0 h1:iRly8YaMwTBAKhn1Ybk7VSdzbnopghktCD031P8ggUE= -github.com/hashicorp/hcl/v2 v2.3.0/go.mod h1:d+FwDBbOLvpAM3Z6J7gPj/VoAGkNe/gm352ZhjJ/Zv8= +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.4.1-0.20200414143053-d3edf31b6320 h1:1/D3zfFHttUKaCaGKZ/dR2roBXv0vKbSCnssIldfQdI= +github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= +github.com/hashicorp/go-hclog v1.6.2 h1:NOtoftovWkDheyUM/8JW3QMiXyxJK3uHRK7wV04nD2I= +github.com/hashicorp/go-hclog v1.6.2/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +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.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= +github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +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.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= +github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hc-install v0.6.2 h1:V1k+Vraqz4olgZ9UzKiAcbman9i9scg9GgSt/U3mw/M= +github.com/hashicorp/hc-install v0.6.2/go.mod h1:2JBpd+NCFKiHiu/yYCGaPyPHhZLxXTpz8oreHa/a3Ps= +github.com/hashicorp/hcl/v2 v2.19.1 h1://i05Jqznmb2EXqa39Nsvyan2o5XyMowW5fnCKW5RPI= +github.com/hashicorp/hcl/v2 v2.19.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE= 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-config-inspect v0.0.0-20191115094559-17f92b0546e8 h1:+RyjwU+Gnd/aTJBPZVDNm903eXVjjqhbaR4Ypx3xYyY= -github.com/hashicorp/terraform-config-inspect v0.0.0-20191115094559-17f92b0546e8/go.mod h1:p+ivJws3dpqbp1iP84+npOyAmTTOLMgCzrXd3GSdn/A= -github.com/hashicorp/terraform-json v0.4.0 h1:KNh29iNxozP5adfUFBJ4/fWd0Cu3taGgjHB38JYqOF4= -github.com/hashicorp/terraform-json v0.4.0/go.mod h1:eAbqb4w0pSlRmdvl8fOyHAi/+8jnkVYN28gJkSJrLhU= -github.com/hashicorp/terraform-plugin-sdk v1.8.0 h1:HE1p52nzcgz88hIJmapUnkmM9noEjV3QhTOLaua5XUA= -github.com/hashicorp/terraform-plugin-sdk v1.8.0/go.mod h1:OjgQmey5VxnPej/buEhe+YqKm0KNvV3QqU4hkqHqPCY= -github.com/hashicorp/terraform-plugin-test v1.2.0 h1:AWFdqyfnOj04sxTdaAF57QqvW7XXrT8PseUHkbKsE8I= -github.com/hashicorp/terraform-plugin-test v1.2.0/go.mod h1:QIJHYz8j+xJtdtLrFTlzQVC0ocr3rf/OjIpgZLK56Hs= -github.com/hashicorp/terraform-svchost v0.0.0-20191011084731-65d371908596/go.mod h1:kNDNcF7sN4DocDLBkQYz73HGKwN1ANB1blq4lIYLYvg= -github.com/hashicorp/terraform-svchost v0.0.0-20191119180714-d2e4933b9136 h1:81Dg7SK6Q5vzqFItO8e1iIF2Nj8bLXV23NXjEgbev/s= -github.com/hashicorp/terraform-svchost v0.0.0-20191119180714-d2e4933b9136/go.mod h1:kNDNcF7sN4DocDLBkQYz73HGKwN1ANB1blq4lIYLYvg= -github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= -github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= -github.com/hashicorp/yamux v0.0.0-20190923154419-df201c70410d h1:W+SIwDdl3+jXWeidYySAgzytE3piq6GumXeBjFBG67c= -github.com/hashicorp/yamux v0.0.0-20190923154419-df201c70410d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE= -github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= -github.com/jmespath/go-jmespath v0.0.0-20160202185014-0b12d6b521d8/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= -github.com/jmespath/go-jmespath v0.3.0 h1:OS12ieG61fsCg5+qLJ+SsW9NicxNkg3b25OyT2yCeUc= -github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1 h1:6QPYqodiu3GuPL+7mfx+NwDdp2eTkp9IfEUpgAwUN0o= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/keybase/go-crypto v0.0.0-20161004153544-93f5b35093ba/go.mod h1:ghbZscTyKdM07+Fw3KSi0hcJm+AlEUWj8QLlPtijN/M= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/hashicorp/terraform-exec v0.19.0 h1:FpqZ6n50Tk95mItTSS9BjeOVUb4eg81SpgVtZNNtFSM= +github.com/hashicorp/terraform-exec v0.19.0/go.mod h1:tbxUpe3JKruE9Cuf65mycSIT8KiNPZ0FkuTE3H4urQg= +github.com/hashicorp/terraform-json v0.18.0 h1:pCjgJEqqDESv4y0Tzdqfxr/edOIGkjs8keY42xfNBwU= +github.com/hashicorp/terraform-json v0.18.0/go.mod h1:qdeBs11ovMzo5puhrRibdD6d2Dq6TyE/28JiU4tIQxk= +github.com/hashicorp/terraform-plugin-go v0.20.0 h1:oqvoUlL+2EUbKNsJbIt3zqqZ7wi6lzn4ufkn/UA51xQ= +github.com/hashicorp/terraform-plugin-go v0.20.0/go.mod h1:Rr8LBdMlY53a3Z/HpP+ZU3/xCDqtKNCkeI9qOyT10QE= +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.31.0 h1:Bl3e2ei2j/Z3Hc2HIS15Gal2KMKyLAZ2om1HCEvK6es= +github.com/hashicorp/terraform-plugin-sdk/v2 v2.31.0/go.mod h1:i2C41tszDjiWfziPQDL5R/f3Zp0gahXe5No/MIO9rCE= +github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= +github.com/hashicorp/terraform-registry-address v0.2.3/go.mod h1:lFHA76T8jfQteVfT7caREqguFrW3c4MFSPhZB7HHgUM= +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/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 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= -github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= -github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +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.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mitchellh/cli v1.0.0 h1:iGBIsUe3+HZ/AD/Vd7DErOt5sU9fa8Uj7A2s1aggv1Y= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= -github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= -github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= -github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/go-testing-interface v1.14.0 h1:/x0XQ6h+3U3nAyk1yx+bHPURrKa9sVVvYbuqZ7pIAtI= -github.com/mitchellh/go-testing-interface v1.14.0/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= -github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= -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.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.2.2 h1:dxe5oCinTXiTIcfgmZecdCzPmAJKd46KsCWc35r0TV4= -github.com/mitchellh/mapstructure v1.2.2/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/mitchellh/reflectwalk v1.0.1 h1:FVzMWA5RllMAKIdUSC8mdWo3XtwoecrH79BY70sEEpE= -github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +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/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.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +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.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= +github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 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.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/posener/complete v1.2.1/go.mod h1:6gapUrK/U1TAN7ciCoNRIdVC5sbdBTUh1DKN0g6uH7E= -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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= -github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= -github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/skeema/knownhosts v1.2.1 h1:SHWdIUa82uGZz+F+47k8SY4QhhI291cXCpopT1lK2AQ= +github.com/skeema/knownhosts v1.2.1/go.mod h1:xYbVRSPxqBZFrdmDyMmsOs+uX1UZC3nTN3ThzgDxUwo= 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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/ulikunitz/xz v0.5.5/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4ABRW8= -github.com/ulikunitz/xz v0.5.7 h1:YvTNdFzX6+W5m9msiYg/zpkSURPPtOlzbqYjrFn7Yt4= -github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/victorops/go-victorops v1.0.1 h1:cwX+APDsok0rywIl2tIS055u0amBcPelmdKG8FEqwV8= github.com/victorops/go-victorops v1.0.1/go.mod h1:f597oumLjVOdxJ38mg6dQ/1rV6wpEYW4XY3K8o/QJNQ= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= -github.com/vmihailenco/msgpack v4.0.1+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/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/zclconf/go-cty v1.0.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= -github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s= -github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= -github.com/zclconf/go-cty v1.2.1/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8= -github.com/zclconf/go-cty v1.3.1 h1:QIOZl+CKKdkv4l2w3lG23nNzXgLoxsWLSEdg1MlX4p0= -github.com/zclconf/go-cty v1.3.1/go.mod h1:YO23e2L18AG+ZYQfSobnY4G65nvwvprPCxBHkufUH1k= -github.com/zclconf/go-cty-yaml v1.0.1 h1:up11wlgAaDvlAGENcFDnZgkn0qUJurso7k6EpURKNF8= -github.com/zclconf/go-cty-yaml v1.0.1/go.mod h1:IP3Ylp0wQpYm50IHK8OZWKMu6sPJIUgKa8XhiVHura0= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3 h1:8sGtKOrtQqkN1bp2AtX+misvLIlOmsEsNd+9NIcPEm8= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +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/zclconf/go-cty v1.14.1 h1:t9fyA35fwjjUMcmL5hLER+e/rEPqrbCK1/OSE4SI9KA= +github.com/zclconf/go-cty v1.14.1/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59 h1:3zb4D3T4G8jdExgVU/95+vQXfpEPiMdCaZgmGVxjNHM= -golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b h1:Wh+f8QHJXR411sJR8/vRBTZ7YapZaRvUcLFFJhusH0k= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0 h1:KU7oHjnv3XNWfa5COkzUifxZmxp1TyI7ImMXqFxLwvQ= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180530234432-1e491301e022/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180811021610-c39426892332/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191009170851-d66e71096ffb/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +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.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/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-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a h1:WXEvlFVvvGxCJLG6REjsT03iWnKLEWinaScsxF2Vm2o= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502175342-a43fa875dd82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190804053845-51ab0e2deafa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200317113312-5766fd39f98d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200317043434-63da46f3035e/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200324201824-1fc30e1f4ccc h1:pdV61EOqqmHmRMCtqTv+eoY+K04cmKfr4R8aA9ysfgY= -golang.org/x/tools v0.0.0-20200324201824-1fc30e1f4ccc/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0 h1:jz2KixHX7EcCPiQrySzPdnYT7DbINAypCqKZ1Z7GM40= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200317114155-1f3552e48f24/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200324203455-a04cca1dde73 h1:+yTMTeazSO5iBqU9NR53hgriivQQbYa5Uuaj8r3qKII= -google.golang.org/genproto v0.0.0-20200324203455-a04cca1dde73/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= -google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0 h1:bO/TA4OxCOummhSf10siHuG7vJOiwh7SpRpFZDkOgl4= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +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-20231212172506-995d672761c0 h1:/jFB8jK5R3Sq3i/lmeZO0cATSzFfZaJq1J2Euan3XKU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0/go.mod h1:FUoWkonphQm3RhTS+kOEhF8h0iDpm4tdXolVCeZ9KKA= +google.golang.org/grpc v1.60.1 h1:26+wFr+cNqSGFcOXcabYC0lUVJVRa2Sb2ortSK7VrEU= +google.golang.org/grpc v1.60.1/go.mod h1:OlCHIeLYqSSsLi6i49B5QGdzaMZK9+M7LXN2FKz4eGM= +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.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= -gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3 h1:sXmLre5bzIR6ypkjXCDI3jHPssRhc8KD/Ome589sc3U= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 6fe4b21..d7d312a 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,31 @@ package main import ( - "github.com/hashicorp/terraform-plugin-sdk/plugin" - "github.com/hashicorp/terraform-plugin-sdk/terraform" + "context" + "flag" + "log" + + "github.com/hashicorp/terraform-plugin-sdk/v2/plugin" "github.com/terraform-providers/terraform-provider-victorops/victorops" ) func main() { - plugin.Serve(&plugin.ServeOpts{ - ProviderFunc: func() terraform.ResourceProvider { - return victorops.Provider() - }, - }) + var debug bool + + flag.BoolVar(&debug, "debug", false, "set to true to run the provider with support for debuggers like delve") + flag.Parse() + + opts := &plugin.ServeOpts{ + ProviderFunc: victorops.Provider, + } + + if debug { + err := plugin.Debug(context.Background(), "registry.terraform.io/victorops/victorops", opts) + if err != nil { + log.Fatal(err.Error()) + } + return + } + + plugin.Serve(opts) } diff --git a/victorops/api_alert_rule.go b/victorops/api_alert_rule.go new file mode 100644 index 0000000..1b936a2 --- /dev/null +++ b/victorops/api_alert_rule.go @@ -0,0 +1,133 @@ +package victorops + +import ( + "encoding/json" + "fmt" + "strconv" +) + +// AlertRule represents an alert rule in VictorOps (API response) +// Note: API response uses "routeKey", while request uses "routingKey" +type AlertRule struct { + ID int `json:"id,omitempty"` + AlertField string `json:"alertField,omitempty"` + AlertValueMatch string `json:"alertValueMatch,omitempty"` + MatchType string `json:"matchType,omitempty"` + Rank int `json:"rank,omitempty"` + StopFlag bool `json:"stopFlag,omitempty"` + LastUpdated int64 `json:"lastUpdated,omitempty"` + LastUpdatedBy string `json:"lastUpdatedBy,omitempty"` + Notes string `json:"notes,omitempty"` + RouteKey string `json:"routeKey,omitempty"` // API response field (not routingKey) + Annotations []AlertAnnotation `json:"annotations,omitempty"` +} + +// AlertAnnotation represents an annotation on an alert rule +type AlertAnnotation struct { + ID int `json:"id,omitempty"` + AnnotationType string `json:"annotationType,omitempty"` + FieldName string `json:"fieldName,omitempty"` + FieldValue string `json:"fieldValue,omitempty"` + Flags int `json:"flags,omitempty"` +} + +// AlertRuleCreateRequest is the request body for creating an alert rule +type AlertRuleCreateRequest struct { + AlertField string `json:"alertField"` + AlertValueMatch string `json:"alertValueMatch"` + MatchType string `json:"matchType"` + Rank int `json:"rank"` + StopFlag bool `json:"stopFlag"` + Notes string `json:"notes,omitempty"` + RoutingKey string `json:"routingKey"` + Annotations []AlertAnnotation `json:"annotations"` +} + +// ListAlertRules gets all alert rules +func (c *APIClient) ListAlertRules() ([]AlertRule, error) { + respBody, statusCode, err := c.doRequest("GET", "/api-public/v1/alertRules", nil) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var rules []AlertRule + if err := json.Unmarshal(respBody, &rules); err != nil { + return nil, err + } + + return rules, nil +} + +// GetAlertRule gets an alert rule by ID +func (c *APIClient) GetAlertRule(ruleID int) (*AlertRule, error) { + rules, err := c.ListAlertRules() + if err != nil { + return nil, err + } + + for _, rule := range rules { + if rule.ID == ruleID { + return &rule, nil + } + } + + return nil, nil +} + +// CreateAlertRule creates a new alert rule +func (c *APIClient) CreateAlertRule(req *AlertRuleCreateRequest) (*AlertRule, error) { + respBody, statusCode, err := c.doRequest("POST", "/api-public/v1/alertRules", req) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var rule AlertRule + if err := json.Unmarshal(respBody, &rule); err != nil { + return nil, err + } + + return &rule, nil +} + +// UpdateAlertRule updates an existing alert rule +func (c *APIClient) UpdateAlertRule(ruleID int, req *AlertRuleCreateRequest) (*AlertRule, error) { + path := fmt.Sprintf("/api-public/v1/alertRules/%s", strconv.Itoa(ruleID)) + respBody, statusCode, err := c.doRequest("PUT", path, req) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var rule AlertRule + if err := json.Unmarshal(respBody, &rule); err != nil { + return nil, err + } + + return &rule, nil +} + +// DeleteAlertRule deletes an alert rule +func (c *APIClient) DeleteAlertRule(ruleID int) error { + path := fmt.Sprintf("/api-public/v1/alertRules/%s", strconv.Itoa(ruleID)) + respBody, statusCode, err := c.doRequest("DELETE", path, nil) + if err != nil { + return err + } + + if statusCode != 200 && statusCode != 204 { + return fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + return nil +} diff --git a/victorops/api_data_sources.go b/victorops/api_data_sources.go new file mode 100644 index 0000000..c1946c3 --- /dev/null +++ b/victorops/api_data_sources.go @@ -0,0 +1,345 @@ +package victorops + +import ( + "encoding/json" + "fmt" +) + +// TeamOncallSchedule represents a team's on-call schedule (spec lines 4707-4715) +type TeamOncallSchedule struct { + Team *TeamInfo `json:"team,omitempty"` // Object, not string + Schedules []PolicySchedule `json:"schedules,omitempty"` +} + +// TeamInfo represents team information in schedule responses (spec lines 4608-4622) +type TeamInfo struct { + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` +} + +// User represents a user object (spec lines 4602-4607) +type User struct { + Username string `json:"username,omitempty"` +} + +// EscalationPolicySummary represents policy summary (spec lines 5215-5235) +type EscalationPolicySummary struct { + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + SelfURL string `json:"_selfUrl,omitempty"` +} + +// OnCallRoll represents a roll in the schedule (spec lines 4637-4653) +type OnCallRoll struct { + Start string `json:"start,omitempty"` // ISO8601 string + End string `json:"end,omitempty"` // ISO8601 string + OnCallUser *User `json:"onCallUser,omitempty"` // Object, not string + IsRoll bool `json:"isRoll,omitempty"` +} + +// OnCallOverride represents an override (spec lines 4655-4667) +type OnCallOverride struct { + OrigOnCallUser *User `json:"origOnCallUser,omitempty"` + OverrideOnCallUser *User `json:"overrideOnCallUser,omitempty"` + Start string `json:"start,omitempty"` // ISO8601 + End string `json:"end,omitempty"` // ISO8601 +} + +// OnCallEntry represents an on-call entry (spec lines 4669-4691) +type OnCallEntry struct { + OnCallUser *User `json:"onCallUser,omitempty"` // Object, not string + OverrideOnCallUser *User `json:"overrideOnCallUser,omitempty"` + OnCallType string `json:"onCallType,omitempty"` + RotationName string `json:"rotationName,omitempty"` + ShiftName string `json:"shiftName,omitempty"` + ShiftRoll string `json:"shiftRoll,omitempty"` // ISO8601 + Rolls []OnCallRoll `json:"rolls,omitempty"` +} + +// PolicySchedule represents a policy schedule (spec lines 4693-4705) +type PolicySchedule struct { + Policy *EscalationPolicySummary `json:"policy,omitempty"` + Schedule []OnCallEntry `json:"schedule,omitempty"` + Overrides []OnCallOverride `json:"overrides,omitempty"` +} + +// Rotation represents a rotation in a team (v2 API) +type Rotation struct { + Label string `json:"label,omitempty"` + TotalMembersInRotation int `json:"totalMembersInRotation,omitempty"` + Shifts []ShiftDetails `json:"shifts,omitempty"` +} + +// ShiftDetails represents details of a shift +type ShiftDetails struct { + Label string `json:"label,omitempty"` + Duration int `json:"duration,omitempty"` + Current *OnCallPeriod `json:"current,omitempty"` + Next *OnCallPeriod `json:"next,omitempty"` + Periods []OnCallPeriod `json:"periods,omitempty"` + ShiftMembers []ShiftMember `json:"shiftMembers,omitempty"` + ShiftType string `json:"shifttype,omitempty"` + Start string `json:"start,omitempty"` + Timezone string `json:"timezone,omitempty"` + Mask *RotationMask `json:"mask,omitempty"` + Mask2 *RotationMask `json:"mask2,omitempty"` + Mask3 *RotationMask `json:"mask3,omitempty"` +} + +// ShiftMember represents a member in a shift +type ShiftMember struct { + Slug string `json:"slug,omitempty"` + Username string `json:"username,omitempty"` +} + +// OnCallPeriod represents an on-call time period +type OnCallPeriod struct { + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + Username string `json:"username,omitempty"` +} + +// RotationMask defines days and time ranges for on-call periods +type RotationMask struct { + Day *MaskDays `json:"day,omitempty"` + Time []MaskTimeRange `json:"time,omitempty"` +} + +// MaskDays represents which days of the week the rotation is active +type MaskDays struct { + Su bool `json:"su,omitempty"` + M bool `json:"m,omitempty"` + T bool `json:"t,omitempty"` + W bool `json:"w,omitempty"` + Th bool `json:"th,omitempty"` + F bool `json:"f,omitempty"` + Sa bool `json:"sa,omitempty"` +} + +// MaskTimeRange represents a time range within a day +type MaskTimeRange struct { + Start *MaskTime `json:"start,omitempty"` + End *MaskTime `json:"end,omitempty"` +} + +// MaskTime represents hour and minute +type MaskTime struct { + Hour int `json:"hour,omitempty"` + Minute int `json:"minute,omitempty"` +} + +// TeamAdmin represents a team admin +type TeamAdmin struct { + Username string `json:"username,omitempty"` +} + +// RoutingKeyInfo represents a routing key with target info +type RoutingKeyInfo struct { + RoutingKey string `json:"routingKey,omitempty"` + Targets []RoutingKeyTarget `json:"targets,omitempty"` + IsDefault bool `json:"isDefault,omitempty"` +} + +// RoutingKeyTarget represents a target for a routing key +type RoutingKeyTarget struct { + PolicySlug string `json:"policySlug,omitempty"` + PolicyName string `json:"policyName,omitempty"` +} + +// UserInfo represents user information +type UserInfo struct { + Username string `json:"username,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` + Email string `json:"email,omitempty"` + Admin bool `json:"admin,omitempty"` +} + +// UserDevice represents a user device +type UserDevice struct { + ExtID string `json:"extId,omitempty"` + DeviceType string `json:"deviceType,omitempty"` + Label string `json:"label,omitempty"` +} + +// GetTeamOncallSchedule gets a team's on-call schedule +func (c *APIClient) GetTeamOncallSchedule(teamID string, daysForward, daysSkip int) (*TeamOncallSchedule, error) { + path := fmt.Sprintf("/api-public/v2/team/%s/oncall/schedule?daysForward=%d&daysSkip=%d", teamID, daysForward, daysSkip) + respBody, statusCode, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + + if statusCode == 404 { + return nil, nil + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var schedule TeamOncallSchedule + if err := json.Unmarshal(respBody, &schedule); err != nil { + return nil, err + } + + return &schedule, nil +} + +// GetUserOncallSchedule gets a user's on-call schedule +func (c *APIClient) GetUserOncallSchedule(username string, daysForward, daysSkip int) ([]TeamOncallSchedule, error) { + path := fmt.Sprintf("/api-public/v2/user/%s/oncall/schedule?daysForward=%d&daysSkip=%d", username, daysForward, daysSkip) + respBody, statusCode, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + + if statusCode == 404 { + return nil, nil + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response struct { + TeamSchedules []TeamOncallSchedule `json:"teamSchedules"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + return response.TeamSchedules, nil +} + +// GetTeamRotations gets a team's rotations +func (c *APIClient) GetTeamRotations(teamID string) ([]Rotation, error) { + path := fmt.Sprintf("/api-public/v2/team/%s/rotations", teamID) + respBody, statusCode, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + + if statusCode == 404 { + return nil, nil + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response struct { + Rotations []Rotation `json:"rotations"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + return response.Rotations, nil +} + +// GetTeamAdmins gets a team's admins +func (c *APIClient) GetTeamAdmins(teamID string) ([]TeamAdmin, error) { + path := fmt.Sprintf("/api-public/v1/team/%s/admins", teamID) + respBody, statusCode, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + + if statusCode == 404 { + return nil, nil + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response struct { + TeamAdmins []TeamAdmin `json:"teamAdmins"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + return response.TeamAdmins, nil +} + +// GetRoutingKeys gets all routing keys +func (c *APIClient) GetRoutingKeys() ([]RoutingKeyInfo, error) { + path := "/api-public/v1/org/routing-keys" + respBody, statusCode, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response struct { + RoutingKeys []RoutingKeyInfo `json:"routingKeys"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + return response.RoutingKeys, nil +} + +// GetUsers gets all users, optionally filtered by email +func (c *APIClient) GetUsers(email string) ([]UserInfo, error) { + path := "/api-public/v2/user" + if email != "" { + path = fmt.Sprintf("%s?email=%s", path, email) + } + + respBody, statusCode, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response struct { + Users []UserInfo `json:"users"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + return response.Users, nil +} + +// GetUserDevices gets a user's devices +func (c *APIClient) GetUserDevices(username string) ([]UserDevice, error) { + path := fmt.Sprintf("/api-public/v1/user/%s/contact-methods/devices", username) + respBody, statusCode, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + + if statusCode == 404 { + return nil, nil + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + // Try to unmarshal as a direct array first + var devices []UserDevice + if err := json.Unmarshal(respBody, &devices); err != nil { + // If that fails, try wrapped response + var response struct { + Devices []UserDevice `json:"devices"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, fmt.Errorf("failed to parse devices response: %s", string(respBody)) + } + return response.Devices, nil + } + + return devices, nil +} diff --git a/victorops/api_maintenance_mode.go b/victorops/api_maintenance_mode.go new file mode 100644 index 0000000..b471636 --- /dev/null +++ b/victorops/api_maintenance_mode.go @@ -0,0 +1,109 @@ +package victorops + +import ( + "encoding/json" + "fmt" +) + +// MaintenanceModeState represents the maintenance mode state +type MaintenanceModeState struct { + CompanyID string `json:"companyId,omitempty"` + ActiveInstances []ActiveMaintenanceMode `json:"activeInstances,omitempty"` +} + +// ActiveMaintenanceMode represents an active maintenance mode instance +type ActiveMaintenanceMode struct { + InstanceID string `json:"instanceId,omitempty"` + StartedBy string `json:"startedBy,omitempty"` + StartedAt int64 `json:"startedAt,omitempty"` + Targets []MaintenanceModeTarget `json:"targets,omitempty"` + IsGlobal bool `json:"isGlobal,omitempty"` + Purpose string `json:"purpose,omitempty"` +} + +// MaintenanceModeTarget represents a target for maintenance mode (spec lines 5508-5519) +type MaintenanceModeTarget struct { + Type string `json:"type,omitempty"` // "RoutingKeys" + Names []string `json:"names,omitempty"` // routing key names +} + +// StartMaintenanceModeRequest is the request body for starting maintenance mode +type StartMaintenanceModeRequest struct { + Type string `json:"type"` + Names []string `json:"names"` + Purpose string `json:"purpose,omitempty"` +} + +// GetMaintenanceModeState gets the current maintenance mode state +func (c *APIClient) GetMaintenanceModeState() (*MaintenanceModeState, error) { + respBody, statusCode, err := c.doRequest("GET", "/api-public/v1/maintenancemode", nil) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var state MaintenanceModeState + if err := json.Unmarshal(respBody, &state); err != nil { + return nil, err + } + + return &state, nil +} + +// StartMaintenanceMode starts maintenance mode +func (c *APIClient) StartMaintenanceMode(req *StartMaintenanceModeRequest) (*MaintenanceModeState, error) { + respBody, statusCode, err := c.doRequest("POST", "/api-public/v1/maintenancemode/start", req) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var state MaintenanceModeState + if err := json.Unmarshal(respBody, &state); err != nil { + return nil, err + } + + return &state, nil +} + +// EndMaintenanceMode ends maintenance mode for the given instance ID +func (c *APIClient) EndMaintenanceMode(instanceID string) (*MaintenanceModeState, error) { + path := fmt.Sprintf("/api-public/v1/maintenancemode/%s/end", instanceID) + respBody, statusCode, err := c.doRequestText("PUT", path) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var state MaintenanceModeState + if err := json.Unmarshal(respBody, &state); err != nil { + return nil, err + } + + return &state, nil +} + +// FindMaintenanceModeByID finds a maintenance mode instance by ID +func (c *APIClient) FindMaintenanceModeByID(instanceID string) (*ActiveMaintenanceMode, error) { + state, err := c.GetMaintenanceModeState() + if err != nil { + return nil, err + } + + for _, instance := range state.ActiveInstances { + if instance.InstanceID == instanceID { + return &instance, nil + } + } + + return nil, nil +} diff --git a/victorops/api_paging_policy.go b/victorops/api_paging_policy.go new file mode 100644 index 0000000..79d7695 --- /dev/null +++ b/victorops/api_paging_policy.go @@ -0,0 +1,174 @@ +package victorops + +import ( + "encoding/json" + "fmt" +) + +// PagingPolicy represents a user's paging policy +type PagingPolicy struct { + Steps []PagingPolicyStep `json:"steps,omitempty"` + SelfURL string `json:"_selfUrl,omitempty"` +} + +// Contact represents a contact for paging (spec lines 5327-5333) +type Contact struct { + ID int `json:"id,omitempty"` + Type string `json:"type,omitempty"` // "email" or "phone" +} + +// PagingPolicyStep represents a step in a paging policy (spec lines 5353-5364) +type PagingPolicyStep struct { + Index int `json:"index,omitempty"` // Changed from "step" + Timeout int `json:"timeout,omitempty"` + Rules []PagingPolicyRule `json:"rules,omitempty"` + SelfURL string `json:"_selfUrl,omitempty"` +} + +// PagingPolicyRule represents a rule in a paging policy step (spec lines 5335-5343) +type PagingPolicyRule struct { + Index int `json:"index,omitempty"` // Changed from "rule" + Type string `json:"type,omitempty"` + Contact *Contact `json:"contact,omitempty"` // Changed from ContactID + SelfURL string `json:"_selfUrl,omitempty"` +} + +// PagingPolicyStepCreateRequest is the request body for creating a step (spec lines 5411-5419) +type PagingPolicyStepCreateRequest struct { + Timeout int `json:"timeout"` + Rules []PagingPolicyRuleAddPayload `json:"rules,omitempty"` +} + +// PagingPolicyRuleAddPayload represents a rule to add (spec lines 5345-5351) +type PagingPolicyRuleAddPayload struct { + Type string `json:"type"` + Contact *Contact `json:"contact,omitempty"` +} + +// PagingPolicyRuleCreateRequest is the request body for creating a rule (spec lines 5421-5427) +type PagingPolicyRuleCreateRequest struct { + Type string `json:"type"` + Contact *Contact `json:"contact,omitempty"` +} + +// GetUserPagingPolicy gets a user's paging policy +func (c *APIClient) GetUserPagingPolicy(username string) (*PagingPolicy, error) { + path := fmt.Sprintf("/api-public/v1/profile/%s/policies", username) + respBody, statusCode, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, err + } + + if statusCode == 404 { + return nil, nil + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response struct { + Steps []PagingPolicyStep `json:"steps"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + return &PagingPolicy{Steps: response.Steps}, nil +} + +// CreatePagingPolicyStep creates a new step in a user's paging policy +func (c *APIClient) CreatePagingPolicyStep(username string, req *PagingPolicyStepCreateRequest) (*PagingPolicyStep, error) { + path := fmt.Sprintf("/api-public/v1/profile/%s/policies", username) + respBody, statusCode, err := c.doRequest("POST", path, req) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response struct { + Step PagingPolicyStep `json:"step"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + return &response.Step, nil +} + +// UpdatePagingPolicyStep updates a step in a user's paging policy +func (c *APIClient) UpdatePagingPolicyStep(username string, stepNum int, req *PagingPolicyStepCreateRequest) (*PagingPolicyStep, error) { + path := fmt.Sprintf("/api-public/v1/profile/%s/policies/%d", username, stepNum) + respBody, statusCode, err := c.doRequest("PUT", path, req) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response struct { + Step PagingPolicyStep `json:"step"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + return &response.Step, nil +} + +// DeletePagingPolicyStep deletes a step from a user's paging policy +func (c *APIClient) DeletePagingPolicyStep(username string, stepNum int) error { + path := fmt.Sprintf("/api-public/v1/profile/%s/policies/%d", username, stepNum) + _, statusCode, err := c.doRequest("DELETE", path, nil) + if err != nil { + return err + } + + if statusCode != 200 && statusCode != 204 { + return fmt.Errorf("API error (%d)", statusCode) + } + + return nil +} + +// CreatePagingPolicyRule creates a new rule in a paging policy step +func (c *APIClient) CreatePagingPolicyRule(username string, stepNum int, req *PagingPolicyRuleCreateRequest) (*PagingPolicyRule, error) { + path := fmt.Sprintf("/api-public/v1/profile/%s/policies/%d", username, stepNum) + respBody, statusCode, err := c.doRequest("POST", path, req) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response struct { + StepRule PagingPolicyRule `json:"stepRule"` + } + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + return &response.StepRule, nil +} + +// DeletePagingPolicyRule deletes a rule from a paging policy step +func (c *APIClient) DeletePagingPolicyRule(username string, stepNum, ruleNum int) error { + path := fmt.Sprintf("/api-public/v1/profile/%s/policies/%d/%d", username, stepNum, ruleNum) + _, statusCode, err := c.doRequest("DELETE", path, nil) + if err != nil { + return err + } + + if statusCode != 200 && statusCode != 204 { + return fmt.Errorf("API error (%d)", statusCode) + } + + return nil +} diff --git a/victorops/api_scheduled_override.go b/victorops/api_scheduled_override.go new file mode 100644 index 0000000..3686fcc --- /dev/null +++ b/victorops/api_scheduled_override.go @@ -0,0 +1,226 @@ +package victorops + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +// ScheduledOverride represents a scheduled override in VictorOps +type ScheduledOverride struct { + PublicID string `json:"publicId,omitempty"` + Username string `json:"username,omitempty"` + Timezone string `json:"timezone,omitempty"` + Start string `json:"start,omitempty"` + End string `json:"end,omitempty"` + Assignments []OverrideAssignment `json:"assignments,omitempty"` + User *ScheduledOverrideUser `json:"user,omitempty"` +} + +// ScheduledOverrideUser represents the user part of a scheduled override +type ScheduledOverrideUser struct { + Username string `json:"username,omitempty"` +} + +// OverrideAssignment represents an assignment for a scheduled override +type OverrideAssignment struct { + Team string `json:"team,omitempty"` + Policy string `json:"policy,omitempty"` + Assigned bool `json:"assigned,omitempty"` + Username string `json:"user,omitempty"` +} + +// ScheduledOverrideCreateRequest is the request body for creating a scheduled override +type ScheduledOverrideCreateRequest struct { + Username string `json:"username"` + Timezone string `json:"timezone"` + Start string `json:"start"` + End string `json:"end"` +} + +// ScheduledOverrideResponse is the API response for scheduled override operations +type ScheduledOverrideResponse struct { + Override *ScheduledOverride `json:"override,omitempty"` + Schedule *ScheduledOverride `json:"schedule,omitempty"` + SelfURL string `json:"_selfUrl,omitempty"` +} + +// AssignmentUpdateRequest is the request body for updating an assignment +type AssignmentUpdateRequest struct { + Username string `json:"username"` + AcceptOverlap bool `json:"acceptOverlap,omitempty"` +} + +// APIClient provides methods for VictorOps API calls not in go-victorops +type APIClient struct { + BaseURL string + APIID string + APIKey string + Client *http.Client +} + +// NewAPIClient creates a new API client +func NewAPIClient(baseURL, apiID, apiKey string) *APIClient { + return &APIClient{ + BaseURL: baseURL, + APIID: apiID, + APIKey: apiKey, + Client: &http.Client{}, + } +} + +func (c *APIClient) doRequest(method, path string, body interface{}) ([]byte, int, error) { + var reqBody io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + reqBody = bytes.NewBuffer(jsonBody) + } + + req, err := http.NewRequest(method, c.BaseURL+path, reqBody) + if err != nil { + return nil, 0, err + } + + req.Header.Set("X-VO-Api-Id", c.APIID) + req.Header.Set("X-VO-Api-Key", c.APIKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := c.Client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + + return respBody, resp.StatusCode, nil +} + +// doRequestText sends a request with text/plain content type (no body) +func (c *APIClient) doRequestText(method, path string) ([]byte, int, error) { + req, err := http.NewRequest(method, c.BaseURL+path, nil) + if err != nil { + return nil, 0, err + } + + req.Header.Set("X-VO-Api-Id", c.APIID) + req.Header.Set("X-VO-Api-Key", c.APIKey) + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("Accept", "application/json") + + resp, err := c.Client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + + return respBody, resp.StatusCode, nil +} + +// CreateScheduledOverride creates a new scheduled override +func (c *APIClient) CreateScheduledOverride(req *ScheduledOverrideCreateRequest) (*ScheduledOverride, error) { + respBody, statusCode, err := c.doRequest("POST", "/api-public/v1/overrides", req) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response ScheduledOverrideResponse + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + if response.Schedule != nil { + return response.Schedule, nil + } + return response.Override, nil +} + +// GetScheduledOverride gets a scheduled override by public ID +func (c *APIClient) GetScheduledOverride(publicID string) (*ScheduledOverride, error) { + respBody, statusCode, err := c.doRequest("GET", "/api-public/v1/overrides/"+publicID, nil) + if err != nil { + return nil, err + } + + if statusCode == 404 { + return nil, nil + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var response ScheduledOverrideResponse + if err := json.Unmarshal(respBody, &response); err != nil { + return nil, err + } + + return response.Override, nil +} + +// DeleteScheduledOverride deletes a scheduled override +func (c *APIClient) DeleteScheduledOverride(publicID string) error { + _, statusCode, err := c.doRequest("DELETE", "/api-public/v1/overrides/"+publicID, nil) + if err != nil { + return err + } + + if statusCode != 200 && statusCode != 204 { + return fmt.Errorf("API error (%d)", statusCode) + } + + return nil +} + +// UpdateAssignment updates an assignment for a scheduled override +func (c *APIClient) UpdateAssignment(publicID, policySlug string, req *AssignmentUpdateRequest) (*OverrideAssignment, error) { + path := fmt.Sprintf("/api-public/v1/overrides/%s/assignments/%s", publicID, policySlug) + respBody, statusCode, err := c.doRequest("PUT", path, req) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var assignment OverrideAssignment + if err := json.Unmarshal(respBody, &assignment); err != nil { + return nil, err + } + + return &assignment, nil +} + +// DeleteAssignment deletes an assignment for a scheduled override +func (c *APIClient) DeleteAssignment(publicID, policySlug string) error { + path := fmt.Sprintf("/api-public/v1/overrides/%s/assignments/%s", publicID, policySlug) + _, statusCode, err := c.doRequest("DELETE", path, nil) + if err != nil { + return err + } + + if statusCode != 200 && statusCode != 204 { + return fmt.Errorf("API error (%d)", statusCode) + } + + return nil +} diff --git a/victorops/api_team.go b/victorops/api_team.go new file mode 100644 index 0000000..facd7a4 --- /dev/null +++ b/victorops/api_team.go @@ -0,0 +1,43 @@ +package victorops + +import ( + "encoding/json" + "fmt" +) + +// TeamUpdateRequest is the request body for updating a team +type TeamUpdateRequest struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// TeamResponse is the API response for team operations +type TeamResponse struct { + Name string `json:"name,omitempty"` + Slug string `json:"slug,omitempty"` + MemberCount int `json:"memberCount,omitempty"` + Version int `json:"version,omitempty"` + IsDefaultTeam bool `json:"isDefaultTeam,omitempty"` + Description string `json:"description,omitempty"` +} + +// UpdateTeam updates a team's name and/or description +// This bypasses the buggy go-victorops library which incorrectly uses team.Name for URL path +func (c *APIClient) UpdateTeam(teamSlug string, req *TeamUpdateRequest) (*TeamResponse, error) { + path := fmt.Sprintf("/api-public/v1/team/%s", teamSlug) + respBody, statusCode, err := c.doRequest("PUT", path, req) + if err != nil { + return nil, err + } + + if statusCode != 200 { + return nil, fmt.Errorf("API error (%d): %s", statusCode, string(respBody)) + } + + var team TeamResponse + if err := json.Unmarshal(respBody, &team); err != nil { + return nil, err + } + + return &team, nil +} diff --git a/victorops/client.go b/victorops/client.go new file mode 100644 index 0000000..3b5baa7 --- /dev/null +++ b/victorops/client.go @@ -0,0 +1,122 @@ +package victorops + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry" + "golang.org/x/time/rate" +) + +const ( + // VictorOps API rate limit is approximately 2 requests per second + defaultRetryMinDelay = 500 * time.Millisecond + defaultRetryMaxDelay = 30 * time.Second + defaultRetryTimeout = 5 * time.Minute +) + +// GlobalRateLimiter is used to limit API requests to 2 per second +// This is a global limiter shared across all resources to respect VictorOps API rate limits +var GlobalRateLimiter = rate.NewLimiter(2, 1) // 2 tokens per second, burst of 1 + +// WaitForRateLimitWithContext waits for the rate limiter before making an API request +func WaitForRateLimitWithContext(ctx context.Context) error { + return GlobalRateLimiter.Wait(ctx) +} + +// WaitForRateLimit waits for the rate limiter (convenience function with background context) +func WaitForRateLimitGlobal() error { + return GlobalRateLimiter.Wait(context.Background()) +} + +// RetryConfig holds configuration for retry behavior +type RetryConfig struct { + MinDelay time.Duration + MaxDelay time.Duration + Timeout time.Duration +} + +// DefaultRetryConfig returns the default retry configuration +func DefaultRetryConfig() RetryConfig { + return RetryConfig{ + MinDelay: defaultRetryMinDelay, + MaxDelay: defaultRetryMaxDelay, + Timeout: defaultRetryTimeout, + } +} + +// IsRetryableError determines if an error or status code should be retried +func IsRetryableError(statusCode int, err error) bool { + if err != nil { + return true + } + + switch statusCode { + case http.StatusTooManyRequests, // 429 + http.StatusServiceUnavailable, // 503 + http.StatusGatewayTimeout, // 504 + http.StatusBadGateway, // 502 + http.StatusInternalServerError: // 500 + return true + } + + return false +} + +// RetryWithBackoff executes a function with exponential backoff retry logic +func RetryWithBackoff(ctx context.Context, config RetryConfig, operation func() (interface{}, int, error)) (interface{}, error) { + var result interface{} + + err := retry.RetryContext(ctx, config.Timeout, func() *retry.RetryError { + res, statusCode, err := operation() + + if err != nil && IsRetryableError(statusCode, err) { + log.Printf("[DEBUG] Retryable error encountered (status: %d): %v", statusCode, err) + return retry.RetryableError(fmt.Errorf("retryable error: %w", err)) + } + + if err != nil { + return retry.NonRetryableError(err) + } + + if IsRetryableError(statusCode, nil) { + log.Printf("[DEBUG] Retryable status code encountered: %d", statusCode) + return retry.RetryableError(fmt.Errorf("retryable status code: %d", statusCode)) + } + + result = res + return nil + }) + + return result, err +} + +// WaitForRateLimit implements a simple rate limiter to stay under 2 req/sec +type RateLimiter struct { + lastRequest time.Time + minInterval time.Duration +} + +// NewRateLimiter creates a new rate limiter with the specified minimum interval +func NewRateLimiter(minInterval time.Duration) *RateLimiter { + return &RateLimiter{ + minInterval: minInterval, + } +} + +// Wait blocks until it's safe to make another request +func (r *RateLimiter) Wait() { + if r.lastRequest.IsZero() { + r.lastRequest = time.Now() + return + } + + elapsed := time.Since(r.lastRequest) + if elapsed < r.minInterval { + time.Sleep(r.minInterval - elapsed) + } + r.lastRequest = time.Now() +} diff --git a/victorops/config.go b/victorops/config.go index cf685e5..3a72352 100644 --- a/victorops/config.go +++ b/victorops/config.go @@ -4,18 +4,19 @@ import "github.com/victorops/go-victorops/victorops" // Config options for the VO Client type Config struct { - APIKey string - APIId string - BaseURL string - VictorOpsClient *victorops.Client + APIKey string + APIId string + BaseURL string + VictorOpsClient *victorops.Client + TerraformVersion string } -// Returns a list of all valid weekday strings +// WeekdayList returns a list of all valid weekday strings func WeekdayList() []string { return []string{"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"} } -// Returns a list of all valid timezones we can utilize. There are no constant arrays in golang. +// TimeZoneList returns a list of all valid timezones func TimeZoneList() []string { return []string{"Africa/Accra", "Africa/Addis_Ababa", "Africa/Algiers", "Africa/Asmara", "Africa/Asmera", "Africa/Bamako", "Africa/Bangui", "Africa/Banjul", "Africa/Bissau", "Africa/Blantyre", "Africa/Brazzaville", "Africa/Bujumbura", "Africa/Cairo", "Africa/Casablanca", "Africa/Ceuta", "Africa/Conakry", "Africa/Dakar", "Africa/Dar_es_Salaam", "Africa/Djibouti", "Africa/Douala", "Africa/El_Aaiun", "Africa/Freetown", "Africa/Gaborone", "Africa/Harare", "Africa/Johannesburg", "Africa/Juba", "Africa/Kampala", "Africa/Khartoum", "Africa/Kigali", "Africa/Kinshasa", "Africa/Lagos", "Africa/Libreville", "Africa/Lome", "Africa/Luanda", "Africa/Lubumbashi", "Africa/Lusaka", "Africa/Malabo", "Africa/Maputo", "Africa/Maseru", "Africa/Mbabane", "Africa/Mogadishu", "Africa/Monrovia", "Africa/Nairobi", "Africa/Ndjamena", "Africa/Niamey", "Africa/Nouakchott", "Africa/Ouagadougou", "Africa/Porto-Novo", "Africa/Sao_Tome", "Africa/Timbuktu", "Africa/Tripoli", "Africa/Tunis", "Africa/Windhoek", "America/Adak", "America/Anchorage", "America/Anguilla", "America/Antigua", "America/Araguaina", "America/Argentina/Buenos_Aires", "America/Argentina/Catamarca", "America/Argentina/ComodRivadavia", "America/Argentina/Cordoba", "America/Argentina/Jujuy", "America/Argentina/La_Rioja", "America/Argentina/Mendoza", "America/Argentina/Rio_Gallegos", "America/Argentina/Salta", "America/Argentina/San_Juan", "America/Argentina/San_Luis", "America/Argentina/Tucuman", "America/Argentina/Ushuaia", "America/Aruba", "America/Asuncion", "America/Atikokan", "America/Atka", "America/Bahia", "America/Bahia_Banderas", "America/Barbados", "America/Belem", "America/Belize", "America/Blanc-Sablon", "America/Boa_Vista", "America/Bogota", "America/Boise", "America/Buenos_Aires", "America/Cambridge_Bay", "America/Campo_Grande", "America/Cancun", "America/Caracas", "America/Catamarca", "America/Cayenne", "America/Cayman", "America/Chicago", "America/Chihuahua", "America/Coral_Harbour", "America/Cordoba", "America/Costa_Rica", "America/Creston", "America/Cuiaba", "America/Curacao", "America/Danmarkshavn", "America/Dawson", "America/Dawson_Creek", "America/Denver", "America/Detroit", "America/Dominica", "America/Edmonton", "America/Eirunepe", "America/El_Salvador", "America/Ensenada", "America/Fort_Nelson", "America/Fort_Wayne", "America/Fortaleza", "America/Glace_Bay", "America/Godthab", "America/Goose_Bay", "America/Grand_Turk", "America/Grenada", "America/Guadeloupe", "America/Guatemala", "America/Guayaquil", "America/Guyana", "America/Halifax", "America/Havana", "America/Hermosillo", "America/Indiana/Indianapolis", "America/Indiana/Knox", "America/Indiana/Marengo", "America/Indiana/Petersburg", "America/Indiana/Tell_City", "America/Indiana/Vevay", "America/Indiana/Vincennes", "America/Indiana/Winamac", "America/Indianapolis", "America/Inuvik", "America/Iqaluit", "America/Jamaica", "America/Jujuy", "America/Juneau", "America/Kentucky/Louisville", "America/Kentucky/Monticello", "America/Knox_IN", "America/Kralendijk", "America/La_Paz", "America/Lima", "America/Los_Angeles", "America/Louisville", "America/Lower_Princes", "America/Maceio", "America/Managua", "America/Manaus", "America/Marigot", "America/Martinique", "America/Matamoros", "America/Mazatlan", "America/Mendoza", "America/Menominee", "America/Merida", "America/Metlakatla", "America/Mexico_City", "America/Miquelon", "America/Moncton", "America/Monterrey", "America/Montevideo", "America/Montreal", "America/Montserrat", "America/Nassau", "America/New_York", "America/Nipigon", "America/Nome", "America/Noronha", "America/North_Dakota/Beulah", "America/North_Dakota/Center", "America/North_Dakota/New_Salem", "America/Ojinaga", "America/Panama", "America/Pangnirtung", "America/Paramaribo", "America/Phoenix", "America/Port-au-Prince", "America/Port_of_Spain", "America/Porto_Acre", "America/Porto_Velho", "America/Puerto_Rico", "America/Punta_Arenas", "America/Rainy_River", "America/Rankin_Inlet", "America/Recife", "America/Regina", "America/Resolute", "America/Rio_Branco", "America/Rosario", "America/Santa_Isabel", "America/Santarem", "America/Santiago", "America/Santo_Domingo", "America/Sao_Paulo", "America/Scoresbysund", "America/Shiprock", "America/Sitka", "America/St_Barthelemy", "America/St_Johns", "America/St_Kitts", "America/St_Lucia", "America/St_Thomas", "America/St_Vincent", "America/Swift_Current", "America/Tegucigalpa", "America/Thule", "America/Thunder_Bay", "America/Tijuana", "America/Toronto", "America/Tortola", "America/Vancouver", "America/Virgin", "America/Whitehorse", "America/Winnipeg", "America/Yakutat", "America/Yellowknife", "Antarctica/Casey", "Antarctica/Davis", "Antarctica/DumontDUrville", "Antarctica/Macquarie", "Antarctica/Mawson", "Antarctica/McMurdo", "Antarctica/Palmer", "Antarctica/Rothera", "Antarctica/South_Pole", "Antarctica/Syowa", "Antarctica/Troll", "Antarctica/Vostok", "Arctic/Longyearbyen", "Asia/Aden", "Asia/Almaty", "Asia/Amman", "Asia/Anadyr", "Asia/Aqtau", "Asia/Aqtobe", "Asia/Ashgabat", "Asia/Ashkhabad", "Asia/Atyrau", "Asia/Baghdad", "Asia/Bahrain", "Asia/Baku", "Asia/Bangkok", "Asia/Barnaul", "Asia/Beirut", "Asia/Bishkek", "Asia/Brunei", "Asia/Calcutta", "Asia/Chita", "Asia/Choibalsan", "Asia/Chongqing", "Asia/Chungking", "Asia/Colombo", "Asia/Dacca", "Asia/Damascus", "Asia/Dhaka", "Asia/Dili", "Asia/Dubai", "Asia/Dushanbe", "Asia/Famagusta", "Asia/Gaza", "Asia/Harbin", "Asia/Hebron", "Asia/Ho_Chi_Minh", "Asia/Hong_Kong", "Asia/Hovd", "Asia/Irkutsk", "Asia/Istanbul", "Asia/Jakarta", "Asia/Jayapura", "Asia/Jerusalem", "Asia/Kabul", "Asia/Kamchatka", "Asia/Karachi", "Asia/Kashgar", "Asia/Kathmandu", "Asia/Katmandu", "Asia/Khandyga", "Asia/Kolkata", "Asia/Krasnoyarsk", "Asia/Kuala_Lumpur", "Asia/Kuching", "Asia/Kuwait", "Asia/Macao", "Asia/Macau", "Asia/Magadan", "Asia/Makassar", "Asia/Manila", "Asia/Muscat", "Asia/Nicosia", "Asia/Novokuznetsk", "Asia/Novosibirsk", "Asia/Omsk", "Asia/Oral", "Asia/Phnom_Penh", "Asia/Pontianak", "Asia/Pyongyang", "Asia/Qatar", "Asia/Qostanay", "Asia/Qyzylorda", "Asia/Rangoon", "Asia/Riyadh", "Asia/Saigon", "Asia/Sakhalin", "Asia/Samarkand", "Asia/Seoul", "Asia/Shanghai", "Asia/Singapore", "Asia/Srednekolymsk", "Asia/Taipei", "Asia/Tashkent", "Asia/Tbilisi", "Asia/Tehran", "Asia/Tel_Aviv", "Asia/Thimbu", "Asia/Thimphu", "Asia/Tokyo", "Asia/Tomsk", "Asia/Ujung_Pandang", "Asia/Ulaanbaatar", "Asia/Ulan_Bator", "Asia/Urumqi", "Asia/Ust-Nera", "Asia/Vientiane", "Asia/Vladivostok", "Asia/Yakutsk", "Asia/Yangon", "Asia/Yekaterinburg", "Asia/Yerevan", "Atlantic/Azores", "Atlantic/Bermuda", "Atlantic/Canary", "Atlantic/Cape_Verde", "Atlantic/Faeroe", "Atlantic/Faroe", "Atlantic/Jan_Mayen", "Atlantic/Madeira", "Atlantic/Reykjavik", "Atlantic/South_Georgia", "Atlantic/St_Helena", "Atlantic/Stanley", "Australia/ACT", "Australia/Adelaide", "Australia/Brisbane", "Australia/Broken_Hill", "Australia/Canberra", "Australia/Currie", "Australia/Darwin", "Australia/Eucla", "Australia/Hobart", "Australia/LHI", "Australia/Lindeman", "Australia/Lord_Howe", "Australia/Melbourne", "Australia/NSW", "Australia/North", "Australia/Perth", "Australia/Queensland", "Australia/South", "Australia/Sydney", "Australia/Tasmania", "Australia/Victoria", "Australia/West", "Australia/Yancowinna", "Brazil/Acre", "Brazil/DeNoronha", "Brazil/East", "Brazil/West", "CET", "CST6CDT", "Canada/Atlantic", "Canada/Central", "Canada/Eastern", "Canada/Mountain", "Canada/Newfoundland", "Canada/Pacific", "Canada/Saskatchewan", "Canada/Yukon", "Chile/Continental", "Chile/EasterIsland", "Cuba", "EET", "EST", "EST5EDT", "Egypt", "Eire", "Etc/GMT", "Etc/GMT+0", "Etc/GMT+1", "Etc/GMT+10", "Etc/GMT+11", "Etc/GMT+12", "Etc/GMT+2", "Etc/GMT+3", "Etc/GMT+4", "Etc/GMT+5", "Etc/GMT+6", "Etc/GMT+7", "Etc/GMT+8", "Etc/GMT+9", "Etc/GMT-0", "Etc/GMT-1", "Etc/GMT-10", "Etc/GMT-11", "Etc/GMT-12", "Etc/GMT-13", "Etc/GMT-14", "Etc/GMT-2", "Etc/GMT-3", "Etc/GMT-4", "Etc/GMT-5", "Etc/GMT-6", "Etc/GMT-7", "Etc/GMT-8", "Etc/GMT-9", "Etc/GMT0", "Etc/Greenwich", "Etc/UCT", "Etc/UTC", "Etc/Universal", "Etc/Zulu", "Europe/Amsterdam", "Europe/Andorra", "Europe/Astrakhan", "Europe/Athens", "Europe/Belfast", "Europe/Belgrade", "Europe/Berlin", "Europe/Bratislava", "Europe/Brussels", "Europe/Bucharest", "Europe/Budapest", "Europe/Busingen", "Europe/Chisinau", "Europe/Copenhagen", "Europe/Dublin", "Europe/Gibraltar", "Europe/Guernsey", "Europe/Helsinki", "Europe/Isle_of_Man", "Europe/Istanbul", "Europe/Jersey", "Europe/Kaliningrad", "Europe/Kiev", "Europe/Kirov", "Europe/Lisbon", "Europe/Ljubljana", "Europe/London", "Europe/Luxembourg", "Europe/Madrid", "Europe/Malta", "Europe/Mariehamn", "Europe/Minsk", "Europe/Monaco", "Europe/Moscow", "Europe/Nicosia", "Europe/Oslo", "Europe/Paris", "Europe/Podgorica", "Europe/Prague", "Europe/Riga", "Europe/Rome", "Europe/Samara", "Europe/San_Marino", "Europe/Sarajevo", "Europe/Saratov", "Europe/Simferopol", "Europe/Skopje", "Europe/Sofia", "Europe/Stockholm", "Europe/Tallinn", "Europe/Tirane", "Europe/Tiraspol", "Europe/Ulyanovsk", "Europe/Uzhgorod", "Europe/Vaduz", "Europe/Vatican", "Europe/Vienna", "Europe/Vilnius", "Europe/Volgograd", "Europe/Warsaw", "Europe/Zagreb", "Europe/Zaporozhye", "Europe/Zurich", "GB", "GB-Eire", "GMT", "GMT+0", "GMT-0", "GMT0", "Greenwich", "HST", "Hongkong", "Iceland", "Indian/Antananarivo", "Indian/Chagos", "Indian/Christmas", "Indian/Cocos", "Indian/Comoro", "Indian/Kerguelen", "Indian/Mahe", "Indian/Maldives", "Indian/Mauritius", "Indian/Mayotte", "Indian/Reunion", "Iran", "Israel", "Jamaica", "Japan", "Kwajalein", "Libya", "MET", "MST", "MST7MDT", "Mexico/BajaNorte", "Mexico/BajaSur", "Mexico/General", "NZ", "NZ-CHAT", "Navajo", "PRC", "PST8PDT", "Pacific/Apia", "Pacific/Auckland", "Pacific/Bougainville", "Pacific/Chatham", "Pacific/Chuuk", "Pacific/Easter", "Pacific/Efate", "Pacific/Enderbury", "Pacific/Fakaofo", "Pacific/Fiji", "Pacific/Funafuti", "Pacific/Galapagos", "Pacific/Gambier", "Pacific/Guadalcanal", "Pacific/Guam", "Pacific/Honolulu", "Pacific/Johnston", "Pacific/Kiritimati", "Pacific/Kosrae", "Pacific/Kwajalein", "Pacific/Majuro", "Pacific/Marquesas", "Pacific/Midway", "Pacific/Nauru", "Pacific/Niue", "Pacific/Norfolk", "Pacific/Noumea", "Pacific/Pago_Pago", "Pacific/Palau", "Pacific/Pitcairn", "Pacific/Pohnpei", "Pacific/Ponape", "Pacific/Port_Moresby", "Pacific/Rarotonga", "Pacific/Saipan", "Pacific/Samoa", "Pacific/Tahiti", "Pacific/Tarawa", "Pacific/Tongatapu", "Pacific/Truk", "Pacific/Wake", "Pacific/Wallis", "Pacific/Yap", "Poland", "Portugal", "ROC", "ROK", "Singapore", "Turkey", "UCT", "US/Alaska", "US/Aleutian", "US/Arizona", "US/Central", "US/East-Indiana", "US/Eastern", "US/Hawaii", "US/Indiana-Starke", "US/Michigan", "US/Mountain", "US/Pacific", "US/Pacific-New", "US/Samoa", "UTC", "Universal", "W-SU", "WET", "Zulu"} } diff --git a/victorops/data_source_victorops_rotations.go b/victorops/data_source_victorops_rotations.go new file mode 100644 index 0000000..e6078b5 --- /dev/null +++ b/victorops/data_source_victorops_rotations.go @@ -0,0 +1,437 @@ +package victorops + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceRotations() *schema.Resource { + return &schema.Resource{ + Description: "Get a team's rotations. Note: Rotations are read-only via the API.", + ReadContext: dataSourceRotationsRead, + + Schema: map[string]*schema.Schema{ + "team_id": { + Type: schema.TypeString, + Required: true, + Description: "The slug/ID of the team.", + }, + "rotations": { + Type: schema.TypeList, + Computed: true, + Description: "The rotations for the team.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "label": { + Type: schema.TypeString, + Computed: true, + Description: "The label/name of the rotation.", + }, + "total_members_in_rotation": { + Type: schema.TypeInt, + Computed: true, + Description: "The total number of members in the rotation.", + }, + "shifts": { + Type: schema.TypeList, + Computed: true, + Description: "The shifts in the rotation.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "label": { + Type: schema.TypeString, + Computed: true, + Description: "The label of the shift.", + }, + "duration": { + Type: schema.TypeInt, + Computed: true, + Description: "The duration of the shift in seconds.", + }, + "shift_type": { + Type: schema.TypeString, + Computed: true, + Description: "The type of the shift (std, fts, cstm, pho).", + }, + "start": { + Type: schema.TypeString, + Computed: true, + Description: "The start time of the shift (ISO8601 timestamp).", + }, + "timezone": { + Type: schema.TypeString, + Computed: true, + Description: "The timezone for the shift.", + }, + "shift_members": { + Type: schema.TypeList, + Computed: true, + Description: "The members of the shift.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "slug": { + Type: schema.TypeString, + Computed: true, + Description: "The unique identifier for the shift member.", + }, + "username": { + Type: schema.TypeString, + Computed: true, + Description: "The username of the shift member.", + }, + }, + }, + }, + "current": { + Type: schema.TypeList, + Computed: true, + Description: "The current on-call period.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "start": { + Type: schema.TypeString, + Computed: true, + Description: "Start time (ISO8601 timestamp).", + }, + "end": { + Type: schema.TypeString, + Computed: true, + Description: "End time (ISO8601 timestamp).", + }, + "username": { + Type: schema.TypeString, + Computed: true, + Description: "Username of the on-call user.", + }, + }, + }, + }, + "next": { + Type: schema.TypeList, + Computed: true, + Description: "The next on-call period.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "start": { + Type: schema.TypeString, + Computed: true, + Description: "Start time (ISO8601 timestamp).", + }, + "end": { + Type: schema.TypeString, + Computed: true, + Description: "End time (ISO8601 timestamp).", + }, + "username": { + Type: schema.TypeString, + Computed: true, + Description: "Username of the on-call user.", + }, + }, + }, + }, + "periods": { + Type: schema.TypeList, + Computed: true, + Description: "List of on-call periods.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "start": { + Type: schema.TypeString, + Computed: true, + Description: "Start time (ISO8601 timestamp).", + }, + "end": { + Type: schema.TypeString, + Computed: true, + Description: "End time (ISO8601 timestamp).", + }, + "username": { + Type: schema.TypeString, + Computed: true, + Description: "Username of the on-call user.", + }, + }, + }, + }, + "mask": { + Type: schema.TypeList, + Computed: true, + Description: "The primary rotation mask defining days and time ranges.", + Elem: rotationMaskSchema(), + }, + "mask2": { + Type: schema.TypeList, + Computed: true, + Description: "Optional secondary rotation mask.", + Elem: rotationMaskSchema(), + }, + "mask3": { + Type: schema.TypeList, + Computed: true, + Description: "Optional tertiary rotation mask.", + Elem: rotationMaskSchema(), + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +// rotationMaskSchema returns the schema for a rotation mask +func rotationMaskSchema() *schema.Resource { + return &schema.Resource{ + Schema: map[string]*schema.Schema{ + "day": { + Type: schema.TypeList, + Computed: true, + Description: "Days of the week the rotation is active.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "su": { + Type: schema.TypeBool, + Computed: true, + Description: "Sunday.", + }, + "m": { + Type: schema.TypeBool, + Computed: true, + Description: "Monday.", + }, + "t": { + Type: schema.TypeBool, + Computed: true, + Description: "Tuesday.", + }, + "w": { + Type: schema.TypeBool, + Computed: true, + Description: "Wednesday.", + }, + "th": { + Type: schema.TypeBool, + Computed: true, + Description: "Thursday.", + }, + "f": { + Type: schema.TypeBool, + Computed: true, + Description: "Friday.", + }, + "sa": { + Type: schema.TypeBool, + Computed: true, + Description: "Saturday.", + }, + }, + }, + }, + "time": { + Type: schema.TypeList, + Computed: true, + Description: "Time ranges for the rotation.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "start": { + Type: schema.TypeList, + Computed: true, + Description: "Start time.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "hour": { + Type: schema.TypeInt, + Computed: true, + Description: "Hour (0-23).", + }, + "minute": { + Type: schema.TypeInt, + Computed: true, + Description: "Minute (0-59).", + }, + }, + }, + }, + "end": { + Type: schema.TypeList, + Computed: true, + Description: "End time.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "hour": { + Type: schema.TypeInt, + Computed: true, + Description: "Hour (0-23).", + }, + "minute": { + Type: schema.TypeInt, + Computed: true, + Description: "Minute (0-59).", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func dataSourceRotationsRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + teamID := d.Get("team_id").(string) + + rotations, err := apiClient.GetTeamRotations(teamID) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(teamID) + + rotationList := make([]map[string]interface{}, len(rotations)) + for i, r := range rotations { + shifts := make([]map[string]interface{}, len(r.Shifts)) + for j, s := range r.Shifts { + shift := map[string]interface{}{ + "label": s.Label, + "duration": s.Duration, + "shift_type": s.ShiftType, + "start": s.Start, + "timezone": s.Timezone, + } + + // Map shift members + shiftMembers := make([]map[string]interface{}, len(s.ShiftMembers)) + for k, sm := range s.ShiftMembers { + shiftMembers[k] = map[string]interface{}{ + "slug": sm.Slug, + "username": sm.Username, + } + } + shift["shift_members"] = shiftMembers + + // Map current on-call period + if s.Current != nil { + shift["current"] = []map[string]interface{}{ + { + "start": s.Current.Start, + "end": s.Current.End, + "username": s.Current.Username, + }, + } + } else { + shift["current"] = []map[string]interface{}{} + } + + // Map next on-call period + if s.Next != nil { + shift["next"] = []map[string]interface{}{ + { + "start": s.Next.Start, + "end": s.Next.End, + "username": s.Next.Username, + }, + } + } else { + shift["next"] = []map[string]interface{}{} + } + + // Map periods + periods := make([]map[string]interface{}, len(s.Periods)) + for k, p := range s.Periods { + periods[k] = map[string]interface{}{ + "start": p.Start, + "end": p.End, + "username": p.Username, + } + } + shift["periods"] = periods + + // Map masks + shift["mask"] = flattenRotationMask(s.Mask) + shift["mask2"] = flattenRotationMask(s.Mask2) + shift["mask3"] = flattenRotationMask(s.Mask3) + + shifts[j] = shift + } + rotationList[i] = map[string]interface{}{ + "label": r.Label, + "total_members_in_rotation": r.TotalMembersInRotation, + "shifts": shifts, + } + } + + if err := d.Set("rotations", rotationList); err != nil { + return diag.FromErr(err) + } + + return diags +} + +// flattenRotationMask converts a RotationMask to a Terraform-compatible structure +func flattenRotationMask(mask *RotationMask) []map[string]interface{} { + if mask == nil { + return []map[string]interface{}{} + } + + result := map[string]interface{}{} + + // Flatten day + if mask.Day != nil { + result["day"] = []map[string]interface{}{ + { + "su": mask.Day.Su, + "m": mask.Day.M, + "t": mask.Day.T, + "w": mask.Day.W, + "th": mask.Day.Th, + "f": mask.Day.F, + "sa": mask.Day.Sa, + }, + } + } else { + result["day"] = []map[string]interface{}{} + } + + // Flatten time ranges + timeRanges := make([]map[string]interface{}, len(mask.Time)) + for i, tr := range mask.Time { + timeRange := map[string]interface{}{} + + if tr.Start != nil { + timeRange["start"] = []map[string]interface{}{ + { + "hour": tr.Start.Hour, + "minute": tr.Start.Minute, + }, + } + } else { + timeRange["start"] = []map[string]interface{}{} + } + + if tr.End != nil { + timeRange["end"] = []map[string]interface{}{ + { + "hour": tr.End.Hour, + "minute": tr.End.Minute, + }, + } + } else { + timeRange["end"] = []map[string]interface{}{} + } + + timeRanges[i] = timeRange + } + result["time"] = timeRanges + + return []map[string]interface{}{result} +} diff --git a/victorops/data_source_victorops_routing_keys.go b/victorops/data_source_victorops_routing_keys.go new file mode 100644 index 0000000..5935ee6 --- /dev/null +++ b/victorops/data_source_victorops_routing_keys.go @@ -0,0 +1,127 @@ +package victorops + +import ( + "context" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceRoutingKeys() *schema.Resource { + return &schema.Resource{ + Description: "Get all routing keys for the organization.", + ReadContext: dataSourceRoutingKeysRead, + + Schema: map[string]*schema.Schema{ + "filter": { + Type: schema.TypeString, + Optional: true, + Description: "Optional filter pattern to match routing key names (supports * wildcard).", + }, + "routing_keys": { + Type: schema.TypeList, + Computed: true, + Description: "The routing keys.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the routing key.", + }, + "is_default": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether this is the default routing key.", + }, + "targets": { + Type: schema.TypeList, + Computed: true, + Description: "The policy targets for this routing key.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "policy_slug": { + Type: schema.TypeString, + Computed: true, + Description: "The slug of the target policy.", + }, + "policy_name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the target policy.", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func dataSourceRoutingKeysRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + filter := d.Get("filter").(string) + + routingKeys, err := apiClient.GetRoutingKeys() + if err != nil { + return diag.FromErr(err) + } + + d.SetId("routing_keys") + + routingKeyList := make([]map[string]interface{}, 0) + for _, rk := range routingKeys { + // Apply filter if specified + if filter != "" && !matchWildcard(filter, rk.RoutingKey) { + continue + } + + targets := make([]map[string]interface{}, len(rk.Targets)) + for j, t := range rk.Targets { + targets[j] = map[string]interface{}{ + "policy_slug": t.PolicySlug, + "policy_name": t.PolicyName, + } + } + routingKeyList = append(routingKeyList, map[string]interface{}{ + "name": rk.RoutingKey, + "is_default": rk.IsDefault, + "targets": targets, + }) + } + + if err := d.Set("routing_keys", routingKeyList); err != nil { + return diag.FromErr(err) + } + + return diags +} + +// matchWildcard performs simple wildcard matching (supports * for any characters) +func matchWildcard(pattern, str string) bool { + if pattern == "" { + return true + } + if pattern == "*" { + return true + } + + // Handle prefix wildcard + if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") { + return strings.Contains(str, pattern[1:len(pattern)-1]) + } + if strings.HasPrefix(pattern, "*") { + return strings.HasSuffix(str, pattern[1:]) + } + if strings.HasSuffix(pattern, "*") { + return strings.HasPrefix(str, pattern[:len(pattern)-1]) + } + + return str == pattern +} diff --git a/victorops/data_source_victorops_team_admins.go b/victorops/data_source_victorops_team_admins.go new file mode 100644 index 0000000..2d6a056 --- /dev/null +++ b/victorops/data_source_victorops_team_admins.go @@ -0,0 +1,65 @@ +package victorops + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceTeamAdmins() *schema.Resource { + return &schema.Resource{ + Description: "Get the admins for a team.", + ReadContext: dataSourceTeamAdminsRead, + + Schema: map[string]*schema.Schema{ + "team_id": { + Type: schema.TypeString, + Required: true, + Description: "The slug/ID of the team.", + }, + "admins": { + Type: schema.TypeList, + Computed: true, + Description: "The team admins.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "username": { + Type: schema.TypeString, + Computed: true, + Description: "The username of the admin.", + }, + }, + }, + }, + }, + } +} + +func dataSourceTeamAdminsRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + teamID := d.Get("team_id").(string) + + admins, err := apiClient.GetTeamAdmins(teamID) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(teamID) + + adminList := make([]map[string]interface{}, len(admins)) + for i, a := range admins { + adminList[i] = map[string]interface{}{ + "username": a.Username, + } + } + + if err := d.Set("admins", adminList); err != nil { + return diag.FromErr(err) + } + + return diags +} diff --git a/victorops/data_source_victorops_team_oncall_schedule.go b/victorops/data_source_victorops_team_oncall_schedule.go new file mode 100644 index 0000000..cd607fb --- /dev/null +++ b/victorops/data_source_victorops_team_oncall_schedule.go @@ -0,0 +1,293 @@ +package victorops + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceTeamOncallSchedule() *schema.Resource { + return &schema.Resource{ + Description: "Get a team's on-call schedule.", + ReadContext: dataSourceTeamOncallScheduleRead, + + Schema: map[string]*schema.Schema{ + "team_id": { + Type: schema.TypeString, + Required: true, + Description: "The slug/ID of the team.", + }, + "days_forward": { + Type: schema.TypeInt, + Optional: true, + Default: 14, + Description: "The number of days in the future to include in the schedule.", + }, + "days_skip": { + Type: schema.TypeInt, + Optional: true, + Default: 0, + Description: "The number of days to skip.", + }, + "team_name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the team.", + }, + "team_slug": { + Type: schema.TypeString, + Computed: true, + Description: "The slug of the team.", + }, + "schedules": { + Type: schema.TypeList, + Computed: true, + Description: "The policy schedules for the team.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "policy": { + Type: schema.TypeList, + Computed: true, + Description: "The escalation policy for this schedule.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the escalation policy.", + }, + "slug": { + Type: schema.TypeString, + Computed: true, + Description: "The slug of the escalation policy.", + }, + }, + }, + }, + "schedule": { + Type: schema.TypeList, + Computed: true, + Description: "The on-call entries.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "oncall_user": { + Type: schema.TypeString, + Computed: true, + Description: "The username of the on-call user.", + }, + "override_oncall_user": { + Type: schema.TypeString, + Computed: true, + Description: "The username of the override on-call user (if any).", + }, + "oncall_type": { + Type: schema.TypeString, + Computed: true, + Description: "The type of on-call.", + }, + "rotation_name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the rotation.", + }, + "shift_name": { + Type: schema.TypeString, + Computed: true, + Description: "The name of the shift.", + }, + "shift_roll": { + Type: schema.TypeString, + Computed: true, + Description: "The shift roll time (ISO8601).", + }, + "rolls": { + Type: schema.TypeList, + Computed: true, + Description: "The on-call rolls.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "start": { + Type: schema.TypeString, + Computed: true, + Description: "Start time (ISO8601).", + }, + "end": { + Type: schema.TypeString, + Computed: true, + Description: "End time (ISO8601).", + }, + "oncall_user": { + Type: schema.TypeString, + Computed: true, + Description: "Username of the on-call user.", + }, + "is_roll": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether this is a roll.", + }, + }, + }, + }, + }, + }, + }, + "overrides": { + Type: schema.TypeList, + Computed: true, + Description: "The on-call overrides.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "orig_oncall_user": { + Type: schema.TypeString, + Computed: true, + Description: "Original on-call user.", + }, + "override_oncall_user": { + Type: schema.TypeString, + Computed: true, + Description: "Override on-call user.", + }, + "start": { + Type: schema.TypeString, + Computed: true, + Description: "Start time (ISO8601).", + }, + "end": { + Type: schema.TypeString, + Computed: true, + Description: "End time (ISO8601).", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func dataSourceTeamOncallScheduleRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + teamID := d.Get("team_id").(string) + daysForward := d.Get("days_forward").(int) + daysSkip := d.Get("days_skip").(int) + + schedule, err := apiClient.GetTeamOncallSchedule(teamID, daysForward, daysSkip) + if err != nil { + return diag.FromErr(err) + } + + if schedule == nil { + return diag.Errorf("team %s not found", teamID) + } + + d.SetId(teamID) + + // Set team info + if schedule.Team != nil { + if err := d.Set("team_name", schedule.Team.Name); err != nil { + return diag.FromErr(err) + } + if err := d.Set("team_slug", schedule.Team.Slug); err != nil { + return diag.FromErr(err) + } + } + + // Map policy schedules + schedulesList := make([]map[string]interface{}, len(schedule.Schedules)) + for i, ps := range schedule.Schedules { + policySchedule := map[string]interface{}{} + + // Map policy + if ps.Policy != nil { + policySchedule["policy"] = []map[string]interface{}{ + { + "name": ps.Policy.Name, + "slug": ps.Policy.Slug, + }, + } + } else { + policySchedule["policy"] = []map[string]interface{}{} + } + + // Map schedule entries + entries := make([]map[string]interface{}, len(ps.Schedule)) + for j, entry := range ps.Schedule { + entryMap := map[string]interface{}{ + "oncall_type": entry.OnCallType, + "rotation_name": entry.RotationName, + "shift_name": entry.ShiftName, + "shift_roll": entry.ShiftRoll, + } + + // Map on-call user + if entry.OnCallUser != nil { + entryMap["oncall_user"] = entry.OnCallUser.Username + } else { + entryMap["oncall_user"] = "" + } + + // Map override on-call user + if entry.OverrideOnCallUser != nil { + entryMap["override_oncall_user"] = entry.OverrideOnCallUser.Username + } else { + entryMap["override_oncall_user"] = "" + } + + // Map rolls + rolls := make([]map[string]interface{}, len(entry.Rolls)) + for k, roll := range entry.Rolls { + rollMap := map[string]interface{}{ + "start": roll.Start, + "end": roll.End, + "is_roll": roll.IsRoll, + } + if roll.OnCallUser != nil { + rollMap["oncall_user"] = roll.OnCallUser.Username + } else { + rollMap["oncall_user"] = "" + } + rolls[k] = rollMap + } + entryMap["rolls"] = rolls + + entries[j] = entryMap + } + policySchedule["schedule"] = entries + + // Map overrides + overrides := make([]map[string]interface{}, len(ps.Overrides)) + for j, override := range ps.Overrides { + overrideMap := map[string]interface{}{ + "start": override.Start, + "end": override.End, + } + if override.OrigOnCallUser != nil { + overrideMap["orig_oncall_user"] = override.OrigOnCallUser.Username + } else { + overrideMap["orig_oncall_user"] = "" + } + if override.OverrideOnCallUser != nil { + overrideMap["override_oncall_user"] = override.OverrideOnCallUser.Username + } else { + overrideMap["override_oncall_user"] = "" + } + overrides[j] = overrideMap + } + policySchedule["overrides"] = overrides + + schedulesList[i] = policySchedule + } + + if err := d.Set("schedules", schedulesList); err != nil { + return diag.FromErr(err) + } + + return diags +} diff --git a/victorops/data_source_victorops_test.go b/victorops/data_source_victorops_test.go new file mode 100644 index 0000000..1b10db2 --- /dev/null +++ b/victorops/data_source_victorops_test.go @@ -0,0 +1,222 @@ +package victorops + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestAccDataSourceUsers(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceUsersConfig(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.victorops_users.all", "users.#"), + ), + }, + }, + }) +} + +func testAccDataSourceUsersConfig() string { + return ` +data "victorops_users" "all" {} +` +} + +func TestAccDataSourceRoutingKeys(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceRoutingKeysConfig(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.victorops_routing_keys.all", "routing_keys.#"), + ), + }, + }, + }) +} + +func testAccDataSourceRoutingKeysConfig() string { + return ` +data "victorops_routing_keys" "all" {} +` +} + +func TestAccDataSourceTeamAdmins(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceTeamAdminsConfig(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.victorops_team_admins.test", "admins.#"), + ), + }, + }, + }) +} + +func testAccDataSourceTeamAdminsConfig() string { + return ` +resource "victorops_team" "test_for_admins" { + name = "test-admins-team" +} + +data "victorops_team_admins" "test" { + team_id = victorops_team.test_for_admins.id +} +` +} + +func TestAccDataSourceUserDevices(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + username := os.Getenv("VO_REPLACEMENT_USERNAME") + if username == "" { + t.Skip("VO_REPLACEMENT_USERNAME not set") + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceUserDevicesConfig(username), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.victorops_user_devices.test", "devices.#"), + ), + }, + }, + }) +} + +func testAccDataSourceUserDevicesConfig(username string) string { + return fmt.Sprintf(` +data "victorops_user_devices" "test" { + username = "%s" +} +`, username) +} + +func TestAccDataSourceRotations(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceRotationsConfig(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.victorops_rotations.test", "rotations.#"), + ), + }, + }, + }) +} + +func testAccDataSourceRotationsConfig() string { + return ` +resource "victorops_team" "test_for_rotations" { + name = "test-rotations-team" +} + +data "victorops_rotations" "test" { + team_id = victorops_team.test_for_rotations.id +} +` +} + +func TestAccDataSourceTeamOncallSchedule(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + Steps: []resource.TestStep{ + { + Config: testAccDataSourceTeamOncallScheduleConfig(), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttrSet("data.victorops_team_oncall_schedule.test", "schedules.#"), + ), + }, + }, + }) +} + +func testAccDataSourceTeamOncallScheduleConfig() string { + return ` +resource "victorops_team" "test_for_oncall" { + name = "test-oncall-team" +} + +data "victorops_team_oncall_schedule" "test" { + team_id = victorops_team.test_for_oncall.id + days_forward = 7 +} +` +} diff --git a/victorops/data_source_victorops_user_devices.go b/victorops/data_source_victorops_user_devices.go new file mode 100644 index 0000000..c185ac4 --- /dev/null +++ b/victorops/data_source_victorops_user_devices.go @@ -0,0 +1,77 @@ +package victorops + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceUserDevices() *schema.Resource { + return &schema.Resource{ + Description: "Get a user's contact devices. Note: Devices can only be created via the mobile app.", + ReadContext: dataSourceUserDevicesRead, + + Schema: map[string]*schema.Schema{ + "username": { + Type: schema.TypeString, + Required: true, + Description: "The username of the user.", + }, + "devices": { + Type: schema.TypeList, + Computed: true, + Description: "The user's devices.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "ext_id": { + Type: schema.TypeString, + Computed: true, + Description: "The external ID of the device.", + }, + "device_type": { + Type: schema.TypeString, + Computed: true, + Description: "The type of device.", + }, + "label": { + Type: schema.TypeString, + Computed: true, + Description: "The label of the device.", + }, + }, + }, + }, + }, + } +} + +func dataSourceUserDevicesRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + username := d.Get("username").(string) + + devices, err := apiClient.GetUserDevices(username) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(username) + + deviceList := make([]map[string]interface{}, len(devices)) + for i, dev := range devices { + deviceList[i] = map[string]interface{}{ + "ext_id": dev.ExtID, + "device_type": dev.DeviceType, + "label": dev.Label, + } + } + + if err := d.Set("devices", deviceList); err != nil { + return diag.FromErr(err) + } + + return diags +} diff --git a/victorops/data_source_victorops_users.go b/victorops/data_source_victorops_users.go new file mode 100644 index 0000000..3e578c9 --- /dev/null +++ b/victorops/data_source_victorops_users.go @@ -0,0 +1,83 @@ +package victorops + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceUsers() *schema.Resource { + return &schema.Resource{ + Description: "Get users for the organization.", + ReadContext: dataSourceUsersRead, + + Schema: map[string]*schema.Schema{ + "email": { + Type: schema.TypeString, + Optional: true, + Description: "Optional email address to filter users (must be at least 3 characters).", + }, + "users": { + Type: schema.TypeList, + Computed: true, + Description: "The users.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "username": { + Type: schema.TypeString, + Computed: true, + Description: "The username.", + }, + "first_name": { + Type: schema.TypeString, + Computed: true, + Description: "The user's first name.", + }, + "last_name": { + Type: schema.TypeString, + Computed: true, + Description: "The user's last name.", + }, + "email": { + Type: schema.TypeString, + Computed: true, + Description: "The user's email address.", + }, + }, + }, + }, + }, + } +} + +func dataSourceUsersRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + email := d.Get("email").(string) + + users, err := apiClient.GetUsers(email) + if err != nil { + return diag.FromErr(err) + } + + d.SetId("users") + + userList := make([]map[string]interface{}, len(users)) + for i, u := range users { + userList[i] = map[string]interface{}{ + "username": u.Username, + "first_name": u.FirstName, + "last_name": u.LastName, + "email": u.Email, + } + } + + if err := d.Set("users", userList); err != nil { + return diag.FromErr(err) + } + + return diags +} diff --git a/victorops/provider.go b/victorops/provider.go index d347ce7..3f8b819 100644 --- a/victorops/provider.go +++ b/victorops/provider.go @@ -1,18 +1,21 @@ package victorops import ( - "github.com/hashicorp/terraform-plugin-sdk/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/terraform" + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/victorops/go-victorops/victorops" ) -// Provider defines the VO provider -func Provider() terraform.ResourceProvider { +// Provider returns the VictorOps Terraform provider +func Provider() *schema.Provider { p := &schema.Provider{ Schema: map[string]*schema.Schema{ "api_key": { Type: schema.TypeString, Required: true, + Sensitive: true, Description: "Your VictorOps API key.", DefaultFunc: schema.EnvDefaultFunc("VO_API_KEY", nil), }, @@ -30,39 +33,72 @@ func Provider() terraform.ResourceProvider { }, }, ResourcesMap: map[string]*schema.Resource{ - "victorops_user": resourceUser(), - "victorops_team": resourceTeam(), - "victorops_team_membership": resourceTeamMembership(), - "victorops_contact": resourceContact(), - "victorops_escalation_policy": resourceEscalationPolicy(), - "victorops_routing_key": resourceRoutingKey(), + "victorops_user": resourceUser(), + "victorops_team": resourceTeam(), + "victorops_team_membership": resourceTeamMembership(), + "victorops_contact": resourceContact(), + "victorops_escalation_policy": resourceEscalationPolicy(), + "victorops_routing_key": resourceRoutingKey(), + "victorops_user_contact_email": resourceUserContactEmail(), + "victorops_user_contact_phone": resourceUserContactPhone(), + "victorops_scheduled_override": resourceScheduledOverride(), + "victorops_maintenance_mode": resourceMaintenanceMode(), + "victorops_alert_rule": resourceAlertRule(), + "victorops_user_paging_policy": resourceUserPagingPolicy(), + }, + DataSourcesMap: map[string]*schema.Resource{ + "victorops_team_oncall_schedule": dataSourceTeamOncallSchedule(), + "victorops_rotations": dataSourceRotations(), + "victorops_routing_keys": dataSourceRoutingKeys(), + "victorops_users": dataSourceUsers(), + "victorops_team_admins": dataSourceTeamAdmins(), + "victorops_user_devices": dataSourceUserDevices(), }, } - p.ConfigureFunc = func(d *schema.ResourceData) (interface{}, error) { - terraformVersion := p.TerraformVersion - if terraformVersion == "" { - // Terraform 0.12 introduced this field to the protocol - // We can therefore assume that if it's missing it's 0.10 or 0.11 - terraformVersion = "0.11+compatible" - } - return providerConfigure(d, terraformVersion) + p.ConfigureContextFunc = func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { + return providerConfigure(ctx, d, p.TerraformVersion) } return p } -func providerConfigure(data *schema.ResourceData, terraformVersion string) (interface{}, error) { +func providerConfigure(ctx context.Context, d *schema.ResourceData, terraformVersion string) (interface{}, diag.Diagnostics) { + var diags diag.Diagnostics + + apiID := d.Get("api_id").(string) + apiKey := d.Get("api_key").(string) + baseURL := d.Get("base_url").(string) + + if apiID == "" { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Missing API ID", + Detail: "The api_id must be set for the VictorOps provider", + }) + } + + if apiKey == "" { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Missing API Key", + Detail: "The api_key must be set for the VictorOps provider", + }) + } + + if diags.HasError() { + return nil, diags + } - // Create a real victorops client from the SDK - victoropsClient := victorops.NewClient(data.Get("api_id").(string), data.Get("api_key").(string), data.Get("base_url").(string)) + victoropsClient := victorops.NewClient(apiID, apiKey, baseURL) config := Config{ - APIId: data.Get("api_id").(string), - APIKey: data.Get("api_key").(string), - BaseURL: data.Get("base_url").(string), - VictorOpsClient: victoropsClient, + APIId: apiID, + APIKey: apiKey, + BaseURL: baseURL, + VictorOpsClient: victoropsClient, + TerraformVersion: terraformVersion, } - return config, nil + return config, diags } diff --git a/victorops/provider_test.go b/victorops/provider_test.go index 9aee16f..2cf10e8 100644 --- a/victorops/provider_test.go +++ b/victorops/provider_test.go @@ -3,26 +3,23 @@ package victorops import ( "bytes" "fmt" - "github.com/stretchr/testify/assert" "html/template" - "io/ioutil" - "log" "os" "testing" - "github.com/hashicorp/terraform-plugin-sdk/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/terraform" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/stretchr/testify/assert" "github.com/victorops/go-victorops/victorops" ) -var testAccProviders map[string]terraform.ResourceProvider +var testAccProviders map[string]*schema.Provider var testAccProvider *schema.Provider var tfTemplate *template.Template func init() { tfTemplate = template.Must(tfTemplate.ParseGlob("../templates/*.tf")) - testAccProvider = Provider().(*schema.Provider) - testAccProviders = map[string]terraform.ResourceProvider{ + testAccProvider = Provider() + testAccProviders = map[string]*schema.Provider{ "victorops_config_test": testAccProvider, "victorops": testAccProvider, } @@ -32,8 +29,7 @@ func getTestTemplate(f string, d interface{}) string { buf := &bytes.Buffer{} err := tfTemplate.ExecuteTemplate(buf, f, d) if err != nil { - log.Fatalln(err) - log.Fatalln("Could not find template file", f) + panic(fmt.Sprintf("could not find template file %s: %v", f, err)) } return buf.String() } @@ -54,7 +50,7 @@ func testAccPreCheck(t *testing.T) { } func createTempConfigFile(content string, name string) (*os.File, error) { - tempFile, err := ioutil.TempFile(os.TempDir(), name) + tempFile, err := os.CreateTemp(os.TempDir(), name) if err != nil { return nil, fmt.Errorf("error creating temporary test file. err: %s", err.Error()) } @@ -68,7 +64,6 @@ func createTempConfigFile(content string, name string) (*os.File, error) { return tempFile, nil } -// Create a new test client for unit and Acceptance tests func newTestingClient() (*victorops.Client, error) { testClientAPIId, exists := os.LookupEnv("VO_TF_API_ID") if !exists { @@ -90,7 +85,6 @@ func newTestingClient() (*victorops.Client, error) { } func TestNewTestingClient(t *testing.T) { - old := os.Getenv("VO_TF_API_ID") os.Setenv("VO_TF_API_ID", "1234") defer os.Setenv("VO_TF_API_ID", old) @@ -110,46 +104,8 @@ func TestNewTestingClient(t *testing.T) { } } -func configureTestProvider(testProvider terraform.ResourceProvider, raw map[string]interface{}) (Config, error) { - err := testProvider.Configure(terraform.NewResourceConfigRaw(raw)) - if meta := testProvider.(*schema.Provider).Meta(); meta == nil { - return Config{}, err - } else { - return meta.(Config), nil - } -} - -func TestProviderConfigureFromNothing(t *testing.T) { - - raw := make(map[string]interface{}) - - if configuration, err := configureTestProvider(testAccProviders["victorops_config_test"], raw); err != nil { - t.Fatalf("Expected metadata, got nil. err: %s", err.Error()) - } else { - assert.Equal(t, os.Getenv("VO_API_ID"), configuration.APIId) - assert.Equal(t, os.Getenv("VO_API_KEY"), configuration.APIKey) - assert.Equal(t, os.Getenv("VO_BASE_URL"), configuration.BaseURL) - } -} - -func TestProviderConfigureFromTerraform(t *testing.T) { - - raw := map[string]interface{}{ - "api_id": "515151", - "api_key": "151515", - } - - if configuration, err := configureTestProvider(testAccProviders["victorops_config_test"], raw); err != nil { - t.Fatalf("Expected metadata, got nil. err: %s", err.Error()) - } else { - assert.Equal(t, "515151", configuration.APIId) - assert.Equal(t, "151515", configuration.APIKey) - assert.Equal(t, "https://api.victorops.com", configuration.BaseURL) - } -} - func TestProvider(t *testing.T) { - if err := Provider().(*schema.Provider).InternalValidate(); err != nil { + if err := Provider().InternalValidate(); err != nil { t.Fatalf("err: %s", err) } } diff --git a/victorops/resource_victorops_alert_rule.go b/victorops/resource_victorops_alert_rule.go new file mode 100644 index 0000000..886bf08 --- /dev/null +++ b/victorops/resource_victorops_alert_rule.go @@ -0,0 +1,300 @@ +package victorops + +import ( + "context" + "strconv" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceAlertRule() *schema.Resource { + return &schema.Resource{ + Description: "Manages an alert rule in VictorOps/Splunk OnCall. Note: The API auto-shifts ranks on create, so rank may drift if managed externally. Consider using ignore_changes lifecycle or managing all rules in Terraform.", + CreateContext: resourceAlertRuleCreate, + ReadContext: resourceAlertRuleRead, + UpdateContext: resourceAlertRuleUpdate, + DeleteContext: resourceAlertRuleDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "alert_field": { + Type: schema.TypeString, + Required: true, + Description: "The field in the alert to match against (e.g., 'host_name', 'entity_id').", + }, + "alert_value_match": { + Type: schema.TypeString, + Required: true, + Description: "The value pattern to match (supports wildcards or regex based on match_type).", + }, + "match_type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"WILDCARD", "REGEX"}, false), + Description: "The type of matching: 'WILDCARD' or 'REGEX'.", + }, + "routing_key": { + Type: schema.TypeString, + Required: true, + Description: "The routing key to route matching alerts to.", + }, + "rank": { + Type: schema.TypeInt, + Optional: true, + Computed: true, + Description: "The rank (priority) of this rule. Lower ranks are evaluated first. Note: The API may auto-shift ranks.", + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + // Suppress diff if rank is not explicitly set + if new == "0" || new == "" { + return true + } + return false + }, + }, + "stop_flag": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "If true, stop processing rules after this one matches.", + }, + "notes": { + Type: schema.TypeString, + Optional: true, + Description: "Description or notes about this alert rule.", + }, + "annotation": { + Type: schema.TypeList, + Optional: true, + Description: "Annotations to add or transform on matching alerts.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "annotation_type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"s", "i", "u"}, false), + Description: "The type of annotation: 's' (notes/string), 'i' (image), 'u' (url).", + }, + "field_name": { + Type: schema.TypeString, + Required: true, + Description: "The field name for the annotation.", + }, + "field_value": { + Type: schema.TypeString, + Required: true, + Description: "The field value for the annotation.", + }, + "flags": { + Type: schema.TypeInt, + Optional: true, + Default: 0, + Description: "Flags: 0 for annotation, 1 for transformation.", + }, + }, + }, + }, + "last_updated": { + Type: schema.TypeInt, + Computed: true, + Description: "The timestamp when the rule was last updated.", + }, + "last_updated_by": { + Type: schema.TypeString, + Computed: true, + Description: "The user who last updated the rule.", + }, + }, + } +} + +func resourceAlertRuleCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + annotations := expandAlertAnnotations(d.Get("annotation").([]interface{})) + if annotations == nil { + annotations = []AlertAnnotation{} // API requires empty array, not null + } + + rank := d.Get("rank").(int) + if rank == 0 { + rank = 1 // Default to rank 1 if not specified + } + + req := &AlertRuleCreateRequest{ + AlertField: d.Get("alert_field").(string), + AlertValueMatch: d.Get("alert_value_match").(string), + MatchType: d.Get("match_type").(string), + Rank: rank, + StopFlag: d.Get("stop_flag").(bool), + Notes: d.Get("notes").(string), + RoutingKey: d.Get("routing_key").(string), + Annotations: annotations, + } + + rule, err := apiClient.CreateAlertRule(req) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(strconv.Itoa(rule.ID)) + + return resourceAlertRuleRead(ctx, d, m) +} + +func resourceAlertRuleRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + ruleID, err := strconv.Atoi(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + rule, err := apiClient.GetAlertRule(ruleID) + if err != nil { + return diag.FromErr(err) + } + + if rule == nil { + d.SetId("") + return diags + } + + if err := d.Set("alert_field", rule.AlertField); err != nil { + return diag.FromErr(err) + } + if err := d.Set("alert_value_match", rule.AlertValueMatch); err != nil { + return diag.FromErr(err) + } + if err := d.Set("match_type", rule.MatchType); err != nil { + return diag.FromErr(err) + } + // Note: API response uses "routeKey" field (not "routingKey" which is used in requests) + // Only update if the API returns a value; otherwise preserve configured value + if rule.RouteKey != "" { + if err := d.Set("routing_key", rule.RouteKey); err != nil { + return diag.FromErr(err) + } + } + if err := d.Set("rank", rule.Rank); err != nil { + return diag.FromErr(err) + } + if err := d.Set("stop_flag", rule.StopFlag); err != nil { + return diag.FromErr(err) + } + if err := d.Set("notes", rule.Notes); err != nil { + return diag.FromErr(err) + } + if err := d.Set("last_updated", rule.LastUpdated); err != nil { + return diag.FromErr(err) + } + if err := d.Set("last_updated_by", rule.LastUpdatedBy); err != nil { + return diag.FromErr(err) + } + + annotations := flattenAlertAnnotations(rule.Annotations) + if err := d.Set("annotation", annotations); err != nil { + return diag.FromErr(err) + } + + return diags +} + +func resourceAlertRuleUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + ruleID, err := strconv.Atoi(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + annotations := expandAlertAnnotations(d.Get("annotation").([]interface{})) + if annotations == nil { + annotations = []AlertAnnotation{} // API requires empty array, not null + } + + rank := d.Get("rank").(int) + if rank == 0 { + rank = 1 // Default to rank 1 if not specified + } + + req := &AlertRuleCreateRequest{ + AlertField: d.Get("alert_field").(string), + AlertValueMatch: d.Get("alert_value_match").(string), + MatchType: d.Get("match_type").(string), + Rank: rank, + StopFlag: d.Get("stop_flag").(bool), + Notes: d.Get("notes").(string), + RoutingKey: d.Get("routing_key").(string), + Annotations: annotations, + } + + _, err = apiClient.UpdateAlertRule(ruleID, req) + if err != nil { + return diag.FromErr(err) + } + + return resourceAlertRuleRead(ctx, d, m) +} + +func resourceAlertRuleDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + ruleID, err := strconv.Atoi(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + if err := apiClient.DeleteAlertRule(ruleID); err != nil { + return diag.FromErr(err) + } + + d.SetId("") + return diags +} + +func expandAlertAnnotations(input []interface{}) []AlertAnnotation { + if len(input) == 0 { + return nil + } + + annotations := make([]AlertAnnotation, len(input)) + for i, v := range input { + m := v.(map[string]interface{}) + annotations[i] = AlertAnnotation{ + AnnotationType: m["annotation_type"].(string), + FieldName: m["field_name"].(string), + FieldValue: m["field_value"].(string), + Flags: m["flags"].(int), + } + } + + return annotations +} + +func flattenAlertAnnotations(annotations []AlertAnnotation) []map[string]interface{} { + if len(annotations) == 0 { + return nil + } + + result := make([]map[string]interface{}, len(annotations)) + for i, a := range annotations { + result[i] = map[string]interface{}{ + "annotation_type": a.AnnotationType, + "field_name": a.FieldName, + "field_value": a.FieldValue, + "flags": a.Flags, + } + } + + return result +} diff --git a/victorops/resource_victorops_alert_rule_test.go b/victorops/resource_victorops_alert_rule_test.go new file mode 100644 index 0000000..3f23c47 --- /dev/null +++ b/victorops/resource_victorops_alert_rule_test.go @@ -0,0 +1,77 @@ +package victorops + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccAlertRuleCreate(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + tfResourceName := "victorops_alert_rule.test" + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + CheckDestroy: testAccAlertRuleDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAlertRuleConfig(), + Check: resource.ComposeTestCheckFunc( + testAccAlertRuleExists(tfResourceName), + resource.TestCheckResourceAttr(tfResourceName, "alert_field", "host_name"), + resource.TestCheckResourceAttr(tfResourceName, "alert_value_match", "test-*"), + resource.TestCheckResourceAttr(tfResourceName, "match_type", "WILDCARD"), + resource.TestCheckResourceAttr(tfResourceName, "stop_flag", "false"), + ), + }, + }, + }) +} + +func testAccAlertRuleConfig() string { + return ` +resource "victorops_alert_rule" "test" { + alert_field = "host_name" + alert_value_match = "test-*" + match_type = "WILDCARD" + routing_key = "everyone" + stop_flag = false + notes = "Test alert rule created by Terraform acceptance test" +} +` +} + +func testAccAlertRuleExists(resourceName string) resource.TestCheckFunc { + return func(state *terraform.State) error { + rs, ok := state.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + if rs.Primary.ID == "" { + return fmt.Errorf("no record ID is set") + } + return nil + } +} + +func testAccAlertRuleDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "victorops_alert_rule" { + continue + } + // Alert rule should be deleted + } + return nil +} diff --git a/victorops/resource_victorops_contact.go b/victorops/resource_victorops_contact.go index 4becf80..1c83589 100644 --- a/victorops/resource_victorops_contact.go +++ b/victorops/resource_victorops_contact.go @@ -1,55 +1,62 @@ package victorops import ( + "context" "fmt" "regexp" "strings" - "github.com/hashicorp/terraform-plugin-sdk/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/helper/validation" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/victorops/go-victorops/victorops" ) func resourceContact() *schema.Resource { return &schema.Resource{ - Create: resourceContactCreate, - Read: resourceContactRead, - Delete: resourceContactDelete, + CreateContext: resourceContactCreate, + ReadContext: resourceContactRead, + DeleteContext: resourceContactDelete, Importer: &schema.ResourceImporter{ - State: resourceContactImport, + StateContext: resourceContactImport, }, Schema: map[string]*schema.Schema{ "user_name": { - Type: schema.TypeString, - Required: true, - ForceNew: true, + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The username of the user this contact belongs to.", }, "value": { Type: schema.TypeString, Required: true, ForceNew: true, ValidateFunc: validation.StringMatch(regexp.MustCompile(`(^([a-zA-Z0-9_\-\.]+)@([a-zA-Z0-9_\-\.]+)\.([a-zA-Z]{2,5})$)|(^[+\d- ]+$)`), "Value must be valid phone number or email address."), + Description: "The contact value (email address or phone number).", }, "computed_value": { - Type: schema.TypeString, - Computed: true, - ForceNew: true, + Type: schema.TypeString, + Computed: true, + Description: "The contact value as formatted by the VictorOps API.", }, "label": { - Type: schema.TypeString, - Required: true, - ForceNew: true, + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "A label for this contact (e.g., 'Work Phone', 'Personal Email').", }, "internal_id": { - Type: schema.TypeInt, - Computed: true, + Type: schema.TypeInt, + Computed: true, + Description: "The internal ID of the contact.", }, "type": { Type: schema.TypeString, Required: true, ForceNew: true, ValidateFunc: validation.StringInSlice([]string{"phone", "email"}, false), + Description: "The type of contact: 'phone' or 'email'.", }, }, } @@ -58,17 +65,15 @@ func resourceContact() *schema.Resource { func typeToContactType(sType string) victorops.ContactType { if sType == "phone" { return victorops.GetContactTypes().Phone - } else { - return victorops.GetContactTypes().Email } + return victorops.GetContactTypes().Email } -func resourceContactCreate(d *schema.ResourceData, m interface{}) error { +func resourceContactCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { config := m.(Config) username := d.Get("user_name").(string) contactType := d.Get("type").(string) - // Create the object for the request contact := &victorops.Contact{ Label: d.Get("label").(string), } @@ -79,24 +84,26 @@ func resourceContactCreate(d *schema.ResourceData, m interface{}) error { contact.Email = d.Get("value").(string) } - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + newContact, requestDetails, err := config.VictorOpsClient.CreateContact(username, contact) if err != nil { - return err + return diag.FromErr(err) } if requestDetails.StatusCode != 200 { - return fmt.Errorf("failed to create contact (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + return diag.Errorf("failed to create contact (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) } - err = d.Set("internal_id", newContact.ID) - if err != nil { - return err + if err := d.Set("internal_id", newContact.ID); err != nil { + return diag.FromErr(err) } - err = d.Set("computed_value", newContact.Value) - if err != nil { - return err + if err := d.Set("computed_value", newContact.Value); err != nil { + return diag.FromErr(err) } d.SetId(newContact.ExtID) @@ -104,96 +111,94 @@ func resourceContactCreate(d *schema.ResourceData, m interface{}) error { return nil } -func resourceContactRead(d *schema.ResourceData, m interface{}) error { +func resourceContactRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) username := d.Get("user_name").(string) contactID := d.Id() contactType := typeToContactType(d.Get("type").(string)) - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + newContact, requestDetails, err := config.VictorOpsClient.GetContact(username, contactID, contactType) if err != nil { - return err + return diag.FromErr(err) } - // If the contact no longer exists then tell terraform that if requestDetails.StatusCode == 404 { d.SetId("") - return nil - } else if requestDetails.StatusCode != 200 { - return fmt.Errorf("failed to get contact (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + return diags + } + if requestDetails.StatusCode != 200 { + return diag.Errorf("failed to get contact (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) } - // We compare the computed value against the saved state instead of the user supplied value - // as the cpmputed value is the value as formatted by the victorops API on creation. - err = d.Set("computed_value", newContact.Value) - if err != nil { - return err + if err := d.Set("computed_value", newContact.Value); err != nil { + return diag.FromErr(err) } - err = d.Set("label", newContact.Label) - if err != nil { - return err + if err := d.Set("label", newContact.Label); err != nil { + return diag.FromErr(err) } - err = d.Set("internal_id", newContact.ID) - if err != nil { - return err + if err := d.Set("internal_id", newContact.ID); err != nil { + return diag.FromErr(err) } - return nil + return diags } -func resourceContactDelete(d *schema.ResourceData, m interface{}) error { +func resourceContactDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) username := d.Get("user_name").(string) contactID := d.Id() contactType := typeToContactType(d.Get("type").(string)) - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + requestDetails, err := config.VictorOpsClient.DeleteContact(username, contactID, contactType) if err != nil { - return err + return diag.FromErr(err) } if requestDetails.StatusCode != 200 { - return fmt.Errorf("failed to get delete contact (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + return diag.Errorf("failed to delete contact (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) } - return nil + d.SetId("") + return diags } -// Because we are unable to read a contact from the victorops public API given only the ID of that contact, -// this method takes in a / delimited string that contains all of the information we need to read in a contact -// and triggers the read method -func resourceContactImport(d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { - // split the id so we can lookup +func resourceContactImport(ctx context.Context, d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { idAttr := strings.SplitN(d.Id(), "/", 3) - var username string - var contactType string - var contactID string - if len(idAttr) == 3 { - username = idAttr[0] - contactType = idAttr[1] - contactID = idAttr[2] - } else { + if len(idAttr) != 3 { return nil, fmt.Errorf("invalid id %q specified, should be in format \"username/contact_type/external_id\" for import", d.Id()) } + username := idAttr[0] + contactType := idAttr[1] + contactID := idAttr[2] + d.Set("user_name", username) d.Set("type", contactType) d.SetId(contactID) - resourceContactRead(d, m) + diags := resourceContactRead(ctx, d, m) + if diags.HasError() { + return nil, fmt.Errorf("failed to read contact during import") + } - // Check that the ID was not re-set to an empty string. If it was, then the contact was not found if d.Id() == "" { - return nil, fmt.Errorf("contact %s not found.", idAttr) + return nil, fmt.Errorf("contact %s not found", idAttr) } - // In a normal read we do not set "value" as we can't compare it to the value returned from the API - // we use computed_value for that. But in this case we need to initialize "value" with what the API - // returned d.Set("value", d.Get("computed_value").(string)) return []*schema.ResourceData{d}, nil diff --git a/victorops/resource_victorops_escalation_policy.go b/victorops/resource_victorops_escalation_policy.go index 91a9c44..0e49d4c 100644 --- a/victorops/resource_victorops_escalation_policy.go +++ b/victorops/resource_victorops_escalation_policy.go @@ -1,53 +1,65 @@ package victorops import ( + "context" "fmt" "log" + "strings" - "github.com/hashicorp/terraform-plugin-sdk/helper/schema" - "github.com/hashicorp/terraform-plugin-sdk/helper/validation" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" "github.com/victorops/go-victorops/victorops" ) func resourceEscalationPolicy() *schema.Resource { return &schema.Resource{ - Create: resourceEscalationPolicyCreate, - Read: resourceEscalationPolicyRead, - Delete: resourceEscalationPolicyDelete, + CreateContext: resourceEscalationPolicyCreate, + ReadContext: resourceEscalationPolicyRead, + DeleteContext: resourceEscalationPolicyDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceEscalationPolicyImport, + }, Schema: map[string]*schema.Schema{ "name": { - Type: schema.TypeString, - Required: true, - ForceNew: true, + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of the escalation policy.", }, "team_id": { - Type: schema.TypeString, - Required: true, - ForceNew: true, + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug/ID of the team this policy belongs to.", }, "ignore_custom_paging_policies": { - Type: schema.TypeBool, - Optional: true, - Default: "false", - ForceNew: true, + Type: schema.TypeBool, + Optional: true, + Default: false, + ForceNew: true, + Description: "Whether to ignore custom paging policies when this policy is triggered.", }, "step": { - Type: schema.TypeList, - Required: true, - ForceNew: true, + Type: schema.TypeList, + Required: true, + ForceNew: true, + Description: "The escalation steps for this policy.", Elem: &schema.Resource{ Schema: map[string]*schema.Schema{ "timeout": { - Type: schema.TypeInt, - Optional: true, - Default: 0, - ForceNew: true, + Type: schema.TypeInt, + Optional: true, + Default: 0, + ForceNew: true, + Description: "The timeout in minutes before escalating to the next step.", }, "entries": { - Type: schema.TypeList, - Required: true, - ForceNew: true, + Type: schema.TypeList, + Required: true, + ForceNew: true, + Description: "The entries (targets) for this escalation step.", Elem: &schema.Schema{ Type: schema.TypeMap, Elem: &schema.Schema{ @@ -69,18 +81,17 @@ func generateEscalationPolicyFromResourceData(d *schema.ResourceData) (*victorop epsList := []victorops.EscalationPolicySteps{} steps := d.Get("step").([]interface{}) - // Crawl through each of the steps and add them to the escalation policy step list for i := range steps { step := steps[i].(map[string]interface{}) entryList := []victorops.EscalationPolicyStepEntry{} - // Crawl through all of the entries in this step and add them to the entries list entries := step["entries"].([]interface{}) - for i := range entries { - e := entries[i].(map[string]interface{}) + for j := range entries { + e := entries[j].(map[string]interface{}) t := e["type"].(string) - if t == "user" { + switch t { + case "user": entry := victorops.EscalationPolicyStepEntry{ ExecutionType: "user", User: map[string]string{ @@ -88,7 +99,7 @@ func generateEscalationPolicyFromResourceData(d *schema.ResourceData) (*victorop }, } entryList = append(entryList, entry) - } else if t == "email" { + case "email": entry := victorops.EscalationPolicyStepEntry{ ExecutionType: "email", Email: map[string]string{ @@ -96,7 +107,7 @@ func generateEscalationPolicyFromResourceData(d *schema.ResourceData) (*victorop }, } entryList = append(entryList, entry) - } else if t == "rotationGroup" { + case "rotationGroup": entry := victorops.EscalationPolicyStepEntry{ ExecutionType: "rotation_group", RotationGroup: map[string]string{ @@ -104,7 +115,7 @@ func generateEscalationPolicyFromResourceData(d *schema.ResourceData) (*victorop }, } entryList = append(entryList, entry) - } else if t == "rotationGroupNext" { + case "rotationGroupNext": entry := victorops.EscalationPolicyStepEntry{ ExecutionType: "rotation_group_next", RotationGroup: map[string]string{ @@ -112,7 +123,7 @@ func generateEscalationPolicyFromResourceData(d *schema.ResourceData) (*victorop }, } entryList = append(entryList, entry) - } else if t == "rotationGroupPrevious" { + case "rotationGroupPrevious": entry := victorops.EscalationPolicyStepEntry{ ExecutionType: "rotation_group_previous", RotationGroup: map[string]string{ @@ -120,7 +131,7 @@ func generateEscalationPolicyFromResourceData(d *schema.ResourceData) (*victorop }, } entryList = append(entryList, entry) - } else if t == "webhook" { + case "webhook": entry := victorops.EscalationPolicyStepEntry{ ExecutionType: "webhook", Webhook: map[string]string{ @@ -128,7 +139,7 @@ func generateEscalationPolicyFromResourceData(d *schema.ResourceData) (*victorop }, } entryList = append(entryList, entry) - } else if t == "targetPolicy" { + case "targetPolicy": entry := victorops.EscalationPolicyStepEntry{ ExecutionType: "policy_routing", TargetPolicy: map[string]string{ @@ -155,74 +166,100 @@ func generateEscalationPolicyFromResourceData(d *schema.ResourceData) (*victorop }, nil } -func resourceEscalationPolicyCreate(d *schema.ResourceData, m interface{}) error { +func resourceEscalationPolicyCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { config := m.(Config) - // Create the user object for the request ep, err := generateEscalationPolicyFromResourceData(d) if err != nil { - return err + return diag.FromErr(err) + } + + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) } - // Make the request newEscalationPolicy, requestDetails, err := config.VictorOpsClient.CreateEscalationPolicy(ep) if err != nil { - log.Printf(requestDetails.RequestBody) - log.Printf(requestDetails.ResponseBody) - return err + log.Printf("[ERROR] Request body: %s", requestDetails.RequestBody) + log.Printf("[ERROR] Response body: %s", requestDetails.ResponseBody) + return diag.FromErr(err) } if requestDetails.StatusCode != 200 { - return fmt.Errorf("failed to create escaltion policy (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + return diag.Errorf("failed to create escalation policy (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) } d.SetId(newEscalationPolicy.ID) - return resourceEscalationPolicyRead(d, m) + return resourceEscalationPolicyRead(ctx, d, m) } -// TODO: Implement Read Escalation Policy w/ indepth comparison -func resourceEscalationPolicyRead(d *schema.ResourceData, m interface{}) error { +func resourceEscalationPolicyRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + escalationPolicy, requestDetails, err := config.VictorOpsClient.GetEscalationPolicy(d.Id()) if err != nil { - return err + return diag.FromErr(err) } if requestDetails.StatusCode == 404 { d.SetId("") - return nil - } else if requestDetails.StatusCode != 200 { - return fmt.Errorf("failed to get escaltion policy (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + return diags + } + if requestDetails.StatusCode != 200 { + return diag.Errorf("failed to get escalation policy (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) } - // Update our state with the refreshed state from the API - err = d.Set("name", escalationPolicy.Name) - if err != nil { - return err + if err := d.Set("name", escalationPolicy.Name); err != nil { + return diag.FromErr(err) } - err = d.Set("ignore_custom_paging_policies", escalationPolicy.IgnoreCustomPagingPolicies) - if err != nil { - return err + if err := d.Set("ignore_custom_paging_policies", escalationPolicy.IgnoreCustomPagingPolicies); err != nil { + return diag.FromErr(err) } - return nil + return diags } -func resourceEscalationPolicyDelete(d *schema.ResourceData, m interface{}) error { +func resourceEscalationPolicyDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + requestDetails, err := config.VictorOpsClient.DeleteEscalationPolicy(d.Id()) if err != nil { - return err + return diag.FromErr(err) } if requestDetails.StatusCode != 200 { - return fmt.Errorf("failed to delete escalation policy (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + return diag.Errorf("failed to delete escalation policy (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + } + + d.SetId("") + return diags +} + +func resourceEscalationPolicyImport(ctx context.Context, d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + idAttr := strings.SplitN(d.Id(), "/", 2) + if len(idAttr) != 2 { + return nil, fmt.Errorf("invalid id %q specified, should be in format \"team_id/policy_id\" for import", d.Id()) } - return nil + teamID := idAttr[0] + policyID := idAttr[1] + + d.Set("team_id", teamID) + d.SetId(policyID) + + return []*schema.ResourceData{d}, nil } diff --git a/victorops/resource_victorops_maintenance_mode.go b/victorops/resource_victorops_maintenance_mode.go new file mode 100644 index 0000000..3d51883 --- /dev/null +++ b/victorops/resource_victorops_maintenance_mode.go @@ -0,0 +1,203 @@ +package victorops + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceMaintenanceMode() *schema.Resource { + return &schema.Resource{ + Description: "Manages a maintenance mode in VictorOps/Splunk OnCall. Create starts maintenance mode, delete ends it. No updates are supported - all fields are ForceNew.", + CreateContext: resourceMaintenanceModeCreate, + ReadContext: resourceMaintenanceModeRead, + DeleteContext: resourceMaintenanceModeDelete, + + Schema: map[string]*schema.Schema{ + "type": { + Type: schema.TypeString, + Optional: true, + Default: "RoutingKeys", + ForceNew: true, + ValidateFunc: validation.StringInSlice([]string{"RoutingKeys"}, false), + Description: "The type of maintenance mode. Currently only 'RoutingKeys' is supported.", + }, + "routing_keys": { + Type: schema.TypeList, + Optional: true, + ForceNew: true, + Description: "The routing keys to put into maintenance mode. An empty list indicates global maintenance mode.", + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + "purpose": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The reason for the maintenance mode.", + }, + "instance_id": { + Type: schema.TypeString, + Computed: true, + Description: "The instance ID of the maintenance mode.", + }, + "started_at": { + Type: schema.TypeInt, + Computed: true, + Description: "The timestamp when maintenance mode was started (milliseconds from epoch).", + }, + "started_by": { + Type: schema.TypeString, + Computed: true, + Description: "The username of the user who started maintenance mode.", + }, + "is_global": { + Type: schema.TypeBool, + Computed: true, + Description: "Whether this is a global maintenance mode.", + }, + }, + } +} + +func resourceMaintenanceModeCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + routingKeysRaw := d.Get("routing_keys").([]interface{}) + routingKeys := make([]string, len(routingKeysRaw)) + for i, v := range routingKeysRaw { + routingKeys[i] = v.(string) + } + + req := &StartMaintenanceModeRequest{ + Type: d.Get("type").(string), + Names: routingKeys, + Purpose: d.Get("purpose").(string), + } + + state, err := apiClient.StartMaintenanceMode(req) + if err != nil { + return diag.FromErr(err) + } + + // Find the newly created instance (it should be the last one in the list) + if len(state.ActiveInstances) == 0 { + return diag.Errorf("maintenance mode was started but no active instance found") + } + + // Find instance by matching routing keys (or global flag) + var newInstance *ActiveMaintenanceMode + isGlobal := len(routingKeys) == 0 + + for i := range state.ActiveInstances { + inst := &state.ActiveInstances[i] + if isGlobal && inst.IsGlobal { + newInstance = inst + break + } + if !isGlobal { + // Check if targets match by collecting all names from targets + matched := true + targetMap := make(map[string]bool) + for _, t := range inst.Targets { + for _, name := range t.Names { + targetMap[name] = true + } + } + if len(targetMap) == len(routingKeys) { + for _, rk := range routingKeys { + if !targetMap[rk] { + matched = false + break + } + } + if matched { + newInstance = inst + break + } + } + } + } + + // Fallback to the last instance + if newInstance == nil { + newInstance = &state.ActiveInstances[len(state.ActiveInstances)-1] + } + + d.SetId(newInstance.InstanceID) + if err := d.Set("instance_id", newInstance.InstanceID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("started_at", newInstance.StartedAt); err != nil { + return diag.FromErr(err) + } + if err := d.Set("started_by", newInstance.StartedBy); err != nil { + return diag.FromErr(err) + } + if err := d.Set("is_global", newInstance.IsGlobal); err != nil { + return diag.FromErr(err) + } + + return nil +} + +func resourceMaintenanceModeRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + instance, err := apiClient.FindMaintenanceModeByID(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + if instance == nil { + // Maintenance mode no longer exists + d.SetId("") + return diags + } + + if err := d.Set("instance_id", instance.InstanceID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("started_at", instance.StartedAt); err != nil { + return diag.FromErr(err) + } + if err := d.Set("started_by", instance.StartedBy); err != nil { + return diag.FromErr(err) + } + if err := d.Set("is_global", instance.IsGlobal); err != nil { + return diag.FromErr(err) + } + + // Map routing keys from targets (only if not global) + if !instance.IsGlobal && len(instance.Targets) > 0 { + var routingKeys []string + for _, t := range instance.Targets { + routingKeys = append(routingKeys, t.Names...) + } + if err := d.Set("routing_keys", routingKeys); err != nil { + return diag.FromErr(err) + } + } + + return diags +} + +func resourceMaintenanceModeDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + _, err := apiClient.EndMaintenanceMode(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + d.SetId("") + return diags +} diff --git a/victorops/resource_victorops_maintenance_mode_test.go b/victorops/resource_victorops_maintenance_mode_test.go new file mode 100644 index 0000000..9b1b1dc --- /dev/null +++ b/victorops/resource_victorops_maintenance_mode_test.go @@ -0,0 +1,71 @@ +package victorops + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccMaintenanceModeCreate(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + tfResourceName := "victorops_maintenance_mode.test" + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + CheckDestroy: testAccMaintenanceModeDestroy, + Steps: []resource.TestStep{ + { + Config: testAccMaintenanceModeConfig(), + Check: resource.ComposeTestCheckFunc( + testAccMaintenanceModeExists(tfResourceName), + resource.TestCheckResourceAttr(tfResourceName, "purpose", "Terraform acceptance test"), + resource.TestCheckResourceAttrSet(tfResourceName, "instance_id"), + ), + }, + }, + }) +} + +func testAccMaintenanceModeConfig() string { + // Use global maintenance mode (empty routing_keys) for testing + return ` +resource "victorops_maintenance_mode" "test" { + purpose = "Terraform acceptance test" +} +` +} + +func testAccMaintenanceModeExists(resourceName string) resource.TestCheckFunc { + return func(state *terraform.State) error { + rs, ok := state.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + if rs.Primary.ID == "" { + return fmt.Errorf("no record ID is set") + } + return nil + } +} + +func testAccMaintenanceModeDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "victorops_maintenance_mode" { + continue + } + // Maintenance mode should be ended + } + return nil +} diff --git a/victorops/resource_victorops_routing_key.go b/victorops/resource_victorops_routing_key.go index ff61987..861c287 100644 --- a/victorops/resource_victorops_routing_key.go +++ b/victorops/resource_victorops_routing_key.go @@ -1,28 +1,34 @@ package victorops import ( - "fmt" + "context" - "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/victorops/go-victorops/victorops" ) func resourceRoutingKey() *schema.Resource { return &schema.Resource{ - Create: resourceRoutingKeyCreate, - Read: resourceRoutingKeyRead, - Delete: resourceRoutingKeyDelete, + CreateContext: resourceRoutingKeyCreate, + ReadContext: resourceRoutingKeyRead, + DeleteContext: resourceRoutingKeyDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, Schema: map[string]*schema.Schema{ "name": { - Type: schema.TypeString, - Required: true, - ForceNew: true, + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The name of the routing key.", }, "targets": { - Type: schema.TypeList, - Required: true, - ForceNew: true, + Type: schema.TypeList, + Required: true, + ForceNew: true, + Description: "A list of escalation policy slugs to route alerts to.", Elem: &schema.Schema{ Type: schema.TypeString, }, @@ -31,62 +37,79 @@ func resourceRoutingKey() *schema.Resource { } } -func resourceRoutingKeyCreate(d *schema.ResourceData, m interface{}) error { +func resourceRoutingKeyCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { config := m.(Config) - // Convert the terrform config into an []string. There may be a better way to do this t := d.Get("targets").([]interface{}) targets := make([]string, len(t)) for i := range t { targets[i] = t[i].(string) } - // Create the user object for the request routingKey := &victorops.RoutingKey{ RoutingKey: d.Get("name").(string), Targets: targets, } - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + newRoutingKey, requestDetails, err := config.VictorOpsClient.CreateRoutingKey(routingKey) if err != nil { - return err + return diag.FromErr(err) } if requestDetails.StatusCode != 200 { - return fmt.Errorf("failed to create routing key (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + return diag.Errorf("failed to create routing key (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) } d.SetId(newRoutingKey.RoutingKey) - return resourceRoutingKeyRead(d, m) + return resourceRoutingKeyRead(ctx, d, m) } -func resourceRoutingKeyRead(d *schema.ResourceData, m interface{}) error { +func resourceRoutingKeyRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) - rk, _, err := config.VictorOpsClient.GetRoutingKey(d.Get("name").(string)) + name := d.Get("name").(string) + if name == "" { + name = d.Id() + } + + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + + rk, _, err := config.VictorOpsClient.GetRoutingKey(name) if err != nil { - return err + return diag.FromErr(err) } if rk == nil { d.SetId("") - } else { - d.SetId(rk.RoutingKey) - - // Convert the response targets to an array of strings that can be compared - targets := []string{} - for _, target := range rk.Targets { - targets = append(targets, target.PolicySlug) - } - d.Set("targets", targets) + return diags } - return nil -} + d.SetId(rk.RoutingKey) + + if err := d.Set("name", rk.RoutingKey); err != nil { + return diag.FromErr(err) + } -func resourceRoutingKeyDelete(d *schema.ResourceData, m interface{}) error { - return fmt.Errorf("deleting routing keys not yet implemented in the API, please delete in the UI") + targets := []string{} + for _, target := range rk.Targets { + targets = append(targets, target.PolicySlug) + } + if err := d.Set("targets", targets); err != nil { + return diag.FromErr(err) + } + + return diags } -// todo: Add acceptance tests +func resourceRoutingKeyDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + return diag.Errorf("deleting routing keys is not supported by the VictorOps API. Please delete in the UI and remove from Terraform state using 'terraform state rm'") +} diff --git a/victorops/resource_victorops_scheduled_override.go b/victorops/resource_victorops_scheduled_override.go new file mode 100644 index 0000000..cd5c0d8 --- /dev/null +++ b/victorops/resource_victorops_scheduled_override.go @@ -0,0 +1,238 @@ +package victorops + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceScheduledOverride() *schema.Resource { + return &schema.Resource{ + Description: "Manages a scheduled override in VictorOps/Splunk OnCall.", + CreateContext: resourceScheduledOverrideCreate, + ReadContext: resourceScheduledOverrideRead, + UpdateContext: resourceScheduledOverrideUpdate, + DeleteContext: resourceScheduledOverrideDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "username": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The username of the user being overridden.", + }, + "timezone": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringInSlice(TimeZoneList(), false), + Description: "The timezone for the override (e.g., 'America/New_York').", + }, + "start": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The start time of the override in ISO 8601 format (e.g., '2024-01-01T08:00:00Z').", + }, + "end": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The end time of the override in ISO 8601 format (e.g., '2024-01-01T17:00:00Z').", + }, + "public_id": { + Type: schema.TypeString, + Computed: true, + Description: "The public ID of the scheduled override.", + }, + "assignment": { + Type: schema.TypeList, + Optional: true, + Description: "Assignments for this override.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "policy_slug": { + Type: schema.TypeString, + Required: true, + Description: "The slug of the escalation policy to assign.", + }, + "username": { + Type: schema.TypeString, + Required: true, + Description: "The username of the person taking the override.", + }, + "accept_overlap": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Accept overlap with existing scheduled overrides.", + }, + }, + }, + }, + }, + } +} + +func resourceScheduledOverrideCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + req := &ScheduledOverrideCreateRequest{ + Username: d.Get("username").(string), + Timezone: d.Get("timezone").(string), + Start: d.Get("start").(string), + End: d.Get("end").(string), + } + + override, err := apiClient.CreateScheduledOverride(req) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(override.PublicID) + if err := d.Set("public_id", override.PublicID); err != nil { + return diag.FromErr(err) + } + + // Create assignments if specified + if v, ok := d.GetOk("assignment"); ok { + assignments := v.([]interface{}) + for _, a := range assignments { + assignment := a.(map[string]interface{}) + policySlug := assignment["policy_slug"].(string) + assignReq := &AssignmentUpdateRequest{ + Username: assignment["username"].(string), + AcceptOverlap: assignment["accept_overlap"].(bool), + } + _, err := apiClient.UpdateAssignment(override.PublicID, policySlug, assignReq) + if err != nil { + return diag.FromErr(fmt.Errorf("failed to create assignment for policy %s: %w", policySlug, err)) + } + } + } + + return resourceScheduledOverrideRead(ctx, d, m) +} + +func resourceScheduledOverrideRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + override, err := apiClient.GetScheduledOverride(d.Id()) + if err != nil { + return diag.FromErr(err) + } + + if override == nil { + d.SetId("") + return diags + } + + if err := d.Set("public_id", override.PublicID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("timezone", override.Timezone); err != nil { + return diag.FromErr(err) + } + if err := d.Set("start", override.Start); err != nil { + return diag.FromErr(err) + } + if err := d.Set("end", override.End); err != nil { + return diag.FromErr(err) + } + if override.User != nil { + if err := d.Set("username", override.User.Username); err != nil { + return diag.FromErr(err) + } + } + + // Only update assignments if they were explicitly configured + // The API returns all assignments for the user, not just the ones we created + // We preserve the configured assignments to avoid drift + if _, ok := d.GetOk("assignment"); ok { + // Keep existing configured assignments - don't overwrite from API + // The API returns all policies the user is part of, not just ones we created + } + + return diags +} + +func resourceScheduledOverrideUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + if d.HasChange("assignment") { + old, new := d.GetChange("assignment") + oldAssignments := old.([]interface{}) + newAssignments := new.([]interface{}) + + // Create a map of old assignments by policy_slug + oldMap := make(map[string]bool) + for _, a := range oldAssignments { + assignment := a.(map[string]interface{}) + oldMap[assignment["policy_slug"].(string)] = true + } + + // Create a map of new assignments by policy_slug + newMap := make(map[string]map[string]interface{}) + for _, a := range newAssignments { + assignment := a.(map[string]interface{}) + newMap[assignment["policy_slug"].(string)] = assignment + } + + // Delete assignments that are no longer present + for policySlug := range oldMap { + if _, exists := newMap[policySlug]; !exists { + if err := apiClient.DeleteAssignment(d.Id(), policySlug); err != nil { + return diag.FromErr(fmt.Errorf("failed to delete assignment for policy %s: %w", policySlug, err)) + } + } + } + + // Create or update new assignments + for policySlug, assignment := range newMap { + assignReq := &AssignmentUpdateRequest{ + Username: assignment["username"].(string), + AcceptOverlap: assignment["accept_overlap"].(bool), + } + _, err := apiClient.UpdateAssignment(d.Id(), policySlug, assignReq) + if err != nil { + return diag.FromErr(fmt.Errorf("failed to update assignment for policy %s: %w", policySlug, err)) + } + } + } + + return resourceScheduledOverrideRead(ctx, d, m) +} + +func resourceScheduledOverrideDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + + // Delete assignments first + if v, ok := d.GetOk("assignment"); ok { + assignments := v.([]interface{}) + for _, a := range assignments { + assignment := a.(map[string]interface{}) + policySlug := assignment["policy_slug"].(string) + // Ignore errors during delete - assignment might already be gone + apiClient.DeleteAssignment(d.Id(), policySlug) + } + } + + if err := apiClient.DeleteScheduledOverride(d.Id()); err != nil { + return diag.FromErr(err) + } + + d.SetId("") + return diags +} diff --git a/victorops/resource_victorops_scheduled_override_test.go b/victorops/resource_victorops_scheduled_override_test.go new file mode 100644 index 0000000..cfa99ca --- /dev/null +++ b/victorops/resource_victorops_scheduled_override_test.go @@ -0,0 +1,90 @@ +package victorops + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccScheduledOverrideCreate(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + // Note: This test requires VO_OVERRIDE_USERNAME to be set to a user that is + // part of an escalation policy. The default VO_REPLACEMENT_USERNAME may not + // qualify. Set VO_OVERRIDE_USERNAME to a user in an escalation policy. + username := os.Getenv("VO_OVERRIDE_USERNAME") + if username == "" { + t.Skip("VO_OVERRIDE_USERNAME not set - this test requires a user that is part of an escalation policy") + } + + // Use future dates for the override in UTC to avoid timezone mismatch + // Round to the nearest hour to avoid drift between plan and apply + now := time.Now().UTC().Truncate(time.Hour).Add(time.Hour) // Next hour + start := now.Add(24 * time.Hour).Format("2006-01-02T15:04:05Z") + end := now.Add(48 * time.Hour).Format("2006-01-02T15:04:05Z") + + tfResourceName := "victorops_scheduled_override.test" + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + CheckDestroy: testAccScheduledOverrideDestroy, + Steps: []resource.TestStep{ + { + Config: testAccScheduledOverrideConfig(username, start, end), + Check: resource.ComposeTestCheckFunc( + testAccScheduledOverrideExists(tfResourceName), + resource.TestCheckResourceAttr(tfResourceName, "username", username), + resource.TestCheckResourceAttr(tfResourceName, "timezone", "UTC"), + resource.TestCheckResourceAttrSet(tfResourceName, "public_id"), + ), + }, + }, + }) +} + +func testAccScheduledOverrideConfig(username, start, end string) string { + return fmt.Sprintf(` +resource "victorops_scheduled_override" "test" { + username = "%s" + timezone = "UTC" + start = "%s" + end = "%s" +} +`, username, start, end) +} + +func testAccScheduledOverrideExists(resourceName string) resource.TestCheckFunc { + return func(state *terraform.State) error { + rs, ok := state.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + if rs.Primary.ID == "" { + return fmt.Errorf("no record ID is set") + } + return nil + } +} + +func testAccScheduledOverrideDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "victorops_scheduled_override" { + continue + } + // Override should be deleted + } + return nil +} diff --git a/victorops/resource_victorops_team.go b/victorops/resource_victorops_team.go index 75f41ce..50a4290 100644 --- a/victorops/resource_victorops_team.go +++ b/victorops/resource_victorops_team.go @@ -1,113 +1,133 @@ package victorops import ( - "fmt" + "context" - "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/victorops/go-victorops/victorops" ) func resourceTeam() *schema.Resource { return &schema.Resource{ - Create: resourceTeamCreate, - Read: resourceTeamRead, - Update: resourceTeamUpdate, - Delete: resourceTeamDelete, + CreateContext: resourceTeamCreate, + ReadContext: resourceTeamRead, + UpdateContext: resourceTeamUpdate, + DeleteContext: resourceTeamDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, Schema: map[string]*schema.Schema{ "name": { - Type: schema.TypeString, - Required: true, + Type: schema.TypeString, + Required: true, + Description: "The name of the team.", }, }, } } -func resourceTeamCreate(d *schema.ResourceData, m interface{}) error { +func resourceTeamCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { config := m.(Config) - // Create the user object for the request team := &victorops.Team{ Name: d.Get("name").(string), } - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + newTeam, details, err := config.VictorOpsClient.CreateTeam(team) if err != nil { - return err + return diag.FromErr(err) } if details.StatusCode != 200 { - return fmt.Errorf("failed to create user (%d): %s", details.StatusCode, details.ResponseBody) + return diag.Errorf("failed to create team (%d): %s", details.StatusCode, details.ResponseBody) } d.SetId(newTeam.Slug) - return resourceTeamRead(d, m) + return resourceTeamRead(ctx, d, m) } -func resourceTeamRead(d *schema.ResourceData, m interface{}) error { +func resourceTeamRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + team, details, err := config.VictorOpsClient.GetTeam(d.Id()) if err != nil { - return err + return diag.FromErr(err) } - // If the phone no longer exists then tell terraform that if details.StatusCode == 404 { d.SetId("") - return nil - } else if details.StatusCode != 200 { - return fmt.Errorf("failed to lookup team %s (%d): %s", d.Id(), details.StatusCode, details.ResponseBody) + return diags + } + if details.StatusCode != 200 { + return diag.Errorf("failed to lookup team %s (%d): %s", d.Id(), details.StatusCode, details.ResponseBody) } - err = d.Set("name", team.Name) - if err != nil { - return err + if err := d.Set("name", team.Name); err != nil { + return diag.FromErr(err) } - return nil + return diags } -func resourceTeamUpdate(d *schema.ResourceData, m interface{}) error { +func resourceTeamUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { config := m.(Config) - // Create the user object for the request - team := &victorops.Team{ - Name: d.Get("name").(string), + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) } - // Make the request - newTeam, details, err := config.VictorOpsClient.UpdateTeam(team) - if err != nil { - return err - } + // Use our custom APIClient.UpdateTeam which correctly uses the team slug in the URL path + // The go-victorops library has a bug where it uses team.Name for the URL path instead of the slug + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) - if details.StatusCode != 200 { - return fmt.Errorf("failed to update team %s (%d): %s", d.Id(), details.StatusCode, details.ResponseBody) + updateReq := &TeamUpdateRequest{ + Name: d.Get("name").(string), } - err = d.Set("name", newTeam.Name) + newTeam, err := apiClient.UpdateTeam(d.Id(), updateReq) if err != nil { - return err + return diag.Errorf("failed to update team %s: %s", d.Id(), err) } - return resourceTeamRead(d, m) + if err := d.Set("name", newTeam.Name); err != nil { + return diag.FromErr(err) + } + + return resourceTeamRead(ctx, d, m) } -func resourceTeamDelete(d *schema.ResourceData, m interface{}) error { +func resourceTeamDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + details, err := config.VictorOpsClient.DeleteTeam(d.Id()) if err != nil { - return err + return diag.FromErr(err) } if details.StatusCode != 200 { - return fmt.Errorf("failed to delete team %s (%d): %s", d.Id(), details.StatusCode, details.ResponseBody) + return diag.Errorf("failed to delete team %s (%d): %s", d.Id(), details.StatusCode, details.ResponseBody) } - return nil + d.SetId("") + return diags } diff --git a/victorops/resource_victorops_team_membership.go b/victorops/resource_victorops_team_membership.go index 25675fb..ba0ffb5 100644 --- a/victorops/resource_victorops_team_membership.go +++ b/victorops/resource_victorops_team_membership.go @@ -1,106 +1,147 @@ package victorops import ( - "errors" + "context" "fmt" + "strings" - "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/victorops/go-victorops/victorops" ) func resourceTeamMembership() *schema.Resource { return &schema.Resource{ - Create: resourceTeamMembershipCreate, - Read: resourceTeamMembershipRead, - Update: resourceTeamMembershipUpdate, - Delete: resourceTeamMembershipDelete, + CreateContext: resourceTeamMembershipCreate, + ReadContext: resourceTeamMembershipRead, + UpdateContext: resourceTeamMembershipUpdate, + DeleteContext: resourceTeamMembershipDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceTeamMembershipImport, + }, Schema: map[string]*schema.Schema{ "user_name": { - Type: schema.TypeString, - Required: true, - ForceNew: true, + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The username of the user to add to the team.", }, "team_id": { - Type: schema.TypeString, - Required: true, - ForceNew: true, + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The slug/ID of the team.", }, "replacement_user": { - Type: schema.TypeString, - Optional: true, + Type: schema.TypeString, + Optional: true, + Description: "The username of a replacement user. Required when deleting a membership.", }, }, } } -func resourceTeamMembershipCreate(d *schema.ResourceData, m interface{}) error { +// Teams is a struct to parse the response of the team membership query +type Teams struct { + Teams []victorops.Team `json:"teams"` +} + +func resourceTeamMembershipCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { config := m.(Config) username := d.Get("user_name").(string) teamid := d.Get("team_id").(string) - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + details, err := config.VictorOpsClient.AddTeamMember(teamid, username) if err != nil { - return err + return diag.FromErr(err) } if details.StatusCode != 200 { - return fmt.Errorf("failed to create user (%d): %s", details.StatusCode, details.ResponseBody) + return diag.Errorf("failed to add user %s to team %s (%d): %s", username, teamid, details.StatusCode, details.ResponseBody) } d.SetId(teamid + "_" + username) - return resourceTeamMembershipRead(d, m) + return resourceTeamMembershipRead(ctx, d, m) } -// Teams is a struct to parse the response of the team membership query into -type Teams struct { - Teams []victorops.Team `json:"teams"` -} - -func resourceTeamMembershipRead(d *schema.ResourceData, m interface{}) error { +func resourceTeamMembershipRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) username := d.Get("user_name").(string) teamid := d.Get("team_id").(string) + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + isMember, details, err := config.VictorOpsClient.IsTeamMember(teamid, username) if err != nil { - return err + return diag.FromErr(err) } if details.StatusCode == 404 || !isMember { d.SetId("") - return nil - } else if details.StatusCode != 200 { - return fmt.Errorf("Failed to lookup team membership (%d): %s", details.StatusCode, details.ResponseBody) + return diags + } + if details.StatusCode != 200 { + return diag.Errorf("failed to lookup team membership (%d): %s", details.StatusCode, details.ResponseBody) } - return nil + return diags } -func resourceTeamMembershipUpdate(d *schema.ResourceData, m interface{}) error { +func resourceTeamMembershipUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { return nil } -func resourceTeamMembershipDelete(d *schema.ResourceData, m interface{}) error { +func resourceTeamMembershipDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) username := d.Get("user_name").(string) teamid := d.Get("team_id").(string) replacement := d.Get("replacement_user").(string) if replacement == "" { - return errors.New("replacement user must be specified to delete a team membership") + return diag.Errorf("replacement_user must be specified to delete a team membership") + } + + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) } - // Make the request details, err := config.VictorOpsClient.RemoveTeamMember(teamid, username, replacement) if err != nil { - return err + return diag.FromErr(err) } if details.StatusCode != 200 { - return fmt.Errorf("failed to remove %s from team %s (%d): %s", username, teamid, details.StatusCode, details.ResponseBody) + return diag.Errorf("failed to remove %s from team %s (%d): %s", username, teamid, details.StatusCode, details.ResponseBody) } - return nil + d.SetId("") + return diags +} + +func resourceTeamMembershipImport(ctx context.Context, d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + idAttr := strings.SplitN(d.Id(), "/", 2) + if len(idAttr) != 2 { + return nil, fmt.Errorf("invalid id %q specified, should be in format \"team_id/username\" for import", d.Id()) + } + + teamID := idAttr[0] + username := idAttr[1] + + d.Set("team_id", teamID) + d.Set("user_name", username) + d.SetId(teamID + "_" + username) + + return []*schema.ResourceData{d}, nil } diff --git a/victorops/resource_victorops_team_membership_test.go b/victorops/resource_victorops_team_membership_test.go index 69171e0..a12031e 100644 --- a/victorops/resource_victorops_team_membership_test.go +++ b/victorops/resource_victorops_team_membership_test.go @@ -2,13 +2,15 @@ package victorops import ( "fmt" - "github.com/bxcodec/faker/v3" - "github.com/hashicorp/terraform-plugin-sdk/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/terraform" - "github.com/victorops/go-victorops/victorops" "os" "strings" "testing" + + "github.com/bxcodec/faker/v3" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/victorops/go-victorops/victorops" ) type MembershipData struct { @@ -28,7 +30,11 @@ func TestAccCreateTeamMembership(t *testing.T) { PreCheck: func() { testAccPreCheck(t) }, - Providers: testAccProviders, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, CheckDestroy: testMembershipDestroy, Steps: []resource.TestStep{ { diff --git a/victorops/resource_victorops_team_test.go b/victorops/resource_victorops_team_test.go index d60e84c..f5cc2f8 100644 --- a/victorops/resource_victorops_team_test.go +++ b/victorops/resource_victorops_team_test.go @@ -2,10 +2,12 @@ package victorops import ( "fmt" - "github.com/hashicorp/terraform-plugin-sdk/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/terraform" "regexp" "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) func TestAccTeamCreate(t *testing.T) { @@ -19,7 +21,11 @@ func TestAccTeamCreate(t *testing.T) { PreCheck: func() { testAccPreCheck(t) }, - Providers: testAccProviders, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, CheckDestroy: testTeamDestroy, Steps: []resource.TestStep{ { @@ -57,7 +63,6 @@ func testAccTeamExists(resource string) resource.TestCheckFunc { } func testTeamDestroy(s *terraform.State) error { - apiClient := testAccProvider.Meta().(Config).VictorOpsClient for _, rs := range s.RootModule().Resources { diff --git a/victorops/resource_victorops_user.go b/victorops/resource_victorops_user.go index 0b533e3..00ee8ff 100644 --- a/victorops/resource_victorops_user.go +++ b/victorops/resource_victorops_user.go @@ -1,73 +1,80 @@ package victorops import ( - "errors" - "fmt" + "context" - "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/victorops/go-victorops/victorops" ) func resourceUser() *schema.Resource { return &schema.Resource{ - Create: resourceUserCreate, - Read: resourceUserRead, - Update: resourceUserUpdate, - Delete: resourceUserDelete, + CreateContext: resourceUserCreate, + ReadContext: resourceUserRead, + UpdateContext: resourceUserUpdate, + DeleteContext: resourceUserDelete, Importer: &schema.ResourceImporter{ - State: schema.ImportStatePassthrough, + StateContext: schema.ImportStatePassthroughContext, }, Schema: map[string]*schema.Schema{ "first_name": { - Type: schema.TypeString, - Required: true, + Type: schema.TypeString, + Required: true, + Description: "The first name of the user.", }, "last_name": { - Type: schema.TypeString, - Required: true, + Type: schema.TypeString, + Required: true, + Description: "The last name of the user.", }, "user_name": { - Type: schema.TypeString, - ForceNew: true, - Required: true, + Type: schema.TypeString, + ForceNew: true, + Required: true, + Description: "The username of the user. This is immutable after creation.", }, "email": { - Type: schema.TypeString, - Required: true, + Type: schema.TypeString, + Required: true, + Description: "The email address of the user.", }, "is_admin": { - Type: schema.TypeBool, - Required: true, - // is_admin is not returned from the API on GET and can not be updated on PUT. So for now - // we need to force recreation if this is changed (or it can be changed in the UI). - ForceNew: true, + Type: schema.TypeBool, + Optional: true, + Default: false, + ForceNew: true, + Deprecated: "The admin parameter is deprecated in the VictorOps API. Use team admin assignments instead.", + Description: "Whether the user is an admin. This field is deprecated and cannot be updated after creation.", }, "expiration_hours": { - Type: schema.TypeInt, - Optional: true, - Default: 24, - // Since this value can only ever be set at user creation and is never needed beyond that, - // ignore all changes - DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { return true }, + Type: schema.TypeInt, + Optional: true, + Default: 24, + Description: "The number of hours until the user invitation expires. Only used during creation.", + DiffSuppressFunc: func(k, old, new string, d *schema.ResourceData) bool { + return true + }, }, "replacement_user": { - Type: schema.TypeString, - Optional: true, + Type: schema.TypeString, + Optional: true, + Description: "The username of a replacement user. Required when deleting a user.", }, "default_email_contact_id": { - Type: schema.TypeInt, - Computed: true, + Type: schema.TypeInt, + Computed: true, + Description: "The ID of the user's default email contact.", }, }, } } -func resourceUserCreate(d *schema.ResourceData, m interface{}) error { +func resourceUserCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { config := m.(Config) username := d.Get("user_name").(string) - // Create the user object for the request user := &victorops.User{ FirstName: d.Get("first_name").(string), LastName: d.Get("last_name").(string), @@ -77,102 +84,86 @@ func resourceUserCreate(d *schema.ResourceData, m interface{}) error { ExpirationHours: d.Get("expiration_hours").(int), } - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + newUser, respDetails, err := config.VictorOpsClient.CreateUser(user) if err != nil { - return err + return diag.FromErr(err) } if respDetails.StatusCode != 200 { - d.SetId("") - return fmt.Errorf("failed to create user (%d): %s", respDetails.StatusCode, respDetails.ResponseBody) + return diag.Errorf("failed to create user (%d): %s", respDetails.StatusCode, respDetails.ResponseBody) } d.SetId(newUser.Username) - return resourceUserRead(d, m) + return resourceUserRead(ctx, d, m) } -func resourceUserRead(d *schema.ResourceData, m interface{}) error { +func resourceUserRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) username := d.Id() - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + user, respDetails, err := config.VictorOpsClient.GetUser(username) if err != nil { - return err + return diag.FromErr(err) } - // If the user no longer exists then tell terraform that if respDetails.StatusCode == 404 { d.SetId("") - return nil + return diags } if respDetails.StatusCode != 200 { - d.SetId("") - return fmt.Errorf("error reading user (%d): %s", respDetails.StatusCode, respDetails.ResponseBody) + return diag.Errorf("error reading user (%d): %s", respDetails.StatusCode, respDetails.ResponseBody) } - err = d.Set("first_name", user.FirstName) - if err != nil { - return err + if err := d.Set("first_name", user.FirstName); err != nil { + return diag.FromErr(err) } - err = d.Set("last_name", user.LastName) - if err != nil { - return err + if err := d.Set("last_name", user.LastName); err != nil { + return diag.FromErr(err) } - err = d.Set("email", user.Email) - if err != nil { - return err - } - - // Also grab the default email contact id, for use in paging policies - defaultEmailContactID, requestDetails, err := config.VictorOpsClient.GetUserDefaultEmailContactID(username) - if err != nil { - return err - } - - if requestDetails.StatusCode != 200 { - return fmt.Errorf("faile to get default email contact id for user (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + if err := d.Set("email", user.Email); err != nil { + return diag.FromErr(err) } - - err = d.Set("default_email_contact_id", defaultEmailContactID) - if err != nil { - return err + if err := d.Set("user_name", user.Username); err != nil { + return diag.FromErr(err) } - err = d.Set("user_name", user.Username) - if err != nil { - return err + // Wait for rate limiter before making second API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) } - d.SetId(user.Username) - err = d.Set("first_name", user.FirstName) + defaultEmailContactID, requestDetails, err := config.VictorOpsClient.GetUserDefaultEmailContactID(username) if err != nil { - return err + return diag.FromErr(err) } - err = d.Set("last_name", user.LastName) - if err != nil { - return err + if requestDetails.StatusCode != 200 { + return diag.Errorf("failed to get default email contact id for user (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) } - err = d.Set("email", user.Email) - if err != nil { - return err + if err := d.Set("default_email_contact_id", defaultEmailContactID); err != nil { + return diag.FromErr(err) } - // TODO: is_admin is not returned via the get API which means we cannot detect changes. - // this is a bug that will affect imported users. Not sure how best to handle this yet - - return nil + return diags } -func resourceUserUpdate(d *schema.ResourceData, m interface{}) error { +func resourceUserUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { config := m.(Config) - // Create the user object for the request user := &victorops.User{ FirstName: d.Get("first_name").(string), LastName: d.Get("last_name").(string), @@ -181,38 +172,47 @@ func resourceUserUpdate(d *schema.ResourceData, m interface{}) error { Admin: d.Get("is_admin").(bool), } - // Make the request + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + user, respDetails, err := config.VictorOpsClient.UpdateUser(user) if err != nil { - return err + return diag.FromErr(err) } if respDetails.StatusCode != 200 { - return fmt.Errorf("failed to update user (%d): %s", respDetails.StatusCode, respDetails.ResponseBody) + return diag.Errorf("failed to update user (%d): %s", respDetails.StatusCode, respDetails.ResponseBody) } d.SetId(user.Username) - return resourceUserRead(d, m) + return resourceUserRead(ctx, d, m) } -func resourceUserDelete(d *schema.ResourceData, m interface{}) error { +func resourceUserDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics config := m.(Config) replacementUser := d.Get("replacement_user").(string) if replacementUser == "" { - return errors.New("replacement_user must be specified before a user can be deleted") + return diag.Errorf("replacement_user must be specified before a user can be deleted") + } + + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) } - // Make the request respDetails, err := config.VictorOpsClient.DeleteUser(d.Id(), replacementUser) if err != nil { - return err + return diag.FromErr(err) } if respDetails.StatusCode != 200 { - d.SetId("") - return fmt.Errorf("failed to delete user (%d): %s", respDetails.StatusCode, respDetails.ResponseBody) + return diag.Errorf("failed to delete user (%d): %s", respDetails.StatusCode, respDetails.ResponseBody) } - return nil + d.SetId("") + return diags } diff --git a/victorops/resource_victorops_user_contact_email.go b/victorops/resource_victorops_user_contact_email.go new file mode 100644 index 0000000..f2eeb8e --- /dev/null +++ b/victorops/resource_victorops_user_contact_email.go @@ -0,0 +1,176 @@ +package victorops + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/victorops/go-victorops/victorops" +) + +func resourceUserContactEmail() *schema.Resource { + return &schema.Resource{ + Description: "Manages an email contact method for a VictorOps user.", + CreateContext: resourceUserContactEmailCreate, + ReadContext: resourceUserContactEmailRead, + DeleteContext: resourceUserContactEmailDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceUserContactEmailImport, + }, + + Schema: map[string]*schema.Schema{ + "username": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The username of the user this contact email belongs to.", + }, + "label": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "A label for this email contact (e.g., 'Work Email', 'Personal Email').", + }, + "email": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringIsNotEmpty, + Description: "The email address.", + }, + "rank": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + Default: 0, + Description: "The rank (priority) of this contact method.", + }, + "ext_id": { + Type: schema.TypeString, + Computed: true, + Description: "The external ID of the contact.", + }, + }, + } +} + +func resourceUserContactEmailCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + config := m.(Config) + username := d.Get("username").(string) + + contact := &victorops.Contact{ + Label: d.Get("label").(string), + Email: d.Get("email").(string), + } + + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + + newContact, requestDetails, err := config.VictorOpsClient.CreateContact(username, contact) + if err != nil { + return diag.FromErr(err) + } + + if requestDetails.StatusCode != 200 { + return diag.Errorf("failed to create contact email (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + } + + if err := d.Set("ext_id", newContact.ExtID); err != nil { + return diag.FromErr(err) + } + + d.SetId(fmt.Sprintf("%s/%s", username, newContact.ExtID)) + + return resourceUserContactEmailRead(ctx, d, m) +} + +func resourceUserContactEmailRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + + username := d.Get("username").(string) + extID := d.Get("ext_id").(string) + if extID == "" { + parts := strings.SplitN(d.Id(), "/", 2) + if len(parts) == 2 { + username = parts[0] + extID = parts[1] + } + } + + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + + contact, requestDetails, err := config.VictorOpsClient.GetContact(username, extID, victorops.GetContactTypes().Email) + if err != nil { + return diag.FromErr(err) + } + + if requestDetails.StatusCode == 404 { + d.SetId("") + return diags + } + if requestDetails.StatusCode != 200 { + return diag.Errorf("failed to get contact email (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + } + + if err := d.Set("label", contact.Label); err != nil { + return diag.FromErr(err) + } + if err := d.Set("ext_id", contact.ExtID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("username", username); err != nil { + return diag.FromErr(err) + } + + return diags +} + +func resourceUserContactEmailDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + + username := d.Get("username").(string) + extID := d.Get("ext_id").(string) + + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + + requestDetails, err := config.VictorOpsClient.DeleteContact(username, extID, victorops.GetContactTypes().Email) + if err != nil { + return diag.FromErr(err) + } + + if requestDetails.StatusCode != 200 { + return diag.Errorf("failed to delete contact email (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + } + + d.SetId("") + return diags +} + +func resourceUserContactEmailImport(ctx context.Context, d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + idAttr := strings.SplitN(d.Id(), "/", 2) + if len(idAttr) != 2 { + return nil, fmt.Errorf("invalid id %q specified, should be in format \"username/ext_id\" for import", d.Id()) + } + + username := idAttr[0] + extID := idAttr[1] + + d.Set("username", username) + d.Set("ext_id", extID) + d.SetId(fmt.Sprintf("%s/%s", username, extID)) + + return []*schema.ResourceData{d}, nil +} diff --git a/victorops/resource_victorops_user_contact_phone.go b/victorops/resource_victorops_user_contact_phone.go new file mode 100644 index 0000000..4565a9d --- /dev/null +++ b/victorops/resource_victorops_user_contact_phone.go @@ -0,0 +1,176 @@ +package victorops + +import ( + "context" + "fmt" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/victorops/go-victorops/victorops" +) + +func resourceUserContactPhone() *schema.Resource { + return &schema.Resource{ + Description: "Manages a phone contact method for a VictorOps user.", + CreateContext: resourceUserContactPhoneCreate, + ReadContext: resourceUserContactPhoneRead, + DeleteContext: resourceUserContactPhoneDelete, + Importer: &schema.ResourceImporter{ + StateContext: resourceUserContactPhoneImport, + }, + + Schema: map[string]*schema.Schema{ + "username": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The username of the user this contact phone belongs to.", + }, + "label": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "A label for this phone contact (e.g., 'Work Phone', 'Mobile').", + }, + "phone": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.StringIsNotEmpty, + Description: "The phone number.", + }, + "rank": { + Type: schema.TypeInt, + Optional: true, + ForceNew: true, + Default: 0, + Description: "The rank (priority) of this contact method.", + }, + "ext_id": { + Type: schema.TypeString, + Computed: true, + Description: "The external ID of the contact.", + }, + }, + } +} + +func resourceUserContactPhoneCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + config := m.(Config) + username := d.Get("username").(string) + + contact := &victorops.Contact{ + Label: d.Get("label").(string), + PhoneNumber: d.Get("phone").(string), + } + + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + + newContact, requestDetails, err := config.VictorOpsClient.CreateContact(username, contact) + if err != nil { + return diag.FromErr(err) + } + + if requestDetails.StatusCode != 200 { + return diag.Errorf("failed to create contact phone (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + } + + if err := d.Set("ext_id", newContact.ExtID); err != nil { + return diag.FromErr(err) + } + + d.SetId(fmt.Sprintf("%s/%s", username, newContact.ExtID)) + + return resourceUserContactPhoneRead(ctx, d, m) +} + +func resourceUserContactPhoneRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + + username := d.Get("username").(string) + extID := d.Get("ext_id").(string) + if extID == "" { + parts := strings.SplitN(d.Id(), "/", 2) + if len(parts) == 2 { + username = parts[0] + extID = parts[1] + } + } + + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + + contact, requestDetails, err := config.VictorOpsClient.GetContact(username, extID, victorops.GetContactTypes().Phone) + if err != nil { + return diag.FromErr(err) + } + + if requestDetails.StatusCode == 404 { + d.SetId("") + return diags + } + if requestDetails.StatusCode != 200 { + return diag.Errorf("failed to get contact phone (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + } + + if err := d.Set("label", contact.Label); err != nil { + return diag.FromErr(err) + } + if err := d.Set("ext_id", contact.ExtID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("username", username); err != nil { + return diag.FromErr(err) + } + + return diags +} + +func resourceUserContactPhoneDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + + username := d.Get("username").(string) + extID := d.Get("ext_id").(string) + + // Wait for rate limiter before making API request + if err := WaitForRateLimitWithContext(ctx); err != nil { + return diag.FromErr(err) + } + + requestDetails, err := config.VictorOpsClient.DeleteContact(username, extID, victorops.GetContactTypes().Phone) + if err != nil { + return diag.FromErr(err) + } + + if requestDetails.StatusCode != 200 { + return diag.Errorf("failed to delete contact phone (%d): %s", requestDetails.StatusCode, requestDetails.ResponseBody) + } + + d.SetId("") + return diags +} + +func resourceUserContactPhoneImport(ctx context.Context, d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + idAttr := strings.SplitN(d.Id(), "/", 2) + if len(idAttr) != 2 { + return nil, fmt.Errorf("invalid id %q specified, should be in format \"username/ext_id\" for import", d.Id()) + } + + username := idAttr[0] + extID := idAttr[1] + + d.Set("username", username) + d.Set("ext_id", extID) + d.SetId(fmt.Sprintf("%s/%s", username, extID)) + + return []*schema.ResourceData{d}, nil +} diff --git a/victorops/resource_victorops_user_contact_test.go b/victorops/resource_victorops_user_contact_test.go new file mode 100644 index 0000000..cd554f9 --- /dev/null +++ b/victorops/resource_victorops_user_contact_test.go @@ -0,0 +1,145 @@ +package victorops + +import ( + "fmt" + "os" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" +) + +func TestAccUserContactEmailCreate(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + username := os.Getenv("VO_REPLACEMENT_USERNAME") + if username == "" { + t.Skip("VO_REPLACEMENT_USERNAME not set") + } + + tfResourceName := "victorops_user_contact_email.test" + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + CheckDestroy: testAccUserContactEmailDestroy, + Steps: []resource.TestStep{ + { + Config: testAccUserContactEmailConfig(username), + Check: resource.ComposeTestCheckFunc( + testAccUserContactEmailExists(tfResourceName), + resource.TestCheckResourceAttr(tfResourceName, "username", username), + resource.TestCheckResourceAttr(tfResourceName, "label", "Test Email"), + resource.TestCheckResourceAttrSet(tfResourceName, "ext_id"), + ), + }, + }, + }) +} + +func testAccUserContactEmailConfig(username string) string { + return fmt.Sprintf(` +resource "victorops_user_contact_email" "test" { + username = "%s" + label = "Test Email" + email = "tf-test-%s@example.com" +} +`, username, username) +} + +func testAccUserContactEmailExists(resourceName string) resource.TestCheckFunc { + return func(state *terraform.State) error { + rs, ok := state.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + if rs.Primary.ID == "" { + return fmt.Errorf("no record ID is set") + } + return nil + } +} + +func testAccUserContactEmailDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "victorops_user_contact_email" { + continue + } + } + return nil +} + +func TestAccUserContactPhoneCreate(t *testing.T) { + if testing.Short() { + t.Skip("skipping testing in short mode") + } + + username := os.Getenv("VO_REPLACEMENT_USERNAME") + if username == "" { + t.Skip("VO_REPLACEMENT_USERNAME not set") + } + + tfResourceName := "victorops_user_contact_phone.test" + resource.Test(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + }, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, + CheckDestroy: testAccUserContactPhoneDestroy, + Steps: []resource.TestStep{ + { + Config: testAccUserContactPhoneConfig(username), + Check: resource.ComposeTestCheckFunc( + testAccUserContactPhoneExists(tfResourceName), + resource.TestCheckResourceAttr(tfResourceName, "username", username), + resource.TestCheckResourceAttr(tfResourceName, "label", "Test Phone"), + resource.TestCheckResourceAttrSet(tfResourceName, "ext_id"), + ), + }, + }, + }) +} + +func testAccUserContactPhoneConfig(username string) string { + return fmt.Sprintf(` +resource "victorops_user_contact_phone" "test" { + username = "%s" + label = "Test Phone" + phone = "+1-555-123-4567" +} +`, username) +} + +func testAccUserContactPhoneExists(resourceName string) resource.TestCheckFunc { + return func(state *terraform.State) error { + rs, ok := state.RootModule().Resources[resourceName] + if !ok { + return fmt.Errorf("not found: %s", resourceName) + } + if rs.Primary.ID == "" { + return fmt.Errorf("no record ID is set") + } + return nil + } +} + +func testAccUserContactPhoneDestroy(s *terraform.State) error { + for _, rs := range s.RootModule().Resources { + if rs.Type != "victorops_user_contact_phone" { + continue + } + } + return nil +} diff --git a/victorops/resource_victorops_user_paging_policy.go b/victorops/resource_victorops_user_paging_policy.go new file mode 100644 index 0000000..71d2f04 --- /dev/null +++ b/victorops/resource_victorops_user_paging_policy.go @@ -0,0 +1,235 @@ +package victorops + +import ( + "context" + "fmt" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" +) + +func resourceUserPagingPolicy() *schema.Resource { + return &schema.Resource{ + Description: `Manages a user's paging policy in VictorOps/Splunk OnCall. + +This resource manages the entire paging policy for a user, including all steps and rules. +Note: This resource fully owns the paging policy. Any steps/rules not defined in Terraform +will be removed when the policy is applied.`, + CreateContext: resourceUserPagingPolicyCreate, + ReadContext: resourceUserPagingPolicyRead, + UpdateContext: resourceUserPagingPolicyUpdate, + DeleteContext: resourceUserPagingPolicyDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "username": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The username whose paging policy to manage.", + }, + "step": { + Type: schema.TypeList, + Required: true, + MinItems: 1, + Description: "The steps in the paging policy, ordered by execution sequence.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "timeout": { + Type: schema.TypeInt, + Required: true, + ValidateFunc: validation.IntInSlice([]int{0, 1, 5, 10, 15, 20, 25, 30, 45, 60}), + Description: "The timeout in minutes before escalating to the next step. Valid values: 0, 1, 5, 10, 15, 20, 25, 30, 45, 60.", + }, + "rule": { + Type: schema.TypeList, + Required: true, + MinItems: 1, + Description: "The rules within this step.", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"push", "email", "phone", "sms"}, false), + Description: "The type of notification: 'push', 'email', 'phone', or 'sms'.", + }, + "contact": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: "The contact for this rule (required for email, phone, sms types).", + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeInt, + Required: true, + Description: "The contact ID.", + }, + "type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice([]string{"email", "phone"}, false), + Description: "The contact type: 'email' or 'phone'.", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func resourceUserPagingPolicyCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + username := d.Get("username").(string) + + // First, clear any existing policy + existingPolicy, err := apiClient.GetUserPagingPolicy(username) + if err != nil { + return diag.FromErr(err) + } + if existingPolicy != nil { + // Delete existing steps in reverse order using Index field + for i := len(existingPolicy.Steps) - 1; i >= 0; i-- { + if err := apiClient.DeletePagingPolicyStep(username, existingPolicy.Steps[i].Index); err != nil { + return diag.FromErr(fmt.Errorf("failed to delete existing step %d: %w", existingPolicy.Steps[i].Index, err)) + } + } + } + + // Create new steps + steps := d.Get("step").([]interface{}) + for _, s := range steps { + stepData := s.(map[string]interface{}) + stepReq := &PagingPolicyStepCreateRequest{ + Timeout: stepData["timeout"].(int), + } + + step, err := apiClient.CreatePagingPolicyStep(username, stepReq) + if err != nil { + return diag.FromErr(fmt.Errorf("failed to create step: %w", err)) + } + + // Create rules for this step + rules := stepData["rule"].([]interface{}) + for _, r := range rules { + ruleData := r.(map[string]interface{}) + ruleReq := &PagingPolicyRuleCreateRequest{ + Type: ruleData["type"].(string), + } + + // Handle contact block + if contactList, ok := ruleData["contact"].([]interface{}); ok && len(contactList) > 0 { + contactData := contactList[0].(map[string]interface{}) + ruleReq.Contact = &Contact{ + ID: contactData["id"].(int), + Type: contactData["type"].(string), + } + } + + _, err := apiClient.CreatePagingPolicyRule(username, step.Index, ruleReq) + if err != nil { + return diag.FromErr(fmt.Errorf("failed to create rule: %w", err)) + } + } + } + + d.SetId(username) + return resourceUserPagingPolicyRead(ctx, d, m) +} + +func resourceUserPagingPolicyRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + username := d.Id() + + policy, err := apiClient.GetUserPagingPolicy(username) + if err != nil { + return diag.FromErr(err) + } + + if policy == nil { + d.SetId("") + return diags + } + + if err := d.Set("username", username); err != nil { + return diag.FromErr(err) + } + + // Map steps and rules + steps := make([]map[string]interface{}, len(policy.Steps)) + for i, s := range policy.Steps { + rules := make([]map[string]interface{}, len(s.Rules)) + for j, r := range s.Rules { + ruleMap := map[string]interface{}{ + "type": r.Type, + } + + // Handle contact object + if r.Contact != nil { + ruleMap["contact"] = []map[string]interface{}{ + { + "id": r.Contact.ID, + "type": r.Contact.Type, + }, + } + } else { + ruleMap["contact"] = []map[string]interface{}{} + } + + rules[j] = ruleMap + } + steps[i] = map[string]interface{}{ + "timeout": s.Timeout, + "rule": rules, + } + } + + if err := d.Set("step", steps); err != nil { + return diag.FromErr(err) + } + + return diags +} + +func resourceUserPagingPolicyUpdate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + // For simplicity, we recreate the entire policy on update + // This ensures proper ordering of steps and rules + return resourceUserPagingPolicyCreate(ctx, d, m) +} + +func resourceUserPagingPolicyDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics { + var diags diag.Diagnostics + config := m.(Config) + apiClient := NewAPIClient(config.BaseURL, config.APIId, config.APIKey) + username := d.Id() + + policy, err := apiClient.GetUserPagingPolicy(username) + if err != nil { + return diag.FromErr(err) + } + + if policy != nil { + // Delete all steps in reverse order using Index field + for i := len(policy.Steps) - 1; i >= 0; i-- { + if err := apiClient.DeletePagingPolicyStep(username, policy.Steps[i].Index); err != nil { + return diag.FromErr(fmt.Errorf("failed to delete step %d: %w", policy.Steps[i].Index, err)) + } + } + } + + d.SetId("") + return diags +} diff --git a/victorops/resource_victorops_user_test.go b/victorops/resource_victorops_user_test.go index 85c2032..09fac4c 100644 --- a/victorops/resource_victorops_user_test.go +++ b/victorops/resource_victorops_user_test.go @@ -2,14 +2,16 @@ package victorops import ( "fmt" - "github.com/bxcodec/faker/v3" - "github.com/hashicorp/terraform-plugin-sdk/helper/resource" - "github.com/hashicorp/terraform-plugin-sdk/terraform" - "github.com/victorops/go-victorops/victorops" "os" "regexp" "strings" "testing" + + "github.com/bxcodec/faker/v3" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/victorops/go-victorops/victorops" ) type UserData struct { @@ -29,7 +31,11 @@ func TestAccUserCreate(t *testing.T) { PreCheck: func() { testAccPreCheck(t) }, - Providers: testAccProviders, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, CheckDestroy: testAccUserDestroy, Steps: []resource.TestStep{ { @@ -57,7 +63,11 @@ func testAccUser_Update(t *testing.T) { PreCheck: func() { testAccPreCheck(t) }, - Providers: testAccProviders, + ProviderFactories: map[string]func() (*schema.Provider, error){ + "victorops": func() (*schema.Provider, error) { + return testAccProvider, nil + }, + }, CheckDestroy: testAccUserDestroy, Steps: []resource.TestStep{ { @@ -132,7 +142,6 @@ func testAccUserExists(resource string) resource.TestCheckFunc { } func testAccUserDestroy(s *terraform.State) error { - apiClient := testAccProvider.Meta().(Config).VictorOpsClient for _, rs := range s.RootModule().Resources {