Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions internal/iacserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,40 @@ func (s *hoverIaCServer) FinalizeApply(_ context.Context, _ *pb.FinalizeApplyReq
return &pb.FinalizeApplyResponse{}, nil
}

// EnumerateAll satisfies pb.IaCProviderEnumeratorServer.EnumerateAll. Mirrors
// the Go-level interfaces.EnumeratorAll on *HoverProvider so the wfctl
// `infra import-all` path can list every Hover-registered zone in one
// account-level round-trip.
func (s *hoverIaCServer) EnumerateAll(ctx context.Context, req *pb.EnumerateAllRequest) (*pb.EnumerateAllResponse, error) {
outs, err := s.provider.EnumerateAll(ctx, req.GetResourceType())
if err != nil {
return nil, err
}
pbOuts := make([]*pb.ResourceOutput, 0, len(outs))
for _, o := range outs {
if o == nil {
continue
}
outputsJSON, err := json.Marshal(o.Outputs)
if err != nil {
return nil, fmt.Errorf("hover iacserver: encode EnumerateAll outputs: %w", err)
}
sensitive := make(map[string]bool, len(o.Sensitive))
for k, v := range o.Sensitive {
sensitive[k] = v
}
pbOuts = append(pbOuts, &pb.ResourceOutput{
Name: o.Name,
Type: o.Type,
ProviderId: o.ProviderID,
OutputsJson: outputsJSON,
Sensitive: sensitive,
Status: o.Status,
})
}
return &pb.EnumerateAllResponse{Outputs: pbOuts}, nil
}

// ── Drift detection ───────────────────────────────────────────────────────────

func (s *hoverIaCServer) DetectDrift(ctx context.Context, req *pb.DetectDriftRequest) (*pb.DetectDriftResponse, error) {
Expand Down
81 changes: 81 additions & 0 deletions internal/iacserver_live_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//go:build live_dns

// Env-gated live integration coverage for EnumerateAll("infra.dns").
//
// Run with:
//
// INFRA_DNS_ENUMERATE_LIVE=1 \
// HOVER_USERNAME=$USER \
// HOVER_PASSWORD=$PASS \
// GOWORK=off go test -tags live_dns \
// -run TestHoverProvider_EnumerateAll_DNS_live ./internal/...
//
// HOVER_TOTP_SECRET is optional; supply it when the test account has MFA
// enabled. Per docs/plans/2026-05-26-dns-provider-contract.md PR 4 (Task 13).
package internal

import (
"context"
"os"
"testing"

"github.com/GoCodeAlone/workflow-plugin-hover/pkg/hoverclient"
)

// newLiveHoverProvider builds a HoverProvider whose `domains` field is
// wired to the production *hoverclient.Client. Credentials come from
// HOVER_USERNAME + HOVER_PASSWORD (+ optional HOVER_TOTP_SECRET); the
// helper aborts the test (t.Fatal) when required env is missing so the
// live-only run is loud rather than silent.
func newLiveHoverProvider(t *testing.T) *HoverProvider {
t.Helper()
user := os.Getenv("HOVER_USERNAME")
pass := os.Getenv("HOVER_PASSWORD")
if user == "" || pass == "" {
t.Fatal("HOVER_USERNAME + HOVER_PASSWORD must be set for live EnumerateAll test")
}
var totpSecret hoverclient.TOTPSecret
if totpRaw := os.Getenv("HOVER_TOTP_SECRET"); totpRaw != "" {
ts, err := hoverclient.ParseBase32(totpRaw)
if err != nil {
t.Fatalf("invalid HOVER_TOTP_SECRET: %v", err)
}
totpSecret = ts
}
creds := hoverclient.Credentials{
Username: user,
Password: pass,
TOTPSecret: totpSecret,
}
c, err := hoverclient.NewClient(creds, nil)
if err != nil {
t.Fatalf("hoverclient.NewClient: %v", err)
}
return &HoverProvider{client: c, domains: c}
}

func TestHoverProvider_EnumerateAll_DNS_live(t *testing.T) {
if os.Getenv("INFRA_DNS_ENUMERATE_LIVE") != "1" {
t.Skip("set INFRA_DNS_ENUMERATE_LIVE=1 + HOVER_USERNAME + HOVER_PASSWORD to run")
}
p := newLiveHoverProvider(t)
out, err := p.EnumerateAll(context.Background(), "infra.dns")
if err != nil {
t.Fatalf("live EnumerateAll: %v", err)
}
if len(out) == 0 {
t.Skip("account has zero domains; cannot validate")
}
for _, o := range out {
if o.ProviderID == "" {
t.Errorf("empty ProviderID for %+v", o.Outputs)
}
if o.Type != "infra.dns" {
t.Errorf("wrong Type %q", o.Type)
}
if _, ok := o.Outputs["zone"]; !ok {
t.Errorf("missing zone output: %+v", o.Outputs)
}
}
t.Logf("enumerated %d hover domains", len(out))
}
43 changes: 43 additions & 0 deletions internal/iacserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package internal

import (
"context"
"encoding/json"
"testing"

"github.com/GoCodeAlone/workflow-plugin-hover/pkg/hoverclient"
"github.com/GoCodeAlone/workflow/interfaces"
pb "github.com/GoCodeAlone/workflow/plugin/external/proto"
)
Expand Down Expand Up @@ -261,3 +263,44 @@ func (d *iacServerFakeDriver) Scale(_ context.Context, ref interfaces.ResourceRe
}

func (d *iacServerFakeDriver) SensitiveKeys() []string { return nil }

// ── EnumerateAll gRPC coverage ─────────────────────────────────────────────

// TestHoverIaCServer_EnumerateAll_DNS exercises the typed gRPC surface
// (hoverIaCServer.EnumerateAll). The SDK auto-registers this service at
// plugin startup because hoverIaCServer embeds the Unimplemented*Enumerator
// stub and overrides EnumerateAll. This test confirms the proto<->Go
// marshalling round-trips zone + domain_id outputs.
func TestHoverIaCServer_EnumerateAll_DNS(t *testing.T) {
srv := &hoverIaCServer{
provider: &HoverProvider{
domains: &fakeHoverClient{
domains: []hoverclient.Domain{
{ID: "dom-1", Name: "alpha.test"},
{ID: "dom-2", Name: "beta.test"},
},
},
},
}
resp, err := srv.EnumerateAll(context.Background(), &pb.EnumerateAllRequest{ResourceType: "infra.dns"})
if err != nil {
t.Fatalf("EnumerateAll: %v", err)
}
if len(resp.GetOutputs()) != 2 {
t.Fatalf("want 2 outputs; got %d", len(resp.GetOutputs()))
}
first := resp.GetOutputs()[0]
if first.GetProviderId() != "alpha.test" {
t.Errorf("providerID = %q; want alpha.test", first.GetProviderId())
}
if first.GetType() != "infra.dns" {
t.Errorf("type = %q; want infra.dns", first.GetType())
}
var outputs map[string]any
if err := json.Unmarshal(first.GetOutputsJson(), &outputs); err != nil {
t.Fatalf("unmarshal outputs: %v", err)
}
if outputs["zone"] != "alpha.test" || outputs["domain_id"] != "dom-1" {
t.Errorf("outputs = %#v", outputs)
}
}
51 changes: 51 additions & 0 deletions internal/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,25 @@ import (
// Version is set at build time via -ldflags.
var Version = "0.0.0"

// hoverDomainLister is the minimal account-level surface EnumerateAll needs.
// *hoverclient.Client satisfies this interface via the ListDomains method
// added in pkg/hoverclient; the test fake (fakeHoverClient) satisfies it
// the same way without spinning up the real login flow.
type hoverDomainLister interface {
ListDomains(ctx context.Context) ([]hoverclient.Domain, error)
}

// HoverProvider implements interfaces.IaCProvider for Hover.
// Supports two resource types:
// - infra.dns — DNS records within Hover's nameservers.
// - infra.dns_delegation — registrar-level nameserver delegation.
type HoverProvider struct {
client *hoverclient.Client
drivers map[string]interfaces.ResourceDriver
// domains is the injected account-level domain lister used by EnumerateAll.
// Defaults to client (which now satisfies hoverDomainLister); tests
// override with a fakeHoverClient.
domains hoverDomainLister
}

var _ interfaces.IaCProvider = (*HoverProvider)(nil)
Expand Down Expand Up @@ -78,6 +90,7 @@ func (p *HoverProvider) Initialize(ctx context.Context, config map[string]any) e
}

p.client = c
p.domains = c
p.drivers = map[string]interfaces.ResourceDriver{
"infra.dns": drivers.NewDNSDriver(c),
"infra.dns_delegation": drivers.NewDelegationDriver(c),
Expand Down Expand Up @@ -280,6 +293,44 @@ func (p *HoverProvider) SupportedCanonicalKeys() []string {
// Close is a no-op; the HTTP client has no persistent connections to tear down.
func (p *HoverProvider) Close() error { return nil }

// EnumerateAll implements interfaces.EnumeratorAll for resource type
// "infra.dns". Walks the account's zones via the injected hoverDomainLister
// (production wraps *hoverclient.Client.ListDomains — added in pkg/hoverclient
// for the cross-repo cascade). Each *ResourceOutput carries the zone name +
// hover-assigned domain_id so the downstream IaCProvider.Import path can
// adopt the zone without re-querying the account list.
//
// Domains with empty Name are dropped rather than emitted with empty
// ProviderID — guards against bogus state-store entries if the Hover
// account-page ever returns a malformed row.
func (p *HoverProvider) EnumerateAll(ctx context.Context, resourceType string) ([]*interfaces.ResourceOutput, error) {
if p.domains == nil {
return nil, fmt.Errorf("hover: EnumerateAll called on provider that is not initialized — call Initialize first")
}
if resourceType != "infra.dns" {
return nil, fmt.Errorf("hover: EnumerateAll: resource type %q not supported", resourceType)
}
domains, err := p.domains.ListDomains(ctx)
if err != nil {
return nil, fmt.Errorf("hover: EnumerateAll infra.dns: %w", err)
}
out := make([]*interfaces.ResourceOutput, 0, len(domains))
for _, d := range domains {
if d.Name == "" {
continue
}
out = append(out, &interfaces.ResourceOutput{
ProviderID: d.Name,
Type: "infra.dns",
Outputs: map[string]any{
"zone": d.Name,
"domain_id": d.ID,
},
})
}
return out, 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
Expand Down
88 changes: 88 additions & 0 deletions internal/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"sync/atomic"
"testing"

"github.com/GoCodeAlone/workflow-plugin-hover/pkg/hoverclient"
"github.com/GoCodeAlone/workflow/interfaces"
)

Expand Down Expand Up @@ -111,3 +112,90 @@ func (d *noopCreateDriver) Scale(context.Context, interfaces.ResourceRef, int) (
func (d *noopCreateDriver) SensitiveKeys() []string {
return nil
}

// ── EnumerateAll(infra.dns) coverage ────────────────────────────────────────

// fakeHoverClient is a slice-backed hoverDomainLister used to drive
// EnumerateAll tests without touching the real hoverclient.Client (which
// requires a live login flow).
type fakeHoverClient struct {
domains []hoverclient.Domain
err error
calls int
}

func (f *fakeHoverClient) ListDomains(_ context.Context) ([]hoverclient.Domain, error) {
f.calls++
if f.err != nil {
return nil, f.err
}
return f.domains, nil
}

func TestHoverProvider_EnumerateAll_DNS(t *testing.T) {
stub := &fakeHoverClient{
domains: []hoverclient.Domain{
// hoverclient.Domain.Name is the Go field (json tag is "domain_name").
{ID: "dom-1", Name: "alpha.test"},
{ID: "dom-2", Name: "beta.test"},
},
}
p := &HoverProvider{domains: stub}
out, err := p.EnumerateAll(context.Background(), "infra.dns")
if err != nil {
t.Fatalf("EnumerateAll: %v", err)
}
if len(out) != 2 {
t.Fatalf("want 2; got %d", len(out))
}
if out[0].ProviderID != "alpha.test" {
t.Errorf("providerID[0] = %q; want alpha.test", out[0].ProviderID)
}
if out[0].Type != "infra.dns" {
t.Errorf("type[0] = %q; want infra.dns", out[0].Type)
}
if out[0].Outputs["zone"] != "alpha.test" {
t.Errorf("zone[0] = %v", out[0].Outputs["zone"])
}
if out[0].Outputs["domain_id"] != "dom-1" {
t.Errorf("domain_id[0] = %v", out[0].Outputs["domain_id"])
}
if stub.calls != 1 {
t.Errorf("ListDomains called %d times; want 1", stub.calls)
}
}

func TestHoverProvider_EnumerateAll_DNS_uninitialized(t *testing.T) {
p := &HoverProvider{}
_, err := p.EnumerateAll(context.Background(), "infra.dns")
if err == nil {
t.Fatalf("want uninitialized error; got nil")
}
}

func TestHoverProvider_EnumerateAll_DNS_unsupportedType(t *testing.T) {
p := &HoverProvider{domains: &fakeHoverClient{}}
_, err := p.EnumerateAll(context.Background(), "infra.compute")
if err == nil {
t.Fatalf("want unsupported-type error; got nil")
}
}

// TestHoverProvider_EnumerateAll_DNS_skipsBlankName ensures zones with empty
// Name strings are dropped rather than emitted with empty ProviderID.
func TestHoverProvider_EnumerateAll_DNS_skipsBlankName(t *testing.T) {
stub := &fakeHoverClient{
domains: []hoverclient.Domain{
{ID: "dom-empty", Name: ""},
{ID: "dom-real", Name: "real.test"},
},
}
p := &HoverProvider{domains: stub}
out, err := p.EnumerateAll(context.Background(), "infra.dns")
if err != nil {
t.Fatalf("EnumerateAll: %v", err)
}
if len(out) != 1 || out[0].ProviderID != "real.test" {
t.Fatalf("want 1 entry with ProviderID=real.test; got %+v", out)
}
}
Loading