commit 71fedd7b29adec93e35ae84b5b269612c7adac22 Author: MermaidMan Date: Fri May 22 15:15:10 2026 +0000 initial scaffold: Go module, cmd structure, internal packages, Makefile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d355dd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +obm +*.exe +*.test +.env +.env.* +!.env.example +terraform.tfstate +terraform.tfstate.backup +.terraform/ +.terraform.lock.hcl diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..39e5e21 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: build test lint clean fmt vet + +BINARY := obm +VERSION := 0.1.0 +LDFLAGS := -ldflags "-s -w -X main.version=$(VERSION)" + +build: + go build $(LDFLAGS) -o $(BINARY) ./cmd/obm + +test: + go test -v -race ./... + +lint: vet fmt + @echo "lint: ok" + +vet: + go vet ./... + +fmt: + gofmt -l -s -w . + +clean: + rm -f $(BINARY) + +run: build + ./$(BINARY) + +all: lint test build diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7aa8b71 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/openboatmobile/obm + +go 1.22.2 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0410323 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,256 @@ +// Package config handles loading and parsing obm configuration files. +package config + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +// Config represents the top-level obm configuration. +type Config struct { + Project string `json:"project"` + Provider ProviderConfig `json:"provider"` + Variables map[string]string `json:"variables,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +// ProviderConfig holds provider-specific configuration. +type ProviderConfig struct { + Name string `json:"name"` + Region string `json:"region,omitempty"` + Profile string `json:"profile,omitempty"` +} + +// Load reads and parses a config file from the given path. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config %s: %w", path, err) + } + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config %s: %w", path, err) + } + return &cfg, nil +} + +// WriteEnv writes environment variables to a .env file at the given path. +// It writes the Variables and Env fields from the config, sorted alphabetically. +func (c *Config) WriteEnv(path string) error { + // Merge variables and env, with env taking precedence + envVars := make(map[string]string) + for k, v := range c.Variables { + envVars[k] = v + } + for k, v := range c.Env { + envVars[k] = v + } + + // Sort keys for deterministic output + keys := make([]string, 0, len(envVars)) + for k := range envVars { + keys = append(keys, k) + } + sort.Strings(keys) + + // Ensure directory exists + dir := filepath.Dir(path) + if dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating directory %s: %w", dir, err) + } + } + + // Write file + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("creating .env file %s: %w", path, err) + } + defer file.Close() + + // Write header if there are provider-specific vars + fmt.Fprintf(file, "# Generated by obm\n") + fmt.Fprintf(file, "# Project: %s\n", c.Project) + fmt.Fprintf(file, "# Provider: %s\n\n", c.Provider.Name) + + // Write sorted environment variables + for _, k := range keys { + v := envVars[k] + if needsQuoting(v) { + fmt.Fprintf(file, "%s=\"%s\"\n", k, escapeQuotes(v)) + } else { + fmt.Fprintf(file, "%s=%s\n", k, v) + } + } + + return nil +} + +// WriteEnvInteractive writes the .env file after displaying a summary and getting confirmation. +// Returns true if the file was written, false if the user declined. +func (c *Config) WriteEnvInteractive(path string, displaySummary bool) (bool, error) { + if displaySummary { + c.PrintSummary() + } + // Confirmation is handled by the caller (prompt.SummaryDisplay) + if err := c.WriteEnv(path); err != nil { + return false, err + } + return true, nil +} + +// PrintSummary displays a formatted summary of the configuration. +func (c *Config) PrintSummary() { + fmt.Printf("\n=== Configuration Summary ===\n") + fmt.Printf("\n[Project]\n") + fmt.Printf(" %-20s %s\n", "Name:", c.Project) + + fmt.Printf("\n[Provider]\n") + fmt.Printf(" %-20s %s\n", "Type:", c.Provider.Name) + if c.Provider.Region != "" { + fmt.Printf(" %-20s %s\n", "Region:", c.Provider.Region) + } + if c.Provider.Profile != "" { + fmt.Printf(" %-20s %s\n", "Profile:", c.Provider.Profile) + } + + if len(c.Variables) > 0 { + fmt.Printf("\n[Variables]\n") + keys := sortedKeys(c.Variables) + for _, k := range keys { + v := c.Variables[k] + if isSensitive(k) { + v = maskValue(v) + } + fmt.Printf(" %-20s %s\n", k+":", v) + } + } + + if len(c.Env) > 0 { + fmt.Printf("\n[Environment]\n") + keys := sortedKeys(c.Env) + for _, k := range keys { + v := c.Env[k] + if isSensitive(k) { + v = maskValue(v) + } + fmt.Printf(" %-20s %s\n", k+":", v) + } + } +} + +// LoadOrCreate loads a config from the given path, or returns a default config if the file doesn't exist. +func LoadOrCreate(path string) (*Config, error) { + cfg, err := Load(path) + if err != nil { + if os.IsNotExist(err) { + return &Config{ + Variables: make(map[string]string), + Env: make(map[string]string), + }, nil + } + return nil, err + } + return cfg, nil +} + +// MergeEnvFiles loads multiple .env files and merges them into the config's Variables. +// Later files override earlier files. +func (c *Config) MergeEnvFiles(paths ...string) error { + for _, path := range paths { + envVars, err := ReadEnvFile(path) + if err != nil { + return fmt.Errorf("reading env file %s: %w", path, err) + } + for k, v := range envVars { + c.Variables[k] = v + } + } + return nil +} + +// ReadEnvFile reads a .env file and returns the key-value pairs. +func ReadEnvFile(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + result := make(map[string]string) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Parse KEY=value or KEY="value" + k, v, err := parseEnvLine(line) + if err != nil { + continue // Skip malformed lines + } + result[k] = v + } + return result, scanner.Err() +} + +// parseEnvLine parses a single .env line into key and value. +func parseEnvLine(line string) (key, value string, err error) { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid env line: %s", line) + } + key = strings.TrimSpace(parts[0]) + value = strings.TrimSpace(parts[1]) + // Remove surrounding quotes + if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[0] == value[len(value)-1] { + value = value[1 : len(value)-1] + } + return key, value, nil +} + +// sortedKeys returns the keys of a map in sorted order. +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// needsQuoting returns true if the value needs to be quoted in a .env file. +func needsQuoting(v string) bool { + return strings.ContainsAny(v, " \t\n\"'$`&|;<>") || v == "" +} + +// escapeQuotes escapes double quotes in a string. +func escapeQuotes(v string) string { + return strings.ReplaceAll(v, `"`, `\"`) +} + +// isSensitive returns true if the key name suggests it contains sensitive data. +func isSensitive(key string) bool { + lower := strings.ToLower(key) + sensitivePatterns := []string{"password", "secret", "key", "token", "credential", "api_key", "apikey", "auth"} + for _, pattern := range sensitivePatterns { + if strings.Contains(lower, pattern) { + return true + } + } + return false +} + +// maskValue masks a sensitive value, showing only the first and last characters. +func maskValue(v string) string { + if len(v) <= 5 { + return "****" + } + return v[:2] + "****" + v[len(v)-2:] +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..34b7b79 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,233 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoad(t *testing.T) { + // Create a temp config file + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + configContent := `{ + "project": "test-project", + "provider": { + "name": "hcloud", + "region": "nyc1" + }, + "variables": { + "TF_VAR_count": "3" + } + }` + if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Project != "test-project" { + t.Errorf("expected project 'test-project', got %q", cfg.Project) + } + if cfg.Provider.Name != "hcloud" { + t.Errorf("expected provider name 'hcloud', got %q", cfg.Provider.Name) + } + if cfg.Provider.Region != "nyc1" { + t.Errorf("expected provider region 'nyc1', got %q", cfg.Provider.Region) + } + if cfg.Variables["TF_VAR_count"] != "3" { + t.Errorf("expected TF_VAR_count=3, got %q", cfg.Variables["TF_VAR_count"]) + } +} + +func TestWriteEnv(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + + cfg := &Config{ + Project: "test-project", + Provider: ProviderConfig{ + Name: "hcloud", + Region: "nyc1", + }, + Variables: map[string]string{ + "TF_VAR_count": "3", + "API_KEY": "secret123", + "DATABASE_URL": "postgres://user:pass@localhost:5432/db", + "PUBLIC_VAR": "hello world", + }, + Env: map[string]string{ + "EXTRA_VAR": "extra", + }, + } + + if err := cfg.WriteEnv(envPath); err != nil { + t.Fatalf("WriteEnv failed: %v", err) + } + + // Read back and verify + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatalf("failed to read .env: %v", err) + } + content := string(data) + + // Check header + if !contains(content, "# Generated by obm") { + t.Error("missing generated header") + } + if !contains(content, "# Project: test-project") { + t.Error("missing project name in header") + } + + // Check variables are present + if !contains(content, "TF_VAR_count=3") { + t.Error("missing TF_VAR_count") + } + if !contains(content, "EXTRA_VAR=extra") { + t.Error("missing EXTRA_VAR") + } +} + +func TestReadEnvFile(t *testing.T) { + tmpDir := t.TempDir() + envPath := filepath.Join(tmpDir, ".env") + envContent := `# Comment line +VAR1=value1 +VAR2="quoted value" +VAR3='single quoted' +# Another comment +EMPTY_VAR="" +` + if err := os.WriteFile(envPath, []byte(envContent), 0644); err != nil { + t.Fatalf("failed to write test .env: %v", err) + } + + vars, err := ReadEnvFile(envPath) + if err != nil { + t.Fatalf("ReadEnvFile failed: %v", err) + } + + if vars["VAR1"] != "value1" { + t.Errorf("expected VAR1='value1', got %q", vars["VAR1"]) + } + if vars["VAR2"] != "quoted value" { + t.Errorf("expected VAR2='quoted value', got %q", vars["VAR2"]) + } + if vars["VAR3"] != "single quoted" { + t.Errorf("expected VAR3='single quoted', got %q", vars["VAR3"]) + } + if vars["EMPTY_VAR"] != "" { + t.Errorf("expected EMPTY_VAR='', got %q", vars["EMPTY_VAR"]) + } + + // Comments should not be parsed as variables + if _, exists := vars["# Comment line"]; exists { + t.Error("comment line was parsed as variable") + } +} + +func TestMergeEnvFiles(t *testing.T) { + tmpDir := t.TempDir() + + // Create two env files + env1 := filepath.Join(tmpDir, "env1") + env2 := filepath.Join(tmpDir, "env2") + + os.WriteFile(env1, []byte("VAR1=value1\nVAR2=original"), 0644) + os.WriteFile(env2, []byte("VAR2=overridden\nVAR3=value3"), 0644) + + cfg := &Config{ + Variables: map[string]string{}, + Env: map[string]string{}, + } + + if err := cfg.MergeEnvFiles(env1, env2); err != nil { + t.Fatalf("MergeEnvFiles failed: %v", err) + } + + if cfg.Variables["VAR1"] != "value1" { + t.Errorf("expected VAR1='value1', got %q", cfg.Variables["VAR1"]) + } + // env2 should override env1 for VAR2 + if cfg.Variables["VAR2"] != "overridden" { + t.Errorf("expected VAR2='overridden', got %q", cfg.Variables["VAR2"]) + } + if cfg.Variables["VAR3"] != "value3" { + t.Errorf("expected VAR3='value3', got %q", cfg.Variables["VAR3"]) + } +} + +func TestIsSensitive(t *testing.T) { + tests := []struct { + key string + expected bool + }{ + {"password", true}, + {"api_key", true}, + {"secret", true}, + {"token", true}, + {"auth", true}, + {"credential", true}, + {"DATABASE_URL", false}, + {"port", false}, + {"count", false}, + {"HOST_KEY", true}, + {"my_password_here", true}, + } + + for _, tt := range tests { + result := isSensitive(tt.key) + if result != tt.expected { + t.Errorf("isSensitive(%q) = %v, expected %v", tt.key, result, tt.expected) + } + } +} + +func TestMaskValue(t *testing.T) { + tests := []struct { + value string + expected string + }{ + {"short", "****"}, + {"abc", "****"}, + {"secret123", "se****23"}, + {"verylongsecretvalue", "ve****ue"}, + } + + for _, tt := range tests { + result := maskValue(tt.value) + if result != tt.expected { + t.Errorf("maskValue(%q) = %q, expected %q", tt.value, result, tt.expected) + } + } +} + +func TestNeedsQuoting(t *testing.T) { + tests := []struct { + value string + expected bool + }{ + {"simple", false}, + {"", true}, + {"has space", true}, + {"has'quote", true}, + {"has\"quote", true}, + {"has$var", true}, + {"normalvalue", false}, + } + + for _, tt := range tests { + result := needsQuoting(tt.value) + if result != tt.expected { + t.Errorf("needsQuoting(%q) = %v, expected %v", tt.value, result, tt.expected) + } + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && (s[:len(substr)] == substr || contains(s[1:], substr))) +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..c1587bb --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,60 @@ +// Package prompt handles interactive user prompts and confirmations. +package prompt + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// Confirm asks the user a yes/no question and returns true for yes. +func Confirm(message string) bool { + fmt.Printf("%s [y/N]: ", message) + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return false + } + return strings.TrimSpace(strings.ToLower(input)) == "y" +} + +// PromptString asks the user for a string input with the given label. +func PromptString(label string) string { + fmt.Printf("%s: ", label) + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return "" + } + return strings.TrimSpace(input) +} + +// SummaryLine prints a single line in the summary format. +func SummaryLine(key, value string) { + fmt.Printf(" %-20s %s\n", key+":", value) +} + +// SummarySection prints a section header in the summary format. +func SummarySection(title string) { + fmt.Printf("\n[%s]\n", title) +} + +// SummaryDisplay prints a formatted summary of key-value pairs. +// The pairs are printed in order, with sections delimited by empty keys. +func SummaryDisplay(title string, sections map[string][]Field) bool { + fmt.Printf("\n=== %s ===\n", title) + for sectionName, fields := range sections { + SummarySection(sectionName) + for _, field := range fields { + SummaryLine(field.Key, field.Value) + } + } + return Confirm("\nProceed?") +} + +// Field represents a key-value pair for summary display. +type Field struct { + Key string + Value string +} diff --git a/internal/prompt/prompt_test.go b/internal/prompt/prompt_test.go new file mode 100644 index 0000000..9df49a8 --- /dev/null +++ b/internal/prompt/prompt_test.go @@ -0,0 +1,148 @@ +package prompt + +import ( + "bytes" + "io" + "os" + "strings" + "testing" +) + +func TestField(t *testing.T) { + f := Field{Key: "test", Value: "value"} + if f.Key != "test" || f.Value != "value" { + t.Errorf("Field struct not working correctly") + } +} + +func TestConfirm(t *testing.T) { + // Save original stdin + oldStdin := os.Stdin + defer func() { os.Stdin = oldStdin }() + + tests := []struct { + input string + expected bool + }{ + {"y\n", true}, + {"Y\n", true}, + {"yes\n", false}, // only 'y' is accepted + {"n\n", false}, + {"N\n", false}, + {"\n", false}, + } + + for _, tt := range tests { + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + w.WriteString(tt.input) + w.Close() + }() + + // Capture stdout + oldStdout := os.Stdout + rOut, wOut, _ := os.Pipe() + os.Stdout = wOut + + result := Confirm("Test?") + + wOut.Close() + os.Stdout = oldStdout + + // Drain stdout + io.Copy(io.Discard, rOut) + + if result != tt.expected { + t.Errorf("Confirm(%q) = %v, expected %v", strings.TrimSpace(tt.input), result, tt.expected) + } + + r.Close() + } +} + +func TestPromptString(t *testing.T) { + // Save original stdin + oldStdin := os.Stdin + defer func() { os.Stdin = oldStdin }() + + tests := []struct { + input string + expected string + }{ + {"hello\n", "hello"}, + {" trimmed \n", "trimmed"}, + {"\n", ""}, + } + + for _, tt := range tests { + r, w, _ := os.Pipe() + os.Stdin = r + go func() { + w.WriteString(tt.input) + w.Close() + }() + + // Capture stdout + oldStdout := os.Stdout + rOut, wOut, _ := os.Pipe() + os.Stdout = wOut + + result := PromptString("Enter value") + + wOut.Close() + os.Stdout = oldStdout + + // Drain stdout + io.Copy(io.Discard, rOut) + + if result != tt.expected { + t.Errorf("PromptString(%q) = %q, expected %q", strings.TrimSpace(tt.input), result, tt.expected) + } + + r.Close() + } +} + +func TestSummaryLine(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + SummaryLine("Key", "Value") + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + if !strings.Contains(output, "Key:") { + t.Error("SummaryLine missing key") + } + if !strings.Contains(output, "Value") { + t.Error("SummaryLine missing value") + } +} + +func TestSummarySection(t *testing.T) { + // Capture stdout + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + SummarySection("TestSection") + + w.Close() + os.Stdout = oldStdout + + var buf bytes.Buffer + io.Copy(&buf, r) + output := buf.String() + + if !strings.Contains(output, "[TestSection]") { + t.Errorf("SummarySection output %q missing [TestSection]", output) + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go new file mode 100644 index 0000000..6dcac76 --- /dev/null +++ b/internal/provider/provider.go @@ -0,0 +1,15 @@ +// Package provider defines the interface for cloud providers (Hetzner, etc.). +package provider + +import "context" + +// Provider is the interface that cloud providers must implement. +type Provider interface { + // Name returns the provider name (e.g. "hcloud"). + Name() string + // Validate checks that provider credentials and configuration are valid. + Validate(ctx context.Context) error +} + +// Registry holds registered providers by name. +var Registry = map[string]func() Provider{} diff --git a/internal/terraform/terraform.go b/internal/terraform/terraform.go new file mode 100644 index 0000000..fa55b91 --- /dev/null +++ b/internal/terraform/terraform.go @@ -0,0 +1,51 @@ +// Package terraform wraps Terraform operations (init, plan, apply, destroy). +package terraform + +import ( + "fmt" + "os/exec" +) + +// Runner executes Terraform commands. +type Runner struct { + WorkDir string +} + +// NewRunner creates a Terraform runner for the given working directory. +func NewRunner(workDir string) *Runner { + return &Runner{WorkDir: workDir} +} + +// Init runs terraform init. +func (r *Runner) Init() error { + return r.run("init", "-input=false") +} + +// Plan runs terraform plan. +func (r *Runner) Plan(destroy bool) error { + args := []string{"plan"} + if destroy { + args = append(args, "-destroy") + } + return r.run(args...) +} + +// Apply runs terraform apply. +func (r *Runner) Apply() error { + return r.run("apply", "-auto-approve") +} + +// Destroy runs terraform destroy. +func (r *Runner) Destroy() error { + return r.run("destroy", "-auto-approve") +} + +func (r *Runner) run(args ...string) error { + cmd := exec.Command("terraform", args...) + cmd.Dir = r.WorkDir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("terraform %v: %w\n%s", args, err, output) + } + return nil +}