From f761d5a414238f90a3206f645523e72e6d41a853 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 11:45:47 -0400 Subject: [PATCH 1/6] feat: implement gRPC IaC ResourceDriver for Hover DNS provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the full typed IaC provider surface (pb.IaCProviderRequiredServer + pb.IaCProviderFinalizerServer + pb.IaCProviderDriftDetectorServer) for Hover DNS, modeled on workflow-plugin-digitalocean. Provider uses a browser-session client (CSRF + TOTP) since Hover has no official API. Key additions: - internal/hover/client.go: public Login method + conditional TOTP (skipped when account has MFA disabled; probed via /signin/totp GET) - internal/drivers/dns.go: ResourceDriver for infra.dns (Create/Read/ Update/Delete/Diff/HealthCheck); structpb-safe Outputs ([]any, not []hover.DNSRecord) - internal/provider.go: HoverProvider implementing interfaces.IaCProvider with platform.ComputePlan delegation - internal/iacserver.go: hoverIaCServer wiring pb RPCs → HoverProvider, ComputePlanVersion "v2", FinalizeApply no-op - go.mod: pin github.com/GoCodeAlone/workflow v0.57.1 - 42 tests, all passing; GOWORK=off go build ./... clean Hover API endpoints used (undocumented; derived from pjslauta/hover-dyn-dns): GET https://www.hover.com/api/domains//dns POST https://www.hover.com/api/dns PUT https://www.hover.com/api/dns/ DELETE https://www.hover.com/api/dns/ Co-Authored-By: Claude Sonnet 4.6 --- README.md | 14 +- go.mod | 152 ++++++++++ go.sum | 499 ++++++++++++++++++++++++++++++++ internal/drivers/dns.go | 418 +++++++++++++++++++++++++++ internal/drivers/dns_test.go | 326 +++++++++++++++++++++ internal/hover/client.go | 69 ++++- internal/hover/client_test.go | 254 +++++++++++++++- internal/iacserver.go | 524 ++++++++++++++++++++++++++++++++++ internal/iacserver_test.go | 113 ++++++++ internal/provider.go | 225 +++++++++++++++ internal/serve.go | 14 +- 11 files changed, 2573 insertions(+), 35 deletions(-) create mode 100644 go.sum create mode 100644 internal/drivers/dns.go create mode 100644 internal/drivers/dns_test.go create mode 100644 internal/iacserver.go create mode 100644 internal/iacserver_test.go create mode 100644 internal/provider.go diff --git a/README.md b/README.md index 0696aca..082b829 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,13 @@ 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 step 3. +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 +38,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 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..0585b39 --- /dev/null +++ b/internal/drivers/dns.go @@ -0,0 +1,418 @@ +// 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 { + 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 + } + if len(desiredRecs) == 0 { + return &interfaces.DiffResult{NeedsUpdate: false}, nil + } + + currentRecs, err := dnsRecordsFromOutput(current) + if err != nil { + return nil, err + } + + // Index current records by (type, name) for O(1) lookup. + 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] + if len(candidates) == 0 { + return &interfaces.DiffResult{NeedsUpdate: true}, nil + } + if candidates[0].Content != dr.Content { + return &interfaces.DiffResult{NeedsUpdate: true}, nil + } + currentByKey[key] = candidates[1:] + } + 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 + } + existing, err := d.client.ListRecords(ctx, domain) + if err != nil { + return fmt.Errorf("hover dns list records %q: %w", domain, err) + } + + existingByKey := make(map[string][]hover.DNSRecord) + for _, r := range existing { + key := recordKey(r.Type, r.Name) + existingByKey[key] = append(existingByKey[key], r) + } + + for _, dr := range desired { + key := recordKey(dr.Type, dr.Name) + candidates := existingByKey[key] + if len(candidates) > 0 { + ex := candidates[0] + existingByKey[key] = candidates[1:] + if ex.Content == dr.Content && (dr.TTL == 0 || ex.TTL == dr.TTL) { + continue // already matches + } + 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, domain, dr) + if err != nil { + return fmt.Errorf("hover dns create %s/%s %q: %w", dr.Type, dr.Name, domain, err) + } + if created != nil { + key2 := recordKey(created.Type, created.Name) + existingByKey[key2] = append(existingByKey[key2], *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, _ := optionalInt(m, "ttl") + 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 +} + +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 i, 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 + } + _ = i + 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..a3e42cb --- /dev/null +++ b/internal/drivers/dns_test.go @@ -0,0 +1,326 @@ +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 { + records []hover.DNSRecord + createErr error + updateErr error + deleteErr error + listErr error + nextID int +} + +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, _ string, rec hover.DNSRecord) (*hover.DNSRecord, error) { + if f.createErr != nil { + return nil, f.createErr + } + 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) + } +} diff --git a/internal/hover/client.go b/internal/hover/client.go index 7cc2526..3807104 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,25 @@ 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) — no _token found → MFA is not enabled; skip TOTP. +// - ("", false, err) — network or parse error. +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() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + m := csrfRe.FindSubmatch(body) + if len(m) < 2 { + // No CSRF token on the page — MFA is not enabled on this account. + return "", false, nil + } + return string(m[1]), true, nil } func (c *Client) fetchCSRF(ctx context.Context, urlStr string) (string, 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/iacserver.go b/internal/iacserver.go new file mode 100644 index 0000000..923a983 --- /dev/null +++ b/internal/iacserver.go @@ -0,0 +1,524 @@ +// 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). +// - Only the Required + Drift services are registered; Optional services +// that Hover cannot satisfy (state backend, sizing, migration repair, +// enumeration) are left as Unimplemented. +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..bf50838 --- /dev/null +++ b/internal/provider.go @@ -0,0 +1,225 @@ +// 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" + "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 removes DNS records for the given refs. +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 } + +func isNotFound(err error) bool { + if err == nil { + return false + } + 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..09d0948 100644 --- a/internal/serve.go +++ b/internal/serve.go @@ -1,11 +1,13 @@ // 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/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{}) } From 222aeb7d28f38091809de40dfd22cd71a8ece084 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 12:10:09 -0400 Subject: [PATCH 2/6] fix(dns): address PR #2 Copilot review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight findings; this commit addresses every substantive one: 1. (CRITICAL) DNSDriver.upsertRecords passed the apex domain NAME into hover.Client.CreateRecord, but Hover's POST /api/dns endpoint requires the hover-assigned `domain_id` (numeric ID). Live calls would have been rejected. Added a Client.GetDomain method that returns the full Domain struct including ID; upsertRecords now resolves the ID up front and passes it to CreateRecord. Bonus: GetDomain returns the embedded record set, so we drop the separate ListRecords round-trip inside upsertRecords. 2. Diff compared only Content, ignoring TTL. A TTL-only change in desired config would never trigger NeedsUpdate=true even though upsertRecords already supports applying a new TTL. Now: when the desired record specifies a TTL (>0), Diff also compares TTL. TTL == 0 still means "leave the existing TTL alone". 3. probeTOTPPage discarded HTTP status codes and io.ReadAll errors and treated everything as "no MFA". A redirect / login failure / Cloudflare gate / body-read error would have been silently misclassified, letting Login appear to succeed before failing on the next API call. Non-2xx + read errors are now surfaced; only a clean 200 with no _token is treated as "MFA disabled". 4. dnsRecordsFromOutput declared an index variable then discarded it with `_ = i`. Replaced with a bare `range` over the slice. 5. internal/serve.go godoc pointed to cmd/main.go; the actual entrypoint is cmd/workflow-plugin-hover/main.go. Updated. 6. README auth-flow step 3 said "skip step 3", but step 3 is the GET probe itself; only the POST submission is conditional. Reworded to "skip the TOTP submission (the GET probe itself still runs)". 7. (was: PR description / go.mod version mismatch — handled in PR description, no code change required.) 8. HoverProvider.Destroy godoc said "removes DNS records"; the driver Delete is a no-op (Hover has no zone-delete API). Updated the godoc to spell out the actual semantics: state is marked destroyed, upstream records remain. New tests: - TestUpsertRecords_UsesDomainIDNotName (regresses finding 1) - TestDiff_TTLChange_DetectedAsUpdate (regresses finding 2) fakeClient extended with GetDomain implementation + a `lastCreateDomainID` capture field for assertions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/superpowers-state/in-progress.jsonl | 1 + README.md | 3 +- internal/drivers/dns.go | 26 +++++-- internal/drivers/dns_test.go | 81 ++++++++++++++++++++- internal/hover/client.go | 54 +++++++++++++- internal/hover/totp_test.go | 4 +- internal/provider.go | 8 +- internal/serve.go | 3 +- 8 files changed, 163 insertions(+), 17 deletions(-) create mode 100644 .claude/superpowers-state/in-progress.jsonl diff --git a/.claude/superpowers-state/in-progress.jsonl b/.claude/superpowers-state/in-progress.jsonl new file mode 100644 index 0000000..89d7515 --- /dev/null +++ b/.claude/superpowers-state/in-progress.jsonl @@ -0,0 +1 @@ +{"ts":"2026-05-20T15:46:39Z","tool":"TaskUpdate","detail":"task_tool=TaskUpdate"} diff --git a/README.md b/README.md index 082b829..33edc12 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ 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 step 3. + - 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 1 hour. diff --git a/internal/drivers/dns.go b/internal/drivers/dns.go index 0585b39..308a1c8 100644 --- a/internal/drivers/dns.go +++ b/internal/drivers/dns.go @@ -26,6 +26,7 @@ import ( // 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 @@ -148,7 +149,14 @@ func (d *DNSDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, cur if len(candidates) == 0 { return &interfaces.DiffResult{NeedsUpdate: true}, nil } - if candidates[0].Content != dr.Content { + cur := candidates[0] + if cur.Content != dr.Content { + return &interfaces.DiffResult{NeedsUpdate: true}, nil + } + // 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' update guard). + if dr.TTL != 0 && cur.TTL != dr.TTL { return &interfaces.DiffResult{NeedsUpdate: true}, nil } currentByKey[key] = candidates[1:] @@ -191,13 +199,18 @@ func (d *DNSDriver) upsertRecords(ctx context.Context, domain string, desired [] if len(desired) == 0 { return nil } - existing, err := d.client.ListRecords(ctx, domain) + + // 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 list records %q: %w", domain, err) + return fmt.Errorf("hover dns resolve domain %q: %w", domain, err) } existingByKey := make(map[string][]hover.DNSRecord) - for _, r := range existing { + for _, r := range dom.Records { key := recordKey(r.Type, r.Name) existingByKey[key] = append(existingByKey[key], r) } @@ -215,7 +228,7 @@ func (d *DNSDriver) upsertRecords(ctx context.Context, domain string, desired [] return fmt.Errorf("hover dns update %s/%s %q: %w", dr.Type, dr.Name, domain, err) } } else { - created, err := d.client.CreateRecord(ctx, domain, dr) + 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) } @@ -366,7 +379,7 @@ func dnsRecordsFromOutput(out *interfaces.ResourceOutput) ([]hover.DNSRecord, er return nil, fmt.Errorf("hover dns: outputs.records: %w", err) } recs := make([]hover.DNSRecord, 0, len(items)) - for i, m := range items { + for _, m := range items { typ, _ := m["type"].(string) name, _ := m["name"].(string) content, _ := m["content"].(string) @@ -375,7 +388,6 @@ func dnsRecordsFromOutput(out *interfaces.ResourceOutput) ([]hover.DNSRecord, er if typ == "" || name == "" { continue } - _ = i recs = append(recs, hover.DNSRecord{ ID: id, Type: typ, diff --git a/internal/drivers/dns_test.go b/internal/drivers/dns_test.go index a3e42cb..7c0b92e 100644 --- a/internal/drivers/dns_test.go +++ b/internal/drivers/dns_test.go @@ -12,12 +12,28 @@ import ( // 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) { @@ -29,10 +45,11 @@ func (f *fakeClient) ListRecords(_ context.Context, _ string) ([]hover.DNSRecord return out, nil } -func (f *fakeClient) CreateRecord(_ context.Context, _ string, rec hover.DNSRecord) (*hover.DNSRecord, error) { +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) @@ -324,3 +341,65 @@ func TestDNSOutput_Structpb(t *testing.T) { 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") + } +} diff --git a/internal/hover/client.go b/internal/hover/client.go index 3807104..7c42501 100644 --- a/internal/hover/client.go +++ b/internal/hover/client.go @@ -140,8 +140,13 @@ func (c *Client) fetchSignInCSRF(ctx context.Context) (string, error) { // probeTOTPPage fetches /signin/totp and returns: // - (token, true, nil) — page contains a _token → MFA is enabled. -// - ("", false, nil) — no _token found → MFA is not enabled; skip TOTP. -// - ("", false, err) — network or parse error. +// - ("", 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) @@ -150,10 +155,17 @@ func (c *Client) probeTOTPPage(ctx context.Context) (string, bool, error) { return "", false, fmt.Errorf("hover: probe TOTP page: %w", err) } defer resp.Body.Close() - body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + 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 { - // No CSRF token on the page — MFA is not enabled on this account. + // Page loaded successfully but no CSRF token present — + // account does not have MFA enabled. return "", false, nil } return string(m[1]), true, nil @@ -210,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/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/provider.go b/internal/provider.go index bf50838..44e4234 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -112,7 +112,13 @@ func (p *HoverProvider) Plan(ctx context.Context, desired []interfaces.ResourceS return &plan, err } -// Destroy removes DNS records for the given refs. +// 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 { diff --git a/internal/serve.go b/internal/serve.go index 09d0948..266864b 100644 --- a/internal/serve.go +++ b/internal/serve.go @@ -5,7 +5,8 @@ import ( sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) -// Serve starts the Hover IaC plugin gRPC server. Called from cmd/main.go. +// 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() { From 053c38a6ec3b43ca54d4c159b5e2c3c75346156d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 12:10:20 -0400 Subject: [PATCH 3/6] chore: gitignore .claude/ state directory The superpowers hook writes a transient .claude/superpowers-state/in-progress.jsonl during agent sessions; it leaked into the prior commit. Ignore it going forward and untrack the existing file. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/superpowers-state/in-progress.jsonl | 1 - .gitignore | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .claude/superpowers-state/in-progress.jsonl create mode 100644 .gitignore diff --git a/.claude/superpowers-state/in-progress.jsonl b/.claude/superpowers-state/in-progress.jsonl deleted file mode 100644 index 89d7515..0000000 --- a/.claude/superpowers-state/in-progress.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"ts":"2026-05-20T15:46:39Z","tool":"TaskUpdate","detail":"task_tool=TaskUpdate"} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c5f206 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.claude/ From 1d8e0387b45e58a283561ee4dbabd2edc7dc3627 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 12:42:36 -0400 Subject: [PATCH 4/6] fix(dns): validate TTL + match multi-record diffs by multiset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2 round-2 Copilot findings: 1. recordFromMap previously discarded the error from optionalInt, so a string TTL ("300"), a non-integral float (300.5), or a negative value would silently coerce to 0 — unset — and let an apparently-typed config produce surprising diffs. Added optionalNonNegativeInt which rejects wrong-type, non-integral, and negative values with a typed error pointing at the offending records[N].ttl field. 2. Diff matched current records by (type, name) and consumed candidates[0] without checking Content. With multiple records sharing the same (type, name) — common for multi-A or multi-TXT on the apex — current and desired in different orders would falsely report NeedsUpdate. Replaced with multiset matching: each desired record consumes the first candidate whose Content (and TTL, when specified) match. 3. Three new tests: - TestDiff_MultipleARecords_OrderingDoesNotMatter - TestRecordFromMap_InvalidTTL_Rejected (negative) - TestRecordFromMap_NonIntegralTTL_Rejected (300.5) - TestRecordFromMap_StringTTL_Rejected (The go.mod / PR description version mismatch finding is addressed in the PR description, not code — go.mod's v0.60.8 is the intended pin.) Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/drivers/dns.go | 71 ++++++++++++++++++++++++++++------- internal/drivers/dns_test.go | 72 ++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 13 deletions(-) diff --git a/internal/drivers/dns.go b/internal/drivers/dns.go index 308a1c8..d0e877f 100644 --- a/internal/drivers/dns.go +++ b/internal/drivers/dns.go @@ -136,7 +136,12 @@ func (d *DNSDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, cur return nil, err } - // Index current records by (type, name) for O(1) lookup. + // 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) @@ -146,20 +151,26 @@ func (d *DNSDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, cur for _, dr := range desiredRecs { key := recordKey(dr.Type, dr.Name) candidates := currentByKey[key] - if len(candidates) == 0 { - return &interfaces.DiffResult{NeedsUpdate: true}, nil - } - cur := candidates[0] - if cur.Content != dr.Content { - return &interfaces.DiffResult{NeedsUpdate: true}, nil + 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 } - // 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' update guard). - if dr.TTL != 0 && cur.TTL != dr.TTL { + if idx < 0 { return &interfaces.DiffResult{NeedsUpdate: true}, nil } - currentByKey[key] = candidates[1:] + // Remove the matched candidate so subsequent desired records + // can't re-match it. + currentByKey[key] = append(candidates[:idx], candidates[idx+1:]...) } return &interfaces.DiffResult{NeedsUpdate: false}, nil } @@ -323,7 +334,10 @@ func recordFromMap(index int, m map[string]any) (hover.DNSRecord, error) { if err != nil { return hover.DNSRecord{}, err } - ttl, _ := optionalInt(m, "ttl") + ttl, err := optionalNonNegativeInt(m, "ttl", index) + if err != nil { + return hover.DNSRecord{}, err + } return hover.DNSRecord{ Type: strings.ToUpper(typ), Name: name, @@ -360,6 +374,37 @@ func optionalInt(m map[string]any, key string) (int, bool) { 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) } diff --git a/internal/drivers/dns_test.go b/internal/drivers/dns_test.go index 7c0b92e..c0b2aa5 100644 --- a/internal/drivers/dns_test.go +++ b/internal/drivers/dns_test.go @@ -403,3 +403,75 @@ func TestDiff_TTLChange_DetectedAsUpdate(t *testing.T) { 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") + } +} From 711068eca2e7e015979f74b1912658f51e6851f4 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 13:02:27 -0400 Subject: [PATCH 5/6] fix: Diff detects extras + DetectDrift via errors.Is sentinel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2 round-3 Copilot findings: 1. Diff only verified desired ⊆ current. Removing a record from config silently went undetected — Plan reported NoOp, then reality stayed drifted forever. Diff now: - returns NeedsUpdate=true when desired is empty but current has records; - after matching every desired record, sweeps for leftover unmatched current candidates and returns NeedsUpdate=true if any remain. Caveat documented inline: upsertRecords today does not prune extras (only adds/updates). The drift signal is still the right Plan output — operators want to see the discrepancy in the plan. Adding the prune path is a separate follow-up. 2. DetectDrift's isNotFound() used err.Error() substring matching to recognise missing resources. The driver wraps these with interfaces.ErrResourceNotFound, so errors.Is is the right check. Kept the substring fallback for raw Client errors that reach DetectDrift unwrapped, but the sentinel path is checked first. Tests: - TestDiff_ExtraCurrentRecord_DetectedAsUpdate - TestDiff_EmptyDesired_WithCurrentRecords_NeedsUpdate Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/drivers/dns.go | 30 ++++++++++++++++--- internal/drivers/dns_test.go | 56 ++++++++++++++++++++++++++++++++++++ internal/provider.go | 9 ++++++ 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/internal/drivers/dns.go b/internal/drivers/dns.go index d0e877f..89f32ec 100644 --- a/internal/drivers/dns.go +++ b/internal/drivers/dns.go @@ -127,15 +127,27 @@ func (d *DNSDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, cur if err != nil { return nil, err } - if len(desiredRecs) == 0 { - return &interfaces.DiffResult{NeedsUpdate: false}, nil - } - 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 @@ -172,6 +184,16 @@ func (d *DNSDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, cur // 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 README "Replace semantics" caveat). + for _, leftover := range currentByKey { + if len(leftover) > 0 { + return &interfaces.DiffResult{NeedsUpdate: true}, nil + } + } return &interfaces.DiffResult{NeedsUpdate: false}, nil } diff --git a/internal/drivers/dns_test.go b/internal/drivers/dns_test.go index c0b2aa5..7ccb829 100644 --- a/internal/drivers/dns_test.go +++ b/internal/drivers/dns_test.go @@ -475,3 +475,59 @@ func TestRecordFromMap_StringTTL_Rejected(t *testing.T) { 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/provider.go b/internal/provider.go index 44e4234..a75a1da 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -5,6 +5,7 @@ package internal import ( "context" + "errors" "fmt" "strings" @@ -222,10 +223,18 @@ func (p *HoverProvider) SupportedCanonicalKeys() []string { // 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") } From 8802e0361ff276c6aad2b9253092c3de356d5d83 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 13:23:47 -0400 Subject: [PATCH 6/6] fix(dns): two-pass upsert + clarify iacserver header + README link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2 round-4 Copilot findings: 1. upsertRecords matched candidates[0] when same-key candidates existed, even when one of the OTHER candidates was already an exact match. With multiple records sharing (type, name) — e.g., two A records on the apex — apply could update record A to record B's content, producing a transient state with two identical records. Split into two passes: - Pass 1: skip-on-match. Every desired record that already has an exact (Content + TTL-when-specified) match upstream consumes that candidate and is treated as no-op. - Pass 2: update-or-create. Remaining desired records consume the next leftover candidate at the same key as an Update target, or fall through to Create if none remain. This matches the multiset semantics introduced in round-3 for Diff and prevents the transient-duplicate state. 2. Diff comment referenced a README "Replace semantics" caveat that didn't exist. Added a "Limitations" section to README covering the no-prune-on-apply gap (and the no-zone-delete caveat); updated the comment to point there. 3. iacserver.go header claimed "Only the Required + Drift services are registered". Misleading — sdk.ServeIaCPlugin registers every service for which the struct embeds an Unimplemented server, and hoverIaCServer embeds the full set. Replaced the line with an accurate statement: every service IS registered; unimplemented methods return typed Unimplemented gRPC status, which is the cross-plugin-uniform shape (clients must check capabilities before calling optional methods). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 12 ++++++++++++ internal/drivers/dns.go | 43 +++++++++++++++++++++++++++++++++++------ internal/iacserver.go | 14 +++++++++++--- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 33edc12..2c23738 100644 --- a/README.md +++ b/README.md @@ -72,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/internal/drivers/dns.go b/internal/drivers/dns.go index 89f32ec..b3d1b7f 100644 --- a/internal/drivers/dns.go +++ b/internal/drivers/dns.go @@ -188,7 +188,7 @@ func (d *DNSDriver) Diff(_ context.Context, desired interfaces.ResourceSpec, cur // 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 README "Replace semantics" caveat). + // separate follow-up; see the "Limitations" section of README.md). for _, leftover := range currentByKey { if len(leftover) > 0 { return &interfaces.DiffResult{NeedsUpdate: true}, nil @@ -248,15 +248,47 @@ func (d *DNSDriver) upsertRecords(ctx context.Context, domain string, desired [] 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 ex.Content == dr.Content && (dr.TTL == 0 || ex.TTL == dr.TTL) { - continue // already matches - } 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) } @@ -266,8 +298,7 @@ func (d *DNSDriver) upsertRecords(ctx context.Context, domain string, desired [] return fmt.Errorf("hover dns create %s/%s %q: %w", dr.Type, dr.Name, domain, err) } if created != nil { - key2 := recordKey(created.Type, created.Name) - existingByKey[key2] = append(existingByKey[key2], *created) + existingByKey[key] = append(existingByKey[key], *created) } } } diff --git a/internal/iacserver.go b/internal/iacserver.go index 923a983..af698cf 100644 --- a/internal/iacserver.go +++ b/internal/iacserver.go @@ -9,9 +9,17 @@ // - 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). -// - Only the Required + Drift services are registered; Optional services -// that Hover cannot satisfy (state backend, sizing, migration repair, -// enumeration) are left as Unimplemented. +// - 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 (