|
1 | 1 | package main |
2 | 2 |
|
3 | 3 | import ( |
| 4 | + "bytes" |
4 | 5 | "context" |
5 | | - "crypto/sha256" |
6 | | - "encoding/json" |
7 | 6 | "fmt" |
8 | 7 | "io" |
9 | 8 | "os" |
@@ -184,13 +183,9 @@ func TestApplyWithProvider_NoChanges(t *testing.T) { |
184 | 183 | Config: map[string]any{"engine": "postgres"}, |
185 | 184 | } |
186 | 185 |
|
187 | | - // Reproduce the hash that platform.ComputePlan computes via configHash: |
188 | | - // sha256(json.Marshal(spec.Config)) in hex. |
189 | | - cfgData, err := json.Marshal(spec.Config) |
190 | | - if err != nil { |
191 | | - t.Fatalf("marshal config: %v", err) |
192 | | - } |
193 | | - cfgHash := fmt.Sprintf("%x", sha256.Sum256(cfgData)) |
| 186 | + // Reproduce the hash that platform.ComputePlan computes via configHash |
| 187 | + // (sorted kv-pair encoding): |
| 188 | + cfgHash := configHashMap(spec.Config) |
194 | 189 |
|
195 | 190 | current := []interfaces.ResourceState{{ |
196 | 191 | Name: spec.Name, |
@@ -220,8 +215,7 @@ func TestApplyWithProvider_DeletesRemovedResource(t *testing.T) { |
220 | 215 | {Name: "bmw-app", Type: "infra.container_service", Config: map[string]any{"image": "registry/app:latest"}}, |
221 | 216 | } |
222 | 217 | // Current: bmw-app + old-db (removed from config, should be deleted). |
223 | | - appData, _ := json.Marshal(specs[0].Config) |
224 | | - appHash := fmt.Sprintf("%x", sha256.Sum256(appData)) |
| 218 | + appHash := configHashMap(specs[0].Config) |
225 | 219 | current := []interfaces.ResourceState{ |
226 | 220 | {Name: "bmw-app", Type: "infra.container_service", ConfigHash: appHash}, |
227 | 221 | {Name: "old-db", Type: "infra.database", ConfigHash: "oldhash"}, |
@@ -464,6 +458,98 @@ modules: |
464 | 458 | } |
465 | 459 | } |
466 | 460 |
|
| 461 | +// ── TestApplyInfraModules_CallsResolveSizing_ForEachSpec ────────────────────── |
| 462 | + |
| 463 | +// sizingCapture is an IaCProvider that records every ResolveSizing call and |
| 464 | +// returns a concrete ProviderSizing so we can assert spec.Config is enriched. |
| 465 | +type sizingCapture struct { |
| 466 | + applyCapture |
| 467 | + sizingCalls []struct { |
| 468 | + resType string |
| 469 | + size interfaces.Size |
| 470 | + } |
| 471 | + sizingResult *interfaces.ProviderSizing |
| 472 | + appliedSpecs []interfaces.ResourceSpec |
| 473 | +} |
| 474 | + |
| 475 | +func (s *sizingCapture) ResolveSizing(resType string, size interfaces.Size, _ *interfaces.ResourceHints) (*interfaces.ProviderSizing, error) { |
| 476 | + s.mu.Lock() |
| 477 | + defer s.mu.Unlock() |
| 478 | + s.sizingCalls = append(s.sizingCalls, struct { |
| 479 | + resType string |
| 480 | + size interfaces.Size |
| 481 | + }{resType: resType, size: size}) |
| 482 | + return s.sizingResult, nil |
| 483 | +} |
| 484 | + |
| 485 | +func (s *sizingCapture) Apply(_ context.Context, plan *interfaces.IaCPlan) (*interfaces.ApplyResult, error) { |
| 486 | + s.mu.Lock() |
| 487 | + defer s.mu.Unlock() |
| 488 | + for _, a := range plan.Actions { |
| 489 | + s.appliedSpecs = append(s.appliedSpecs, a.Resource) |
| 490 | + } |
| 491 | + return &interfaces.ApplyResult{}, nil |
| 492 | +} |
| 493 | + |
| 494 | +// TestApplyInfraModules_CallsResolveSizing_ForEachSpec verifies that |
| 495 | +// applyWithProviderAndStore invokes provider.ResolveSizing for each spec |
| 496 | +// that has a non-empty Size field, and that the resolved InstanceType and |
| 497 | +// extra Specs are merged into spec.Config before the plan is computed. |
| 498 | +func TestApplyInfraModules_CallsResolveSizing_ForEachSpec(t *testing.T) { |
| 499 | + specs := []interfaces.ResourceSpec{ |
| 500 | + {Name: "db", Type: "infra.database", Size: interfaces.SizeM, Config: map[string]any{"engine": "postgres"}}, |
| 501 | + {Name: "vpc", Type: "infra.vpc", Config: map[string]any{"region": "nyc3"}}, // no Size → ResolveSizing should NOT be called |
| 502 | + {Name: "app", Type: "infra.container_service", Size: interfaces.SizeS, Config: map[string]any{"image": "nginx"}}, |
| 503 | + } |
| 504 | + |
| 505 | + fake := &sizingCapture{ |
| 506 | + sizingResult: &interfaces.ProviderSizing{ |
| 507 | + InstanceType: "s-1vcpu-2gb", |
| 508 | + Specs: map[string]any{"memory_mb": 2048}, |
| 509 | + }, |
| 510 | + } |
| 511 | + |
| 512 | + if err := applyWithProviderAndStore(t.Context(), fake, "fake-cloud", specs, nil, nil); err != nil { |
| 513 | + t.Fatalf("applyWithProviderAndStore: %v", err) |
| 514 | + } |
| 515 | + |
| 516 | + // ResolveSizing should have been called twice (db + app), not for vpc. |
| 517 | + fake.mu.Lock() |
| 518 | + calls := fake.sizingCalls |
| 519 | + applied := fake.appliedSpecs |
| 520 | + fake.mu.Unlock() |
| 521 | + |
| 522 | + if len(calls) != 2 { |
| 523 | + t.Errorf("ResolveSizing calls = %d, want 2 (only sized specs)", len(calls)) |
| 524 | + } |
| 525 | + callTypes := map[string]interfaces.Size{} |
| 526 | + for _, c := range calls { |
| 527 | + callTypes[c.resType] = c.size |
| 528 | + } |
| 529 | + if callTypes["infra.database"] != interfaces.SizeM { |
| 530 | + t.Errorf("infra.database sizing call size = %q, want %q", callTypes["infra.database"], interfaces.SizeM) |
| 531 | + } |
| 532 | + if callTypes["infra.container_service"] != interfaces.SizeS { |
| 533 | + t.Errorf("infra.container_service sizing call size = %q, want %q", callTypes["infra.container_service"], interfaces.SizeS) |
| 534 | + } |
| 535 | + |
| 536 | + // The applied specs should carry the resolved instance_type in their Config. |
| 537 | + if len(applied) == 0 { |
| 538 | + t.Fatal("no specs were applied — Apply was not called or plan had no actions") |
| 539 | + } |
| 540 | + for _, s := range applied { |
| 541 | + if s.Size == "" { |
| 542 | + continue // vpc — no sizing expected |
| 543 | + } |
| 544 | + if s.Config["instance_type"] != "s-1vcpu-2gb" { |
| 545 | + t.Errorf("spec %q: Config[instance_type] = %v, want s-1vcpu-2gb", s.Name, s.Config["instance_type"]) |
| 546 | + } |
| 547 | + if s.Config["memory_mb"] != 2048 { |
| 548 | + t.Errorf("spec %q: Config[memory_mb] = %v, want 2048", s.Name, s.Config["memory_mb"]) |
| 549 | + } |
| 550 | + } |
| 551 | +} |
| 552 | + |
467 | 553 | // TestHasInfraModules verifies detection of infra.* vs platform.* configs. |
468 | 554 | func TestHasInfraModules(t *testing.T) { |
469 | 555 | dir := t.TempDir() |
@@ -495,3 +581,75 @@ modules: |
495 | 581 | t.Error("hasInfraModules: want false for platform.* config, got true") |
496 | 582 | } |
497 | 583 | } |
| 584 | + |
| 585 | +// ── TestApplyWithProvider_LogsCloseError ────────────────────────────────────── |
| 586 | + |
| 587 | +// errCloser is an io.Closer that always returns an error. |
| 588 | +type errCloser struct{ msg string } |
| 589 | + |
| 590 | +func (e *errCloser) Close() error { return fmt.Errorf("%s", e.msg) } |
| 591 | + |
| 592 | +// TestApplyWithProvider_LogsCloseError verifies that when the provider closer |
| 593 | +// returns an error during applyInfraModules, a warning is written to stderr |
| 594 | +// (instead of silently discarding the error via nolint:errcheck). |
| 595 | +func TestApplyWithProvider_LogsCloseError(t *testing.T) { |
| 596 | + dir := t.TempDir() |
| 597 | + cfgPath := filepath.Join(dir, "infra.yaml") |
| 598 | + if err := os.WriteFile(cfgPath, []byte(` |
| 599 | +modules: |
| 600 | + - name: myprov |
| 601 | + type: iac.provider |
| 602 | + config: |
| 603 | + provider: fake-cloud |
| 604 | + - name: my-vpc |
| 605 | + type: infra.vpc |
| 606 | + config: |
| 607 | + provider: myprov |
| 608 | + region: nyc3 |
| 609 | +`), 0o600); err != nil { |
| 610 | + t.Fatalf("write config: %v", err) |
| 611 | + } |
| 612 | + |
| 613 | + // Override resolveIaCProvider to return a provider + error-producing closer. |
| 614 | + orig := resolveIaCProvider |
| 615 | + fake := &applyCapture{} |
| 616 | + closerErr := "shutdown-sentinel-error" |
| 617 | + resolveIaCProvider = func(_ context.Context, _ string, _ map[string]any) (interfaces.IaCProvider, io.Closer, error) { |
| 618 | + return fake, &errCloser{msg: closerErr}, nil |
| 619 | + } |
| 620 | + t.Cleanup(func() { resolveIaCProvider = orig }) |
| 621 | + |
| 622 | + // Redirect stderr to capture warning output. |
| 623 | + oldStderr := os.Stderr |
| 624 | + r, w, pipeErr := os.Pipe() |
| 625 | + if pipeErr != nil { |
| 626 | + t.Fatalf("os.Pipe: %v", pipeErr) |
| 627 | + } |
| 628 | + os.Stderr = w |
| 629 | + t.Cleanup(func() { |
| 630 | + os.Stderr = oldStderr |
| 631 | + _ = w.Close() |
| 632 | + _ = r.Close() |
| 633 | + }) |
| 634 | + |
| 635 | + err := applyInfraModules(context.Background(), cfgPath, "") |
| 636 | + |
| 637 | + w.Close() |
| 638 | + os.Stderr = oldStderr |
| 639 | + |
| 640 | + var buf bytes.Buffer |
| 641 | + if _, readErr := buf.ReadFrom(r); readErr != nil { |
| 642 | + t.Fatalf("read stderr: %v", readErr) |
| 643 | + } |
| 644 | + stderrOutput := buf.String() |
| 645 | + |
| 646 | + if err != nil { |
| 647 | + t.Fatalf("applyInfraModules returned unexpected error: %v", err) |
| 648 | + } |
| 649 | + if !strings.Contains(stderrOutput, closerErr) { |
| 650 | + t.Errorf("stderr = %q, want it to contain %q", stderrOutput, closerErr) |
| 651 | + } |
| 652 | + if !strings.Contains(stderrOutput, "warning") { |
| 653 | + t.Errorf("stderr = %q, want it to contain 'warning'", stderrOutput) |
| 654 | + } |
| 655 | +} |
0 commit comments