a73x

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)
}