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