diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..4c5f206
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.claude/
diff --git a/README.md b/README.md
index 0696aca..2c23738 100644
--- a/README.md
+++ b/README.md
@@ -12,11 +12,14 @@
1. GET `/signin` → parse `` (CSRF token).
2. POST `/signin` with `username`, `password`, `_token`.
-3. GET `/signin/totp` → parse fresh `_token`.
-4. POST `/signin/totp` with `code` (TOTP RFC 6238) + `_token`.
-5. Session cookie now carries subsequent `/api/dns` requests.
+3. GET `/signin/totp` to probe for MFA:
+ - If the page contains a `_token`: account has MFA enabled → POST `/signin/totp`
+ with `code` (RFC 6238 TOTP) + `_token`.
+ - If no `_token`: MFA is disabled → skip the TOTP submission (the
+ GET probe itself still runs).
+4. Session cookies are stored in-memory for subsequent `/api/dns*` calls.
-Re-auth fires whenever the in-memory session is older than 1h.
+Re-auth fires whenever the in-memory session is older than 1 hour.
## Configuration
@@ -36,8 +39,8 @@ resources:
provider: hover
domain: example.com
records:
- - { type: A, name: '@', data: 203.0.113.10, ttl: 900 }
- - { type: CNAME, name: 'www', data: example.com., ttl: 900 }
+ - { type: A, name: '@', content: 203.0.113.10, ttl: 900 }
+ - { type: CNAME, name: 'www', content: example.com., ttl: 900 }
```
## Required secrets
@@ -69,6 +72,18 @@ against [RFC 6238 Appendix B vectors](https://datatracker.ietf.org/doc/html/rfc6
- **Rate limit**: Stick to small zones; Hover's account portal
isn't optimised for bulk DNS edits.
+## Limitations
+
+- **No prune on apply**: `upsertRecords` only adds/updates. Records
+ that exist upstream but are not in the desired config are NOT
+ deleted on `apply`. `Diff` does flag them (so Plan reports drift),
+ but converging the actual record set requires manually deleting
+ the orphan records via Hover's UI or via a future explicit prune
+ path. Track follow-up via the project issue list.
+- **No zone delete**: Hover exposes no API to drop a DNS zone.
+ Resource `Delete` is a no-op — the IaC state is cleared but
+ upstream records remain.
+
## Development
```sh
diff --git a/go.mod b/go.mod
index 8699e2c..86a5185 100644
--- a/go.mod
+++ b/go.mod
@@ -1,3 +1,155 @@
module github.com/GoCodeAlone/workflow-plugin-hover
go 1.26.0
+
+require (
+ github.com/GoCodeAlone/workflow v0.60.8
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
+)
+
+require (
+ github.com/BurntSushi/toml v1.6.0 // indirect
+ github.com/DataDog/datadog-go/v5 v5.8.3 // indirect
+ github.com/GoCodeAlone/go-plugin v1.7.0 // indirect
+ github.com/GoCodeAlone/modular v1.13.0 // indirect
+ github.com/GoCodeAlone/modular/modules/auth v1.15.0 // indirect
+ github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0 // indirect
+ github.com/GoCodeAlone/yaegi v0.17.2 // indirect
+ github.com/IBM/sarama v1.47.0 // indirect
+ github.com/Microsoft/go-winio v0.6.2 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect
+ github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
+ github.com/aws/aws-sdk-go-v2/config v1.32.16 // indirect
+ github.com/aws/aws-sdk-go-v2/credentials v1.19.15 // indirect
+ github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
+ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
+ github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect
+ github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4 // indirect
+ github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 // indirect
+ github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
+ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
+ github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect
+ github.com/aws/smithy-go v1.25.0 // indirect
+ github.com/beorn7/perks v1.0.1 // indirect
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/cloudevents/sdk-go/v2 v2.16.2 // indirect
+ github.com/containerd/errdefs v1.0.0 // indirect
+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
+ github.com/containerd/log v0.1.0 // indirect
+ github.com/danieljoos/wincred v1.2.3 // indirect
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/distribution/reference v0.6.0 // indirect
+ github.com/docker/docker v28.5.2+incompatible // indirect
+ github.com/docker/go-connections v0.7.0 // indirect
+ github.com/docker/go-units v0.5.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/eapache/go-resiliency v1.7.0 // indirect
+ github.com/eapache/queue v1.1.0 // indirect
+ github.com/expr-lang/expr v1.17.8 // indirect
+ github.com/fatih/color v1.19.0 // indirect
+ github.com/felixge/httpsnoop v1.0.4 // indirect
+ github.com/fsnotify/fsnotify v1.9.0 // indirect
+ github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/godbus/dbus/v5 v5.2.2 // indirect
+ github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
+ github.com/golobby/cast v1.3.3 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-hclog v1.6.3 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
+ github.com/hashicorp/go-rootcerts v1.0.2 // indirect
+ github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect
+ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect
+ github.com/hashicorp/go-sockaddr v1.0.7 // indirect
+ github.com/hashicorp/go-uuid v1.0.3 // indirect
+ github.com/hashicorp/golang-lru v1.0.2 // indirect
+ github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
+ github.com/hashicorp/vault/api v1.23.0 // indirect
+ github.com/hashicorp/yamux v0.1.2 // indirect
+ github.com/itchyny/gojq v0.12.18 // indirect
+ github.com/itchyny/timefmt-go v0.1.7 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/pgx/v5 v5.9.1 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jcmturner/aescts/v2 v2.0.0 // indirect
+ github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
+ github.com/jcmturner/gofork v1.7.6 // indirect
+ github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
+ github.com/jcmturner/rpc/v2 v2.0.3 // indirect
+ github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
+ github.com/klauspost/compress v1.18.6 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
+ github.com/mattn/go-isatty v0.0.22 // indirect
+ github.com/mitchellh/go-homedir v1.1.0 // indirect
+ github.com/mitchellh/go-testing-interface v1.14.1 // indirect
+ github.com/mitchellh/mapstructure v1.5.0 // indirect
+ github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/sys/sequential v0.6.0 // indirect
+ github.com/moby/term v0.5.2 // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+ github.com/nats-io/nats.go v1.52.0 // indirect
+ github.com/nats-io/nkeys v0.4.15 // indirect
+ github.com/nats-io/nuid v1.0.1 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/oklog/run v1.2.0 // indirect
+ github.com/opencontainers/go-digest v1.0.0 // indirect
+ github.com/opencontainers/image-spec v1.1.1 // indirect
+ github.com/pierrec/lz4/v4 v4.1.26 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/prometheus/client_golang v1.23.2 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.67.5 // indirect
+ github.com/prometheus/procfs v0.20.1 // indirect
+ github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 // indirect
+ github.com/redis/go-redis/v9 v9.19.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/ryanuber/go-glob v1.0.0 // indirect
+ github.com/xdg-go/pbkdf2 v1.0.0 // indirect
+ github.com/xdg-go/scram v1.2.0 // indirect
+ github.com/xdg-go/stringprep v1.0.4 // indirect
+ github.com/zalando/go-keyring v0.2.8 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
+ go.opentelemetry.io/otel v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp 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
+ go.uber.org/zap v1.28.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.4 // indirect
+ golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/net v0.54.0 // indirect
+ golang.org/x/oauth2 v0.36.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.44.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect
+ google.golang.org/grpc v1.81.1 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ modernc.org/libc v1.70.0 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.47.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..70081a4
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,499 @@
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/DataDog/datadog-go/v5 v5.8.3 h1:s58CUJ9s8lezjhTNJO/SxkPBv2qZjS3ktpRSqGF5n0s=
+github.com/DataDog/datadog-go/v5 v5.8.3/go.mod h1:K9kcYBlxkcPP8tvvjZZKs/m1edNAUFzBbdpTUKfCsuw=
+github.com/GoCodeAlone/go-plugin v1.7.0 h1:EwnhqPlXiNmp85S+MXnKKvm3YlfA6O4NzBb4+GSlEVY=
+github.com/GoCodeAlone/go-plugin v1.7.0/go.mod h1:HbGQRZUIa+jbDfjsaZIMJYvrz+LnxL0mJpggfynSTMk=
+github.com/GoCodeAlone/modular v1.13.0 h1:UfsegfAmPWcPYQOqYZFsw/LNySBmMDcthiOQe5bscqE=
+github.com/GoCodeAlone/modular v1.13.0/go.mod h1:b06Pvgcc8HsGxvl30iO39zGH2jIWz467QEj2+OQL2Do=
+github.com/GoCodeAlone/modular/modules/auth v1.15.0 h1:pBSkPSf4k4GLSbUQFLuPa+nFbfoJXGzSz9q89VoapZk=
+github.com/GoCodeAlone/modular/modules/auth v1.15.0/go.mod h1:vmIm/LQrcURS2p02YwaELb+CZoHPtT0XB0v1i+sj9i4=
+github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0 h1:buYs0TGNbAZgtTq1Qb+dfmTv3+ZOBIN0HbvVBLyNqxE=
+github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.8.0/go.mod h1:329flAKmwrPq2JEwu9iltWv6A83H/Di82Xze+kvdKDw=
+github.com/GoCodeAlone/workflow v0.60.8 h1:bAdoihftpdWb9wUItgd/qVCDKlUOP8y3ISJ/LFp+lCU=
+github.com/GoCodeAlone/workflow v0.60.8/go.mod h1:QHJdc14vscDDo92Jw0yn9YFrFCT1Us85VpukXOCY1fk=
+github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM=
+github.com/GoCodeAlone/yaegi v0.17.2/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0=
+github.com/IBM/sarama v1.47.0 h1:GcQFEd12+KzfPYeLgN69Fh7vLCtYRhVIx0rO4TZO318=
+github.com/IBM/sarama v1.47.0/go.mod h1:7gLLIU97nznOmA6TX++Qds+DRxH89P2XICY2KAQUzAY=
+github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI=
+github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
+github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op h1:Ucf+QxEKMbPogRO5guBNe5cgd9uZgfoJLOYs8WWhtjM=
+github.com/antithesishq/antithesis-sdk-go v0.5.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
+github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
+github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
+github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
+github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
+github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
+github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
+github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
+github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
+github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
+github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
+github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ=
+github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
+github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA=
+github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw=
+github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4 h1:3m9iJtMtLq75jKRAfw0kapoHUlbzi0CRVigysBN/FHA=
+github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.4/go.mod h1:O2L6vGm4xacEuN2otHFMgn7yXXlgzFKzxrba0fy/yk8=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU=
+github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
+github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
+github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
+github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
+github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
+github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
+github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
+github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
+github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
+github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
+github.com/bufbuild/protocompile v0.10.0 h1:+jW/wnLMLxaCEG8AX9lD0bQ5v9h1RUiMKOBOT5ll9dM=
+github.com/bufbuild/protocompile v0.10.0/go.mod h1:G9qQIQo0xZ6Uyj6CMNz0saGmx2so+KONo8/KrELABiY=
+github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
+github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
+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/cloudevents/sdk-go/v2 v2.16.2 h1:ZYDFrYke4FD+jM8TZTJJO6JhKHzOQl2oqpFK1D+NnQM=
+github.com/cloudevents/sdk-go/v2 v2.16.2/go.mod h1:laOcGImm4nVJEU+PHnUrKL56CKmRL65RlQF0kRmW/kg=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI=
+github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0=
+github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI=
+github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8=
+github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI=
+github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s=
+github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
+github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
+github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
+github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/eapache/go-resiliency v1.7.0 h1:n3NRTnBn5N0Cbi/IeOHuQn9s2UwVUH7Ga0ZWcP+9JTA=
+github.com/eapache/go-resiliency v1.7.0/go.mod h1:5yPzW0MIvSe0JDsv0v+DvcjEv2FyD6iZYSs1ZI+iQho=
+github.com/eapache/queue v1.1.0 h1:YOEu7KNc61ntiQlcEeUIoDTJ2o8mQznoNvUhiigpIqc=
+github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
+github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM=
+github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
+github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
+github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+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/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
+github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
+github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
+github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
+github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
+github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
+github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
+github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
+github.com/golobby/cast v1.3.3 h1:s2Lawb9RMz7YyYf8IrfMQY4IFmA1R/lgfmj97Vc6fig=
+github.com/golobby/cast v1.3.3/go.mod h1:0oDO5IT84HTXcbLDf1YXuk0xtg/cRDrxhbpWKxwtJCY=
+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/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
+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/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-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
+github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
+github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
+github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo=
+github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y=
+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-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
+github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
+github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
+github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
+github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM=
+github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
+github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
+github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw=
+github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw=
+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
+github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
+github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
+github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I=
+github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM=
+github.com/hashicorp/vault/api v1.23.0 h1:gXgluBsSECfRWTSW9niY2jwg2e9mMJc4WoHNv4g3h6A=
+github.com/hashicorp/vault/api v1.23.0/go.mod h1:zransKiB9ftp+kgY8ydjnvCU7Wk8i9L0DYWpXeMj9ko=
+github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
+github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
+github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
+github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
+github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
+github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
+github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
+github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
+github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
+github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
+github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
+github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
+github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
+github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
+github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
+github.com/jhump/protoreflect v1.16.0 h1:54fZg+49widqXYQ0b+usAFHbMkBGR4PpXrsHc8+TBDg=
+github.com/jhump/protoreflect v1.16.0/go.mod h1:oYPd7nPvcBw/5wlDfm/AVmU9zH9BgqGCI469pGxfj/8=
+github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
+github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
+github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
+github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
+github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
+github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
+github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
+github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
+github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 h1:KGuD/pM2JpL9FAYvBrnBBeENKZNh6eNtjqytV6TYjnk=
+github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
+github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU=
+github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8=
+github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
+github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
+github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
+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/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
+github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
+github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
+github.com/nats-io/jwt/v2 v2.8.0 h1:K7uzyz50+yGZDO5o772eRE7atlcSEENpL7P+b74JV1g=
+github.com/nats-io/jwt/v2 v2.8.0/go.mod h1:me11pOkwObtcBNR8AiMrUbtVOUGkqYjMQZ6jnSdVUIA=
+github.com/nats-io/nats-server/v2 v2.12.4 h1:ZnT10v2LU2Xcoiy8ek9X6Se4YG8EuMfIfvAEuFVx1Ts=
+github.com/nats-io/nats-server/v2 v2.12.4/go.mod h1:5MCp/pqm5SEfsvVZ31ll1088ZTwEUdvRX1Hmh/mTTDg=
+github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
+github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
+github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
+github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
+github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
+github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/oklog/run v1.2.0 h1:O8x3yXwah4A73hJdlrwo/2X6J62gE5qTMusH0dvz60E=
+github.com/oklog/run v1.2.0/go.mod h1:mgDbKRSwPhJfesJ4PntqFUbKQRZ50NgmZTSPlFA0YFk=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
+github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
+github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
+github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
+github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
+github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
+github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
+github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
+github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
+github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9 h1:bsUq1dX0N8AOIL7EB/X911+m4EHsnWEHeJ0c+3TTBrg=
+github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
+github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
+github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+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/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
+github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
+github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+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/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
+github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
+github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
+github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
+github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
+github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
+github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
+github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
+github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
+github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
+github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
+github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
+github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
+github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
+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/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
+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/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/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
+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.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/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
+go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q=
+go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
+golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
+golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.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-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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
+golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
+golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
+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-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-20260511170946-3700d4141b60 h1:3WsB1FAbiRIf2tOxscWKs3pQBD9he1NsrnbhMuWfekc=
+google.golang.org/genproto/googleapis/api v0.0.0-20260511170946-3700d4141b60/go.mod h1:7yoXV7RIh5gblj/xVYoogxAWvA9wUeVbpsK/M694l00=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+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=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
+modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
+modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
+modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
+modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
+modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/internal/drivers/dns.go b/internal/drivers/dns.go
new file mode 100644
index 0000000..b3d1b7f
--- /dev/null
+++ b/internal/drivers/dns.go
@@ -0,0 +1,528 @@
+// Package drivers provides IaC ResourceDriver implementations for the Hover
+// DNS provider.
+//
+// Hover exposes NO official API. All endpoints below are derived from
+// https://github.com/pjslauta/hover-dyn-dns and browser traffic inspection.
+// Endpoint inventory (all relative to https://www.hover.com):
+//
+// GET /api/domains//dns — list records for a zone
+// POST /api/dns — create a record (form: domain_id, name, type, content, ttl)
+// PUT /api/dns/ — update a record (form: content, ttl)
+// DELETE /api/dns/ — delete a record
+//
+// These are undocumented; the Hover site uses them directly from the control
+// panel SPA. They may change without notice.
+package drivers
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/GoCodeAlone/workflow-plugin-hover/internal/hover"
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+// HoverDNSClient is the subset of hover.Client used by DNSDriver (injectable for tests).
+type HoverDNSClient interface {
+ GetDomain(ctx context.Context, domain string) (*hover.Domain, error)
+ ListRecords(ctx context.Context, domain string) ([]hover.DNSRecord, error)
+ CreateRecord(ctx context.Context, domainID string, rec hover.DNSRecord) (*hover.DNSRecord, error)
+ UpdateRecord(ctx context.Context, recordID string, rec hover.DNSRecord) error
+ DeleteRecord(ctx context.Context, recordID string) error
+}
+
+// DNSDriver manages Hover DNS zones and records (infra.dns).
+// ProviderID is the apex domain name (e.g. "example.com").
+type DNSDriver struct {
+ client HoverDNSClient
+}
+
+// NewDNSDriver creates a DNSDriver backed by a real hover.Client.
+func NewDNSDriver(c *hover.Client) *DNSDriver {
+ return &DNSDriver{client: c}
+}
+
+// NewDNSDriverWithClient creates a driver with an injected client (for tests).
+func NewDNSDriverWithClient(c HoverDNSClient) *DNSDriver {
+ return &DNSDriver{client: c}
+}
+
+// Create idempotently reconciles a DNS zone on Hover. It creates missing
+// records and updates existing ones that differ. Hover does not support
+// creating zones via API — the domain must already be registered and in the
+// account.
+//
+// Config keys:
+//
+// domain string — apex zone name (e.g. "example.com"). Falls back to spec.Name.
+// records []any — each element: {type, name, content, ttl?}
+func (d *DNSDriver) Create(ctx context.Context, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) {
+ domain, err := domainFromSpec(spec)
+ if err != nil {
+ return nil, err
+ }
+ desired, err := declaredRecords(spec.Config)
+ if err != nil {
+ return nil, err
+ }
+ if err := d.upsertRecords(ctx, domain, desired); err != nil {
+ return nil, err
+ }
+ return d.readOutput(ctx, domain, spec.Name)
+}
+
+func (d *DNSDriver) Read(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.ResourceOutput, error) {
+ return d.readOutput(ctx, ref.ProviderID, ref.Name)
+}
+
+func (d *DNSDriver) Update(ctx context.Context, ref interfaces.ResourceRef, spec interfaces.ResourceSpec) (*interfaces.ResourceOutput, error) {
+ domain, err := domainFromSpec(spec)
+ if err != nil {
+ return nil, err
+ }
+ if ref.ProviderID != "" && !strings.EqualFold(domain, ref.ProviderID) {
+ return nil, fmt.Errorf("hover dns: cannot rename zone from %q to %q — delete and re-create", ref.ProviderID, domain)
+ }
+ desired, err := declaredRecords(spec.Config)
+ if err != nil {
+ return nil, err
+ }
+ if err := d.upsertRecords(ctx, domain, desired); err != nil {
+ return nil, err
+ }
+ return d.readOutput(ctx, domain, spec.Name)
+}
+
+func (d *DNSDriver) Delete(_ context.Context, _ interfaces.ResourceRef) error {
+ // Hover does not expose a "delete zone" API.
+ // Deleting individual records is possible but not done here because the
+ // zone itself (the domain registration) must be managed outside the IaC
+ // surface. Destroying infra.dns is therefore a no-op; operators must
+ // manually remove records they no longer need.
+ return nil
+}
+
+func (d *DNSDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, current *interfaces.ResourceOutput) (*interfaces.DiffResult, error) {
+ if current == nil {
+ return &interfaces.DiffResult{NeedsUpdate: true}, nil
+ }
+
+ desiredDomain, hasDomain, err := domainFromConfigIfPresent(desired.Config)
+ if err != nil {
+ return nil, err
+ }
+ if hasDomain && !strings.EqualFold(desiredDomain, current.ProviderID) {
+ return &interfaces.DiffResult{
+ NeedsUpdate: true,
+ NeedsReplace: true,
+ Changes: []interfaces.FieldChange{
+ {Path: "domain", Old: current.ProviderID, New: desiredDomain, ForceNew: true},
+ },
+ }, nil
+ }
+
+ desiredRecs, err := declaredRecords(desired.Config)
+ if err != nil {
+ return nil, err
+ }
+ currentRecs, err := dnsRecordsFromOutput(current)
+ if err != nil {
+ return nil, err
+ }
+
+ // Empty desired record set with no current records → in sync.
+ // Empty desired with leftover current records → drift (everything
+ // extra needs deletion). Note: upsertRecords today does NOT delete
+ // extras (it only adds/updates), so this Diff signal currently
+ // produces a NeedsUpdate the engine cannot fully satisfy. The
+ // alternative — silently letting extras persist — is worse: the
+ // declared spec never matches reality. Operators who want strict
+ // pruning need to either explicitly add `Delete` plumbing or
+ // document the gap; flagging it is the right Plan signal.
+ if len(desiredRecs) == 0 {
+ if len(currentRecs) == 0 {
+ return &interfaces.DiffResult{NeedsUpdate: false}, nil
+ }
+ return &interfaces.DiffResult{NeedsUpdate: true}, nil
+ }
+
+ // Index current records by (type, name) for O(1) lookup. Multiple
+ // records sharing the same (type, name) — e.g., several A or
+ // AAAA records on the apex — are accumulated in the candidate
+ // slice and matched against desired records by exact Content
+ // (and TTL when specified) rather than by slice position. Each
+ // match consumes one candidate to preserve multiset semantics.
+ currentByKey := make(map[string][]hover.DNSRecord)
+ for _, r := range currentRecs {
+ key := recordKey(r.Type, r.Name)
+ currentByKey[key] = append(currentByKey[key], r)
+ }
+
+ for _, dr := range desiredRecs {
+ key := recordKey(dr.Type, dr.Name)
+ candidates := currentByKey[key]
+ idx := -1
+ for i, cur := range candidates {
+ if cur.Content != dr.Content {
+ continue
+ }
+ // TTL is part of the record's effective state. Only consider
+ // it when the desired record specifies one (TTL == 0 means
+ // "leave the existing TTL alone" per upsertRecords).
+ if dr.TTL != 0 && cur.TTL != dr.TTL {
+ continue
+ }
+ idx = i
+ break
+ }
+ if idx < 0 {
+ return &interfaces.DiffResult{NeedsUpdate: true}, nil
+ }
+ // Remove the matched candidate so subsequent desired records
+ // can't re-match it.
+ currentByKey[key] = append(candidates[:idx], candidates[idx+1:]...)
+ }
+ // Any remaining candidates are records that exist upstream but
+ // are not in the desired set. Treat that as drift so the engine
+ // surfaces it during Plan, even though upsertRecords does not
+ // currently prune the extras (an explicit prune path is a
+ // separate follow-up; see the "Limitations" section of README.md).
+ for _, leftover := range currentByKey {
+ if len(leftover) > 0 {
+ return &interfaces.DiffResult{NeedsUpdate: true}, nil
+ }
+ }
+ return &interfaces.DiffResult{NeedsUpdate: false}, nil
+}
+
+func (d *DNSDriver) HealthCheck(ctx context.Context, ref interfaces.ResourceRef) (*interfaces.HealthResult, error) {
+ _, err := d.client.ListRecords(ctx, ref.ProviderID)
+ if err != nil {
+ return &interfaces.HealthResult{Healthy: false, Message: err.Error()}, nil
+ }
+ return &interfaces.HealthResult{Healthy: true}, nil
+}
+
+func (d *DNSDriver) Scale(_ context.Context, _ interfaces.ResourceRef, _ int) (*interfaces.ResourceOutput, error) {
+ return nil, fmt.Errorf("hover dns: scale is not supported")
+}
+
+func (d *DNSDriver) SensitiveKeys() []string { return nil }
+
+func (d *DNSDriver) ProviderIDFormat() interfaces.ProviderIDFormat {
+ return interfaces.IDFormatDomainName
+}
+
+// ── helpers ──────────────────────────────────────────────────────────────────
+
+func (d *DNSDriver) readOutput(ctx context.Context, domain, name string) (*interfaces.ResourceOutput, error) {
+ recs, err := d.client.ListRecords(ctx, domain)
+ if err != nil {
+ if strings.Contains(err.Error(), "not found") {
+ return nil, fmt.Errorf("%w: hover dns zone %q", interfaces.ErrResourceNotFound, domain)
+ }
+ return nil, fmt.Errorf("hover dns read %q: %w", domain, err)
+ }
+ return dnsOutput(domain, name, recs), nil
+}
+
+func (d *DNSDriver) upsertRecords(ctx context.Context, domain string, desired []hover.DNSRecord) error {
+ if len(desired) == 0 {
+ return nil
+ }
+
+ // Hover's POST /api/dns endpoint requires `domain_id` (hover-assigned
+ // numeric ID), NOT the apex name. Resolve the domain up front via
+ // GetDomain, which returns both the ID and the current record set;
+ // reuse the embedded record set to avoid a second ListRecords round trip.
+ dom, err := d.client.GetDomain(ctx, domain)
+ if err != nil {
+ return fmt.Errorf("hover dns resolve domain %q: %w", domain, err)
+ }
+
+ existingByKey := make(map[string][]hover.DNSRecord)
+ for _, r := range dom.Records {
+ key := recordKey(r.Type, r.Name)
+ existingByKey[key] = append(existingByKey[key], r)
+ }
+
+ // Two-pass match. First pass: skip any desired record that ALREADY
+ // has an exact-content (and TTL when specified) match upstream —
+ // no work needed. Consume those candidates so they can't be
+ // re-used by the update pass. Second pass: every remaining
+ // desired record either updates a leftover same-key candidate
+ // (Content/TTL change on an existing record) or creates a new one.
+ //
+ // Without this two-pass split the previous code matched
+ // candidates[0] every time and could update one record's content
+ // to a value that another already-fine record holds — producing
+ // a transient state with two identical records. Diff would still
+ // converge eventually, but apply would do unnecessary writes and
+ // could fail under multi-record configs.
+ deferredUpdates := make([]hover.DNSRecord, 0, len(desired))
+ for _, dr := range desired {
+ key := recordKey(dr.Type, dr.Name)
+ candidates := existingByKey[key]
+ matched := -1
+ for i, c := range candidates {
+ if c.Content != dr.Content {
+ continue
+ }
+ if dr.TTL != 0 && c.TTL != dr.TTL {
+ continue
+ }
+ matched = i
+ break
+ }
+ if matched >= 0 {
+ existingByKey[key] = append(candidates[:matched], candidates[matched+1:]...)
+ continue
+ }
+ deferredUpdates = append(deferredUpdates, dr)
+ }
+
+ for _, dr := range deferredUpdates {
+ key := recordKey(dr.Type, dr.Name)
+ candidates := existingByKey[key]
+ if len(candidates) > 0 {
+ ex := candidates[0]
+ existingByKey[key] = candidates[1:]
+ if err := d.client.UpdateRecord(ctx, ex.ID, dr); err != nil {
+ return fmt.Errorf("hover dns update %s/%s %q: %w", dr.Type, dr.Name, domain, err)
+ }
+ } else {
+ created, err := d.client.CreateRecord(ctx, dom.ID, dr)
+ if err != nil {
+ return fmt.Errorf("hover dns create %s/%s %q: %w", dr.Type, dr.Name, domain, err)
+ }
+ if created != nil {
+ existingByKey[key] = append(existingByKey[key], *created)
+ }
+ }
+ }
+ return nil
+}
+
+func domainFromSpec(spec interfaces.ResourceSpec) (string, error) {
+ domain, ok, err := domainFromConfigIfPresent(spec.Config)
+ if err != nil {
+ return "", err
+ }
+ if !ok {
+ domain = spec.Name
+ }
+ if domain == "" {
+ return "", fmt.Errorf("hover dns: domain is required (set config.domain or resource name)")
+ }
+ return domain, nil
+}
+
+func domainFromConfigIfPresent(config map[string]any) (string, bool, error) {
+ v, ok := config["domain"]
+ if !ok {
+ return "", false, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", true, fmt.Errorf("hover dns: config.domain must be a string")
+ }
+ if s == "" {
+ return "", true, fmt.Errorf("hover dns: config.domain must not be empty")
+ }
+ return s, true, nil
+}
+
+// declaredRecords parses config["records"] into a []hover.DNSRecord slice.
+func declaredRecords(config map[string]any) ([]hover.DNSRecord, error) {
+ raw, ok := config["records"]
+ if !ok {
+ return nil, nil
+ }
+ items, err := toSliceOfMaps(raw)
+ if err != nil {
+ return nil, fmt.Errorf("hover dns: config.records: %w", err)
+ }
+ out := make([]hover.DNSRecord, 0, len(items))
+ for i, m := range items {
+ rec, err := recordFromMap(i, m)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, rec)
+ }
+ return out, nil
+}
+
+func toSliceOfMaps(v any) ([]map[string]any, error) {
+ switch typed := v.(type) {
+ case []map[string]any:
+ return typed, nil
+ case []any:
+ out := make([]map[string]any, 0, len(typed))
+ for i, item := range typed {
+ m, ok := item.(map[string]any)
+ if !ok {
+ return nil, fmt.Errorf("records[%d] must be an object", i)
+ }
+ out = append(out, m)
+ }
+ return out, nil
+ default:
+ return nil, errors.New("must be a list")
+ }
+}
+
+func recordFromMap(index int, m map[string]any) (hover.DNSRecord, error) {
+ typ, err := requiredString(m, "type", index)
+ if err != nil {
+ return hover.DNSRecord{}, err
+ }
+ name, err := requiredString(m, "name", index)
+ if err != nil {
+ return hover.DNSRecord{}, err
+ }
+ content, err := requiredString(m, "content", index)
+ if err != nil {
+ return hover.DNSRecord{}, err
+ }
+ ttl, err := optionalNonNegativeInt(m, "ttl", index)
+ if err != nil {
+ return hover.DNSRecord{}, err
+ }
+ return hover.DNSRecord{
+ Type: strings.ToUpper(typ),
+ Name: name,
+ Content: content,
+ TTL: ttl,
+ }, nil
+}
+
+func requiredString(m map[string]any, key string, index int) (string, error) {
+ v, ok := m[key]
+ if !ok {
+ return "", fmt.Errorf("hover dns: records[%d].%s is required", index, key)
+ }
+ s, ok := v.(string)
+ if !ok || s == "" {
+ return "", fmt.Errorf("hover dns: records[%d].%s must be a non-empty string", index, key)
+ }
+ return s, nil
+}
+
+func optionalInt(m map[string]any, key string) (int, bool) {
+ v, ok := m[key]
+ if !ok {
+ return 0, false
+ }
+ switch n := v.(type) {
+ case int:
+ return n, true
+ case int64:
+ return int(n), true
+ case float64:
+ return int(n), true
+ }
+ return 0, false
+}
+
+// optionalNonNegativeInt is like optionalInt but rejects values that
+// are present but the wrong type or negative, returning a typed error
+// instead of silently coercing to 0. Used for record TTL where a
+// silent 0 would skip the field in upsertRecords AND mask user typos.
+func optionalNonNegativeInt(m map[string]any, key string, index int) (int, error) {
+ v, ok := m[key]
+ if !ok {
+ return 0, nil
+ }
+ var n int
+ switch t := v.(type) {
+ case int:
+ n = t
+ case int64:
+ n = int(t)
+ case float64:
+ // Reject non-integral floats (e.g. 1800.5) — Hover's TTL is
+ // integer seconds.
+ if t != float64(int64(t)) {
+ return 0, fmt.Errorf("hover dns: records[%d].%s = %v must be a non-negative integer", index, key, t)
+ }
+ n = int(t)
+ default:
+ return 0, fmt.Errorf("hover dns: records[%d].%s = %v (%T) must be a non-negative integer", index, key, v, v)
+ }
+ if n < 0 {
+ return 0, fmt.Errorf("hover dns: records[%d].%s = %d must be a non-negative integer", index, key, n)
+ }
+ return n, nil
+}
+
+func recordKey(typ, name string) string {
+ return strings.ToUpper(typ) + "\x00" + strings.ToLower(name)
+}
+
+// dnsRecordsFromOutput deserialises the "records" key from a ResourceOutput
+// Outputs map back into []hover.DNSRecord for diffing.
+func dnsRecordsFromOutput(out *interfaces.ResourceOutput) ([]hover.DNSRecord, error) {
+ if out == nil || out.Outputs == nil {
+ return nil, nil
+ }
+ raw, ok := out.Outputs["records"]
+ if !ok || raw == nil {
+ return nil, nil
+ }
+ items, err := toSliceOfMaps(raw)
+ if err != nil {
+ return nil, fmt.Errorf("hover dns: outputs.records: %w", err)
+ }
+ recs := make([]hover.DNSRecord, 0, len(items))
+ for _, m := range items {
+ typ, _ := m["type"].(string)
+ name, _ := m["name"].(string)
+ content, _ := m["content"].(string)
+ ttl, _ := optionalInt(m, "ttl")
+ id, _ := m["id"].(string)
+ if typ == "" || name == "" {
+ continue
+ }
+ recs = append(recs, hover.DNSRecord{
+ ID: id,
+ Type: typ,
+ Name: name,
+ Content: content,
+ TTL: ttl,
+ })
+ }
+ return recs, nil
+}
+
+// dnsOutput builds the structpb-safe ResourceOutput for a Hover DNS zone.
+// All Outputs values are primitive leaves (string/int/bool) — no typed slices.
+// Records are encoded as []map[string]any per the structpb-boundary invariant.
+func dnsOutput(domain, name string, records []hover.DNSRecord) *interfaces.ResourceOutput {
+ outputs := map[string]any{
+ "domain": domain,
+ }
+ if records != nil {
+ recs := make([]any, 0, len(records))
+ for _, r := range records {
+ entry := map[string]any{
+ "id": r.ID,
+ "type": r.Type,
+ "name": r.Name,
+ "content": r.Content,
+ "ttl": r.TTL,
+ }
+ recs = append(recs, entry)
+ }
+ outputs["records"] = recs
+ }
+ return &interfaces.ResourceOutput{
+ Name: name,
+ Type: "infra.dns",
+ ProviderID: domain,
+ Outputs: outputs,
+ Status: "active",
+ }
+}
diff --git a/internal/drivers/dns_test.go b/internal/drivers/dns_test.go
new file mode 100644
index 0000000..7ccb829
--- /dev/null
+++ b/internal/drivers/dns_test.go
@@ -0,0 +1,533 @@
+package drivers
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "testing"
+
+ "github.com/GoCodeAlone/workflow-plugin-hover/internal/hover"
+ "github.com/GoCodeAlone/workflow/interfaces"
+)
+
+// fakeClient is a test double for HoverDNSClient.
+type fakeClient struct {
+ domainID string // hover-assigned domain ID returned by GetDomain
+ records []hover.DNSRecord
+ createErr error
+ updateErr error
+ deleteErr error
+ listErr error
+ nextID int
+
+ lastCreateDomainID string // captured for assertions
+}
+
+func (f *fakeClient) GetDomain(_ context.Context, domain string) (*hover.Domain, error) {
+ if f.listErr != nil {
+ return nil, f.listErr
+ }
+ id := f.domainID
+ if id == "" {
+ id = "dom1"
+ }
+ recs := make([]hover.DNSRecord, len(f.records))
+ copy(recs, f.records)
+ return &hover.Domain{ID: id, Name: domain, Records: recs}, nil
+}
+
+func (f *fakeClient) ListRecords(_ context.Context, _ string) ([]hover.DNSRecord, error) {
+ if f.listErr != nil {
+ return nil, f.listErr
+ }
+ out := make([]hover.DNSRecord, len(f.records))
+ copy(out, f.records)
+ return out, nil
+}
+
+func (f *fakeClient) CreateRecord(_ context.Context, domainID string, rec hover.DNSRecord) (*hover.DNSRecord, error) {
+ if f.createErr != nil {
+ return nil, f.createErr
+ }
+ f.lastCreateDomainID = domainID
+ f.nextID++
+ rec.ID = fmt.Sprintf("dns%d", f.nextID)
+ f.records = append(f.records, rec)
+ cp := rec
+ return &cp, nil
+}
+
+func (f *fakeClient) UpdateRecord(_ context.Context, id string, rec hover.DNSRecord) error {
+ if f.updateErr != nil {
+ return f.updateErr
+ }
+ for i, r := range f.records {
+ if r.ID == id {
+ f.records[i].Content = rec.Content
+ if rec.TTL > 0 {
+ f.records[i].TTL = rec.TTL
+ }
+ return nil
+ }
+ }
+ return fmt.Errorf("record %q not found", id)
+}
+
+func (f *fakeClient) DeleteRecord(_ context.Context, id string) error {
+ if f.deleteErr != nil {
+ return f.deleteErr
+ }
+ for i, r := range f.records {
+ if r.ID == id {
+ f.records = append(f.records[:i], f.records[i+1:]...)
+ return nil
+ }
+ }
+ return fmt.Errorf("record %q not found", id)
+}
+
+func newDriver(records ...hover.DNSRecord) (*DNSDriver, *fakeClient) {
+ fc := &fakeClient{records: records}
+ return NewDNSDriverWithClient(fc), fc
+}
+
+func TestDNSDriver_Create_Empty(t *testing.T) {
+ d, _ := newDriver()
+ spec := interfaces.ResourceSpec{Name: "example.com", Type: "infra.dns", Config: map[string]any{}}
+ out, err := d.Create(context.Background(), spec)
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if out.ProviderID != "example.com" {
+ t.Errorf("ProviderID = %q want %q", out.ProviderID, "example.com")
+ }
+}
+
+func TestDNSDriver_Create_WithRecords(t *testing.T) {
+ d, fc := newDriver()
+ spec := interfaces.ResourceSpec{
+ Name: "example.com", Type: "infra.dns",
+ Config: map[string]any{
+ "records": []any{
+ map[string]any{"type": "A", "name": "@", "content": "1.2.3.4", "ttl": 300},
+ },
+ },
+ }
+ out, err := d.Create(context.Background(), spec)
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if len(fc.records) != 1 {
+ t.Errorf("client.records len = %d want 1", len(fc.records))
+ }
+ recs, ok := out.Outputs["records"].([]any)
+ if !ok || len(recs) != 1 {
+ t.Errorf("outputs.records: %v", out.Outputs["records"])
+ }
+}
+
+func TestDNSDriver_Create_UpdatesExistingRecord(t *testing.T) {
+ existing := hover.DNSRecord{ID: "r1", Type: "A", Name: "@", Content: "1.1.1.1", TTL: 300}
+ d, fc := newDriver(existing)
+
+ spec := interfaces.ResourceSpec{
+ Name: "example.com", Type: "infra.dns",
+ Config: map[string]any{
+ "records": []any{
+ map[string]any{"type": "A", "name": "@", "content": "2.2.2.2"},
+ },
+ },
+ }
+ _, err := d.Create(context.Background(), spec)
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if fc.records[0].Content != "2.2.2.2" {
+ t.Errorf("record not updated: content=%q", fc.records[0].Content)
+ }
+}
+
+func TestDNSDriver_Diff_NilCurrent(t *testing.T) {
+ d, _ := newDriver()
+ spec := interfaces.ResourceSpec{Name: "example.com", Type: "infra.dns", Config: map[string]any{}}
+ diff, err := d.Diff(context.Background(), spec, nil)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ if !diff.NeedsUpdate {
+ t.Error("expected NeedsUpdate=true for nil current")
+ }
+}
+
+func TestDNSDriver_Diff_UpToDate(t *testing.T) {
+ d, _ := newDriver()
+ spec := interfaces.ResourceSpec{
+ Name: "example.com", Type: "infra.dns",
+ Config: map[string]any{
+ "records": []any{
+ map[string]any{"type": "A", "name": "@", "content": "1.2.3.4"},
+ },
+ },
+ }
+ current := &interfaces.ResourceOutput{
+ ProviderID: "example.com",
+ Outputs: map[string]any{
+ "records": []any{
+ map[string]any{"id": "r1", "type": "A", "name": "@", "content": "1.2.3.4", "ttl": 300},
+ },
+ },
+ }
+ diff, err := d.Diff(context.Background(), spec, current)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ if diff.NeedsUpdate {
+ t.Error("expected NeedsUpdate=false for up-to-date state")
+ }
+}
+
+func TestDNSDriver_Diff_RecordChanged(t *testing.T) {
+ d, _ := newDriver()
+ spec := interfaces.ResourceSpec{
+ Name: "example.com", Type: "infra.dns",
+ Config: map[string]any{
+ "records": []any{
+ map[string]any{"type": "A", "name": "@", "content": "9.9.9.9"},
+ },
+ },
+ }
+ current := &interfaces.ResourceOutput{
+ ProviderID: "example.com",
+ Outputs: map[string]any{
+ "records": []any{
+ map[string]any{"id": "r1", "type": "A", "name": "@", "content": "1.1.1.1", "ttl": 300},
+ },
+ },
+ }
+ diff, err := d.Diff(context.Background(), spec, current)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ if !diff.NeedsUpdate {
+ t.Error("expected NeedsUpdate=true for changed record")
+ }
+}
+
+func TestDNSDriver_Diff_DomainChange_ForceReplace(t *testing.T) {
+ d, _ := newDriver()
+ spec := interfaces.ResourceSpec{
+ Name: "new.com",
+ Type: "infra.dns",
+ Config: map[string]any{"domain": "new.com"},
+ }
+ current := &interfaces.ResourceOutput{ProviderID: "old.com"}
+ diff, err := d.Diff(context.Background(), spec, current)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ if !diff.NeedsReplace {
+ t.Error("expected NeedsReplace=true for domain change")
+ }
+}
+
+func TestDNSDriver_Read_NotFound(t *testing.T) {
+ d, fc := newDriver()
+ fc.listErr = errors.New("not found: no such domain")
+ _, err := d.Read(context.Background(), interfaces.ResourceRef{Name: "gone.com", ProviderID: "gone.com"})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if !errors.Is(err, interfaces.ErrResourceNotFound) {
+ t.Errorf("want ErrResourceNotFound wrapping, got: %v", err)
+ }
+}
+
+func TestDNSDriver_Update_DomainRenameRejected(t *testing.T) {
+ d, _ := newDriver()
+ spec := interfaces.ResourceSpec{
+ Name: "new.com", Type: "infra.dns",
+ Config: map[string]any{"domain": "new.com"},
+ }
+ ref := interfaces.ResourceRef{Name: "old.com", ProviderID: "old.com"}
+ _, err := d.Update(context.Background(), ref, spec)
+ if err == nil {
+ t.Fatal("expected error for domain rename")
+ }
+}
+
+func TestDNSDriver_Delete_NoOp(t *testing.T) {
+ d, _ := newDriver(hover.DNSRecord{ID: "r1", Type: "A", Name: "@", Content: "1.1.1.1"})
+ err := d.Delete(context.Background(), interfaces.ResourceRef{Name: "example.com", ProviderID: "example.com"})
+ if err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+}
+
+func TestDNSDriver_HealthCheck_Healthy(t *testing.T) {
+ d, _ := newDriver()
+ h, err := d.HealthCheck(context.Background(), interfaces.ResourceRef{ProviderID: "example.com"})
+ if err != nil {
+ t.Fatalf("HealthCheck: %v", err)
+ }
+ if !h.Healthy {
+ t.Error("expected healthy")
+ }
+}
+
+func TestDNSDriver_HealthCheck_Unhealthy(t *testing.T) {
+ d, fc := newDriver()
+ fc.listErr = errors.New("API down")
+ h, err := d.HealthCheck(context.Background(), interfaces.ResourceRef{ProviderID: "example.com"})
+ if err != nil {
+ t.Fatalf("HealthCheck: %v", err)
+ }
+ if h.Healthy {
+ t.Error("expected unhealthy")
+ }
+}
+
+func TestDNSDriver_Scale_Unsupported(t *testing.T) {
+ d, _ := newDriver()
+ _, err := d.Scale(context.Background(), interfaces.ResourceRef{}, 2)
+ if err == nil {
+ t.Fatal("expected error from Scale")
+ }
+}
+
+func TestDNSDriver_SensitiveKeys(t *testing.T) {
+ d, _ := newDriver()
+ if keys := d.SensitiveKeys(); keys != nil {
+ t.Errorf("SensitiveKeys = %v; want nil", keys)
+ }
+}
+
+func TestDeclaredRecords_BadType(t *testing.T) {
+ _, err := declaredRecords(map[string]any{"records": "not-a-list"})
+ if err == nil {
+ t.Fatal("expected error for non-list records")
+ }
+}
+
+func TestDeclaredRecords_MissingType(t *testing.T) {
+ _, err := declaredRecords(map[string]any{
+ "records": []any{
+ map[string]any{"name": "@", "content": "1.1.1.1"},
+ },
+ })
+ if err == nil {
+ t.Fatal("expected error for missing type")
+ }
+}
+
+func TestDNSOutput_Structpb(t *testing.T) {
+ records := []hover.DNSRecord{
+ {ID: "r1", Type: "A", Name: "@", Content: "1.2.3.4", TTL: 300},
+ }
+ out := dnsOutput("example.com", "my-zone", records)
+ // outputs["records"] must be []any, not []hover.DNSRecord,
+ // to be structpb-safe.
+ recs, ok := out.Outputs["records"].([]any)
+ if !ok {
+ t.Fatalf("outputs.records must be []any for structpb safety; got %T", out.Outputs["records"])
+ }
+ if len(recs) != 1 {
+ t.Fatalf("expected 1 record, got %d", len(recs))
+ }
+ entry, ok := recs[0].(map[string]any)
+ if !ok {
+ t.Fatalf("record entry must be map[string]any; got %T", recs[0])
+ }
+ if entry["type"] != "A" || entry["name"] != "@" || entry["content"] != "1.2.3.4" {
+ t.Errorf("unexpected record entry: %v", entry)
+ }
+}
+
+// TestUpsertRecords_UsesDomainIDNotName regresses a bug where Create
+// was passing the apex domain name into the Hover POST /api/dns
+// `domain_id` form field, which Hover rejects (it requires the
+// numeric hover-assigned ID). After the fix, upsertRecords resolves
+// the domain ID via GetDomain and passes it to CreateRecord.
+func TestUpsertRecords_UsesDomainIDNotName(t *testing.T) {
+ fc := &fakeClient{
+ domainID: "1234567",
+ records: nil, // empty → upsertRecords will Create
+ }
+ d := NewDNSDriverWithClient(fc)
+ spec := interfaces.ResourceSpec{
+ Name: "example.com",
+ Type: "infra.dns",
+ Config: map[string]any{
+ "domain": "example.com",
+ "records": []any{
+ map[string]any{"type": "A", "name": "@", "content": "1.2.3.4", "ttl": 1800},
+ },
+ },
+ }
+ if _, err := d.Create(context.Background(), spec); err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if fc.lastCreateDomainID != "1234567" {
+ t.Fatalf("CreateRecord called with domain_id=%q, want %q (hover-assigned numeric ID)",
+ fc.lastCreateDomainID, "1234567")
+ }
+}
+
+// TestDiff_TTLChange_DetectedAsUpdate regresses a bug where Diff
+// compared only Content, missing TTL changes — Update would never
+// fire even though upsertRecords would have applied a new TTL.
+func TestDiff_TTLChange_DetectedAsUpdate(t *testing.T) {
+ current := &interfaces.ResourceOutput{
+ ProviderID: "example.com",
+ Outputs: map[string]any{
+ "records": []any{
+ map[string]any{"type": "A", "name": "@", "content": "1.2.3.4", "ttl": float64(1800), "id": "dns1"},
+ },
+ },
+ }
+ spec := interfaces.ResourceSpec{
+ Name: "example.com",
+ Type: "infra.dns",
+ Config: map[string]any{
+ "domain": "example.com",
+ "records": []any{
+ map[string]any{"type": "A", "name": "@", "content": "1.2.3.4", "ttl": 3600},
+ },
+ },
+ }
+ d := NewDNSDriverWithClient(&fakeClient{})
+ res, err := d.Diff(context.Background(), spec, current)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ if !res.NeedsUpdate {
+ t.Fatal("expected NeedsUpdate=true for TTL change")
+ }
+}
+
+// TestDiff_MultipleARecords_OrderingDoesNotMatter regresses a bug where
+// Diff matched candidates[0] only and could falsely report NeedsUpdate
+// when multiple records share the same (type, name) but appear in a
+// different order between current and desired.
+func TestDiff_MultipleARecords_OrderingDoesNotMatter(t *testing.T) {
+ d, _ := newDriver()
+ spec := interfaces.ResourceSpec{
+ Name: "example.com", Type: "infra.dns",
+ Config: map[string]any{
+ "records": []any{
+ map[string]any{"type": "A", "name": "@", "content": "1.1.1.1"},
+ map[string]any{"type": "A", "name": "@", "content": "2.2.2.2"},
+ },
+ },
+ }
+ // Current returns the same set but in reverse order.
+ current := &interfaces.ResourceOutput{
+ ProviderID: "example.com",
+ Outputs: map[string]any{
+ "records": []any{
+ map[string]any{"id": "r2", "type": "A", "name": "@", "content": "2.2.2.2"},
+ map[string]any{"id": "r1", "type": "A", "name": "@", "content": "1.1.1.1"},
+ },
+ },
+ }
+ diff, err := d.Diff(context.Background(), spec, current)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ if diff.NeedsUpdate {
+ t.Error("expected NeedsUpdate=false for same multiset of records (order-independent)")
+ }
+}
+
+func TestRecordFromMap_InvalidTTL_Rejected(t *testing.T) {
+ // Negative TTL must surface as a typed error rather than coerce to 0.
+ _, err := recordFromMap(0, map[string]any{
+ "type": "A",
+ "name": "@",
+ "content": "1.2.3.4",
+ "ttl": -1,
+ })
+ if err == nil {
+ t.Fatal("expected error for negative TTL")
+ }
+}
+
+func TestRecordFromMap_NonIntegralTTL_Rejected(t *testing.T) {
+ // Floats that aren't whole numbers (e.g., 300.5) must error.
+ _, err := recordFromMap(0, map[string]any{
+ "type": "A",
+ "name": "@",
+ "content": "1.2.3.4",
+ "ttl": 300.5,
+ })
+ if err == nil {
+ t.Fatal("expected error for non-integral float TTL")
+ }
+}
+
+func TestRecordFromMap_StringTTL_Rejected(t *testing.T) {
+ _, err := recordFromMap(0, map[string]any{
+ "type": "A",
+ "name": "@",
+ "content": "1.2.3.4",
+ "ttl": "300",
+ })
+ if err == nil {
+ t.Fatal("expected error for string TTL")
+ }
+}
+
+// TestDiff_ExtraCurrentRecord_DetectedAsUpdate regresses a bug where
+// Diff only checked desired ⊆ current, missing records that exist
+// upstream but were removed from desired. Removing a record from
+// config must show up in Plan even though upsertRecords doesn't
+// currently prune them (separate follow-up).
+func TestDiff_ExtraCurrentRecord_DetectedAsUpdate(t *testing.T) {
+ d, _ := newDriver()
+ spec := interfaces.ResourceSpec{
+ Name: "example.com", Type: "infra.dns",
+ Config: map[string]any{
+ "records": []any{
+ map[string]any{"type": "A", "name": "@", "content": "1.1.1.1"},
+ },
+ },
+ }
+ current := &interfaces.ResourceOutput{
+ ProviderID: "example.com",
+ Outputs: map[string]any{
+ "records": []any{
+ map[string]any{"id": "r1", "type": "A", "name": "@", "content": "1.1.1.1"},
+ map[string]any{"id": "r2", "type": "A", "name": "www", "content": "1.1.1.1"},
+ },
+ },
+ }
+ diff, err := d.Diff(context.Background(), spec, current)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ if !diff.NeedsUpdate {
+ t.Error("expected NeedsUpdate=true when current has an extra record")
+ }
+}
+
+func TestDiff_EmptyDesired_WithCurrentRecords_NeedsUpdate(t *testing.T) {
+ d, _ := newDriver()
+ spec := interfaces.ResourceSpec{
+ Name: "example.com", Type: "infra.dns",
+ Config: map[string]any{"records": []any{}},
+ }
+ current := &interfaces.ResourceOutput{
+ ProviderID: "example.com",
+ Outputs: map[string]any{
+ "records": []any{
+ map[string]any{"id": "r1", "type": "A", "name": "@", "content": "1.1.1.1"},
+ },
+ },
+ }
+ diff, err := d.Diff(context.Background(), spec, current)
+ if err != nil {
+ t.Fatalf("Diff: %v", err)
+ }
+ if !diff.NeedsUpdate {
+ t.Error("expected NeedsUpdate=true when desired is empty but current has records")
+ }
+}
diff --git a/internal/hover/client.go b/internal/hover/client.go
index 7cc2526..7c42501 100644
--- a/internal/hover/client.go
+++ b/internal/hover/client.go
@@ -61,6 +61,22 @@ func NewClient(creds Credentials, httpClient *http.Client) (*Client, error) {
return &Client{http: httpClient, creds: creds, UserAgent: defaultUserAgent}, nil
}
+// Login performs a full authentication cycle against Hover's control panel.
+// It is safe to call when already authenticated — it re-authenticates only
+// when the session is older than sessionStaleAfter (1 hour). Safe for
+// concurrent use; the internal mutex serialises calls.
+//
+// The underlying auth flow (derived from pjslauta/hover-dyn-dns):
+// 1. GET https://www.hover.com/signin → extract CSRF _token
+// 2. POST https://www.hover.com/signin (username + password + _token)
+// 3. Probe https://www.hover.com/signin/totp for a TOTP form.
+// If a _token is present → account has MFA enabled → submit TOTP code.
+// If the CSRF token is absent → MFA is not enabled → skip.
+// 4. Session cookies are stored in the jar for subsequent API calls.
+func (c *Client) Login(ctx context.Context) error {
+ return c.ensureLogin(ctx)
+}
+
// ensureLogin re-authenticates iff the session is stale. Safe to call
// before every API hit; idempotent within sessionStaleAfter.
func (c *Client) ensureLogin(ctx context.Context) error {
@@ -85,19 +101,31 @@ func (c *Client) ensureLogin(ctx context.Context) error {
return fmt.Errorf("hover signin step 1: %w", err)
}
- // Step 2 — submit TOTP. Hover re-issues a fresh `_token` on the
- // TOTP page; refetch.
- csrf2, err := c.fetchTOTPCSRF(ctx)
+ // Step 2 — TOTP (conditional). Probe the TOTP page for a _token.
+ // If a token is found the account has MFA enabled and we must submit
+ // a 6-digit code. If the page contains no _token the account has MFA
+ // disabled and we skip this step — no TOTP submission required.
+ //
+ // This matches the pjslauta/hover-dyn-dns behaviour: it checks the
+ // response `status == 'need_2fa'` before posting to auth2.json.
+ // The form-based portal equivalent is the presence of _token on the
+ // TOTP page.
+ csrf2, totpEnabled, err := c.probeTOTPPage(ctx)
if err != nil {
return err
}
- code := c.creds.TOTPSecret.Code()
- form = url.Values{
- "code": {code},
- "_token": {csrf2},
- }
- if err := c.postForm(ctx, hoverHost+"/signin/totp", form); err != nil {
- return fmt.Errorf("hover signin step 2 (totp): %w", err)
+ if totpEnabled {
+ if c.creds.TOTPSecret.key == nil {
+ return fmt.Errorf("hover: account has MFA enabled but no totp_secret was provided")
+ }
+ code := c.creds.TOTPSecret.Code()
+ form = url.Values{
+ "code": {code},
+ "_token": {csrf2},
+ }
+ if err := c.postForm(ctx, hoverHost+"/signin/totp", form); err != nil {
+ return fmt.Errorf("hover signin step 2 (totp): %w", err)
+ }
}
c.loggedAt = time.Now()
@@ -110,8 +138,37 @@ func (c *Client) fetchSignInCSRF(ctx context.Context) (string, error) {
return c.fetchCSRF(ctx, hoverHost+"/signin")
}
-func (c *Client) fetchTOTPCSRF(ctx context.Context) (string, error) {
- return c.fetchCSRF(ctx, hoverHost+"/signin/totp")
+// probeTOTPPage fetches /signin/totp and returns:
+// - (token, true, nil) — page contains a _token → MFA is enabled.
+// - ("", false, nil) — page loaded but no _token → MFA is not enabled.
+// - ("", false, err) — network error, non-2xx status, or body read failure.
+//
+// Treating a non-200 response (redirect, login failure, Cloudflare gate)
+// as "MFA not enabled" would silently misclassify these errors and let
+// login appear to succeed before failing on the first API call. Status
+// + body errors are now surfaced rather than swallowed.
+func (c *Client) probeTOTPPage(ctx context.Context) (string, bool, error) {
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, hoverHost+"/signin/totp", nil)
+ req.Header.Set("User-Agent", c.UserAgent)
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return "", false, fmt.Errorf("hover: probe TOTP page: %w", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ return "", false, fmt.Errorf("hover: probe TOTP page: unexpected HTTP %d", resp.StatusCode)
+ }
+ body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if readErr != nil {
+ return "", false, fmt.Errorf("hover: probe TOTP page: read body: %w", readErr)
+ }
+ m := csrfRe.FindSubmatch(body)
+ if len(m) < 2 {
+ // Page loaded successfully but no CSRF token present —
+ // account does not have MFA enabled.
+ return "", false, nil
+ }
+ return string(m[1]), true, nil
}
func (c *Client) fetchCSRF(ctx context.Context, urlStr string) (string, error) {
@@ -165,6 +222,40 @@ type Domain struct {
Records []DNSRecord `json:"entries"`
}
+// GetDomain returns the full Domain struct (including the
+// hover-assigned ID) for the named zone. The ID is required when
+// creating new records via CreateRecord; the human-readable name is
+// not accepted by the POST /api/dns endpoint.
+func (c *Client) GetDomain(ctx context.Context, domain string) (*Domain, error) {
+ if err := c.ensureLogin(ctx); err != nil {
+ return nil, err
+ }
+ endpoint := fmt.Sprintf("%s/api/domains/%s/dns", hoverHost, url.PathEscape(domain))
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ req.Header.Set("User-Agent", c.UserAgent)
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
+ return nil, fmt.Errorf("hover get domain %q: HTTP %d: %s", domain, resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ var wrap struct {
+ Domains []Domain `json:"domains"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&wrap); err != nil {
+ return nil, fmt.Errorf("hover get domain parse: %w", err)
+ }
+ for i := range wrap.Domains {
+ if strings.EqualFold(wrap.Domains[i].Name, domain) {
+ return &wrap.Domains[i], nil
+ }
+ }
+ return nil, fmt.Errorf("hover: domain %q not found in account", domain)
+}
+
// ListRecords returns records for the named zone. Caller MUST pass
// the apex domain (e.g. "example.com").
func (c *Client) ListRecords(ctx context.Context, domain string) ([]DNSRecord, error) {
diff --git a/internal/hover/client_test.go b/internal/hover/client_test.go
index 0540860..b0de390 100644
--- a/internal/hover/client_test.go
+++ b/internal/hover/client_test.go
@@ -42,7 +42,7 @@ func (r rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return http.DefaultTransport.RoundTrip(clone)
}
-func TestClient_Login_TwoStep(t *testing.T) {
+func TestClient_Login_TwoStep_WithMFA(t *testing.T) {
var hits []string
var totpForm string
c, srv := newStubClient(t, func(w http.ResponseWriter, r *http.Request) {
@@ -57,6 +57,7 @@ func TestClient_Login_TwoStep(t *testing.T) {
w.WriteHeader(http.StatusOK)
case "/signin/totp":
if r.Method == http.MethodGet {
+ // Returning signinCSRFHTML signals that MFA is enabled.
_, _ = w.Write([]byte(signinCSRFHTML))
return
}
@@ -69,8 +70,8 @@ func TestClient_Login_TwoStep(t *testing.T) {
})
defer srv.Close()
- if err := c.ensureLogin(context.Background()); err != nil {
- t.Fatalf("ensureLogin: %v", err)
+ if err := c.Login(context.Background()); err != nil {
+ t.Fatalf("Login: %v", err)
}
wantHits := []string{
@@ -98,27 +99,78 @@ func TestClient_Login_TwoStep(t *testing.T) {
}
}
+func TestClient_Login_NoMFA(t *testing.T) {
+ // Hover account with MFA disabled: /signin/totp GET returns a page
+ // without a _token, so the TOTP POST step is skipped.
+ var hits []string
+ c, srv := newStubClient(t, func(w http.ResponseWriter, r *http.Request) {
+ hits = append(hits, r.Method+" "+r.URL.Path)
+ switch r.URL.Path {
+ case "/signin":
+ if r.Method == http.MethodGet {
+ _, _ = w.Write([]byte(signinCSRFHTML))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ case "/signin/totp":
+ if r.Method == http.MethodGet {
+ // No _token → MFA not enabled on this account.
+ _, _ = w.Write([]byte("no token here — already logged in"))
+ return
+ }
+ t.Errorf("unexpected TOTP POST — account has no MFA")
+ default:
+ t.Errorf("unexpected hit: %s %s", r.Method, r.URL.Path)
+ }
+ })
+ defer srv.Close()
+
+ if err := c.Login(context.Background()); err != nil {
+ t.Fatalf("Login (no-MFA): %v", err)
+ }
+
+ wantHits := []string{
+ "GET /signin",
+ "POST /signin",
+ "GET /signin/totp",
+ }
+ if len(hits) != len(wantHits) {
+ t.Fatalf("hits = %v; want %v", hits, wantHits)
+ }
+}
+
func TestClient_Login_SkipsWhenFresh(t *testing.T) {
var hits int
c, srv := newStubClient(t, func(w http.ResponseWriter, r *http.Request) {
hits++
- if r.Method == http.MethodGet {
- _, _ = w.Write([]byte(signinCSRFHTML))
- return
+ switch r.URL.Path {
+ case "/signin":
+ if r.Method == http.MethodGet {
+ _, _ = w.Write([]byte(signinCSRFHTML))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ case "/signin/totp":
+ if r.Method == http.MethodGet {
+ // No MFA on this account.
+ _, _ = w.Write([]byte("no token"))
+ return
+ }
+ default:
+ w.WriteHeader(http.StatusOK)
}
- w.WriteHeader(http.StatusOK)
})
defer srv.Close()
- if err := c.ensureLogin(context.Background()); err != nil {
+ if err := c.Login(context.Background()); err != nil {
t.Fatal(err)
}
firstRound := hits
- if err := c.ensureLogin(context.Background()); err != nil {
+ if err := c.Login(context.Background()); err != nil {
t.Fatal(err)
}
if hits != firstRound {
- t.Errorf("second ensureLogin hit network; want cache hit. first=%d second=%d", firstRound, hits)
+ t.Errorf("second Login hit network; want cache hit. first=%d second=%d", firstRound, hits)
}
}
@@ -128,7 +180,9 @@ func TestClient_CSRFParseFailure_RaisesClearError(t *testing.T) {
})
defer srv.Close()
- err := c.ensureLogin(context.Background())
+ // The /signin GET will return no CSRF token — Login must surface a
+ // clear error rather than silently failing.
+ err := c.Login(context.Background())
if err == nil {
t.Fatal("expected CSRF parse error")
}
@@ -143,3 +197,181 @@ func TestNewClient_RequiresCredentials(t *testing.T) {
t.Fatal("expected error on empty creds")
}
}
+
+// ── record API tests ──────────────────────────────────────────────────────────
+//
+// These tests share a single mux that handles both the login flow (no-MFA
+// path) and the DNS record API endpoints.
+
+// newRecordStub returns a stub server that handles the login flow (no-MFA)
+// and invokes apiHandler for any path that starts with /api/.
+func newRecordStub(t *testing.T, apiHandler http.HandlerFunc) (*Client, *httptest.Server) {
+ t.Helper()
+ mux := http.NewServeMux()
+
+ // Login flow
+ mux.HandleFunc("/signin", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ _, _ = w.Write([]byte(signinCSRFHTML))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ mux.HandleFunc("/signin/totp", func(w http.ResponseWriter, r *http.Request) {
+ // No MFA token → skip TOTP.
+ _, _ = w.Write([]byte("logged in"))
+ })
+
+ // API endpoints — delegate to caller.
+ mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
+ apiHandler(w, r)
+ })
+
+ srv := httptest.NewServer(mux)
+ jar, _ := cookiejar.New(nil)
+ httpc := &http.Client{
+ Jar: jar,
+ Transport: rewriteTransport{base: srv.URL},
+ }
+ creds := Credentials{Username: "alice", Password: "pw"}
+ c, err := NewClient(creds, httpc)
+ if err != nil {
+ t.Fatalf("NewClient: %v", err)
+ }
+ // Force login eagerly so subsequent calls skip the login hit count.
+ if err := c.Login(context.Background()); err != nil {
+ t.Fatalf("Login: %v", err)
+ }
+ return c, srv
+}
+
+func TestClient_ListRecords(t *testing.T) {
+ // Hover GET /api/domains//dns response shape.
+ // The client wraps the response in a {domains:[{domain_name, entries:[...]}]} envelope.
+ respBody := `{
+ "domains": [{
+ "id": "dom1",
+ "domain_name": "example.com",
+ "entries": [
+ {"id": "r1", "type": "A", "name": "@", "content": "1.2.3.4", "ttl": 300}
+ ]
+ }]
+ }`
+ c, srv := newRecordStub(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || !strings.HasSuffix(r.URL.Path, "/dns") {
+ t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
+ http.Error(w, "unexpected", 400)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(respBody))
+ })
+ defer srv.Close()
+
+ recs, err := c.ListRecords(context.Background(), "example.com")
+ if err != nil {
+ t.Fatalf("ListRecords: %v", err)
+ }
+ if len(recs) != 1 {
+ t.Fatalf("expected 1 record, got %d", len(recs))
+ }
+ if recs[0].ID != "r1" || recs[0].Type != "A" || recs[0].Content != "1.2.3.4" {
+ t.Errorf("unexpected record: %+v", recs[0])
+ }
+}
+
+func TestClient_CreateRecord(t *testing.T) {
+ var received map[string]string
+ respBody := `{"dns_record": {"id": "newid", "type": "A", "name": "sub", "content": "5.5.5.5", "ttl": 300}}`
+ c, srv := newRecordStub(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ t.Errorf("expected POST, got %s", r.Method)
+ }
+ _ = r.ParseForm()
+ received = map[string]string{
+ "domain_id": r.FormValue("domain_id"),
+ "type": r.FormValue("type"),
+ "name": r.FormValue("name"),
+ "content": r.FormValue("content"),
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(respBody))
+ })
+ defer srv.Close()
+
+ rec := DNSRecord{Type: "A", Name: "sub", Content: "5.5.5.5", TTL: 300}
+ created, err := c.CreateRecord(context.Background(), "dom1", rec)
+ if err != nil {
+ t.Fatalf("CreateRecord: %v", err)
+ }
+ if created.ID != "newid" {
+ t.Errorf("created.ID = %q want %q", created.ID, "newid")
+ }
+ if received["domain_id"] != "dom1" {
+ t.Errorf("form domain_id = %q want %q", received["domain_id"], "dom1")
+ }
+ if received["content"] != "5.5.5.5" {
+ t.Errorf("form content = %q want %q", received["content"], "5.5.5.5")
+ }
+}
+
+func TestClient_UpdateRecord(t *testing.T) {
+ var receivedContent string
+ c, srv := newRecordStub(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut {
+ t.Errorf("expected PUT, got %s", r.Method)
+ }
+ _ = r.ParseForm()
+ receivedContent = r.FormValue("content")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{}`))
+ })
+ defer srv.Close()
+
+ err := c.UpdateRecord(context.Background(), "r1", DNSRecord{Content: "9.9.9.9"})
+ if err != nil {
+ t.Fatalf("UpdateRecord: %v", err)
+ }
+ if receivedContent != "9.9.9.9" {
+ t.Errorf("content = %q want %q", receivedContent, "9.9.9.9")
+ }
+}
+
+func TestClient_DeleteRecord(t *testing.T) {
+ var deletedPath string
+ c, srv := newRecordStub(t, func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodDelete {
+ t.Errorf("expected DELETE, got %s", r.Method)
+ }
+ deletedPath = r.URL.Path
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{}`))
+ })
+ defer srv.Close()
+
+ err := c.DeleteRecord(context.Background(), "r1")
+ if err != nil {
+ t.Fatalf("DeleteRecord: %v", err)
+ }
+ if !strings.HasSuffix(deletedPath, "/r1") {
+ t.Errorf("deletedPath = %q; want suffix /r1", deletedPath)
+ }
+}
+
+func TestClient_ListRecords_DomainNotFound(t *testing.T) {
+ // API returns empty domains list — our client must return a clear error.
+ respBody := `{"domains": []}`
+ c, srv := newRecordStub(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(respBody))
+ })
+ defer srv.Close()
+
+ _, err := c.ListRecords(context.Background(), "notinaccount.com")
+ if err == nil {
+ t.Fatal("expected error for domain not in account")
+ }
+ if !strings.Contains(err.Error(), "not found") {
+ t.Errorf("unexpected error: %v", err)
+ }
+}
diff --git a/internal/hover/totp_test.go b/internal/hover/totp_test.go
index d6a5a73..4439b05 100644
--- a/internal/hover/totp_test.go
+++ b/internal/hover/totp_test.go
@@ -63,8 +63,8 @@ func TestCodeAt_RFC6238Vectors(t *testing.T) {
t int64
want string
}{
- {59, "287082"}, // Truncated to last 6 of 94287082
- {1111111109, "081804"}, // Truncated to last 6 of 7081804
+ {59, "287082"}, // Truncated to last 6 of 94287082
+ {1111111109, "081804"}, // Truncated to last 6 of 7081804
{1111111111, "050471"},
{1234567890, "005924"},
{2000000000, "279037"},
diff --git a/internal/iacserver.go b/internal/iacserver.go
new file mode 100644
index 0000000..af698cf
--- /dev/null
+++ b/internal/iacserver.go
@@ -0,0 +1,532 @@
+// Package internal — Hover typed IaC gRPC server.
+//
+// hoverIaCServer wraps *HoverProvider and satisfies the pb.IaCProvider*Server
+// interface set required by sdk.ServeIaCPlugin. The shape mirrors
+// workflow-plugin-digitalocean/internal/iacserver.go; only the provider-
+// specific dial surface differs.
+//
+// Hard invariants:
+// - NO structpb.Struct on the wire; config / outputs cross as JSON bytes.
+// - ComputePlanVersion "v2" (Apply is removed; FinalizeApply returns empty
+// since Hover has no deferred operations).
+// - sdk.ServeIaCPlugin auto-registers every typed pb.IaCProvider*Server
+// interface this struct satisfies (see internal/serve.go). hoverIaCServer
+// embeds pb.Unimplemented*Server for the full surface
+// (Required + Finalizer + DriftDetector + Enumerator + Validator +
+// DriftConfigDetector + CredentialRevoker + MigrationRepairer +
+// ResourceDriver + PluginService), so every service registers — but
+// Hover only implements method bodies for Required + Finalizer +
+// DriftDetector. The unimplemented services respond with a typed
+// "Unimplemented" gRPC status rather than "service not registered",
+// which is the right shape for cross-plugin uniformity but means
+// clients must check capabilities before invoking optional methods.
+package internal
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "math"
+ "time"
+
+ "github.com/GoCodeAlone/workflow/interfaces"
+ pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
+ "google.golang.org/protobuf/types/known/timestamppb"
+)
+
+// hoverIaCServer wraps *HoverProvider and exposes the typed pb.IaCProvider*Server
+// surface required by sdk.ServeIaCPlugin.
+type hoverIaCServer struct {
+ pb.UnimplementedIaCProviderRequiredServer
+ pb.UnimplementedIaCProviderDriftDetectorServer
+ pb.UnimplementedIaCProviderEnumeratorServer
+ pb.UnimplementedIaCProviderCredentialRevokerServer
+ pb.UnimplementedIaCProviderMigrationRepairerServer
+ pb.UnimplementedIaCProviderValidatorServer
+ pb.UnimplementedIaCProviderDriftConfigDetectorServer
+ pb.UnimplementedIaCProviderFinalizerServer
+ pb.UnimplementedResourceDriverServer
+ pb.UnimplementedPluginServiceServer
+ pb.UnimplementedIaCStateBackendServer
+
+ provider *HoverProvider
+}
+
+// Compile-time interface assertions.
+var (
+ _ pb.IaCProviderRequiredServer = (*hoverIaCServer)(nil)
+ _ pb.IaCProviderDriftDetectorServer = (*hoverIaCServer)(nil)
+ _ pb.IaCProviderFinalizerServer = (*hoverIaCServer)(nil)
+)
+
+// NewIaCServer is the package entrypoint for cmd/main.go.
+func NewIaCServer() *hoverIaCServer {
+ return &hoverIaCServer{provider: NewHoverProvider()}
+}
+
+// ── Required service ──────────────────────────────────────────────────────────
+
+func (s *hoverIaCServer) Initialize(ctx context.Context, req *pb.InitializeRequest) (*pb.InitializeResponse, error) {
+ cfg, err := unmarshalJSONMap(req.GetConfigJson())
+ if err != nil {
+ return nil, fmt.Errorf("hover iacserver: parse Initialize config_json: %w", err)
+ }
+ if err := s.provider.Initialize(ctx, cfg); err != nil {
+ return nil, err
+ }
+ return &pb.InitializeResponse{}, nil
+}
+
+func (s *hoverIaCServer) Name(_ context.Context, _ *pb.NameRequest) (*pb.NameResponse, error) {
+ return &pb.NameResponse{Name: s.provider.Name()}, nil
+}
+
+func (s *hoverIaCServer) Version(_ context.Context, _ *pb.VersionRequest) (*pb.VersionResponse, error) {
+ return &pb.VersionResponse{Version: s.provider.Version()}, nil
+}
+
+func (s *hoverIaCServer) Capabilities(_ context.Context, _ *pb.CapabilitiesRequest) (*pb.CapabilitiesResponse, error) {
+ caps := s.provider.Capabilities()
+ out := make([]*pb.IaCCapabilityDeclaration, 0, len(caps))
+ for _, c := range caps {
+ tier := c.Tier
+ if tier < math.MinInt32 {
+ tier = math.MinInt32
+ } else if tier > math.MaxInt32 {
+ tier = math.MaxInt32
+ }
+ out = append(out, &pb.IaCCapabilityDeclaration{
+ ResourceType: c.ResourceType,
+ Tier: int32(tier), //nolint:gosec // G115: clamped above
+ Operations: append([]string(nil), c.Operations...),
+ })
+ }
+ return &pb.CapabilitiesResponse{
+ Capabilities: out,
+ ComputePlanVersion: "v2",
+ }, nil
+}
+
+func (s *hoverIaCServer) Plan(ctx context.Context, req *pb.PlanRequest) (*pb.PlanResponse, error) {
+ desired, err := specsFromPB(req.GetDesired())
+ if err != nil {
+ return nil, fmt.Errorf("hover iacserver: decode Plan desired: %w", err)
+ }
+ current, err := statesFromPB(req.GetCurrent())
+ if err != nil {
+ return nil, fmt.Errorf("hover iacserver: decode Plan current: %w", err)
+ }
+ plan, err := s.provider.Plan(ctx, desired, current)
+ if err != nil {
+ return nil, err
+ }
+ pbPlan, err := planToPB(plan)
+ if err != nil {
+ return nil, fmt.Errorf("hover iacserver: encode Plan response: %w", err)
+ }
+ return &pb.PlanResponse{Plan: pbPlan}, nil
+}
+
+func (s *hoverIaCServer) Destroy(ctx context.Context, req *pb.DestroyRequest) (*pb.DestroyResponse, error) {
+ refs := refsFromPB(req.GetRefs())
+ result, err := s.provider.Destroy(ctx, refs)
+ if err != nil {
+ return nil, err
+ }
+ return &pb.DestroyResponse{Result: destroyResultToPB(result)}, nil
+}
+
+func (s *hoverIaCServer) Status(ctx context.Context, req *pb.StatusRequest) (*pb.StatusResponse, error) {
+ refs := refsFromPB(req.GetRefs())
+ statuses, err := s.provider.Status(ctx, refs)
+ if err != nil {
+ return nil, err
+ }
+ pbStatuses, err := statusesToPB(statuses)
+ if err != nil {
+ return nil, fmt.Errorf("hover iacserver: encode Status response: %w", err)
+ }
+ return &pb.StatusResponse{Statuses: pbStatuses}, nil
+}
+
+func (s *hoverIaCServer) Import(ctx context.Context, req *pb.ImportRequest) (*pb.ImportResponse, error) {
+ _, err := s.provider.Import(ctx, req.GetProviderId(), req.GetResourceType())
+ if err != nil {
+ return nil, err
+ }
+ return &pb.ImportResponse{}, nil
+}
+
+func (s *hoverIaCServer) ResolveSizing(_ context.Context, _ *pb.ResolveSizingRequest) (*pb.ResolveSizingResponse, error) {
+ return nil, fmt.Errorf("hover: ResolveSizing is not supported")
+}
+
+func (s *hoverIaCServer) BootstrapStateBackend(_ context.Context, _ *pb.BootstrapStateBackendRequest) (*pb.BootstrapStateBackendResponse, error) {
+ return &pb.BootstrapStateBackendResponse{}, nil
+}
+
+// FinalizeApply satisfies pb.IaCProviderFinalizerServer. Hover has no
+// deferred operations so this is always a no-op empty success.
+func (s *hoverIaCServer) FinalizeApply(_ context.Context, _ *pb.FinalizeApplyRequest) (*pb.FinalizeApplyResponse, error) {
+ return &pb.FinalizeApplyResponse{}, nil
+}
+
+// ── Drift detection ───────────────────────────────────────────────────────────
+
+func (s *hoverIaCServer) DetectDrift(ctx context.Context, req *pb.DetectDriftRequest) (*pb.DetectDriftResponse, error) {
+ refs := refsFromPB(req.GetRefs())
+ drifts, err := s.provider.DetectDrift(ctx, refs)
+ if err != nil {
+ return nil, err
+ }
+ pbDrifts, err := driftsToPB(drifts)
+ if err != nil {
+ return nil, fmt.Errorf("hover iacserver: encode DetectDrift response: %w", err)
+ }
+ return &pb.DetectDriftResponse{Drifts: pbDrifts}, nil
+}
+
+// ── Marshalling helpers ───────────────────────────────────────────────────────
+
+func unmarshalJSONMap(b []byte) (map[string]any, error) {
+ if len(b) == 0 {
+ return nil, nil
+ }
+ var out map[string]any
+ if err := json.Unmarshal(b, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func marshalJSONMap(m map[string]any) ([]byte, error) {
+ if m == nil {
+ return nil, nil
+ }
+ return json.Marshal(m)
+}
+
+func marshalJSONAny(v any) ([]byte, error) {
+ if v == nil {
+ return nil, nil
+ }
+ return json.Marshal(v)
+}
+
+func unmarshalJSONAny(b []byte) (any, error) {
+ if len(b) == 0 {
+ return nil, nil
+ }
+ var out any
+ if err := json.Unmarshal(b, &out); err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+func refFromPB(r *pb.ResourceRef) interfaces.ResourceRef {
+ if r == nil {
+ return interfaces.ResourceRef{}
+ }
+ return interfaces.ResourceRef{Name: r.GetName(), Type: r.GetType(), ProviderID: r.GetProviderId()}
+}
+
+func refsFromPB(refs []*pb.ResourceRef) []interfaces.ResourceRef {
+ out := make([]interfaces.ResourceRef, 0, len(refs))
+ for _, r := range refs {
+ out = append(out, refFromPB(r))
+ }
+ return out
+}
+
+func hintsFromPB(h *pb.ResourceHints) *interfaces.ResourceHints {
+ if h == nil {
+ return nil
+ }
+ return &interfaces.ResourceHints{CPU: h.GetCpu(), Memory: h.GetMemory(), Storage: h.GetStorage()}
+}
+
+func specFromPB(s *pb.ResourceSpec) (interfaces.ResourceSpec, error) {
+ if s == nil {
+ return interfaces.ResourceSpec{}, nil
+ }
+ cfg, err := unmarshalJSONMap(s.GetConfigJson())
+ if err != nil {
+ return interfaces.ResourceSpec{}, err
+ }
+ return interfaces.ResourceSpec{
+ Name: s.GetName(),
+ Type: s.GetType(),
+ Config: cfg,
+ Size: interfaces.Size(s.GetSize()),
+ Hints: hintsFromPB(s.GetHints()),
+ DependsOn: append([]string(nil), s.GetDependsOn()...),
+ }, nil
+}
+
+func specsFromPB(specs []*pb.ResourceSpec) ([]interfaces.ResourceSpec, error) {
+ out := make([]interfaces.ResourceSpec, 0, len(specs))
+ for _, s := range specs {
+ gs, err := specFromPB(s)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, gs)
+ }
+ return out, nil
+}
+
+func stateFromPB(s *pb.ResourceState) (*interfaces.ResourceState, error) {
+ if s == nil {
+ return nil, nil
+ }
+ applied, err := unmarshalJSONMap(s.GetAppliedConfigJson())
+ if err != nil {
+ return nil, err
+ }
+ outputs, err := unmarshalJSONMap(s.GetOutputsJson())
+ if err != nil {
+ return nil, err
+ }
+ return &interfaces.ResourceState{
+ ID: s.GetId(),
+ Name: s.GetName(),
+ Type: s.GetType(),
+ Provider: s.GetProvider(),
+ ProviderRef: s.GetProviderRef(),
+ ProviderID: s.GetProviderId(),
+ ConfigHash: s.GetConfigHash(),
+ AppliedConfig: applied,
+ AppliedConfigSource: s.GetAppliedConfigSource(),
+ Outputs: outputs,
+ Dependencies: append([]string(nil), s.GetDependencies()...),
+ CreatedAt: timeFromPB(s.GetCreatedAt()),
+ UpdatedAt: timeFromPB(s.GetUpdatedAt()),
+ LastDriftCheck: timeFromPB(s.GetLastDriftCheck()),
+ }, nil
+}
+
+func statesFromPB(states []*pb.ResourceState) ([]interfaces.ResourceState, error) {
+ out := make([]interfaces.ResourceState, 0, len(states))
+ for _, s := range states {
+ gs, err := stateFromPB(s)
+ if err != nil {
+ return nil, err
+ }
+ if gs != nil {
+ out = append(out, *gs)
+ }
+ }
+ return out, nil
+}
+
+func stateToPB(st *interfaces.ResourceState) (*pb.ResourceState, error) {
+ if st == nil {
+ return nil, nil
+ }
+ appliedJSON, err := marshalJSONMap(st.AppliedConfig)
+ if err != nil {
+ return nil, err
+ }
+ outputsJSON, err := marshalJSONMap(st.Outputs)
+ if err != nil {
+ return nil, err
+ }
+ return &pb.ResourceState{
+ Id: st.ID,
+ Name: st.Name,
+ Type: st.Type,
+ Provider: st.Provider,
+ ProviderRef: st.ProviderRef,
+ ProviderId: st.ProviderID,
+ ConfigHash: st.ConfigHash,
+ AppliedConfigJson: appliedJSON,
+ AppliedConfigSource: st.AppliedConfigSource,
+ OutputsJson: outputsJSON,
+ Dependencies: append([]string(nil), st.Dependencies...),
+ CreatedAt: timeToPB(st.CreatedAt),
+ UpdatedAt: timeToPB(st.UpdatedAt),
+ LastDriftCheck: timeToPB(st.LastDriftCheck),
+ }, nil
+}
+
+func specToPB(s interfaces.ResourceSpec) (*pb.ResourceSpec, error) {
+ cfgJSON, err := marshalJSONMap(s.Config)
+ if err != nil {
+ return nil, err
+ }
+ var hintsPB *pb.ResourceHints
+ if s.Hints != nil {
+ hintsPB = &pb.ResourceHints{Cpu: s.Hints.CPU, Memory: s.Hints.Memory, Storage: s.Hints.Storage}
+ }
+ return &pb.ResourceSpec{
+ Name: s.Name,
+ Type: s.Type,
+ ConfigJson: cfgJSON,
+ Size: string(s.Size),
+ Hints: hintsPB,
+ DependsOn: append([]string(nil), s.DependsOn...),
+ }, nil
+}
+
+func changesToPB(changes []interfaces.FieldChange) ([]*pb.FieldChange, error) {
+ out := make([]*pb.FieldChange, 0, len(changes))
+ for _, c := range changes {
+ oldJSON, err := marshalJSONAny(c.Old)
+ if err != nil {
+ return nil, err
+ }
+ newJSON, err := marshalJSONAny(c.New)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, &pb.FieldChange{
+ Path: c.Path,
+ OldJson: oldJSON,
+ NewJson: newJSON,
+ ForceNew: c.ForceNew,
+ })
+ }
+ return out, nil
+}
+
+func planActionToPB(a interfaces.PlanAction) (*pb.PlanAction, error) {
+ pbSpec, err := specToPB(a.Resource)
+ if err != nil {
+ return nil, err
+ }
+ var pbCurrent *pb.ResourceState
+ if a.Current != nil {
+ pbCurrent, err = stateToPB(a.Current)
+ if err != nil {
+ return nil, err
+ }
+ }
+ pbChanges, err := changesToPB(a.Changes)
+ if err != nil {
+ return nil, err
+ }
+ return &pb.PlanAction{
+ Action: a.Action,
+ Resource: pbSpec,
+ Current: pbCurrent,
+ Changes: pbChanges,
+ ResolvedConfigHash: a.ResolvedConfigHash,
+ }, nil
+}
+
+func planToPB(p *interfaces.IaCPlan) (*pb.IaCPlan, error) {
+ if p == nil {
+ return nil, nil
+ }
+ pbActions := make([]*pb.PlanAction, 0, len(p.Actions))
+ for i := range p.Actions {
+ pa, err := planActionToPB(p.Actions[i])
+ if err != nil {
+ return nil, err
+ }
+ pbActions = append(pbActions, pa)
+ }
+ if p.SchemaVersion < math.MinInt32 || p.SchemaVersion > math.MaxInt32 {
+ return nil, fmt.Errorf("hover iacserver: plan SchemaVersion %d out of int32 range", p.SchemaVersion)
+ }
+ return &pb.IaCPlan{
+ Id: p.ID,
+ Actions: pbActions,
+ CreatedAt: timeToPB(p.CreatedAt),
+ DesiredHash: p.DesiredHash,
+ SchemaVersion: int32(p.SchemaVersion), //nolint:gosec // G115: range-checked above
+ InputSnapshot: copyStringMap(p.InputSnapshot),
+ }, nil
+}
+
+func destroyResultToPB(r *interfaces.DestroyResult) *pb.DestroyResult {
+ if r == nil {
+ return nil
+ }
+ errs := make([]*pb.ActionError, 0, len(r.Errors))
+ for _, e := range r.Errors {
+ errs = append(errs, &pb.ActionError{Resource: e.Resource, Action: e.Action, Error: e.Error})
+ }
+ return &pb.DestroyResult{Destroyed: append([]string(nil), r.Destroyed...), Errors: errs}
+}
+
+func statusesToPB(ss []interfaces.ResourceStatus) ([]*pb.ResourceStatus, error) {
+ out := make([]*pb.ResourceStatus, 0, len(ss))
+ for i := range ss {
+ o, err := marshalJSONMap(ss[i].Outputs)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, &pb.ResourceStatus{
+ Name: ss[i].Name,
+ Type: ss[i].Type,
+ ProviderId: ss[i].ProviderID,
+ Status: ss[i].Status,
+ OutputsJson: o,
+ })
+ }
+ return out, nil
+}
+
+func driftClassToPB(c interfaces.DriftClass) pb.DriftClass {
+ switch c {
+ case interfaces.DriftClassInSync:
+ return pb.DriftClass_DRIFT_CLASS_IN_SYNC
+ case interfaces.DriftClassGhost:
+ return pb.DriftClass_DRIFT_CLASS_GHOST
+ case interfaces.DriftClassConfig:
+ return pb.DriftClass_DRIFT_CLASS_CONFIG
+ default:
+ return pb.DriftClass_DRIFT_CLASS_UNKNOWN
+ }
+}
+
+func driftsToPB(drifts []interfaces.DriftResult) ([]*pb.DriftResult, error) {
+ out := make([]*pb.DriftResult, 0, len(drifts))
+ for _, d := range drifts {
+ expectedJSON, err := marshalJSONMap(d.Expected)
+ if err != nil {
+ return nil, err
+ }
+ actualJSON, err := marshalJSONMap(d.Actual)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, &pb.DriftResult{
+ Name: d.Name,
+ Type: d.Type,
+ Drifted: d.Drifted,
+ Class: driftClassToPB(d.Class),
+ ExpectedJson: expectedJSON,
+ ActualJson: actualJSON,
+ Fields: append([]string(nil), d.Fields...),
+ })
+ }
+ return out, nil
+}
+
+func timeToPB(t time.Time) *timestamppb.Timestamp {
+ if t.IsZero() {
+ return nil
+ }
+ return timestamppb.New(t)
+}
+
+func timeFromPB(t *timestamppb.Timestamp) time.Time {
+ if t == nil {
+ return time.Time{}
+ }
+ return t.AsTime()
+}
+
+func copyStringMap(m map[string]string) map[string]string {
+ if m == nil {
+ return nil
+ }
+ out := make(map[string]string, len(m))
+ for k, v := range m {
+ out[k] = v
+ }
+ return out
+}
diff --git a/internal/iacserver_test.go b/internal/iacserver_test.go
new file mode 100644
index 0000000..3568be1
--- /dev/null
+++ b/internal/iacserver_test.go
@@ -0,0 +1,113 @@
+package internal
+
+import (
+ "context"
+ "testing"
+
+ pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
+)
+
+func TestHoverIaCServer_Name(t *testing.T) {
+ srv := NewIaCServer()
+ resp, err := srv.Name(context.Background(), &pb.NameRequest{})
+ if err != nil {
+ t.Fatalf("Name: %v", err)
+ }
+ if resp.GetName() != "hover" {
+ t.Errorf("Name = %q want %q", resp.GetName(), "hover")
+ }
+}
+
+func TestHoverIaCServer_Version(t *testing.T) {
+ srv := NewIaCServer()
+ resp, err := srv.Version(context.Background(), &pb.VersionRequest{})
+ if err != nil {
+ t.Fatalf("Version: %v", err)
+ }
+ if resp.GetVersion() == "" {
+ t.Error("Version returned empty string")
+ }
+}
+
+func TestHoverIaCServer_Capabilities(t *testing.T) {
+ srv := NewIaCServer()
+ resp, err := srv.Capabilities(context.Background(), &pb.CapabilitiesRequest{})
+ if err != nil {
+ t.Fatalf("Capabilities: %v", err)
+ }
+ if resp.GetComputePlanVersion() != "v2" {
+ t.Errorf("ComputePlanVersion = %q want %q", resp.GetComputePlanVersion(), "v2")
+ }
+ if len(resp.GetCapabilities()) != 1 {
+ t.Fatalf("expected 1 capability, got %d", len(resp.GetCapabilities()))
+ }
+ cap := resp.GetCapabilities()[0]
+ if cap.GetResourceType() != "infra.dns" {
+ t.Errorf("ResourceType = %q want %q", cap.GetResourceType(), "infra.dns")
+ }
+}
+
+func TestHoverIaCServer_FinalizeApply_NoOp(t *testing.T) {
+ srv := NewIaCServer()
+ resp, err := srv.FinalizeApply(context.Background(), &pb.FinalizeApplyRequest{})
+ if err != nil {
+ t.Fatalf("FinalizeApply: %v", err)
+ }
+ if len(resp.GetErrors()) != 0 {
+ t.Errorf("expected no errors, got %v", resp.GetErrors())
+ }
+}
+
+func TestHoverIaCServer_Initialize_MissingUsername(t *testing.T) {
+ srv := NewIaCServer()
+ _, err := srv.Initialize(context.Background(), &pb.InitializeRequest{
+ ConfigJson: []byte(`{"password": "pw"}`),
+ })
+ if err == nil {
+ t.Fatal("expected error for missing username")
+ }
+}
+
+func TestHoverIaCServer_Initialize_MissingPassword(t *testing.T) {
+ srv := NewIaCServer()
+ _, err := srv.Initialize(context.Background(), &pb.InitializeRequest{
+ ConfigJson: []byte(`{"username": "user"}`),
+ })
+ if err == nil {
+ t.Fatal("expected error for missing password")
+ }
+}
+
+func TestHoverIaCServer_Initialize_InvalidTOTPSecret(t *testing.T) {
+ srv := NewIaCServer()
+ _, err := srv.Initialize(context.Background(), &pb.InitializeRequest{
+ ConfigJson: []byte(`{"username": "u", "password": "p", "totp_secret": "!not-valid-base32!"}`),
+ })
+ if err == nil {
+ t.Fatal("expected error for invalid TOTP secret")
+ }
+}
+
+func TestHoverIaCServer_Plan_EmptyDesired(t *testing.T) {
+ // Plan with an empty desired list is valid — returns a plan with no actions.
+ srv := NewIaCServer()
+ resp, err := srv.Plan(context.Background(), &pb.PlanRequest{})
+ if err != nil {
+ t.Fatalf("Plan with empty desired: %v", err)
+ }
+ if resp.GetPlan() != nil && len(resp.GetPlan().GetActions()) != 0 {
+ t.Errorf("expected no actions for empty desired, got %d", len(resp.GetPlan().GetActions()))
+ }
+}
+
+func TestHoverIaCServer_Destroy_EmptyRefs(t *testing.T) {
+ srv := NewIaCServer()
+ // Destroy with zero refs is a no-op regardless of initialization state.
+ resp, err := srv.Destroy(context.Background(), &pb.DestroyRequest{})
+ if err != nil {
+ t.Fatalf("Destroy: %v", err)
+ }
+ if len(resp.GetResult().GetDestroyed()) != 0 {
+ t.Errorf("expected no destroyed, got %v", resp.GetResult().GetDestroyed())
+ }
+}
diff --git a/internal/provider.go b/internal/provider.go
new file mode 100644
index 0000000..a75a1da
--- /dev/null
+++ b/internal/provider.go
@@ -0,0 +1,240 @@
+// Package internal implements the Hover IaC provider. Hover has no official
+// API; this plugin uses the browser-side session flow (see
+// internal/hover/client.go).
+package internal
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/GoCodeAlone/workflow-plugin-hover/internal/drivers"
+ "github.com/GoCodeAlone/workflow-plugin-hover/internal/hover"
+ "github.com/GoCodeAlone/workflow/interfaces"
+ "github.com/GoCodeAlone/workflow/platform"
+)
+
+// Version is set at build time via -ldflags.
+var Version = "dev"
+
+// HoverProvider implements interfaces.IaCProvider for Hover.
+// It supports a single resource type: infra.dns.
+type HoverProvider struct {
+ client *hover.Client
+ drivers map[string]interfaces.ResourceDriver
+}
+
+var _ interfaces.IaCProvider = (*HoverProvider)(nil)
+
+// NewHoverProvider creates an uninitialised HoverProvider.
+func NewHoverProvider() *HoverProvider { return &HoverProvider{} }
+
+func (p *HoverProvider) Name() string { return "hover" }
+func (p *HoverProvider) Version() string { return Version }
+
+// Initialize parses provider config and eagerly authenticates with Hover.
+// Required keys:
+//
+// username — Hover account username / email
+// password — Hover account password
+//
+// Optional keys:
+//
+// totp_secret — Base32-encoded TOTP seed (required if the account has MFA
+// enabled; safe to omit when MFA is off)
+func (p *HoverProvider) Initialize(ctx context.Context, config map[string]any) error {
+ username, _ := config["username"].(string)
+ password, _ := config["password"].(string)
+ totpRaw, _ := config["totp_secret"].(string)
+
+ if username == "" {
+ return fmt.Errorf("hover: missing required config key 'username'")
+ }
+ if password == "" {
+ return fmt.Errorf("hover: missing required config key 'password'")
+ }
+
+ var totpSecret hover.TOTPSecret
+ if totpRaw != "" {
+ ts, err := hover.ParseBase32(totpRaw)
+ if err != nil {
+ return fmt.Errorf("hover: invalid totp_secret: %w", err)
+ }
+ totpSecret = ts
+ }
+
+ creds := hover.Credentials{
+ Username: username,
+ Password: password,
+ TOTPSecret: totpSecret,
+ }
+ c, err := hover.NewClient(creds, nil)
+ if err != nil {
+ return fmt.Errorf("hover: client init: %w", err)
+ }
+
+ // Eager login so config errors (bad creds, MFA failure) surface at
+ // Configure time rather than at first Plan/Apply invocation.
+ if err := c.Login(ctx); err != nil {
+ return fmt.Errorf("hover: initial login failed: %w", err)
+ }
+
+ p.client = c
+ p.drivers = map[string]interfaces.ResourceDriver{
+ "infra.dns": drivers.NewDNSDriver(c),
+ }
+ return nil
+}
+
+// Capabilities returns the resource types Hover supports.
+func (p *HoverProvider) Capabilities() []interfaces.IaCCapabilityDeclaration {
+ return []interfaces.IaCCapabilityDeclaration{
+ {
+ ResourceType: "infra.dns",
+ Tier: 1,
+ Operations: []string{"create", "read", "update", "delete"},
+ },
+ }
+}
+
+// ResourceDriver returns the driver for the given resource type.
+func (p *HoverProvider) ResourceDriver(resourceType string) (interfaces.ResourceDriver, error) {
+ d, ok := p.drivers[resourceType]
+ if !ok {
+ return nil, fmt.Errorf("hover: unsupported resource type %q", resourceType)
+ }
+ return d, nil
+}
+
+// Plan delegates to platform.ComputePlan which dispatches driver.Diff per-resource.
+func (p *HoverProvider) Plan(ctx context.Context, desired []interfaces.ResourceSpec, current []interfaces.ResourceState) (*interfaces.IaCPlan, error) {
+ plan, err := platform.ComputePlan(ctx, p, desired, current)
+ return &plan, err
+}
+
+// Destroy invokes the per-resource driver Delete for each ref.
+// For infra.dns this is a no-op: Hover exposes no API to delete a
+// DNS zone (only individual records). The resource is marked
+// "destroyed" in IaC state because workflow has nothing further to
+// reconcile, but the upstream records remain in Hover. Operators
+// who want to drop all records must do so manually via the Hover
+// control panel.
+func (p *HoverProvider) Destroy(ctx context.Context, resources []interfaces.ResourceRef) (*interfaces.DestroyResult, error) {
+ result := &interfaces.DestroyResult{}
+ for _, ref := range resources {
+ d, err := p.ResourceDriver(ref.Type)
+ if err != nil {
+ result.Errors = append(result.Errors, interfaces.ActionError{
+ Resource: ref.Name, Action: "delete", Error: err.Error(),
+ })
+ continue
+ }
+ if err := d.Delete(ctx, ref); err != nil {
+ result.Errors = append(result.Errors, interfaces.ActionError{
+ Resource: ref.Name, Action: "delete", Error: err.Error(),
+ })
+ continue
+ }
+ result.Destroyed = append(result.Destroyed, ref.Name)
+ }
+ return result, nil
+}
+
+// Status returns the live status of the given refs.
+func (p *HoverProvider) Status(ctx context.Context, resources []interfaces.ResourceRef) ([]interfaces.ResourceStatus, error) {
+ var statuses []interfaces.ResourceStatus
+ for _, ref := range resources {
+ d, err := p.ResourceDriver(ref.Type)
+ if err != nil {
+ statuses = append(statuses, interfaces.ResourceStatus{
+ Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID, Status: "unknown",
+ })
+ continue
+ }
+ out, err := d.Read(ctx, ref)
+ if err != nil {
+ statuses = append(statuses, interfaces.ResourceStatus{
+ Name: ref.Name, Type: ref.Type, ProviderID: ref.ProviderID, Status: "unknown",
+ })
+ continue
+ }
+ statuses = append(statuses, interfaces.ResourceStatus{
+ Name: out.Name, Type: out.Type, ProviderID: out.ProviderID,
+ Status: out.Status, Outputs: out.Outputs,
+ })
+ }
+ return statuses, nil
+}
+
+// DetectDrift checks for ghost resources (state has entry, cloud says 404).
+func (p *HoverProvider) DetectDrift(ctx context.Context, resources []interfaces.ResourceRef) ([]interfaces.DriftResult, error) {
+ var results []interfaces.DriftResult
+ for _, ref := range resources {
+ d, err := p.ResourceDriver(ref.Type)
+ if err != nil {
+ results = append(results, interfaces.DriftResult{
+ Name: ref.Name, Type: ref.Type, Drifted: true,
+ Class: interfaces.DriftClassUnknown,
+ Fields: []string{"provider: " + err.Error()},
+ })
+ continue
+ }
+ _, err = d.Read(ctx, ref)
+ if err != nil {
+ if isNotFound(err) {
+ results = append(results, interfaces.DriftResult{
+ Name: ref.Name, Type: ref.Type, Drifted: true,
+ Class: interfaces.DriftClassGhost,
+ })
+ continue
+ }
+ return nil, fmt.Errorf("hover DetectDrift %q: %w", ref.Name, err)
+ }
+ results = append(results, interfaces.DriftResult{
+ Name: ref.Name, Type: ref.Type, Drifted: false,
+ Class: interfaces.DriftClassInSync,
+ })
+ }
+ return results, nil
+}
+
+// Import is a stub: Hover does not support resource import via cloud ID.
+func (p *HoverProvider) Import(_ context.Context, _ string, _ string) (*interfaces.ResourceState, error) {
+ return nil, fmt.Errorf("hover: Import is not supported")
+}
+
+// ResolveSizing is a stub: Hover has no compute sizing.
+func (p *HoverProvider) ResolveSizing(_ string, _ interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) {
+ return nil, fmt.Errorf("hover: ResolveSizing is not supported")
+}
+
+// BootstrapStateBackend is a stub: Hover does not manage state backends.
+func (p *HoverProvider) BootstrapStateBackend(_ context.Context, _ map[string]any) (*interfaces.BootstrapResult, error) {
+ return nil, nil
+}
+
+// SupportedCanonicalKeys returns the full canonical key set; Hover maps only
+// the dns-relevant subset but there's no harm reporting all to the validator.
+func (p *HoverProvider) SupportedCanonicalKeys() []string {
+ return interfaces.CanonicalKeys()
+}
+
+// Close is a no-op; the HTTP client has no persistent connections to tear down.
+func (p *HoverProvider) Close() error { return nil }
+
+// isNotFound recognises a "resource doesn't exist upstream" error.
+// The driver wraps these with interfaces.ErrResourceNotFound, so
+// prefer the sentinel check via errors.Is. The string fallback
+// remains for any non-driver code path (raw Client errors that
+// haven't been wrapped) that still reports 404 via message text.
+func isNotFound(err error) bool {
+ if err == nil {
+ return false
+ }
+ if errors.Is(err, interfaces.ErrResourceNotFound) {
+ return true
+ }
+ msg := err.Error()
+ return strings.Contains(msg, "not found") || strings.Contains(msg, "404")
+}
diff --git a/internal/serve.go b/internal/serve.go
index ef822e6..266864b 100644
--- a/internal/serve.go
+++ b/internal/serve.go
@@ -1,11 +1,14 @@
// Package internal — Hover plugin entry point.
package internal
-// Serve is the gRPC plugin entry-point. Full driver registration
-// lands once workflow#640 Phase 3 (typed IaC ResourceDriver
-// surface) stabilises. Until then the Hover client lives in
-// internal/hover and the scaffold compiles + tests on its own.
+import (
+ sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk"
+)
+
+// Serve starts the Hover IaC plugin gRPC server. Called from
+// cmd/workflow-plugin-hover/main.go.
+// The SDK auto-registers every typed pb.IaCProvider*Server interface that
+// *hoverIaCServer satisfies via Go type-assertion at plugin startup.
func Serve() {
- // placeholder; see workflow-plugin-namecheap/internal/serve.go
- // for the eventual SDK invocation.
+ sdk.ServeIaCPlugin(NewIaCServer(), sdk.IaCServeOptions{})
}