aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore6
-rw-r--r--LICENSE192
-rw-r--r--Makefile37
-rw-r--r--README.md111
-rw-r--r--examples/yaml/Pulumi.yaml36
-rw-r--r--go.mod129
-rw-r--r--go.sum370
-rw-r--r--internal/porkbun/client.go248
-rw-r--r--internal/porkbun/client_test.go271
-rw-r--r--main.go20
-rw-r--r--provider/config.go46
-rw-r--r--provider/dns_record.go240
-rw-r--r--provider/dns_record_test.go408
-rw-r--r--provider/fake_porkbun_test.go182
-rw-r--r--provider/names_test.go37
-rw-r--r--provider/provider.go29
-rw-r--r--provider/provider_test.go77
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
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..733609b
--- /dev/null
+++ b/LICENSE
@@ -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}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..eddba5d
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..1dbe692
--- /dev/null
+++ b/go.sum
@@ -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)
+ }
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..a9a1ef2
--- /dev/null
+++ b/main.go
@@ -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")
+}