256 lines
6.7 KiB
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:]
|
|
}
|