From 355bd1f4263b76d53651763224c378045bdbe355 Mon Sep 17 00:00:00 2001 From: Jayson Grace Date: Mon, 20 Apr 2026 10:23:13 -0600 Subject: [PATCH] feat: add automatic inventory bootstrap from example template **Added:** - Introduced `bootstrapInventory` helper to copy a `.example` inventory file to the target path if missing, enabling seamless setup for new environments - Added unit tests for `bootstrapInventory` covering copy, no-op, and error scenarios (`bootstrap_test.go`) **Changed:** - Updated `runInventorySync` and `ensureInventorySynced` to invoke `bootstrapInventory`, ensuring inventory exists before proceeding and improving user experience for fresh setups --- cli/cmd/bootstrap_test.go | 64 +++++++++++++++++++++++++++++++++++++++ cli/cmd/inventory.go | 4 +-- cli/cmd/provision.go | 25 +++++++++++++++ 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 cli/cmd/bootstrap_test.go diff --git a/cli/cmd/bootstrap_test.go b/cli/cmd/bootstrap_test.go new file mode 100644 index 00000000..03903192 --- /dev/null +++ b/cli/cmd/bootstrap_test.go @@ -0,0 +1,64 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +func TestBootstrapInventory(t *testing.T) { + t.Run("copies example when inventory missing", func(t *testing.T) { + dir := t.TempDir() + invPath := filepath.Join(dir, "dev-inventory") + examplePath := invPath + ".example" + + exampleContent := []byte("[all:vars]\nenv=dev\nregion=us-west-2\n") + if err := os.WriteFile(examplePath, exampleContent, 0o644); err != nil { + t.Fatalf("write example: %v", err) + } + + if err := bootstrapInventory(invPath); err != nil { + t.Fatalf("bootstrapInventory() error: %v", err) + } + + got, err := os.ReadFile(invPath) + if err != nil { + t.Fatalf("read bootstrapped inventory: %v", err) + } + if string(got) != string(exampleContent) { + t.Errorf("content mismatch:\ngot: %q\nwant: %q", got, exampleContent) + } + }) + + t.Run("no-op when inventory exists", func(t *testing.T) { + dir := t.TempDir() + invPath := filepath.Join(dir, "dev-inventory") + + existing := []byte("[all:vars]\nenv=dev\ninstance=i-abc123\n") + if err := os.WriteFile(invPath, existing, 0o644); err != nil { + t.Fatalf("write existing: %v", err) + } + + if err := bootstrapInventory(invPath); err != nil { + t.Fatalf("bootstrapInventory() error: %v", err) + } + + got, err := os.ReadFile(invPath) + if err != nil { + t.Fatalf("read inventory: %v", err) + } + if string(got) != string(existing) { + t.Errorf("existing inventory was overwritten") + } + }) + + t.Run("errors when neither file exists", func(t *testing.T) { + dir := t.TempDir() + invPath := filepath.Join(dir, "dev-inventory") + + err := bootstrapInventory(invPath) + if err == nil { + t.Fatal("expected error when no inventory or example exists") + } + }) +} diff --git a/cli/cmd/inventory.go b/cli/cmd/inventory.go index 3103d44a..14be1aab 100644 --- a/cli/cmd/inventory.go +++ b/cli/cmd/inventory.go @@ -63,8 +63,8 @@ func runInventorySync(cmd *cobra.Command, args []string) error { } invPath := cfg.InventoryPath() - if _, err := os.Stat(invPath); os.IsNotExist(err) { - return fmt.Errorf("inventory file not found: %s", invPath) + if err := bootstrapInventory(invPath); err != nil { + return err } backup, _ := cmd.Flags().GetBool("backup") diff --git a/cli/cmd/provision.go b/cli/cmd/provision.go index 7d41c3c2..7dd9e4ea 100644 --- a/cli/cmd/provision.go +++ b/cli/cmd/provision.go @@ -145,11 +145,36 @@ func preflightChecks(ctx context.Context, cfg *config.Config) error { return nil } +// bootstrapInventory copies the example inventory file to the target path if +// the target does not exist. This allows provision and inventory commands to +// work on a fresh environment without a manual copy step. +func bootstrapInventory(invPath string) error { + if _, err := os.Stat(invPath); err == nil { + return nil + } + examplePath := invPath + ".example" + if _, err := os.Stat(examplePath); err != nil { + return fmt.Errorf("inventory file not found: %s (no .example template either)", invPath) + } + data, err := os.ReadFile(examplePath) + if err != nil { + return fmt.Errorf("read example inventory: %w", err) + } + if err := os.WriteFile(invPath, data, 0o644); err != nil { + return fmt.Errorf("write inventory from example: %w", err) + } + slog.Info("bootstrapped inventory from example template", "path", invPath) + return nil +} + // ensureInventorySynced compares inventory instance IDs against live EC2 // state and auto-syncs if they diverge. This prevents provisioning against // stale instance IDs after an infra destroy/apply cycle. func ensureInventorySynced(ctx context.Context, cfg *config.Config) error { invPath := cfg.InventoryPath() + if err := bootstrapInventory(invPath); err != nil { + return err + } parsed, err := inv.Parse(invPath) if err != nil { return fmt.Errorf("parse inventory: %w", err)