diff --git a/go.mod b/go.mod index 225d7e3..345757b 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.7 github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 github.com/joho/godotenv v1.5.1 - github.com/luthersystems/insideout-terraform-presets v0.11.1-0.20260605233704-4ab729343696 + github.com/luthersystems/insideout-terraform-presets v0.11.1-0.20260609165415-4669c80c4789 github.com/sosedoff/ansible-vault-go v0.2.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index 20033ef..bc9e55a 100644 --- a/go.sum +++ b/go.sum @@ -218,8 +218,8 @@ 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/luthersystems/insideout-terraform-presets v0.11.1-0.20260605233704-4ab729343696 h1:esEOIEXbMdeN+x5jUiJg84auuPY9xRx67ck3sxQ782k= -github.com/luthersystems/insideout-terraform-presets v0.11.1-0.20260605233704-4ab729343696/go.mod h1:fyI1Jx4oP2pDH+BChbS2F7M07nPAr4AF23K9Ll+AlrA= +github.com/luthersystems/insideout-terraform-presets v0.11.1-0.20260609165415-4669c80c4789 h1:KOhCFSbYArhGRDMJfkNt7B4D11fk5RQy8iDappSTyrw= +github.com/luthersystems/insideout-terraform-presets v0.11.1-0.20260609165415-4669c80c4789/go.mod h1:fyI1Jx4oP2pDH+BChbS2F7M07nPAr4AF23K9Ll+AlrA= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= diff --git a/internal/reverseimportjob/job.go b/internal/reverseimportjob/job.go index 1c6035b..dfaad4b 100644 --- a/internal/reverseimportjob/job.go +++ b/internal/reverseimportjob/job.go @@ -9,9 +9,12 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "time" + "github.com/luthersystems/insideout-terraform-presets/cmd/insideout-import/reversedisco" + "github.com/luthersystems/insideout-terraform-presets/pkg/composer/imported" "github.com/luthersystems/insideout-terraform-presets/pkg/reverseimport" reversejob "github.com/luthersystems/insideout-terraform-presets/pkg/reverseimport/job" ) @@ -23,6 +26,95 @@ const ( type Runner func(context.Context, reversejob.Request, reverseimport.Options) (reversejob.Result, error) +// discovererFactory builds the closure-capable cloud discoverer the +// reverse-import engine needs for selection-closure expansion and dep-chase. +// It mirrors reversedisco.New; the package var lets tests inject a fake so +// they can assert the engine Options are wired with a non-nil discoverer +// without standing up real cloud clients. +// +// luthersystems/mars#195: before this seam existed, Main never set +// Options.Discoverer or Options.ClosureDiscoverer, so the engine emitted the +// selection_closure_unavailable diagnostic and silently skipped closure + +// dep-chase. +type discovererFactory func(ctx context.Context, cloud, region, gcpProjectID, awsEndpointURL string) (reverseimport.Discoverer, func(), error) + +// newDiscoverer is the production discoverer constructor. Tests override it to +// inject a fake; production code never reassigns it. +var newDiscoverer discovererFactory = reversedisco.New + +// lazyDiscoverer defers constructing the real cloud discoverer until the engine +// first needs it (selection-closure expansion or dep-chase), which happens +// after the engine's cheap selection validation. It implements both +// reverseimport.Discoverer and reverseimport.ClosureDiscoverer so the engine +// treats it as closure-capable, yet building it costs nothing until a method is +// called — so a credential/API gap surfaces only on the closure path and does +// not mask the engine's structured validation result for an invalid selection +// (luthersystems/mars#195). +// +// Not safe for concurrent first-use; the engine drives closure then dep-chase +// sequentially within a single run, so this is sufficient. +type lazyDiscoverer struct { + newDiscoverer discovererFactory + cloud string + region string + gcpProjectID string + awsEndpointURL string + + inner reverseimport.Discoverer + cleanup func() + err error + built bool +} + +// resolve builds the underlying discoverer once, caching the result (and any +// construction error) for subsequent calls. +func (l *lazyDiscoverer) resolve(ctx context.Context) (reverseimport.Discoverer, error) { + if !l.built { + l.built = true + l.inner, l.cleanup, l.err = l.newDiscoverer(ctx, l.cloud, l.region, l.gcpProjectID, l.awsEndpointURL) + if l.err != nil { + l.err = fmt.Errorf("build closure discoverer: %w", l.err) + } + } + return l.inner, l.err +} + +func (l *lazyDiscoverer) DiscoverByID(ctx context.Context, tfType, id, region, accountID string) (imported.ImportedResource, error) { + d, err := l.resolve(ctx) + if err != nil { + return imported.ImportedResource{}, err + } + return d.DiscoverByID(ctx, tfType, id, region, accountID) +} + +func (l *lazyDiscoverer) DiscoverClosure(ctx context.Context, req reverseimport.ClosureRequest) ([]imported.ImportedResource, error) { + d, err := l.resolve(ctx) + if err != nil { + return nil, err + } + closer, ok := d.(reverseimport.ClosureDiscoverer) + if !ok { + // reversedisco.New always returns a closure-capable adapter; this guards + // a future factory that does not. + return nil, fmt.Errorf("discoverer %T does not support closure discovery", d) + } + return closer.DiscoverClosure(ctx, req) +} + +// Close releases the underlying discoverer's resources if it was ever built. +func (l *lazyDiscoverer) Close() { + if l.cleanup != nil { + l.cleanup() + } +} + +// Compile-time proof that lazyDiscoverer satisfies both engine surfaces, so the +// engine never falls back to the selection_closure_unavailable diagnostic. +var ( + _ reverseimport.Discoverer = (*lazyDiscoverer)(nil) + _ reverseimport.ClosureDiscoverer = (*lazyDiscoverer)(nil) +) + func Main(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer, run Runner) int { if run == nil { run = reverseimport.Run @@ -39,17 +131,65 @@ func Main(ctx context.Context, args []string, stdout io.Writer, stderr io.Writer return 1 } + // Hand the engine a closure-capable cloud discoverer. Without it the engine + // emits the selection_closure_unavailable diagnostic and skips both + // dependency-closure expansion (the "auto-included N dependencies" behavior) + // and dep-chase. The discoverer reuses the same cloud/region/endpoint config + // the run already targets, so it talks to the same account/project the + // import reads back. See luthersystems/mars#195. + // + // The cloud/region/GCP-project flags are optional overrides; when empty we + // derive them from the request the same way the engine does + // (reverseimport.Run derives cloud from resources[0].Identity), so the + // discoverer is built for the right provider even when the dispatcher omits + // the flags. + // + // Construction is lazy: the real cloud client (e.g. the GCP Cloud Asset gRPC + // client, which needs ADC + an enabled API) is built on first use by the + // engine's closure/dep-chase phases, which run AFTER the engine's cheap + // selection validation (e.g. the InsideOutImported rejection). Eagerly + // dialing the cloud here would let a credential/API gap mask the structured + // validation result the engine produces for an invalid selection. + cloud := firstNonEmpty(cfg.cloud, requestCloud(req)) + region := firstNonEmpty(cfg.region, requestRegion(req)) + gcpProjectID := firstNonEmpty(cfg.gcpProjectID, requestGCPProjectID(req)) + discoverer := &lazyDiscoverer{ + newDiscoverer: newDiscoverer, + cloud: cloud, + region: region, + gcpProjectID: gcpProjectID, + awsEndpointURL: cfg.awsEndpointURL, + } + defer discoverer.Close() + result, err := run(ctx, req, reverseimport.Options{ - OutputDir: cfg.outputDir, - Workdir: cfg.workDir, - Cloud: cfg.cloud, - Region: cfg.region, - GCPProjectID: cfg.gcpProjectID, + OutputDir: cfg.outputDir, + Workdir: cfg.workDir, + // Pass the request-derived cloud context (not just the raw flags) so the + // engine and the discoverer built above agree on provider/region. The + // engine derives these the same way when they are empty, so this is + // purely making the two paths consistent. + Cloud: cloud, + Region: region, + GCPProjectID: gcpProjectID, + // DiscoverRegions scopes selection-closure discovery. For a + // multi-region selection (no --region override) it carries every + // distinct region across the selected resources so closure discovery + // scans them all; the engine falls back to Region only when this is + // empty. Mirrors the CLI reverse path. + DiscoverRegions: requestRegions(req, cfg.region), AWSEndpointURL: cfg.awsEndpointURL, ImportProjectID: cfg.importProjectID, ImportSessionID: cfg.importSessionID, ImportedAt: time.Now().UTC(), TerraformBinary: cfg.terraformBinary, + // The concrete discoverer implements both reverseimport.Discoverer + // (dep-chase, DiscoverByID) and reverseimport.ClosureDiscoverer + // (selection-closure expansion, DiscoverClosure); the engine resolves + // the closure surface from Options.Discoverer when it is + // closure-capable (pkg/reverseimport/closure.go), so setting Discoverer + // wires both. + Discoverer: discoverer, // Stream the engine's live phase progress + terraform subprocess // output to the job's stdout so Oracle's follow=1 stream (and the // InsideOut import wizard's log console) shows continuous progress @@ -146,6 +286,76 @@ func readRequest(path string) (reversejob.Request, error) { return req, nil } +// firstNonEmpty returns the first non-blank string in order, or "". +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" +} + +// requestCloud / requestRegion / requestGCPProjectID derive the provider +// context from the selected resources when the corresponding CLI flag is +// omitted, mirroring how reverseimport.Run derives cloud from the resource +// identities. The first resource carrying a non-empty value wins. +func requestCloud(req reversejob.Request) string { + for _, r := range req.Resources { + if c := strings.TrimSpace(r.Identity.Cloud); c != "" { + return c + } + } + return "" +} + +func requestRegion(req reversejob.Request) string { + for _, r := range req.Resources { + if region := strings.TrimSpace(r.Identity.Region); region != "" { + return region + } + } + return "" +} + +func requestGCPProjectID(req reversejob.Request) string { + for _, r := range req.Resources { + if p := strings.TrimSpace(r.Identity.ProjectID); p != "" { + return p + } + } + return "" +} + +// requestRegions returns the set of regions closure discovery should scan, +// mirroring the CLI's reverse path (cmd/insideout-import/reverse.go). When the +// caller passes a single --region override it wins (the run targets one +// region); otherwise we collect every distinct region across the selected +// resources so closure discovery for a multi-region selection scans them all +// rather than just the first. The engine falls back to opts.Region only when +// this slice is empty, so a multi-region request without an override would +// otherwise silently discover children in just one region. +func requestRegions(req reversejob.Request, override string) []string { + if o := strings.TrimSpace(override); o != "" { + return []string{o} + } + seen := map[string]struct{}{} + var out []string + for _, r := range req.Resources { + region := strings.TrimSpace(r.Identity.Region) + if region == "" { + continue + } + if _, ok := seen[region]; ok { + continue + } + seen[region] = struct{}{} + out = append(out, region) + } + sort.Strings(out) + return out +} + func ensureFailureResult(outputDir string, result reversejob.Result, runErr error) error { if strings.TrimSpace(outputDir) == "" { return nil diff --git a/internal/reverseimportjob/job_test.go b/internal/reverseimportjob/job_test.go index 4d71961..1b3f9f4 100644 --- a/internal/reverseimportjob/job_test.go +++ b/internal/reverseimportjob/job_test.go @@ -17,6 +17,36 @@ import ( reversejob "github.com/luthersystems/insideout-terraform-presets/pkg/reverseimport/job" ) +// fakeClosureDiscoverer is a stub cloud discoverer used by unit tests that +// drive Main without standing up real AWS/GCP clients. It implements both +// reverseimport.Discoverer (dep-chase) and reverseimport.ClosureDiscoverer +// (selection-closure expansion), so when Main wires it onto the engine +// Options the engine does NOT emit the selection_closure_unavailable +// diagnostic (luthersystems/mars#195). +type fakeClosureDiscoverer struct{} + +func (fakeClosureDiscoverer) DiscoverByID(context.Context, string, string, string, string) (imported.ImportedResource, error) { + return imported.ImportedResource{}, nil +} + +func (fakeClosureDiscoverer) DiscoverClosure(context.Context, reverseimport.ClosureRequest) ([]imported.ImportedResource, error) { + return nil, nil +} + +// useFakeDiscoverer swaps the package-level production discoverer factory for +// a fake that never touches the cloud, restoring the original when the test +// ends. Tests that exercise Main's request/Options plumbing (rather than the +// discoverer wiring specifically) use this so the empty/arbitrary --cloud +// values they pass don't trip reversedisco.New's unknown-cloud guard. +func useFakeDiscoverer(t *testing.T) { + t.Helper() + orig := newDiscoverer + newDiscoverer = func(context.Context, string, string, string, string) (reverseimport.Discoverer, func(), error) { + return fakeClosureDiscoverer{}, func() {}, nil + } + t.Cleanup(func() { newDiscoverer = orig }) +} + func TestMainRequiresRequest(t *testing.T) { var stdout, stderr bytes.Buffer @@ -86,6 +116,9 @@ func TestMainDecodesRequestAndPassesOptions(t *testing.T) { if gotOpts.Cloud != "aws" || gotOpts.Region != "us-west-2" { t.Fatalf("cloud/region = %q/%q", gotOpts.Cloud, gotOpts.Region) } + if len(gotOpts.DiscoverRegions) != 1 || gotOpts.DiscoverRegions[0] != "us-west-2" { + t.Fatalf("DiscoverRegions = %v, want [us-west-2]", gotOpts.DiscoverRegions) + } if gotOpts.AWSEndpointURL != "http://localhost:4566" { t.Fatalf("AWSEndpointURL = %q", gotOpts.AWSEndpointURL) } @@ -108,12 +141,262 @@ func TestMainDecodesRequestAndPassesOptions(t *testing.T) { if gotOpts.Stdout != io.Writer(&stdout) { t.Fatalf("Options.Stdout = %v, want the job stdout writer", gotOpts.Stdout) } + // The closure-capable cloud discoverer must be wired onto the engine + // Options — otherwise the engine emits selection_closure_unavailable and + // skips dependency-closure expansion + dep-chase (luthersystems/mars#195). + // This case uses the real production newDiscoverer (cloud=aws, which only + // loads SDK config, no network), so it exercises the production wiring. + if gotOpts.Discoverer == nil { + t.Fatal("Options.Discoverer = nil, want a closure-capable cloud discoverer") + } + if _, ok := gotOpts.Discoverer.(reverseimport.ClosureDiscoverer); !ok { + t.Fatalf("Options.Discoverer %T does not implement reverseimport.ClosureDiscoverer; "+ + "the engine would emit selection_closure_unavailable", gotOpts.Discoverer) + } if !strings.Contains(stdout.String(), "reverse import succeeded: 1 imported, 0 add, 0 change, 0 destroy") { t.Fatalf("stdout = %q", stdout.String()) } } +// TestMainWiresClosureDiscovererForRegisteredParents is the regression guard +// for luthersystems/mars#195. The reverse-import engine only runs +// selection-closure expansion + dep-chase when Options.ClosureDiscoverer is +// set or Options.Discoverer is itself closure-capable; otherwise it emits the +// selection_closure_unavailable diagnostic and silently skips both +// (pkg/reverseimport/closure.go). Before the fix, Main set neither field. +// +// We select a registered parent type (aws_kms_key has registered child +// aws_kms_alias in the presets labels registry), then assert Main passes the +// engine a non-nil Discoverer that implements reverseimport.ClosureDiscoverer +// — exactly the condition the engine checks before deciding whether to expand +// the closure. With the real production newDiscoverer (cloud=aws), this proves +// the wiring is present end to end without standing up real AWS or terraform. +func TestMainWiresClosureDiscovererForRegisteredParents(t *testing.T) { + dir := t.TempDir() + requestPath := filepath.Join(dir, "request.json") + req := reversejob.Request{ + Version: reversejob.Version, + Resources: []reversejob.ResourceSpec{{ + Identity: imported.ResourceIdentity{ + Cloud: "aws", + Type: "aws_kms_key", + Address: "aws_kms_key.example", + Region: "us-west-2", + ImportID: "1234abcd-12ab-34cd-56ef-1234567890ab", + }, + }}, + } + body, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(requestPath, body, 0o644); err != nil { + t.Fatal(err) + } + + var gotOpts reverseimport.Options + run := func(_ context.Context, _ reversejob.Request, opts reverseimport.Options) (reversejob.Result, error) { + gotOpts = opts + return reversejob.Result{Version: reversejob.Version, Status: reversejob.StatusSucceeded}, nil + } + + var stdout, stderr bytes.Buffer + code := Main(context.Background(), []string{ + "--request", requestPath, + "--out-dir", filepath.Join(dir, "out"), + "--cloud", "aws", + "--region", "us-west-2", + }, &stdout, &stderr, run) + if code != 0 { + t.Fatalf("exit code = %d, stderr:\n%s", code, stderr.String()) + } + if gotOpts.Discoverer == nil { + t.Fatal("Options.Discoverer = nil: engine would emit selection_closure_unavailable " + + "and skip closure expansion + dep-chase (#195)") + } + if _, ok := gotOpts.Discoverer.(reverseimport.ClosureDiscoverer); !ok { + t.Fatalf("Options.Discoverer %T does not implement reverseimport.ClosureDiscoverer: "+ + "engine would emit selection_closure_unavailable (#195)", gotOpts.Discoverer) + } +} + +// TestMainValidatesSelectionBeforeBuildingCloudClient guards the lazy-discoverer +// ordering (codex P2 on #196). A failing discoverer factory (e.g. GCP ADC +// missing / Cloud Asset API disabled) must NOT mask the engine's structured +// selection validation: an InsideOutImported selection should still be rejected +// with the validation result, and the cloud client must never be constructed +// because validation rejects before the closure phase that needs it. +func TestMainValidatesSelectionBeforeBuildingCloudClient(t *testing.T) { + dir := t.TempDir() + requestPath := filepath.Join(dir, "request.json") + outputDir := filepath.Join(dir, "out") + req := reversejob.Request{ + Version: reversejob.Version, + Resources: []reversejob.ResourceSpec{{ + Identity: imported.ResourceIdentity{ + Cloud: "aws", + Type: "aws_s3_bucket", + Address: "aws_s3_bucket.example", + Region: "us-west-2", + ImportID: "example-bucket", + Tags: map[string]string{ + "InsideOutImported": "true", + "InsideOutImportProject": "4b982735-ff89-4295-a3fa-8a75a554ffc9", + }, + }, + }}, + } + body, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(requestPath, body, 0o644); err != nil { + t.Fatal(err) + } + + // The factory errors like a missing-credential / disabled-API path would, + // and records whether it was ever called. + built := false + orig := newDiscoverer + newDiscoverer = func(context.Context, string, string, string, string) (reverseimport.Discoverer, func(), error) { + built = true + return nil, func() {}, errors.New("cloud asset client unavailable: ADC missing") + } + t.Cleanup(func() { newDiscoverer = orig }) + + var stdout, stderr bytes.Buffer + // run=nil → the real reverseimport.Run, which runs selection validation + // before any closure/dep-chase discovery. + code := Main(context.Background(), []string{ + "--request", requestPath, + "--out-dir", outputDir, + "--import-project-id", "io-current", + }, &stdout, &stderr, nil) + + if code == 0 { + t.Fatalf("exit code = %d, want non-zero", code) + } + if built { + t.Fatal("cloud discoverer was constructed before selection validation; " + + "a credential/API gap would mask the validation result (#196)") + } + if !strings.Contains(stderr.String(), "selected resource cannot be imported") { + t.Fatalf("stderr = %q, want the structured unimportable-selection rejection "+ + "(not a discoverer-construction error)", stderr.String()) + } + raw, err := os.ReadFile(filepath.Join(outputDir, resultFile)) + if err != nil { + t.Fatalf("read result: %v", err) + } + var result reversejob.Result + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatalf("decode result: %v\n%s", err, raw) + } + if len(result.ValidationIssues) != 1 || result.ValidationIssues[0].Code != imported.ReasonInsideOutImported { + t.Fatalf("validation issues = %#v, want insideout_imported", result.ValidationIssues) + } +} + +// TestMainScansAllSelectedRegionsForClosure guards the multi-region closure +// case (codex P2 on #196): when a request selects parents across several AWS +// regions and no --region override is passed, closure discovery must scan +// every distinct region, not just the first. The engine reads +// Options.DiscoverRegions for closure scope and only falls back to a single +// Region when DiscoverRegions is empty. +func TestMainScansAllSelectedRegionsForClosure(t *testing.T) { + useFakeDiscoverer(t) + dir := t.TempDir() + requestPath := filepath.Join(dir, "request.json") + req := reversejob.Request{ + Version: reversejob.Version, + Resources: []reversejob.ResourceSpec{ + {Identity: imported.ResourceIdentity{Cloud: "aws", Type: "aws_kms_key", Address: "aws_kms_key.east", Region: "us-east-1", ImportID: "key-east"}}, + {Identity: imported.ResourceIdentity{Cloud: "aws", Type: "aws_kms_key", Address: "aws_kms_key.west", Region: "us-west-2", ImportID: "key-west"}}, + // Duplicate region should be de-duplicated. + {Identity: imported.ResourceIdentity{Cloud: "aws", Type: "aws_kms_key", Address: "aws_kms_key.west2", Region: "us-west-2", ImportID: "key-west2"}}, + }, + } + body, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(requestPath, body, 0o644); err != nil { + t.Fatal(err) + } + + var gotOpts reverseimport.Options + run := func(_ context.Context, _ reversejob.Request, opts reverseimport.Options) (reversejob.Result, error) { + gotOpts = opts + return reversejob.Result{Version: reversejob.Version, Status: reversejob.StatusSucceeded}, nil + } + + var stdout, stderr bytes.Buffer + // No --region override: closure scope is derived from the request. + code := Main(context.Background(), []string{ + "--request", requestPath, + "--out-dir", filepath.Join(dir, "out"), + }, &stdout, &stderr, run) + if code != 0 { + t.Fatalf("exit code = %d, stderr:\n%s", code, stderr.String()) + } + want := []string{"us-east-1", "us-west-2"} + if len(gotOpts.DiscoverRegions) != len(want) { + t.Fatalf("DiscoverRegions = %v, want %v", gotOpts.DiscoverRegions, want) + } + for i, region := range want { + if gotOpts.DiscoverRegions[i] != region { + t.Fatalf("DiscoverRegions = %v, want %v", gotOpts.DiscoverRegions, want) + } + } +} + +// TestMainRegionOverrideWinsForClosureScope asserts that an explicit --region +// override pins closure discovery to that single region even when the request +// spans others, mirroring the CLI reverse path. +func TestMainRegionOverrideWinsForClosureScope(t *testing.T) { + useFakeDiscoverer(t) + dir := t.TempDir() + requestPath := filepath.Join(dir, "request.json") + req := reversejob.Request{ + Version: reversejob.Version, + Resources: []reversejob.ResourceSpec{ + {Identity: imported.ResourceIdentity{Cloud: "aws", Type: "aws_kms_key", Address: "aws_kms_key.east", Region: "us-east-1", ImportID: "key-east"}}, + {Identity: imported.ResourceIdentity{Cloud: "aws", Type: "aws_kms_key", Address: "aws_kms_key.west", Region: "us-west-2", ImportID: "key-west"}}, + }, + } + body, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(requestPath, body, 0o644); err != nil { + t.Fatal(err) + } + + var gotOpts reverseimport.Options + run := func(_ context.Context, _ reversejob.Request, opts reverseimport.Options) (reversejob.Result, error) { + gotOpts = opts + return reversejob.Result{Version: reversejob.Version, Status: reversejob.StatusSucceeded}, nil + } + + var stdout, stderr bytes.Buffer + code := Main(context.Background(), []string{ + "--request", requestPath, + "--out-dir", filepath.Join(dir, "out"), + "--region", "eu-west-1", + }, &stdout, &stderr, run) + if code != 0 { + t.Fatalf("exit code = %d, stderr:\n%s", code, stderr.String()) + } + if len(gotOpts.DiscoverRegions) != 1 || gotOpts.DiscoverRegions[0] != "eu-west-1" { + t.Fatalf("DiscoverRegions = %v, want [eu-west-1]", gotOpts.DiscoverRegions) + } + if gotOpts.Region != "eu-west-1" { + t.Fatalf("Region = %q, want eu-west-1", gotOpts.Region) + } +} + func TestMainUsesDefaultOutputDir(t *testing.T) { + useFakeDiscoverer(t) dir := t.TempDir() requestPath := filepath.Join(dir, "request.json") writeRequest(t, requestPath) @@ -182,6 +465,7 @@ func TestMainRejectsUnreadableOrMalformedRequestBeforeCallingSDK(t *testing.T) { } func TestMainLeavesSDKArtifactsInOutputDir(t *testing.T) { + useFakeDiscoverer(t) dir := t.TempDir() requestPath := filepath.Join(dir, "request.json") outputDir := filepath.Join(dir, "out") @@ -231,6 +515,7 @@ func TestMainLeavesSDKArtifactsInOutputDir(t *testing.T) { } func TestMainWritesFailureResultWhenSDKFailsBeforeResultArtifact(t *testing.T) { + useFakeDiscoverer(t) dir := t.TempDir() requestPath := filepath.Join(dir, "request.json") outputDir := filepath.Join(dir, "out") @@ -264,6 +549,7 @@ func TestMainWritesFailureResultWhenSDKFailsBeforeResultArtifact(t *testing.T) { } func TestMainWritesFailureResultWhenSDKReturnsFailedStatus(t *testing.T) { + useFakeDiscoverer(t) dir := t.TempDir() requestPath := filepath.Join(dir, "request.json") outputDir := filepath.Join(dir, "out") @@ -299,6 +585,7 @@ func TestMainWritesFailureResultWhenSDKReturnsFailedStatus(t *testing.T) { } func TestMainRejectsInsideOutImportedMarkerBeforePlan(t *testing.T) { + useFakeDiscoverer(t) dir := t.TempDir() requestPath := filepath.Join(dir, "request.json") outputDir := filepath.Join(dir, "out") @@ -359,6 +646,7 @@ func TestMainRejectsInsideOutImportedMarkerBeforePlan(t *testing.T) { } func TestMainPreservesExistingFailureResult(t *testing.T) { + useFakeDiscoverer(t) dir := t.TempDir() requestPath := filepath.Join(dir, "request.json") outputDir := filepath.Join(dir, "out")