internal/cli/config.go
Ref: Size: 3.3 KiB History
package cli
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// Config is the client's on-disk settings, written by `eitri init` and read by
// every command underneath the environment. It holds the two facts a connect
// name needs — which plane, which tenant — and the paths of the keys that sign
// for it, so a laptop that has run init needs no EITRI_* variable at all: not
// even a token, since minting a cert is local and the host-CA pin comes from a
// public endpoint.
//
// It never holds a credential. A PAT is typed, used to ask the plane who you
// are, and forgotten; nothing here is secret, and a leaked config leaks only
// where you keep your keys.
type Config struct {
URL string `json:"url,omitempty"`
Gate string `json:"gate,omitempty"`
Tenant string `json:"tenant,omitempty"`
CA string `json:"ca,omitempty"`
Key string `json:"key,omitempty"`
}
// The known-hosts pin file is deliberately absent: it is a cache eitri rewrites
// on every connect, not a setting, and EITRI_KNOWN_HOSTS still moves it.
// ConfigPath is where the client keeps its settings: EITRI_CONFIG, else
// ~/.eitri/config.json — the same ~/.eitri a Mac agent keeps its state under.
func ConfigPath() (string, error) {
if p := os.Getenv("EITRI_CONFIG"); p != "" {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".eitri", "config.json"), nil
}
// LoadConfig reads the config at path. A missing file is the zero Config and no
// error — an unconfigured laptop is the normal case, and every field falls back.
// A file that exists but does not parse is an error naming the path: treating it
// as empty would hide a half-written config behind whatever default happened to
// apply, and the user would be told nothing while talking to the wrong plane.
func LoadConfig(path string) (Config, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return Config{}, nil
}
if err != nil {
return Config{}, err
}
var c Config
if err := json.Unmarshal(raw, &c); err != nil {
return Config{}, fmt.Errorf("%s: not readable as an eitri config (delete it and run 'eitri init'): %w", path, err)
}
return c, nil
}
// SaveConfig writes c to path through a temp file in the same directory and a
// rename, so what is on disk is always a whole config: an interrupted write
// leaves the previous file untouched rather than a truncated one for the next
// run to trip over.
func SaveConfig(path string, c Config) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
raw, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
f, err := os.CreateTemp(dir, ".config-*.json")
if err != nil {
return err
}
tmp := f.Name()
defer os.Remove(tmp)
if err := f.Chmod(0o600); err != nil {
f.Close()
return err
}
if _, err := f.Write(append(raw, '\n')); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, path)
}
// ConfigFromDisk loads the config from its resolved path — the pairing FromEnv
// uses, and the one `eitri init` reads to show what it would change.
func ConfigFromDisk() (Config, error) {
path, err := ConfigPath()
if err != nil {
return Config{}, err
}
return LoadConfig(path)
}