~bigbes/ci-cacher

ref: 4c3e470117ab195e7523ae3918a4da1a5f957f59 ci-cacher/internal/config/config.go -rw-r--r-- 5.6 KiB
4c3e4701 — Eugene Blikh test.yml: use published cacher binary instead of building from source 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
// Package config loads cacher configuration with precedence:
// flag > env (CACHER_*) > config file (~/.config/cacher/config.toml) > defaults.
package config

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"github.com/BurntSushi/toml"
)

// Config is the persisted cacher configuration. The TOML file under
// ~/.config/cacher/config.toml holds the same fields. Every field is
// overridable via CACHER_<UPPER> environment variables.
type Config struct {
	Endpoint   string `toml:"endpoint"`
	Region     string `toml:"region"`
	Bucket     string `toml:"bucket"`
	Prefix     string `toml:"prefix"`
	ArchSuffix bool   `toml:"arch_suffix"`

	// KeyFile / SecretFile point at files that contain the raw credential
	// material (whitespace is stripped on load). Used in CI where secrets
	// are mounted as files. Env vars CACHER_S3_KEY_ID / CACHER_S3_SECRET
	// take priority for local-dev runs.
	KeyFile    string `toml:"key_file"`
	SecretFile string `toml:"secret_file"`
}

// Defaults returns built-in defaults. They are overlaid by file → env → flags.
func Defaults() Config {
	return Config{
		Region:     "us-east-1",
		KeyFile:    "~/.s3-cache-key-id",
		SecretFile: "~/.s3-cache-key-secret",
	}
}

// DefaultPath returns ~/.config/cacher/config.toml on every platform.
// We deliberately do not use os.UserConfigDir (which returns
// ~/Library/Application Support on macOS) because cacher is a CI tool
// that needs the same path on a developer's mac and on a Linux build
// runner.
func DefaultPath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("user home dir: %w", err)
	}
	return filepath.Join(home, ".config", "cacher", "config.toml"), nil
}

// Load reads the config file at path (empty path = DefaultPath). A missing
// file is not an error — it returns Defaults(). Env vars and flag overlays
// must be applied by the caller afterwards.
func Load(path string) (Config, error) {
	cfg := Defaults()
	if path == "" {
		p, err := DefaultPath()
		if err != nil {
			return cfg, err
		}
		path = p
	}
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return cfg, nil
	}
	if err != nil {
		return cfg, fmt.Errorf("read %s: %w", path, err)
	}
	if err := toml.Unmarshal(data, &cfg); err != nil {
		return cfg, fmt.Errorf("parse %s: %w", path, err)
	}
	return cfg, nil
}

// Save writes the config to path (creating parent dirs). Empty path =
// DefaultPath.
func Save(path string, cfg Config) error {
	if path == "" {
		p, err := DefaultPath()
		if err != nil {
			return err
		}
		path = p
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err)
	}
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
	if err != nil {
		return fmt.Errorf("open %s: %w", path, err)
	}
	defer f.Close()
	return toml.NewEncoder(f).Encode(cfg)
}

// ApplyEnv overlays CACHER_<UPPER> environment variables onto cfg.
// Unset/empty variables are ignored (do not zero out file values).
func ApplyEnv(cfg *Config) error {
	if v, ok := os.LookupEnv("CACHER_ENDPOINT"); ok && v != "" {
		cfg.Endpoint = v
	}
	if v, ok := os.LookupEnv("CACHER_REGION"); ok && v != "" {
		cfg.Region = v
	}
	if v, ok := os.LookupEnv("CACHER_BUCKET"); ok && v != "" {
		cfg.Bucket = v
	}
	if v, ok := os.LookupEnv("CACHER_PREFIX"); ok && v != "" {
		cfg.Prefix = v
	}
	if v, ok := os.LookupEnv("CACHER_ARCH_SUFFIX"); ok && v != "" {
		b, err := strconv.ParseBool(v)
		if err != nil {
			return fmt.Errorf("CACHER_ARCH_SUFFIX=%q: %w", v, err)
		}
		cfg.ArchSuffix = b
	}
	if v, ok := os.LookupEnv("CACHER_KEY_FILE"); ok && v != "" {
		cfg.KeyFile = v
	}
	if v, ok := os.LookupEnv("CACHER_SECRET_FILE"); ok && v != "" {
		cfg.SecretFile = v
	}
	return nil
}

// ExpandPath expands a leading ~ in a path against $HOME.
func ExpandPath(p string) string {
	if p == "" || !strings.HasPrefix(p, "~") {
		return p
	}
	home, err := os.UserHomeDir()
	if err != nil {
		return p
	}
	if p == "~" {
		return home
	}
	if strings.HasPrefix(p, "~/") {
		return filepath.Join(home, p[2:])
	}
	return p
}

// Credentials resolves the S3 access key + secret with precedence:
//  1. CACHER_S3_KEY_ID / CACHER_S3_SECRET env vars
//  2. files at cfg.KeyFile / cfg.SecretFile
//
// Whitespace (including trailing newlines) is trimmed from file contents.
func (c Config) Credentials() (keyID, secret string, err error) {
	keyID = os.Getenv("CACHER_S3_KEY_ID")
	secret = os.Getenv("CACHER_S3_SECRET")
	if keyID == "" {
		v, e := readTrimmed(ExpandPath(c.KeyFile))
		if e != nil {
			return "", "", fmt.Errorf("read key file %s: %w", c.KeyFile, e)
		}
		keyID = v
	}
	if secret == "" {
		v, e := readTrimmed(ExpandPath(c.SecretFile))
		if e != nil {
			return "", "", fmt.Errorf("read secret file %s: %w", c.SecretFile, e)
		}
		secret = v
	}
	if keyID == "" || secret == "" {
		return "", "", errors.New("no credentials: set CACHER_S3_KEY_ID/_SECRET or --key-file/--secret-file")
	}
	return keyID, secret, nil
}

func readTrimmed(p string) (string, error) {
	if p == "" {
		return "", nil
	}
	b, err := os.ReadFile(p)
	if errors.Is(err, os.ErrNotExist) {
		return "", nil
	}
	if err != nil {
		return "", err
	}
	return strings.TrimSpace(string(b)), nil
}

// Validate ensures the minimum viable config for an S3 operation.
func (c Config) Validate() error {
	var missing []string
	if c.Endpoint == "" {
		missing = append(missing, "endpoint")
	}
	if c.Region == "" {
		missing = append(missing, "region")
	}
	if c.Bucket == "" {
		missing = append(missing, "bucket")
	}
	if len(missing) > 0 {
		return fmt.Errorf("config missing fields: %s (run `cacher init` or set CACHER_* env vars)", strings.Join(missing, ", "))
	}
	return nil
}