diff options
| author | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-07-03 15:21:56 +0200 |
|---|---|---|
| committer | Ruben Beltran del Rio <jj@r.bdr.sh> | 2026-07-03 15:21:58 +0200 |
| commit | 95dc66cb108a311065917362a00b86c08767303d (patch) | |
| tree | 46ca65be6548224be0dd5a42bb86f2ecede14da8 | |
| -rw-r--r-- | .gitignore | 6 | ||||
| -rw-r--r-- | LICENSE | 192 | ||||
| -rw-r--r-- | Makefile | 37 | ||||
| -rw-r--r-- | README.md | 111 | ||||
| -rw-r--r-- | examples/yaml/Pulumi.yaml | 36 | ||||
| -rw-r--r-- | go.mod | 129 | ||||
| -rw-r--r-- | go.sum | 370 | ||||
| -rw-r--r-- | internal/porkbun/client.go | 248 | ||||
| -rw-r--r-- | internal/porkbun/client_test.go | 271 | ||||
| -rw-r--r-- | main.go | 20 | ||||
| -rw-r--r-- | provider/config.go | 46 | ||||
| -rw-r--r-- | provider/dns_record.go | 240 | ||||
| -rw-r--r-- | provider/dns_record_test.go | 408 | ||||
| -rw-r--r-- | provider/fake_porkbun_test.go | 182 | ||||
| -rw-r--r-- | provider/names_test.go | 37 | ||||
| -rw-r--r-- | provider/provider.go | 29 | ||||
| -rw-r--r-- | provider/provider_test.go | 77 |
17 files changed, 2439 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..176f0de --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/bin/ +/sdk/ +*.test +.DS_Store +Pulumi.*.yaml +!examples/**/Pulumi.yaml @@ -0,0 +1,192 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Ruben Beltran del Rio + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4505ecc --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +PROJECT := git.sr.ht/~rbdr/pulumi-porkbun +PROVIDER := pulumi-resource-porkbun +VERSION ?= 1.0.0 +LDFLAGS := -X $(PROJECT)/provider.Version=$(VERSION) + +.PHONY: build +build: + go build -ldflags "$(LDFLAGS)" -o bin/$(PROVIDER) . + +.PHONY: test +test: + go test ./... + +.PHONY: lint +lint: + go vet ./... + +.PHONY: schema +schema: build + pulumi package get-schema ./bin/$(PROVIDER) > schema.json + +.PHONY: gen-sdks +gen-sdks: build + rm -rf sdk + pulumi package gen-sdk ./bin/$(PROVIDER) --language typescript,python,go --out sdk + +.PHONY: install +install: build + pulumi plugin install resource porkbun $(VERSION) --file ./bin/$(PROVIDER) --reinstall + +.PHONY: clean +clean: + rm -rf bin sdk + +.PHONY: help +help: + @grep -E '^[a-zA-Z_-]+:.*## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*## "}; {printf "%-10s %s\n", $$1, $$2}' diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bbc66c --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# Pulumi Porkbun Provider + +A native [Pulumi](https://www.pulumi.com/) provider for managing DNS records on +[Porkbun](https://porkbun.com/), built with the +[pulumi-go-provider](https://github.com/pulumi/pulumi-go-provider) framework. + +NOTE: This is currently pretty incomplete. I only built up what I need for +my use-case, which is controlling DNS records. + +## Resources + +| Resource | Description | +| --- | --- | +| `porkbun:index:DnsRecord` | A DNS record on a domain using Porkbun's nameservers. | + +### `DnsRecord` + +| Input | Type | Description | +| --- | --- | --- | +| `domain` (required) | string | The domain the record belongs to, e.g. `example.com`. Changing it replaces the record. | +| `name` | string | The subdomain, e.g. `www`. Empty for the root domain, `*` for a wildcard. Changing it replaces the record. | +| `type` (required) | string | One of `A`, `AAAA`, `ALIAS`, `CAA`, `CNAME`, `HTTPS`, `MX`, `NS`, `SRV`, `SSHFP`, `SVCB`, `TLSA`, `TXT`. | +| `content` (required) | string | The record's answer, e.g. an IP address for an `A` record. | +| `ttl` | int | Time to live in seconds. Defaults to `600`. | +| `prio` | int | Priority, for record types that support it (`MX`, `SRV`). Defaults to `0`. | + +Outputs: all inputs, plus `fqdn` (the fully qualified record name) and `id` +(the Porkbun record ID). + +Changes to `content`, `ttl`, `prio`, and `type` are applied in place through +Porkbun's edit endpoint; changes to `domain` or `name` replace the record. + +## Configuration + +Credentials come from Pulumi config or the environment. Create an API key pair +at <https://porkbun.com/account/api> and enable API access for each domain you +want to manage. + +```sh +pulumi config set porkbun:apiKey --secret pk1_... +pulumi config set porkbun:secretApiKey --secret sk1_... +``` + +or: + +```sh +export PORKBUN_API_KEY=pk1_... +export PORKBUN_SECRET_API_KEY=sk1_... +``` + +| Config key | Env var | Description | +| --- | --- | --- | +| `porkbun:apiKey` (secret) | `PORKBUN_API_KEY` | The Porkbun API key. | +| `porkbun:secretApiKey` (secret) | `PORKBUN_SECRET_API_KEY` | The Porkbun secret API key. | +| `porkbun:baseUrl` | — | Override the API endpoint (mainly for testing). | + +## Usage + +YAML: + +```yaml +resources: + www: + type: porkbun:DnsRecord + properties: + domain: example.com + name: www + type: A + content: 192.0.2.1 +``` + +TypeScript (after `make gen-sdks`): + +```typescript +import * as porkbun from "@pulumi/porkbun"; + +const www = new porkbun.DnsRecord("www", { + domain: "example.com", + name: "www", + type: "A", + content: "192.0.2.1", +}); +``` + +### Importing existing records + +Use an ID of the form `<domain>/<recordId>`: + +```sh +pulumi import porkbun:index:DnsRecord www example.com/106926652 +``` + +The record ID is visible in the Porkbun DNS dashboard or via the API's +retrieve endpoint. + +## Development + +```sh +make build # build bin/pulumi-resource-porkbun +make test # run unit + provider integration tests +make schema # regenerate schema.json +make gen-sdks # generate TypeScript/Python/Go SDKs into sdk/ +make install # install the built provider as a local Pulumi plugin +``` + +Tests run entirely against an in-memory fake of the Porkbun API; no +credentials or network access are required. + +A runnable example lives in [`examples/yaml`](examples/yaml). It references +the locally built provider via `plugins.providers`, so `make build` first, +then run `pulumi up` inside the example directory. diff --git a/examples/yaml/Pulumi.yaml b/examples/yaml/Pulumi.yaml new file mode 100644 index 0000000..d9cd94c --- /dev/null +++ b/examples/yaml/Pulumi.yaml @@ -0,0 +1,36 @@ +name: porkbun-example +runtime: yaml +description: Example Pulumi program managing Porkbun DNS records. + +plugins: + providers: + - name: porkbun + path: ../../bin + +config: + domain: + type: string + default: example.com + +resources: + www: + type: porkbun:DnsRecord + properties: + domain: ${domain} + name: www + type: A + content: 192.0.2.1 + + mail: + type: porkbun:DnsRecord + properties: + domain: ${domain} + name: "" + type: MX + content: mail.${domain} + prio: 10 + ttl: 3600 + +outputs: + wwwFqdn: ${www.fqdn} + wwwRecordId: ${www.id} @@ -0,0 +1,129 @@ +module git.sr.ht/~rbdr/pulumi-porkbun + +go 1.26.3 + +require ( + github.com/blang/semver v3.5.1+incompatible + github.com/pulumi/pulumi-go-provider v1.3.2 + github.com/pulumi/pulumi/sdk/v3 v3.232.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/agext/levenshtein v1.2.3 // indirect + github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect + github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/cheggaaa/pb v1.0.29 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/djherbis/times v1.5.0 // indirect + github.com/edsrzf/mmap-go v1.1.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/go-git/go-git/v5 v5.18.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-version v1.9.0 // indirect + github.com/hashicorp/hcl/v2 v2.22.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.21 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/mitchellh/go-ps v1.0.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/natefinch/atomic v1.0.1 // indirect + github.com/opentracing/basictracer-go v1.1.0 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect + github.com/pgavlin/fx v0.1.6 // indirect + github.com/pgavlin/fx/v2 v2.0.12 // indirect + github.com/pgavlin/goldmark v1.1.33-0.20200616210433-b5eb04559386 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/term v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231 // indirect + github.com/pulumi/esc v0.23.0 // indirect + github.com/pulumi/pulumi/pkg/v3 v3.232.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect + github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.3.5 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/texttheater/golang-levenshtein v1.0.1 // indirect + github.com/uber/jaeger-client-go v2.30.0+incompatible // indirect + github.com/uber/jaeger-lib v2.4.1+incompatible // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/zclconf/go-cty v1.13.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/collector/featuregate v1.56.0 // indirect + go.opentelemetry.io/collector/pdata v1.56.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/bridge/opentracing v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 // indirect + google.golang.org/grpc v1.80.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + lukechampine.com/frand v1.5.1 // indirect +) @@ -0,0 +1,370 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM= +github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= +github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= +github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= +github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= +github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/cheggaaa/pb v1.0.29 h1:FckUN5ngEk2LpvuG0fw1GEFx6LtyY2pWI/Z2QgCnEYo= +github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +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/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU= +github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0= +github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ= +github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +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/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +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.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= +github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= +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-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-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= +github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl/v2 v2.22.0 h1:hkZ3nCtqeJsDhPRFz5EA9iwcG1hNWGePOTw6oyul12M= +github.com/hashicorp/hcl/v2 v2.22.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +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/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +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.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= +github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/opentracing/basictracer-go v1.1.0 h1:Oa1fTSBvAl8pa3U+IJYqrKm0NALwH9OsgwOqDv4xJW0= +github.com/opentracing/basictracer-go v1.1.0/go.mod h1:V2HZueSJEp879yv285Aap1BS69fQMD+MNP1mRs6mBQc= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/pgavlin/fx v0.1.6 h1:r9jEg69DhNoCd3Xh0+5mIbdbS3PqWrVWujkY76MFRTU= +github.com/pgavlin/fx v0.1.6/go.mod h1:KWZJ6fqBBSh8GxHYqwYCf3rYE7Gp2p0N8tJp8xv9u9M= +github.com/pgavlin/fx/v2 v2.0.12 h1:SjjaJ68Dt8Z4zHwOpY/RPijd7lShs6xYupJbF9ra00M= +github.com/pgavlin/fx/v2 v2.0.12/go.mod h1:M/nF/ooAOy+NUBooYYXl2REARzJ/giPJxfMs8fINfKc= +github.com/pgavlin/goldmark v1.1.33-0.20200616210433-b5eb04559386 h1:LoCV5cscNVWyK5ChN/uCoIFJz8jZD63VQiGJIRgr6uo= +github.com/pgavlin/goldmark v1.1.33-0.20200616210433-b5eb04559386/go.mod h1:MRxHTJrf9FhdfNQ8Hdeh9gmHevC9RJE/fu8M3JIGjoE= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/term v1.1.0 h1:xIAAdCMh3QIAy+5FrE8Ad8XoDhEU4ufwbaSozViP9kk= +github.com/pkg/term v1.1.0/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= +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/pulumi/appdash v0.0.0-20231130102222-75f619a67231 h1:vkHw5I/plNdTr435cARxCW6q9gc0S/Yxz7Mkd38pOb0= +github.com/pulumi/appdash v0.0.0-20231130102222-75f619a67231/go.mod h1:murToZ2N9hNJzewjHBgfFdXhZKjY3z5cYC1VXk+lbFE= +github.com/pulumi/esc v0.23.0 h1:5lOXO+5vvXOEQxXw7cTuYhjg9lVng23f9XNLWDR9EP4= +github.com/pulumi/esc v0.23.0/go.mod h1:mkghIFn/TvN3XnP4jmCB4U5BG1I4UjGluARi39ckrCE= +github.com/pulumi/pulumi-go-provider v1.3.2 h1:G02Jzt1a5D8QwcdHTJIcdJ+IG6RUYTVDL1hg5yh4JaU= +github.com/pulumi/pulumi-go-provider v1.3.2/go.mod h1:L99PGubfMAQ3nZcPIg2a6kQ5QA8dvF4bUmDgiXzRSc8= +github.com/pulumi/pulumi/pkg/v3 v3.232.0 h1:XEfFaUvhZazyo9AHYQoaJ0H0rfkL1sfc8qROXyi7D7I= +github.com/pulumi/pulumi/pkg/v3 v3.232.0/go.mod h1:KWvm9fb1yvVDgJxqWSD026l5PA+z3Mk14bWIf4YZHZ8= +github.com/pulumi/pulumi/sdk/v3 v3.232.0 h1:pU9j5qYHYtYZGaIKha1KFuB9ggp2obcMD1U523tpEnk= +github.com/pulumi/pulumi/sdk/v3 v3.232.0/go.mod h1:33NoTpvQXlTtAuaPbompgZNA3Ye4XEvxNydELFlWza8= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= +github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 h1:TToq11gyfNlrMFZiYujSekIsPd9AmsA2Bj/iv+s4JHE= +github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.3.5 h1:UZEiaZ55nlXGDL92scoVuw00RmiRCazIEmvPSbSvt8Y= +github.com/segmentio/encoding v0.3.5/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +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/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/texttheater/golang-levenshtein v1.0.1 h1:+cRNoVrfiwufQPhoMzB6N0Yf/Mqajr6t1lOv8GyGE2U= +github.com/texttheater/golang-levenshtein v1.0.1/go.mod h1:PYAKrbF5sAiq9wd+H82hs7gNaen0CplQ9uvm6+enD/8= +github.com/uber/jaeger-client-go v2.30.0+incompatible h1:D6wyKGCecFaSRUpo8lCVbaOOb6ThwMmTEbhRwtKR97o= +github.com/uber/jaeger-client-go v2.30.0+incompatible/go.mod h1:WVhlPFC8FDjOFMMWRy2pZqQJSXxYSwNYOkTr/Z6d3Kk= +github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVKhn2Um6rjCsSsg= +github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= +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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zclconf/go-cty v1.13.2 h1:4GvrUxe/QUDYuJKAav4EYqdM47/kZa672LwmXFmEKT0= +github.com/zclconf/go-cty v1.13.2/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/collector/featuregate v1.56.0 h1:NjcbOZkdCSXddAJmFLdO+pv1gmAgrU6sC5PBga2KlKI= +go.opentelemetry.io/collector/featuregate v1.56.0/go.mod h1:4ga1QBMPEejXXmpyJS8lmaRpknJ3Lb9Bvk6e420bUFU= +go.opentelemetry.io/collector/internal/testutil v0.150.0 h1:J4PLQGPfbLVaL5eI1aMc0m0TMixV9wzBhNhoHU00J0I= +go.opentelemetry.io/collector/internal/testutil v0.150.0/go.mod h1:Jkjs6rkqs973LqgZ0Fe3zrokQRKULYXPIf4HuqStiEE= +go.opentelemetry.io/collector/pdata v1.56.0 h1:W+QAfN2Iz8SNss1T5JNzRWFnw+7oP1vXBQH9ZuOJkXY= +go.opentelemetry.io/collector/pdata v1.56.0/go.mod h1:usR9utboXufbD1rp1oJy+3smQXXpZ+CsI3WN7QsiOs0= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/bridge/opentracing v1.33.0 h1:eH88qvKdY7ns7Xu6WlJBQNOzZ3MVvBR6tEl2euaYS9w= +go.opentelemetry.io/otel/bridge/opentracing v1.33.0/go.mod h1:FNai/nhRSn/kHyv+V1zaf/30BU8hO/DXo0MvV0PaUS8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/proto/slim/otlp v1.10.0 h1:iR97Vs/ZDR+y9TfuP9b1XBtdPWeC+OMslIBmhcLU7jM= +go.opentelemetry.io/proto/slim/otlp v1.10.0/go.mod h1:lV9250stpjYLPNA5viFabIgP2QlUGRT1GdTgAf8SIUk= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.3.0 h1:RUF5rO0hAlgiJt1fzQVzcVs3vZVNHIcMLgOgG4rWNcQ= +go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.3.0/go.mod h1:I89cynRj8y+383o7tEQVg2SVA6SRgDVIouWPUVXjx0U= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.3.0 h1:CQvJSldHRUN6Z8jsUeYv8J0lXRvygALXIzsmAeCcZE0= +go.opentelemetry.io/proto/slim/otlp/profiles/v1development v0.3.0/go.mod h1:xSQ+mEfJe/GjK1LXEyVOoSI1N9JV9ZI923X5kup43W4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +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-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529 h1:zUWMZsvo/IJcD1t6MNCPO/azZTwz0TvwCBqr5aifoVY= +google.golang.org/genproto/googleapis/api v0.0.0-20260420184626-e10c466a9529/go.mod h1:a5OGAgyRr4lqco7AG9hQM9Fwh0N2ZV4grR0eXFEsXQg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529 h1:XF8+t6QQiS0o9ArVan/HW8Q7cycNPGsJf6GA2nXxYAg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +lukechampine.com/frand v1.5.1 h1:fg0eRtdmGFIxhP5zQJzM1lFDbD6CUfu/f+7WgAZd5/w= +lukechampine.com/frand v1.5.1/go.mod h1:4VstaWc2plN4Mjr10chUD46RAVGWhpkZ5Nja8+Azp0Q= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/porkbun/client.go b/internal/porkbun/client.go new file mode 100644 index 0000000..93c54eb --- /dev/null +++ b/internal/porkbun/client.go @@ -0,0 +1,248 @@ +package porkbun + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + neturl "net/url" + "strconv" + "strings" + "time" +) + +const DefaultBaseURL = "https://api.porkbun.com/api/json/v3" + +type Client struct { + apiKey string + secretAPIKey string + baseURL string + httpClient *http.Client +} + +type Option func(*Client) + +func WithBaseURL(u string) Option { + return func(c *Client) { c.baseURL = strings.TrimRight(u, "/") } +} + +func WithHTTPClient(h *http.Client) Option { + return func(c *Client) { c.httpClient = h } +} + +func NewClient(apiKey, secretAPIKey string, opts ...Option) *Client { + c := &Client{ + apiKey: apiKey, + secretAPIKey: secretAPIKey, + baseURL: DefaultBaseURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } + for _, opt := range opts { + opt(c) + } + return c +} + +type APIError struct { + HTTPStatus int + Status string + Message string +} + +func (e *APIError) Error() string { + msg := e.Message + if msg == "" { + msg = "no error message returned" + } + return fmt.Sprintf("porkbun API error (HTTP %d, status %q): %s", e.HTTPStatus, e.Status, msg) +} + +type Record struct { + ID string + Name string + Type string + Content string + TTL int + Prio int +} + +type RecordSpec struct { + Name string + Type string + Content string + TTL int + Prio int +} + +func (c *Client) Ping(ctx context.Context) (string, error) { + var out struct { + YourIP string `json:"yourIp"` + } + if err := c.post(ctx, "/ping", nil, &out); err != nil { + return "", err + } + return out.YourIP, nil +} + +func (c *Client) CreateRecord(ctx context.Context, domain string, spec RecordSpec) (string, error) { + body := map[string]any{ + "name": spec.Name, + "type": spec.Type, + "content": spec.Content, + // The API documents ttl and prio as strings. + "ttl": strconv.Itoa(spec.TTL), + "prio": strconv.Itoa(spec.Prio), + } + var out struct { + ID flexString `json:"id"` + } + if err := c.post(ctx, "/dns/create/"+url(domain), body, &out); err != nil { + return "", err + } + if out.ID == "" { + return "", fmt.Errorf("porkbun API reported success creating a record on %q but returned no record ID", domain) + } + return string(out.ID), nil +} + +func (c *Client) EditRecord(ctx context.Context, domain, id string, spec RecordSpec) error { + body := map[string]any{ + "type": spec.Type, + "content": spec.Content, + "ttl": strconv.Itoa(spec.TTL), + "prio": strconv.Itoa(spec.Prio), + } + return c.post(ctx, "/dns/edit/"+url(domain)+"/"+url(id), body, nil) +} + +func (c *Client) DeleteRecord(ctx context.Context, domain, id string) error { + return c.post(ctx, "/dns/delete/"+url(domain)+"/"+url(id), nil, nil) +} + +func (c *Client) GetRecord(ctx context.Context, domain, id string) (*Record, error) { + var out struct { + Records []wireRecord `json:"records"` + } + if err := c.post(ctx, "/dns/retrieve/"+url(domain)+"/"+url(id), nil, &out); err != nil { + return nil, err + } + if len(out.Records) == 0 { + return nil, nil + } + rec := out.Records[0].toRecord() + return &rec, nil +} + +type wireRecord struct { + ID flexString `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Content string `json:"content"` + TTL flexInt `json:"ttl"` + Prio flexInt `json:"prio"` +} + +func (w wireRecord) toRecord() Record { + return Record{ + ID: string(w.ID), + Name: w.Name, + Type: w.Type, + Content: w.Content, + TTL: int(w.TTL), + Prio: int(w.Prio), + } +} + +type flexString string + +func (f *flexString) UnmarshalJSON(b []byte) error { + var s string + if err := json.Unmarshal(b, &s); err == nil { + *f = flexString(s) + return nil + } + var n json.Number + if err := json.Unmarshal(b, &n); err == nil { + *f = flexString(n.String()) + return nil + } + return fmt.Errorf("expected string or number, got %s", b) +} + +type flexInt int + +func (f *flexInt) UnmarshalJSON(b []byte) error { + if string(b) == "null" || string(b) == `""` { + *f = 0 + return nil + } + var n int + if err := json.Unmarshal(b, &n); err == nil { + *f = flexInt(n) + return nil + } + var s string + if err := json.Unmarshal(b, &s); err == nil { + v, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil { + return fmt.Errorf("expected numeric string, got %q", s) + } + *f = flexInt(v) + return nil + } + return fmt.Errorf("expected number or numeric string, got %s", b) +} + +func url(segment string) string { + return neturl.PathEscape(segment) +} + +func (c *Client) post(ctx context.Context, path string, body map[string]any, out any) error { + payload := map[string]any{ + "apikey": c.apiKey, + "secretapikey": c.secretAPIKey, + } + for k, v := range body { + payload[k] = v + } + buf, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("encoding request for %s: %w", path, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(buf)) + if err != nil { + return fmt.Errorf("building request for %s: %w", path, err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("calling porkbun API %s: %w", path, err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return fmt.Errorf("reading response from %s: %w", path, err) + } + + var envelope struct { + Status string `json:"status"` + Message string `json:"message"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return fmt.Errorf("porkbun API %s returned HTTP %d with an unparseable body: %w", path, resp.StatusCode, err) + } + if envelope.Status != "SUCCESS" { + return &APIError{HTTPStatus: resp.StatusCode, Status: envelope.Status, Message: envelope.Message} + } + if out != nil { + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("decoding response from %s: %w", path, err) + } + } + return nil +} diff --git a/internal/porkbun/client_test.go b/internal/porkbun/client_test.go new file mode 100644 index 0000000..a33f698 --- /dev/null +++ b/internal/porkbun/client_test.go @@ -0,0 +1,271 @@ +package porkbun + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +const ( + testAPIKey = "pk1_test" + testSecretKey = "sk1_test" +) + +// newTestClient starts an httptest server running handler and returns a +// client pointed at it. +func newTestClient(t *testing.T, handler http.HandlerFunc) *Client { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + return NewClient(testAPIKey, testSecretKey, WithBaseURL(srv.URL)) +} + +// decodeBody decodes the request body and verifies the credentials were sent. +func decodeBody(t *testing.T, r *http.Request) map[string]any { + t.Helper() + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decoding request body: %v", err) + } + if body["apikey"] != testAPIKey || body["secretapikey"] != testSecretKey { + t.Errorf("credentials not sent: got apikey=%v secretapikey=%v", body["apikey"], body["secretapikey"]) + } + return body +} + +func TestCreateRecord(t *testing.T) { + var gotPath string + var gotBody map[string]any + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + gotBody = decodeBody(t, r) + // Porkbun documents the ID as a string but returns a JSON number. + w.Write([]byte(`{"status":"SUCCESS","id":106926652}`)) + }) + + id, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{ + Name: "www", Type: "A", Content: "192.0.2.1", TTL: 600, Prio: 0, + }) + if err != nil { + t.Fatalf("CreateRecord: %v", err) + } + if id != "106926652" { + t.Errorf("expected id 106926652, got %q", id) + } + if gotPath != "/dns/create/example.com" { + t.Errorf("unexpected path %q", gotPath) + } + for key, want := range map[string]any{ + "name": "www", "type": "A", "content": "192.0.2.1", "ttl": "600", "prio": "0", + } { + if gotBody[key] != want { + t.Errorf("body[%q] = %v, want %v", key, gotBody[key], want) + } + } +} + +func TestCreateRecordStringID(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","id":"42"}`)) + }) + id, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{Type: "A", Content: "192.0.2.1", TTL: 600}) + if err != nil { + t.Fatalf("CreateRecord: %v", err) + } + if id != "42" { + t.Errorf("expected id 42, got %q", id) + } +} + +func TestCreateRecordMissingID(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + if _, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{Type: "A", Content: "192.0.2.1", TTL: 600}); err == nil { + t.Fatal("expected an error when the API returns no record ID") + } +} + +func TestAPIError(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"status":"ERROR","message":"Invalid record type."}`)) + }) + _, err := client.CreateRecord(context.Background(), "example.com", RecordSpec{Type: "BOGUS", Content: "x", TTL: 600}) + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + if apiErr.HTTPStatus != http.StatusBadRequest || apiErr.Status != "ERROR" || apiErr.Message != "Invalid record type." { + t.Errorf("unexpected APIError: %+v", apiErr) + } +} + +func TestEditRecord(t *testing.T) { + var gotPath string + var gotBody map[string]any + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotBody = decodeBody(t, r) + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + + err := client.EditRecord(context.Background(), "example.com", "42", RecordSpec{ + Name: "www", Type: "A", Content: "192.0.2.2", TTL: 700, Prio: 0, + }) + if err != nil { + t.Fatalf("EditRecord: %v", err) + } + if gotPath != "/dns/edit/example.com/42" { + t.Errorf("unexpected path %q", gotPath) + } + // The edit endpoint does not accept a name; it must not be sent. + if _, ok := gotBody["name"]; ok { + t.Errorf("edit request must not include a name field, got %v", gotBody["name"]) + } + if gotBody["content"] != "192.0.2.2" || gotBody["ttl"] != "700" { + t.Errorf("unexpected edit body: %v", gotBody) + } +} + +func TestDeleteRecord(t *testing.T) { + var gotPath string + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + decodeBody(t, r) + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + if err := client.DeleteRecord(context.Background(), "example.com", "42"); err != nil { + t.Fatalf("DeleteRecord: %v", err) + } + if gotPath != "/dns/delete/example.com/42" { + t.Errorf("unexpected path %q", gotPath) + } +} + +func TestGetRecord(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/dns/retrieve/example.com/42" { + t.Errorf("unexpected path %q", r.URL.Path) + } + // String-typed numeric fields, as documented. + w.Write([]byte(`{"status":"SUCCESS","records":[ + {"id":"42","name":"www.example.com","type":"A","content":"192.0.2.1","ttl":"600","prio":"0","notes":""} + ]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "42") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + want := Record{ID: "42", Name: "www.example.com", Type: "A", Content: "192.0.2.1", TTL: 600, Prio: 0} + if rec == nil || *rec != want { + t.Errorf("GetRecord = %+v, want %+v", rec, want) + } +} + +func TestGetRecordNumericFields(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","records":[ + {"id":42,"name":"mail.example.com","type":"MX","content":"mx.example.com","ttl":600,"prio":10} + ]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "42") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + want := Record{ID: "42", Name: "mail.example.com", Type: "MX", Content: "mx.example.com", TTL: 600, Prio: 10} + if rec == nil || *rec != want { + t.Errorf("GetRecord = %+v, want %+v", rec, want) + } +} + +func TestGetRecordNullPrio(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","records":[ + {"id":"42","name":"example.com","type":"A","content":"192.0.2.1","ttl":"600","prio":null} + ]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "42") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + if rec.Prio != 0 { + t.Errorf("expected null prio to parse as 0, got %d", rec.Prio) + } +} + +func TestGetRecordNotFound(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"status":"SUCCESS","records":[]}`)) + }) + rec, err := client.GetRecord(context.Background(), "example.com", "999") + if err != nil { + t.Fatalf("GetRecord: %v", err) + } + if rec != nil { + t.Errorf("expected nil record for missing ID, got %+v", rec) + } +} + +func TestPing(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/ping" { + t.Errorf("unexpected path %q", r.URL.Path) + } + decodeBody(t, r) + w.Write([]byte(`{"status":"SUCCESS","yourIp":"203.0.113.7"}`)) + }) + ip, err := client.Ping(context.Background()) + if err != nil { + t.Fatalf("Ping: %v", err) + } + if ip != "203.0.113.7" { + t.Errorf("expected ip 203.0.113.7, got %q", ip) + } +} + +func TestBaseURLTrailingSlash(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Write([]byte(`{"status":"SUCCESS","yourIp":"1.2.3.4"}`)) + })) + t.Cleanup(srv.Close) + + client := NewClient(testAPIKey, testSecretKey, WithBaseURL(srv.URL+"/")) + if _, err := client.Ping(context.Background()); err != nil { + t.Fatalf("Ping: %v", err) + } + if gotPath != "/ping" { + t.Errorf("trailing slash not normalized: path %q", gotPath) + } +} + +func TestUnparseableResponse(t *testing.T) { + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(`<html>bad gateway</html>`)) + }) + if _, err := client.GetRecord(context.Background(), "example.com", "42"); err == nil { + t.Fatal("expected an error for a non-JSON response") + } +} + +func TestPathEscaping(t *testing.T) { + var gotPath string + client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.EscapedPath() + w.Write([]byte(`{"status":"SUCCESS"}`)) + }) + // A malicious "domain" must not be able to change the request path. + _ = client.DeleteRecord(context.Background(), "example.com/../../admin", "42") + if gotPath != "/dns/delete/example.com%2F..%2F..%2Fadmin/42" { + t.Errorf("path traversal not escaped: %q", gotPath) + } +} @@ -0,0 +1,20 @@ +package main + +import ( + "context" + "fmt" + "os" + + "git.sr.ht/~rbdr/pulumi-porkbun/provider" +) + +func main() { + porkbun_provider, err := provider.New() + if err == nil { + err = porkbun_provider.Run(context.Background(), provider.Name, provider.Version) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/provider/config.go b/provider/config.go new file mode 100644 index 0000000..956cbef --- /dev/null +++ b/provider/config.go @@ -0,0 +1,46 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/pulumi/pulumi-go-provider/infer" + + "git.sr.ht/~rbdr/pulumi-porkbun/internal/porkbun" +) + +type Config struct { + APIKey string `pulumi:"apiKey,optional" provider:"secret"` + SecretAPIKey string `pulumi:"secretApiKey,optional" provider:"secret"` + BaseURL string `pulumi:"baseUrl,optional"` + + client *porkbun.Client +} + +func (c *Config) Annotate(a infer.Annotator) { + a.Describe(&c, "Configuration for the Porkbun provider.") + a.Describe(&c.APIKey, "The Porkbun API key. May also be set via the `PORKBUN_API_KEY` environment variable.") + a.Describe(&c.SecretAPIKey, "The Porkbun secret API key. May also be set via the `PORKBUN_SECRET_API_KEY` environment variable.") + a.Describe(&c.BaseURL, "The base URL of the Porkbun API. Provided for testing purpouses; defaults to the public endpoint.") + a.SetDefault(&c.APIKey, "", "PORKBUN_API_KEY") + a.SetDefault(&c.SecretAPIKey, "", "PORKBUN_SECRET_API_KEY") + a.SetDefault(&c.BaseURL, porkbun.DefaultBaseURL) +} + +func (c *Config) Configure(ctx context.Context) error { + if c.APIKey == "" || c.SecretAPIKey == "" { + return fmt.Errorf("porkbun API credentials are missing: set the porkbun:apiKey and porkbun:secretApiKey " + + "config values (e.g. `pulumi config set porkbun:apiKey --secret`) or the PORKBUN_API_KEY and " + + "PORKBUN_SECRET_API_KEY environment variables") + } + opts := []porkbun.Option{} + if c.BaseURL != "" { + opts = append(opts, porkbun.WithBaseURL(c.BaseURL)) + } + c.client = porkbun.NewClient(c.APIKey, c.SecretAPIKey, opts...) + return nil +} + +func (c Config) Client() *porkbun.Client { + return c.client +} diff --git a/provider/dns_record.go b/provider/dns_record.go new file mode 100644 index 0000000..dc2855e --- /dev/null +++ b/provider/dns_record.go @@ -0,0 +1,240 @@ +package provider + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi-go-provider/infer" + + "git.sr.ht/~rbdr/pulumi-porkbun/internal/porkbun" +) + +const defaultTTL = 600 + +var recordTypes = map[string]bool{ + "A": true, "AAAA": true, "MX": true, "CNAME": true, "ALIAS": true, + "TXT": true, "NS": true, "SRV": true, "TLSA": true, "CAA": true, + "HTTPS": true, "SVCB": true, "SSHFP": true, +} + +type DnsRecord struct{} + +type DnsRecordArgs struct { + Domain string `pulumi:"domain"` + Name string `pulumi:"name,optional"` + Type string `pulumi:"type"` + Content string `pulumi:"content"` + TTL int `pulumi:"ttl,optional"` + Prio int `pulumi:"prio,optional"` +} + +type DnsRecordState struct { + DnsRecordArgs + Fqdn string `pulumi:"fqdn"` +} + +func (r *DnsRecord) Annotate(a infer.Annotator) { + a.Describe(&r, "A DNS record on a domain using Porkbun's nameservers.\n\n"+ + "To import an existing record, use an ID of the form `<domain>/<recordId>`, "+ + "e.g. `pulumi import porkbun:index:DnsRecord www example.com/123456789`.") +} + +func (d *DnsRecordArgs) Annotate(a infer.Annotator) { + a.Describe(&d.Domain, "The domain the record belongs to, e.g. `example.com`. Changing this forces a new record.") + a.Describe(&d.Name, "The subdomain of the record, e.g. `www`. Leave empty for the root domain. "+ + "Use `*` for a wildcard. Changing this forces a new record.") + a.Describe(&d.Type, "The DNS record type: "+strings.Join(sortedRecordTypes(), ", ")+".") + a.Describe(&d.Content, "The content (answer) of the record, e.g. an IP address for an A record.") + a.Describe(&d.TTL, "The time to live of the record in seconds. The minimum is determined by "+ + "Porkbun account settings (typically 600); values below it are rejected by the API.") + a.Describe(&d.Prio, "The priority of the record, for record types that support it (MX, SRV).") + a.SetDefault(&d.TTL, defaultTTL) + a.SetDefault(&d.Prio, 0) +} + +func (d *DnsRecordState) Annotate(a infer.Annotator) { + a.Describe(&d.Fqdn, "The fully qualified name of the record, e.g. `www.example.com`.") +} + +func (r *DnsRecord) Check(ctx context.Context, req infer.CheckRequest) (infer.CheckResponse[DnsRecordArgs], error) { + args, failures, err := infer.DefaultCheck[DnsRecordArgs](ctx, req.NewInputs) + if err != nil { + return infer.CheckResponse[DnsRecordArgs]{Inputs: args, Failures: failures}, err + } + + // DNS types are case-insensitive; normalize so diffs are stable. + args.Type = strings.ToUpper(strings.TrimSpace(args.Type)) + args.Domain = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(args.Domain)), ".") + args.Name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(args.Name)), ".") + + if args.Domain == "" { + failures = append(failures, p.CheckFailure{Property: "domain", Reason: "domain must not be empty"}) + } + if !recordTypes[args.Type] { + failures = append(failures, p.CheckFailure{ + Property: "type", + Reason: fmt.Sprintf("%q is not a supported record type (expected one of %s)", args.Type, strings.Join(sortedRecordTypes(), ", ")), + }) + } + + if args.TTL < 0 { + failures = append(failures, p.CheckFailure{Property: "ttl", Reason: "ttl must not be negative"}) + } + if args.Prio < 0 || args.Prio > 65535 { + failures = append(failures, p.CheckFailure{Property: "prio", Reason: "prio must be between 0 and 65535"}) + } + if suffix := "." + args.Domain; strings.HasSuffix(args.Name, suffix) { + failures = append(failures, p.CheckFailure{ + Property: "name", + Reason: fmt.Sprintf("name must be the subdomain only (e.g. %q), not the fully qualified name %q", + strings.TrimSuffix(args.Name, suffix), args.Name), + }) + } + + return infer.CheckResponse[DnsRecordArgs]{Inputs: args, Failures: failures}, nil +} + +func (r *DnsRecord) Diff(ctx context.Context, req infer.DiffRequest[DnsRecordArgs, DnsRecordState]) (infer.DiffResponse, error) { + diff := map[string]p.PropertyDiff{} + if req.Inputs.Domain != req.State.Domain { + diff["domain"] = p.PropertyDiff{Kind: p.UpdateReplace, InputDiff: true} + } + if req.Inputs.Name != req.State.Name { + diff["name"] = p.PropertyDiff{Kind: p.UpdateReplace, InputDiff: true} + } + if req.Inputs.Type != req.State.Type { + diff["type"] = p.PropertyDiff{Kind: p.Update, InputDiff: true} + } + if req.Inputs.Content != req.State.Content { + diff["content"] = p.PropertyDiff{Kind: p.Update, InputDiff: true} + } + if req.Inputs.TTL != req.State.TTL { + diff["ttl"] = p.PropertyDiff{Kind: p.Update, InputDiff: true} + } + if req.Inputs.Prio != req.State.Prio { + diff["prio"] = p.PropertyDiff{Kind: p.Update, InputDiff: true} + } + return p.DiffResponse{HasChanges: len(diff) > 0, DetailedDiff: diff}, nil +} + +func (r *DnsRecord) Create(ctx context.Context, req infer.CreateRequest[DnsRecordArgs]) (infer.CreateResponse[DnsRecordState], error) { + state := newState(req.Inputs) + if req.DryRun { + return infer.CreateResponse[DnsRecordState]{Output: state}, nil + } + + client := infer.GetConfig[Config](ctx).Client() + id, err := client.CreateRecord(ctx, req.Inputs.Domain, recordSpec(req.Inputs)) + if err != nil { + return infer.CreateResponse[DnsRecordState]{}, fmt.Errorf("creating %s record %q on %q: %w", + req.Inputs.Type, req.Inputs.Name, req.Inputs.Domain, err) + } + return infer.CreateResponse[DnsRecordState]{ID: id, Output: state}, nil +} + +func (r *DnsRecord) Update(ctx context.Context, req infer.UpdateRequest[DnsRecordArgs, DnsRecordState]) (infer.UpdateResponse[DnsRecordState], error) { + state := newState(req.Inputs) + if req.DryRun { + return infer.UpdateResponse[DnsRecordState]{Output: state}, nil + } + + client := infer.GetConfig[Config](ctx).Client() + if err := client.EditRecord(ctx, req.Inputs.Domain, req.ID, recordSpec(req.Inputs)); err != nil { + return infer.UpdateResponse[DnsRecordState]{}, fmt.Errorf("updating DNS record %s on %q: %w", + req.ID, req.Inputs.Domain, err) + } + return infer.UpdateResponse[DnsRecordState]{Output: state}, nil +} + +func (r *DnsRecord) Delete(ctx context.Context, req infer.DeleteRequest[DnsRecordState]) (infer.DeleteResponse, error) { + client := infer.GetConfig[Config](ctx).Client() + err := client.DeleteRecord(ctx, req.State.Domain, req.ID) + if err == nil { + return infer.DeleteResponse{}, nil + } + + var apiErr *porkbun.APIError + if errors.As(err, &apiErr) { + if rec, lookupErr := client.GetRecord(ctx, req.State.Domain, req.ID); lookupErr == nil && rec == nil { + return infer.DeleteResponse{}, nil + } + } + return infer.DeleteResponse{}, fmt.Errorf("deleting DNS record %s on %q: %w", req.ID, req.State.Domain, err) +} + +func (r *DnsRecord) Read(ctx context.Context, req infer.ReadRequest[DnsRecordArgs, DnsRecordState]) (infer.ReadResponse[DnsRecordArgs, DnsRecordState], error) { + id := req.ID + domain := req.State.Domain + if before, after, found := strings.Cut(id, "/"); found { + domain, id = strings.ToLower(before), after + } + if domain == "" || id == "" { + return infer.ReadResponse[DnsRecordArgs, DnsRecordState]{}, fmt.Errorf( + "cannot read DNS record %q: unknown domain; to import a record use the ID form \"<domain>/<recordId>\"", req.ID) + } + + client := infer.GetConfig[Config](ctx).Client() + rec, err := client.GetRecord(ctx, domain, id) + if err != nil { + return infer.ReadResponse[DnsRecordArgs, DnsRecordState]{}, fmt.Errorf("reading DNS record %s on %q: %w", id, domain, err) + } + if rec == nil { + return infer.ReadResponse[DnsRecordArgs, DnsRecordState]{}, nil + } + + args := DnsRecordArgs{ + Domain: domain, + Name: subdomain(rec.Name, domain), + Type: rec.Type, + Content: rec.Content, + TTL: rec.TTL, + Prio: rec.Prio, + } + return infer.ReadResponse[DnsRecordArgs, DnsRecordState]{ + ID: id, + Inputs: args, + State: newState(args), + }, nil +} + +func recordSpec(args DnsRecordArgs) porkbun.RecordSpec { + return porkbun.RecordSpec{ + Name: args.Name, + Type: args.Type, + Content: args.Content, + TTL: args.TTL, + Prio: args.Prio, + } +} + +func newState(args DnsRecordArgs) DnsRecordState { + return DnsRecordState{DnsRecordArgs: args, Fqdn: fqdn(args.Name, args.Domain)} +} + +func fqdn(name, domain string) string { + if name == "" { + return domain + } + return name + "." + domain +} + +func subdomain(fullName, domain string) string { + fullName = strings.TrimSuffix(strings.ToLower(fullName), ".") + if fullName == domain { + return "" + } + return strings.TrimSuffix(fullName, "."+domain) +} + +func sortedRecordTypes() []string { + types := make([]string, 0, len(recordTypes)) + for t := range recordTypes { + types = append(types, t) + } + sort.Strings(types) + return types +} diff --git a/provider/dns_record_test.go b/provider/dns_record_test.go new file mode 100644 index 0000000..deaea67 --- /dev/null +++ b/provider/dns_record_test.go @@ -0,0 +1,408 @@ +package provider_test + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/blang/semver" + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi-go-provider/integration" + presource "github.com/pulumi/pulumi/sdk/v3/go/common/resource" + "github.com/pulumi/pulumi/sdk/v3/go/property" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "git.sr.ht/~rbdr/pulumi-porkbun/provider" +) + +const dnsRecordToken = "porkbun:index:DnsRecord" + +var testURN = presource.NewURN("test", "provider", "", dnsRecordToken, "test") + +func newTestServer(t *testing.T, fake *fakePorkbun) integration.Server { + t.Helper() + httpSrv := httptest.NewServer(fake) + t.Cleanup(httpSrv.Close) + + server := newIntegrationServer(t) + require.NoError(t, server.Configure(p.ConfigureRequest{ + Args: property.NewMap(map[string]property.Value{ + "apiKey": property.New(testAPIKey), + "secretApiKey": property.New(testSecretKey), + "baseUrl": property.New(httpSrv.URL), + }), + })) + return server +} + +func newIntegrationServer(t *testing.T) integration.Server { + t.Helper() + prov, err := provider.New() + require.NoError(t, err) + server, err := integration.NewServer(context.Background(), provider.Name, + semver.MustParse("1.0.0"), integration.WithProvider(prov)) + require.NoError(t, err) + return server +} + +func recordInputs(overrides map[string]property.Value) property.Map { + inputs := map[string]property.Value{ + "domain": property.New("example.com"), + "name": property.New("www"), + "type": property.New("A"), + "content": property.New("192.0.2.1"), + } + for k, v := range overrides { + inputs[k] = v + } + return property.NewMap(inputs) +} + +func checkedValues(overrides map[string]property.Value, extra map[string]property.Value) property.Map { + values := map[string]property.Value{ + "domain": property.New("example.com"), + "name": property.New("www"), + "type": property.New("A"), + "content": property.New("192.0.2.1"), + "ttl": property.New(600.0), + "prio": property.New(0.0), + } + for k, v := range extra { + values[k] = v + } + for k, v := range overrides { + values[k] = v + } + return property.NewMap(values) +} + +func checkedInputs(overrides map[string]property.Value) property.Map { + return checkedValues(overrides, nil) +} + +func checkedState(overrides map[string]property.Value) property.Map { + return checkedValues(overrides, map[string]property.Value{ + "fqdn": property.New("www.example.com"), + }) +} + +func TestDnsRecordLifecycle(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + expectCreate := checkedState(nil) + expectContentUpdate := checkedState(map[string]property.Value{ + "content": property.New("192.0.2.2"), + }) + expectRename := checkedState(map[string]property.Value{ + "content": property.New("192.0.2.2"), + "name": property.New("api"), + "fqdn": property.New("api.example.com"), + }) + + integration.LifeCycleTest{ + Resource: dnsRecordToken, + Create: integration.Operation{ + Inputs: recordInputs(nil), + ExpectedOutput: &expectCreate, + Hook: func(inputs, output property.Map) { + assert.Equal(t, 1, fake.recordCount(), "exactly one record after create") + }, + }, + Updates: []integration.Operation{ + { + Inputs: recordInputs(map[string]property.Value{"content": property.New("192.0.2.2")}), + ExpectedOutput: &expectContentUpdate, + }, + { + Inputs: recordInputs(map[string]property.Value{ + "content": property.New("192.0.2.2"), + "name": property.New("api"), + }), + ExpectedOutput: &expectRename, + Hook: func(inputs, output property.Map) { + assert.Equal(t, 2, fake.recordCount(), "replacement creates the new record before deleting the old") + }, + }, + }, + }.Run(t, server) + + assert.Equal(t, 0, fake.recordCount(), "destroy must remove the record") + assert.Equal(t, 2, fake.createCalls, "one create plus one replacement") + assert.Equal(t, 1, fake.editCalls, "content change should edit in place") + assert.Equal(t, 2, fake.deleteCalls, "replacement delete plus final destroy") +} + +func TestCreateStoresPorkbunID(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + resp, err := server.Create(p.CreateRequest{ + Urn: testURN, + Properties: checkedInputs(nil), + }) + require.NoError(t, err) + assert.NotEmpty(t, resp.ID) + require.NotNil(t, fake.record("example.com", resp.ID), "resource ID must be the Porkbun record ID") + + rec := fake.record("example.com", resp.ID) + assert.Equal(t, "www", rec.name) + assert.Equal(t, "A", rec.typ) + assert.Equal(t, "192.0.2.1", rec.content) + assert.Equal(t, 600, rec.ttl) +} + +func TestCreatePreviewMakesNoAPICalls(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + resp, err := server.Create(p.CreateRequest{ + Urn: testURN, + Properties: checkedInputs(nil), + DryRun: true, + }) + require.NoError(t, err) + assert.Equal(t, 0, fake.createCalls, "preview must not create records") + assert.Equal(t, property.New("www"), resp.Properties.Get("name"), "inputs pass through the preview") + assert.Equal(t, property.New(property.Computed), resp.Properties.Get("fqdn"), + "computed outputs are unknown during preview") +} + +func TestUpdatePreviewMakesNoAPICalls(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "www", typ: "A", content: "192.0.2.1", ttl: 600}) + + _, err := server.Update(p.UpdateRequest{ + ID: id, + Urn: testURN, + State: checkedState(nil), + Inputs: checkedInputs(map[string]property.Value{"content": property.New("192.0.2.9")}), + DryRun: true, + }) + require.NoError(t, err) + assert.Equal(t, 0, fake.editCalls, "preview must not edit records") + assert.Equal(t, "192.0.2.1", fake.record("example.com", id).content) +} + +func TestDiff(t *testing.T) { + server := newTestServer(t, newFakePorkbun(t)) + + diff := func(newInputs property.Map) p.DiffResponse { + resp, err := server.Diff(p.DiffRequest{ + ID: "42", + Urn: testURN, + State: checkedState(nil), + Inputs: newInputs, + }) + require.NoError(t, err) + return resp + } + + t.Run("no changes", func(t *testing.T) { + resp := diff(checkedInputs(nil)) + assert.False(t, resp.HasChanges) + }) + + t.Run("content and ttl update in place", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "content": property.New("192.0.2.9"), + "ttl": property.New(900.0), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.Update, resp.DetailedDiff["content"].Kind) + assert.Equal(t, p.Update, resp.DetailedDiff["ttl"].Kind) + }) + + t.Run("domain change forces replacement", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "domain": property.New("example.org"), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.UpdateReplace, resp.DetailedDiff["domain"].Kind) + }) + + t.Run("name change forces replacement", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "name": property.New("api"), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.UpdateReplace, resp.DetailedDiff["name"].Kind) + }) + + t.Run("type change updates in place", func(t *testing.T) { + resp := diff(checkedInputs(map[string]property.Value{ + "type": property.New("CNAME"), + "content": property.New("target.example.net"), + })) + assert.True(t, resp.HasChanges) + assert.Equal(t, p.Update, resp.DetailedDiff["type"].Kind) + }) +} + +func TestCheck(t *testing.T) { + server := newTestServer(t, newFakePorkbun(t)) + + check := func(inputs property.Map) p.CheckResponse { + resp, err := server.Check(p.CheckRequest{Urn: testURN, Inputs: inputs}) + require.NoError(t, err) + return resp + } + + failedProperties := func(resp p.CheckResponse) []string { + props := []string{} + for _, f := range resp.Failures { + props = append(props, f.Property) + } + return props + } + + t.Run("applies defaults", func(t *testing.T) { + resp := check(recordInputs(nil)) + assert.Empty(t, resp.Failures) + assert.Equal(t, property.New(600.0), resp.Inputs.Get("ttl")) + assert.Equal(t, property.New(0.0), resp.Inputs.Get("prio")) + }) + + t.Run("normalizes case", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{ + "type": property.New("a"), + "domain": property.New("EXAMPLE.com"), + "name": property.New("WWW"), + })) + assert.Empty(t, resp.Failures) + assert.Equal(t, property.New("A"), resp.Inputs.Get("type")) + assert.Equal(t, property.New("example.com"), resp.Inputs.Get("domain")) + assert.Equal(t, property.New("www"), resp.Inputs.Get("name")) + }) + + t.Run("rejects unknown record type", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"type": property.New("BOGUS")})) + assert.Equal(t, []string{"type"}, failedProperties(resp)) + }) + + t.Run("accepts ttl below the typical minimum", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"ttl": property.New(300.0)})) + assert.Empty(t, resp.Failures) + assert.Equal(t, property.New(300.0), resp.Inputs.Get("ttl")) + }) + + t.Run("rejects negative ttl", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"ttl": property.New(-1.0)})) + assert.Equal(t, []string{"ttl"}, failedProperties(resp)) + }) + + t.Run("rejects out of range prio", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"prio": property.New(70000.0)})) + assert.Equal(t, []string{"prio"}, failedProperties(resp)) + }) + + t.Run("rejects fully qualified name", func(t *testing.T) { + resp := check(recordInputs(map[string]property.Value{"name": property.New("www.example.com")})) + assert.Equal(t, []string{"name"}, failedProperties(resp)) + }) +} + +func TestReadRefreshesDrift(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "www", typ: "A", content: "192.0.2.99", ttl: 900}) + + resp, err := server.Read(p.ReadRequest{ + ID: id, + Urn: testURN, + Properties: checkedState(nil), + }) + require.NoError(t, err) + assert.Equal(t, id, resp.ID) + assert.Equal(t, property.New("192.0.2.99"), resp.Properties.Get("content")) + assert.Equal(t, property.New(900.0), resp.Properties.Get("ttl")) + assert.Equal(t, property.New("www"), resp.Properties.Get("name")) + assert.Equal(t, property.New("www.example.com"), resp.Properties.Get("fqdn")) +} + +func TestReadRootRecord(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "", typ: "A", content: "192.0.2.1", ttl: 600}) + + resp, err := server.Read(p.ReadRequest{ + ID: id, + Urn: testURN, + Properties: checkedState(map[string]property.Value{ + "name": property.New(""), + "fqdn": property.New("example.com"), + }), + }) + require.NoError(t, err) + assert.Equal(t, property.New(""), resp.Properties.Get("name")) + assert.Equal(t, property.New("example.com"), resp.Properties.Get("fqdn")) +} + +func TestReadDeletedRecord(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + resp, err := server.Read(p.ReadRequest{ + ID: "9999", + Urn: testURN, + Properties: checkedState(nil), + }) + require.NoError(t, err) + assert.Empty(t, resp.ID, "an empty ID signals the record no longer exists") +} + +func TestImportWithCompositeID(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + id := fake.seed("example.com", fakeRecord{name: "mail", typ: "MX", content: "mx.example.com", ttl: 600, prio: 10}) + + resp, err := server.Read(p.ReadRequest{ + ID: "example.com/" + id, + Urn: testURN, + }) + require.NoError(t, err) + assert.Equal(t, id, resp.ID, "the canonical ID is the bare record ID") + assert.Equal(t, property.New("example.com"), resp.Properties.Get("domain")) + assert.Equal(t, property.New("mail"), resp.Properties.Get("name")) + assert.Equal(t, property.New("MX"), resp.Properties.Get("type")) + assert.Equal(t, property.New(10.0), resp.Properties.Get("prio")) + assert.Equal(t, property.New("mail.example.com"), resp.Properties.Get("fqdn")) +} + +func TestImportWithoutDomainFails(t *testing.T) { + server := newTestServer(t, newFakePorkbun(t)) + + _, err := server.Read(p.ReadRequest{ID: "12345", Urn: testURN}) + require.Error(t, err) + assert.Contains(t, err.Error(), "<domain>/<recordId>") +} + +func TestDeleteIsIdempotent(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + err := server.Delete(p.DeleteRequest{ + ID: "4242", + Urn: testURN, + Properties: checkedState(nil), + }) + require.NoError(t, err) + assert.Equal(t, 1, fake.deleteCalls) + assert.Equal(t, 1, fake.retrieveCalls, "a failed delete should be verified with a lookup") +} + +func TestUpdateSurfacesAPIErrors(t *testing.T) { + fake := newFakePorkbun(t) + server := newTestServer(t, fake) + + _, err := server.Update(p.UpdateRequest{ + ID: "4242", + Urn: testURN, + State: checkedState(nil), + Inputs: checkedInputs(map[string]property.Value{"content": property.New("192.0.2.9")}), + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "Invalid record ID.") +} diff --git a/provider/fake_porkbun_test.go b/provider/fake_porkbun_test.go new file mode 100644 index 0000000..a6a602d --- /dev/null +++ b/provider/fake_porkbun_test.go @@ -0,0 +1,182 @@ +package provider_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + "testing" +) + +const ( + testAPIKey = "pk1_test" + testSecretKey = "sk1_test" +) + +type fakeRecord struct { + name string + typ string + content string + ttl int + prio int +} + +type fakePorkbun struct { + t *testing.T + + mu sync.Mutex + nextID int + records map[string]map[string]*fakeRecord + + createCalls, editCalls, deleteCalls, retrieveCalls int +} + +func newFakePorkbun(t *testing.T) *fakePorkbun { + return &fakePorkbun{t: t, nextID: 1000, records: map[string]map[string]*fakeRecord{}} +} + +func (f *fakePorkbun) seed(domain string, rec fakeRecord) string { + f.mu.Lock() + defer f.mu.Unlock() + f.nextID++ + id := strconv.Itoa(f.nextID) + if f.records[domain] == nil { + f.records[domain] = map[string]*fakeRecord{} + } + f.records[domain][id] = &rec + return id +} + +func (f *fakePorkbun) record(domain, id string) *fakeRecord { + f.mu.Lock() + defer f.mu.Unlock() + rec, ok := f.records[domain][id] + if !ok { + return nil + } + cp := *rec + return &cp +} + +func (f *fakePorkbun) recordCount() int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, recs := range f.records { + n += len(recs) + } + return n +} + +func (f *fakePorkbun) ServeHTTP(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + f.fail(w, http.StatusBadRequest, "Invalid JSON.") + return + } + if body["apikey"] != testAPIKey || body["secretapikey"] != testSecretKey { + f.fail(w, http.StatusForbidden, "Invalid API key. (002)") + return + } + + parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + switch { + case len(parts) == 1 && parts[0] == "ping": + f.ok(w, map[string]any{"yourIp": "203.0.113.7"}) + + case len(parts) == 3 && parts[0] == "dns" && parts[1] == "create": + f.createCalls++ + domain := parts[2] + f.nextID++ + id := strconv.Itoa(f.nextID) + if f.records[domain] == nil { + f.records[domain] = map[string]*fakeRecord{} + } + f.records[domain][id] = &fakeRecord{ + name: str(body["name"]), + typ: str(body["type"]), + content: str(body["content"]), + ttl: atoi(f.t, str(body["ttl"])), + prio: atoi(f.t, str(body["prio"])), + } + + f.ok(w, map[string]any{"id": f.nextID}) + + case len(parts) == 4 && parts[0] == "dns" && parts[1] == "edit": + f.editCalls++ + rec, ok := f.records[parts[2]][parts[3]] + if !ok { + f.fail(w, http.StatusBadRequest, "Invalid record ID.") + return + } + rec.typ = str(body["type"]) + rec.content = str(body["content"]) + rec.ttl = atoi(f.t, str(body["ttl"])) + rec.prio = atoi(f.t, str(body["prio"])) + f.ok(w, nil) + + case len(parts) == 4 && parts[0] == "dns" && parts[1] == "delete": + f.deleteCalls++ + if _, ok := f.records[parts[2]][parts[3]]; !ok { + f.fail(w, http.StatusBadRequest, "Invalid record ID.") + return + } + delete(f.records[parts[2]], parts[3]) + f.ok(w, nil) + + case len(parts) == 4 && parts[0] == "dns" && parts[1] == "retrieve": + f.retrieveCalls++ + domain, id := parts[2], parts[3] + records := []map[string]any{} + if rec, ok := f.records[domain][id]; ok { + fullName := domain + if rec.name != "" { + fullName = rec.name + "." + domain + } + + records = append(records, map[string]any{ + "id": id, "name": fullName, "type": rec.typ, "content": rec.content, + "ttl": strconv.Itoa(rec.ttl), "prio": strconv.Itoa(rec.prio), "notes": "", + }) + } + f.ok(w, map[string]any{"records": records}) + + default: + f.fail(w, http.StatusNotFound, fmt.Sprintf("Unknown endpoint %q.", r.URL.Path)) + } +} + +func (f *fakePorkbun) ok(w http.ResponseWriter, extra map[string]any) { + resp := map[string]any{"status": "SUCCESS"} + for k, v := range extra { + resp[k] = v + } + json.NewEncoder(w).Encode(resp) +} + +func (f *fakePorkbun) fail(w http.ResponseWriter, code int, message string) { + w.WriteHeader(code) + json.NewEncoder(w).Encode(map[string]any{"status": "ERROR", "message": message}) +} + +func str(v any) string { + s, _ := v.(string) + return s +} + +func atoi(t *testing.T, s string) int { + t.Helper() + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil { + t.Errorf("fake porkbun: expected numeric string, got %q", s) + } + return n +} diff --git a/provider/names_test.go b/provider/names_test.go new file mode 100644 index 0000000..ee2dd86 --- /dev/null +++ b/provider/names_test.go @@ -0,0 +1,37 @@ +package provider + +import "testing" + +func TestFqdn(t *testing.T) { + cases := []struct { + name, domain, want string + }{ + {"www", "example.com", "www.example.com"}, + {"", "example.com", "example.com"}, + {"*", "example.com", "*.example.com"}, + {"a.b", "example.com", "a.b.example.com"}, + } + for _, c := range cases { + if got := fqdn(c.name, c.domain); got != c.want { + t.Errorf("fqdn(%q, %q) = %q, want %q", c.name, c.domain, got, c.want) + } + } +} + +func TestSubdomain(t *testing.T) { + cases := []struct { + fullName, domain, want string + }{ + {"www.example.com", "example.com", "www"}, + {"example.com", "example.com", ""}, + {"WWW.EXAMPLE.COM", "example.com", "www"}, + {"www.example.com.", "example.com", "www"}, + {"a.b.example.com", "example.com", "a.b"}, + {"*.example.com", "example.com", "*"}, + } + for _, c := range cases { + if got := subdomain(c.fullName, c.domain); got != c.want { + t.Errorf("subdomain(%q, %q) = %q, want %q", c.fullName, c.domain, got, c.want) + } + } +} diff --git a/provider/provider.go b/provider/provider.go new file mode 100644 index 0000000..8545bf8 --- /dev/null +++ b/provider/provider.go @@ -0,0 +1,29 @@ +package provider + +import ( + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi-go-provider/infer" + "github.com/pulumi/pulumi/sdk/v3/go/common/tokens" +) + +const Name = "porkbun" + +var Version = "1.0.0" + +func New() (p.Provider, error) { + return infer.NewProviderBuilder(). + WithDisplayName("Porkbun"). + WithDescription("A Pulumi provider for managing DNS records on Porkbun."). + WithHomepage("https://git.sr.ht/~rbdr/pulumi-porkbun"). + WithRepository("https://git.sr.ht/~rbdr/pulumi-porkbun"). + WithLicense("Apache-2.0"). + WithKeywords("pulumi", "porkbun", "dns", "category/network"). + WithNamespace("rbdr"). + WithGoImportPath("git.sr.ht/~rbdr/pulumi-porkbun/sdk/go/porkbun"). + WithConfig(infer.Config(&Config{})). + WithResources( + infer.Resource(&DnsRecord{}), + ). + WithModuleMap(map[tokens.ModuleName]tokens.ModuleName{"provider": "index"}). + Build() +} diff --git a/provider/provider_test.go b/provider/provider_test.go new file mode 100644 index 0000000..ade33a6 --- /dev/null +++ b/provider/provider_test.go @@ -0,0 +1,77 @@ +package provider_test + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + p "github.com/pulumi/pulumi-go-provider" + "github.com/pulumi/pulumi/sdk/v3/go/property" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigureRequiresCredentials(t *testing.T) { + + t.Setenv("PORKBUN_API_KEY", "") + t.Setenv("PORKBUN_SECRET_API_KEY", "") + + server := newIntegrationServer(t) + err := server.Configure(p.ConfigureRequest{Args: property.Map{}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "PORKBUN_API_KEY") +} + +func TestConfigureReadsCredentialsFromEnvironment(t *testing.T) { + fake := newFakePorkbun(t) + httpSrv := httptest.NewServer(fake) + t.Cleanup(httpSrv.Close) + + t.Setenv("PORKBUN_API_KEY", testAPIKey) + t.Setenv("PORKBUN_SECRET_API_KEY", testSecretKey) + + server := newIntegrationServer(t) + checked, err := server.CheckConfig(p.CheckRequest{ + Inputs: property.NewMap(map[string]property.Value{ + "baseUrl": property.New(httpSrv.URL), + }), + }) + require.NoError(t, err) + require.NoError(t, server.Configure(p.ConfigureRequest{Args: checked.Inputs})) + + resp, err := server.Create(p.CreateRequest{ + Urn: testURN, + Properties: checkedInputs(nil), + }) + require.NoError(t, err) + assert.NotNil(t, fake.record("example.com", resp.ID)) +} + +func TestSchema(t *testing.T) { + server := newIntegrationServer(t) + resp, err := server.GetSchema(p.GetSchemaRequest{}) + require.NoError(t, err) + + var schema struct { + Name string `json:"name"` + Config struct { + Variables map[string]struct { + Secret bool `json:"secret"` + } `json:"variables"` + } `json:"config"` + Resources map[string]struct { + RequiredInputs []string `json:"requiredInputs"` + } `json:"resources"` + } + require.NoError(t, json.Unmarshal([]byte(resp.Schema), &schema)) + + assert.Equal(t, "porkbun", schema.Name) + require.Contains(t, schema.Resources, dnsRecordToken) + assert.ElementsMatch(t, []string{"domain", "type", "content"}, + schema.Resources[dnsRecordToken].RequiredInputs) + + require.Contains(t, schema.Config.Variables, "apiKey") + require.Contains(t, schema.Config.Variables, "secretApiKey") + assert.True(t, schema.Config.Variables["apiKey"].Secret, "apiKey must be marked secret") + assert.True(t, schema.Config.Variables["secretApiKey"].Secret, "secretApiKey must be marked secret") +} |