initial scaffold: Go module, cmd structure, internal packages, Makefile
This commit is contained in:
commit
71fedd7b29
9 changed files with 804 additions and 0 deletions
256
internal/config/config.go
Normal file
256
internal/config/config.go
Normal 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:]
|
||||
}
|
||||
233
internal/config/config_test.go
Normal file
233
internal/config/config_test.go
Normal 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
60
internal/prompt/prompt.go
Normal 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
|
||||
}
|
||||
148
internal/prompt/prompt_test.go
Normal file
148
internal/prompt/prompt_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
15
internal/provider/provider.go
Normal file
15
internal/provider/provider.go
Normal 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{}
|
||||
51
internal/terraform/terraform.go
Normal file
51
internal/terraform/terraform.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue