initial scaffold: Go module, cmd structure, internal packages, Makefile

This commit is contained in:
MermaidMan 2026-05-22 15:15:10 +00:00
commit 71fedd7b29
9 changed files with 804 additions and 0 deletions

256
internal/config/config.go Normal file
View file

@ -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:]
}

View file

@ -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)))
}

60
internal/prompt/prompt.go Normal file
View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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{}

View file

@ -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
}