diff --git a/README.md b/README.md index 14efcb6..7f9c389 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,22 @@ to avoid the "I forgot the key and lost my zone" failure mode. `wfctl secrets setup --plugin workflow-plugin-hover` prompts for each; sensitive fields are masked. +## Importing existing state + +The plugin supports read-only import for existing Hover domains: + +```sh +wfctl infra import --config infra.yaml --name example-com-dns --id example.com +wfctl infra import --config infra.yaml --name example-com-delegation --id example.com +``` + +Declare the target resource in config first so `wfctl` can resolve the Hover +provider and resource type. `infra.dns` imports the zone records returned by +Hover. `infra.dns_delegation` imports the current registrar nameservers. +Imported state is marked as adoption-shaped state so follow-up plans can +compare against live outputs without treating the imported record set as a +user-authored apply config. + ## TOTP In-process RFC 6238 (SHA-1, 30s step, 6 digits). The seed is decoded diff --git a/internal/iacserver.go b/internal/iacserver.go index 2474901..0eb8e2b 100644 --- a/internal/iacserver.go +++ b/internal/iacserver.go @@ -150,11 +150,15 @@ func (s *hoverIaCServer) Status(ctx context.Context, req *pb.StatusRequest) (*pb } func (s *hoverIaCServer) Import(ctx context.Context, req *pb.ImportRequest) (*pb.ImportResponse, error) { - _, err := s.provider.Import(ctx, req.GetProviderId(), req.GetResourceType()) + state, err := s.provider.Import(ctx, req.GetProviderId(), req.GetResourceType()) if err != nil { return nil, err } - return &pb.ImportResponse{}, nil + pbState, err := stateToPB(state) + if err != nil { + return nil, fmt.Errorf("hover iacserver: encode Import state: %w", err) + } + return &pb.ImportResponse{State: pbState}, nil } func (s *hoverIaCServer) ResolveSizing(_ context.Context, _ *pb.ResolveSizingRequest) (*pb.ResolveSizingResponse, error) { diff --git a/internal/iacserver_test.go b/internal/iacserver_test.go index a5154fb..79751c0 100644 --- a/internal/iacserver_test.go +++ b/internal/iacserver_test.go @@ -158,6 +158,59 @@ func TestHoverIaCServer_ResourceDriverReadAndDiff(t *testing.T) { } } +func TestHoverIaCServer_Import_ReturnsResourceState(t *testing.T) { + driver := &iacServerFakeDriver{ + readOut: &interfaces.ResourceOutput{ + Name: "example.com", + Type: "infra.dns", + ProviderID: "example.com", + Outputs: map[string]any{ + "domain": "example.com", + "records": []any{ + map[string]any{"type": "MX", "name": "@", "content": "mail.protonmail.ch", "ttl": 300}, + }, + }, + Status: "active", + }, + } + srv := &hoverIaCServer{ + provider: &HoverProvider{drivers: map[string]interfaces.ResourceDriver{ + "infra.dns": driver, + }}, + } + + resp, err := srv.Import(context.Background(), &pb.ImportRequest{ProviderId: "example.com"}) + if err != nil { + t.Fatalf("Import: %v", err) + } + state := resp.GetState() + if state == nil { + t.Fatal("Import state is nil") + } + if state.GetProvider() != "hover" { + t.Fatalf("Provider = %q, want hover", state.GetProvider()) + } + if state.GetType() != "infra.dns" { + t.Fatalf("Type = %q, want infra.dns", state.GetType()) + } + if state.GetProviderId() != "example.com" { + t.Fatalf("ProviderId = %q, want example.com", state.GetProviderId()) + } + if state.GetAppliedConfigSource() != "adoption" { + t.Fatalf("AppliedConfigSource = %q, want adoption", state.GetAppliedConfigSource()) + } + outputs, err := unmarshalJSONMap(state.GetOutputsJson()) + if err != nil { + t.Fatalf("outputs JSON: %v", err) + } + if outputs["domain"] != "example.com" { + t.Fatalf("outputs.domain = %v, want example.com", outputs["domain"]) + } + if driver.readRef != (interfaces.ResourceRef{Name: "example.com", Type: "infra.dns", ProviderID: "example.com"}) { + t.Fatalf("driver read ref = %+v, want provider-id import ref", driver.readRef) + } +} + func TestHoverIaCServer_Destroy_EmptyRefs(t *testing.T) { srv := NewIaCServer() // Destroy with zero refs is a no-op regardless of initialization state. diff --git a/internal/provider.go b/internal/provider.go index 6029e50..2310a27 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/GoCodeAlone/workflow-plugin-hover/internal/drivers" "github.com/GoCodeAlone/workflow-plugin-hover/internal/hover" @@ -222,9 +223,42 @@ func (p *HoverProvider) DetectDrift(ctx context.Context, resources []interfaces. 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") +// Import reads an existing Hover-managed resource and returns IaC adoption +// state. cloudID is the domain name for both infra.dns and infra.dns_delegation. +func (p *HoverProvider) Import(ctx context.Context, cloudID string, resourceType string) (*interfaces.ResourceState, error) { + if cloudID == "" { + return nil, fmt.Errorf("hover import: provider_id is required") + } + if resourceType == "" { + resourceType = "infra.dns" + } + d, err := p.ResourceDriver(resourceType) + if err != nil { + return nil, err + } + out, err := d.Read(ctx, interfaces.ResourceRef{Name: cloudID, Type: resourceType, ProviderID: cloudID}) + if err != nil { + return nil, fmt.Errorf("hover import %q: %w", cloudID, err) + } + if out == nil { + return nil, fmt.Errorf("hover import %q: driver returned nil output", cloudID) + } + now := time.Now() + id := out.ProviderID + if id == "" { + id = cloudID + } + return &interfaces.ResourceState{ + ID: id, + Name: out.Name, + Type: out.Type, + Provider: "hover", + ProviderID: id, + AppliedConfigSource: "adoption", + Outputs: out.Outputs, + CreatedAt: now, + UpdatedAt: now, + }, nil } // ResolveSizing is a stub: Hover has no compute sizing.