obm/internal/config/config.go

256 lines
6.7 KiB
Go

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