A self-hosted orchestration system crafted for power users. composia.xyz
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579package cli
import ( "bufio" "errors" "fmt" "io" "os" "path/filepath" "sort" "strings"
keyring "github.com/zalando/go-keyring" "golang.org/x/term")
const ( cliConfigKeyAddr = "addr" cliConfigKeyToken = "token" cliConfigKeyTokenFile = "token_file" cliConfigKeyTokenKeyring = "token_keyring" defaultCLIKeyringName = "default" cliKeyringService = "composia" redactedSecretValue = "<redacted>")
var cliKeyring = keyringBackend{ Get: keyring.Get, Set: keyring.Set, Delete: keyring.Delete,}
var saveCLIConfigFunc = saveCLIConfig
type keyringBackend struct { Get func(service, user string) (string, error) Set func(service, user, password string) error Delete func(service, user string) error}
type cliConfig map[string]string
func (application *app) runConfigGet(args []string) error { if len(args) > 1 { return errors.New("usage: composia config get [key]") } cfg, err := loadCLIConfig() if err != nil { return err } if len(args) == 1 { if !isCLIConfigKey(args[0]) { return fmt.Errorf("unknown config key %q", args[0]) } _, err := fmt.Fprintln(application.out, printableCLIConfigValue(args[0], cfg[args[0]])) return err } keys := make([]string, 0, len(cfg)) for key := range cfg { keys = append(keys, key) } sort.Strings(keys) pairs := make([][2]string, 0, len(keys)) for _, key := range keys { pairs = append(pairs, [2]string{key, printableCLIConfigValue(key, cfg[key])}) } return application.writeKV(pairs)}
func (application *app) runConfigSet(args []string) error { if err := requireArgs(args, 2, "composia config set <addr|token_file|token_keyring> <value>"); err != nil { return err } if args[0] == cliConfigKeyToken { return errors.New("use composia config set-token to store controller access tokens") } if !isCLIConfigKey(args[0]) { return fmt.Errorf("unknown config key %q", args[0]) } if strings.ContainsAny(args[1], "\n\r\x00") { return fmt.Errorf("config value for %q must not contain newline, carriage return, or NUL", args[0]) } cfg, err := loadCLIConfig() if err != nil { return err } previousTokenFile := cfg[cliConfigKeyTokenFile] previousTokenKeyring := cfg[cliConfigKeyTokenKeyring] cfg[args[0]] = args[1] if args[0] == cliConfigKeyTokenFile { delete(cfg, cliConfigKeyToken) delete(cfg, cliConfigKeyTokenKeyring) } if args[0] == cliConfigKeyTokenKeyring { delete(cfg, cliConfigKeyToken) delete(cfg, cliConfigKeyTokenFile) } if err := saveCLIConfig(cfg); err != nil { return err } if err := cleanupPreviousTokenBackend(previousTokenFile, cfg[cliConfigKeyTokenFile], previousTokenKeyring, cfg[cliConfigKeyTokenKeyring]); err != nil { return err } _, err = fmt.Fprintln(application.out, "updated") return err}
func (application *app) runConfigUnset(args []string) error { if err := requireArgs(args, 1, "composia config unset <addr|token|token_file|token_keyring>"); err != nil { return err } if !isCLIConfigKey(args[0]) { return fmt.Errorf("unknown config key %q", args[0]) } cfg, err := loadCLIConfig() if err != nil { return err } delete(cfg, args[0]) if err := saveCLIConfig(cfg); err != nil { return err } _, err = fmt.Fprintln(application.out, "updated") return err}
func (application *app) runConfigSetToken(args []string) error { fs := newCommandFlagSet("config set-token") stdin := fs.Bool("stdin", false, "read token from stdin") inline := fs.Bool("inline", false, "store token inline in CLI config") file := fs.Bool("file", false, "store token in the default CLI token file") if err := fs.Parse(args); err != nil { return err } if fs.NArg() != 0 { return errors.New("usage: composia config set-token [--stdin] [--file|--inline]") } storage, err := cliTokenStorageMode(*file, *inline) if err != nil { return err } token, err := readCLISecret("Controller access token", *stdin) if err != nil { return err } return application.saveConfigToken(token, storage)}
func (application *app) runConfigUnsetToken(args []string) error { if err := requireArgs(args, 0, "composia config unset-token"); err != nil { return err } cfg, err := loadCLIConfig() if err != nil { return err } tokenFile := cfg[cliConfigKeyTokenFile] tokenKeyring := cfg[cliConfigKeyTokenKeyring] delete(cfg, cliConfigKeyToken) delete(cfg, cliConfigKeyTokenFile) delete(cfg, cliConfigKeyTokenKeyring) if err := saveCLIConfig(cfg); err != nil { return err } defaultTokenPath, err := cliTokenPath() if err != nil { return err } if tokenFile == defaultTokenPath { if err := os.Remove(defaultTokenPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove CLI token file %q: %w", defaultTokenPath, err) } } if tokenKeyring != "" { if err := deleteCLIKeyringToken(tokenKeyring); err != nil && !errors.Is(err, keyring.ErrNotFound) { return fmt.Errorf("delete CLI token from system keyring: %w", err) } } _, err = fmt.Fprintln(application.out, "updated") return err}
func (application *app) runConfigSetup(args []string) error { fs := newCommandFlagSet("config setup") stdin := fs.Bool("stdin", false, "read token from stdin") inline := fs.Bool("inline", false, "store token inline in CLI config") file := fs.Bool("file", false, "store token in the default CLI token file") if err := fs.Parse(args); err != nil { return err } if fs.NArg() != 0 { return errors.New("usage: composia config setup [--stdin] [--file|--inline]") } storage, err := cliTokenStorageMode(*file, *inline) if err != nil { return err } cfg, err := loadCLIConfig() if err != nil { return err } path, err := cliConfigPath() if err != nil { return err } _, _ = fmt.Fprintf(application.out, "CLI config: %s\n", path) addr, err := promptCLIValue(application.out, "Controller address", cfg[cliConfigKeyAddr]) if err != nil { return err } if strings.TrimSpace(addr) != "" { cfg[cliConfigKeyAddr] = strings.TrimSpace(addr) } token, err := readCLISecret("Controller access token", *stdin) if err != nil { return err } previousTokenFile := cfg[cliConfigKeyTokenFile] previousTokenKeyring := cfg[cliConfigKeyTokenKeyring] if err := saveConfigTokenToConfig(cfg, token, storage); err != nil { return err } if err := saveCLIConfigFunc(cfg); err != nil { return rollbackNewTokenBackend(previousTokenFile, cfg[cliConfigKeyTokenFile], previousTokenKeyring, cfg[cliConfigKeyTokenKeyring], err) } if err := cleanupPreviousTokenBackend(previousTokenFile, cfg[cliConfigKeyTokenFile], previousTokenKeyring, cfg[cliConfigKeyTokenKeyring]); err != nil { return err } _, err = fmt.Fprintln(application.out, "updated") return err}
func (application *app) saveConfigToken(token string, storage cliTokenStorage) error { cfg, err := loadCLIConfig() if err != nil { return err } previousTokenFile := cfg[cliConfigKeyTokenFile] previousTokenKeyring := cfg[cliConfigKeyTokenKeyring] if err := saveConfigTokenToConfig(cfg, token, storage); err != nil { return err } if err := saveCLIConfigFunc(cfg); err != nil { return rollbackNewTokenBackend(previousTokenFile, cfg[cliConfigKeyTokenFile], previousTokenKeyring, cfg[cliConfigKeyTokenKeyring], err) } if err := cleanupPreviousTokenBackend(previousTokenFile, cfg[cliConfigKeyTokenFile], previousTokenKeyring, cfg[cliConfigKeyTokenKeyring]); err != nil { return err } _, err = fmt.Fprintln(application.out, "updated") return err}
type cliTokenStorage string
const ( cliTokenStorageKeyring cliTokenStorage = "keyring" cliTokenStorageFile cliTokenStorage = "file" cliTokenStorageInline cliTokenStorage = "inline")
func cliTokenStorageMode(file bool, inline bool) (cliTokenStorage, error) { if file && inline { return "", errors.New("--file and --inline are mutually exclusive") } if inline { return cliTokenStorageInline, nil } if file { return cliTokenStorageFile, nil } return cliTokenStorageKeyring, nil}
func saveConfigTokenToConfig(cfg cliConfig, token string, storage cliTokenStorage) error { token = strings.TrimSpace(token) if token == "" { return errors.New("controller access token must not be empty") } delete(cfg, cliConfigKeyToken) delete(cfg, cliConfigKeyTokenFile) delete(cfg, cliConfigKeyTokenKeyring) switch storage { case cliTokenStorageInline: cfg[cliConfigKeyToken] = token case cliTokenStorageFile: path, err := cliTokenPath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("create CLI config directory: %w", err) } if err := writePrivateFile(path, []byte(token+"\n")); err != nil { return fmt.Errorf("write CLI token file %q: %w", path, err) } cfg[cliConfigKeyTokenFile] = path case cliTokenStorageKeyring: if err := saveCLIKeyringToken(defaultCLIKeyringName, token); err != nil { return fmt.Errorf("store token in system keyring failed: %w. Use --file to store the token at %s instead", err, mustCLITokenPathForError()) } cfg[cliConfigKeyTokenKeyring] = defaultCLIKeyringName default: return fmt.Errorf("unknown token storage mode %q", storage) } return nil}
func (application *app) runConfigPath(args []string) error { if err := requireArgs(args, 0, "composia config path"); err != nil { return err } path, err := cliConfigPath() if err != nil { return err } _, err = fmt.Fprintln(application.out, path) return err}
func loadCLIConfig() (cliConfig, error) { path, err := cliConfigPath() if err != nil { return nil, err } file, err := os.Open(path) //nolint:gosec if err != nil { if os.IsNotExist(err) { return cliConfig{}, nil } return nil, fmt.Errorf("read CLI config %q: %w", path, err) } defer func() { _ = file.Close() }() return parseCLIConfig(file)}
func parseCLIConfig(r io.Reader) (cliConfig, error) { cfg := cliConfig{} scanner := bufio.NewScanner(r) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } key, value, ok := strings.Cut(line, "=") if !ok { return nil, fmt.Errorf("invalid CLI config line %q", line) } key = strings.TrimSpace(key) value = strings.TrimSpace(value) if !isCLIConfigKey(key) { return nil, fmt.Errorf("unknown CLI config key %q", key) } cfg[key] = value } if err := scanner.Err(); err != nil { return nil, err } if err := validateCLITokenSourceConfig(cfg); err != nil { return nil, err } return cfg, nil}
func saveCLIConfig(cfg cliConfig) error { path, err := cliConfigPath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return fmt.Errorf("create CLI config directory: %w", err) } keys := make([]string, 0, len(cfg)) for key := range cfg { if isCLIConfigKey(key) && cfg[key] != "" { keys = append(keys, key) } } sort.Strings(keys) var b strings.Builder for _, key := range keys { b.WriteString(key) b.WriteString("=") b.WriteString(cfg[key]) b.WriteString("\n") } return writePrivateFile(path, []byte(b.String()))}
func writePrivateFile(path string, content []byte) error { tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+"-*") if err != nil { return err } tmpPath := tmp.Name() defer func() { _ = os.Remove(tmpPath) }() if err := tmp.Chmod(0o600); err != nil { _ = tmp.Close() return err } if _, err := tmp.Write(content); err != nil { _ = tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmpPath, path)}
func rollbackNewTokenBackend(previousFile, currentFile, previousKeyring, currentKeyring string, saveErr error) error { if currentFile != "" && currentFile != previousFile { defaultTokenPath, err := cliTokenPath() if err != nil { return saveErr } if currentFile == defaultTokenPath { if err := os.Remove(currentFile); err != nil && !os.IsNotExist(err) { return fmt.Errorf("%w; additionally failed to roll back CLI token file %q: %v", saveErr, currentFile, err) } } } if currentKeyring != "" && currentKeyring != previousKeyring { if err := deleteCLIKeyringToken(currentKeyring); err != nil && !errors.Is(err, keyring.ErrNotFound) { return fmt.Errorf("%w; additionally failed to roll back CLI token from system keyring: %v", saveErr, err) } } return saveErr}
func cleanupPreviousTokenBackend(previousFile, currentFile, previousKeyring, currentKeyring string) error { if previousFile != "" && previousFile != currentFile { defaultTokenPath, err := cliTokenPath() if err != nil { return err } if previousFile == defaultTokenPath { if err := os.Remove(previousFile); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove previous CLI token file %q: %w", previousFile, err) } } } if previousKeyring != "" && previousKeyring != currentKeyring { if err := deleteCLIKeyringToken(previousKeyring); err != nil && !errors.Is(err, keyring.ErrNotFound) { return fmt.Errorf("delete previous CLI token from system keyring: %w", err) } } return nil}
func cliConfigPath() (string, error) { dir, err := cliConfigDir() if err != nil { return "", err } return filepath.Join(dir, "config"), nil}
func cliTokenPath() (string, error) { dir, err := cliConfigDir() if err != nil { return "", err } return filepath.Join(dir, "token"), nil}
func cliConfigDir() (string, error) { configHome := os.Getenv("XDG_CONFIG_HOME") if configHome == "" { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } configHome = filepath.Join(home, ".config") } return filepath.Join(configHome, "composia", "cli"), nil}
func isCLIConfigKey(key string) bool { switch key { case cliConfigKeyAddr, cliConfigKeyToken, cliConfigKeyTokenFile, cliConfigKeyTokenKeyring: return true default: return false }}
func validateCLITokenSourceConfig(cfg cliConfig) error { keys := make([]string, 0, 3) for _, key := range []string{cliConfigKeyToken, cliConfigKeyTokenFile, cliConfigKeyTokenKeyring} { if strings.TrimSpace(cfg[key]) != "" { keys = append(keys, key) } } if len(keys) > 1 { return fmt.Errorf("CLI config keys %s are mutually exclusive", strings.Join(keys, ", ")) } return nil}
func readCLIKeyringToken(name string) (string, error) { return cliKeyring.Get(cliKeyringService, cliKeyringAccount(name))}
func saveCLIKeyringToken(name string, token string) error { return cliKeyring.Set(cliKeyringService, cliKeyringAccount(name), token)}
func deleteCLIKeyringToken(name string) error { return cliKeyring.Delete(cliKeyringService, cliKeyringAccount(name))}
func cliKeyringAccount(name string) string { name = strings.TrimSpace(name) if name == "" { name = defaultCLIKeyringName } return "cli:" + name + ":controller-token"}
func mustCLITokenPathForError() string { path, err := cliTokenPath() if err != nil { return "the default CLI token file" } return path}
func printableCLIConfigValue(key string, value string) string { if key == cliConfigKeyToken && value != "" { return redactedSecretValue } return value}
func readCLISecret(prompt string, stdin bool) (string, error) { if stdin { content, err := io.ReadAll(os.Stdin) if err != nil { return "", fmt.Errorf("read token from stdin: %w", err) } return strings.TrimSpace(string(content)), nil } fd := int(os.Stdin.Fd()) //nolint:gosec if !term.IsTerminal(fd) { return "", errors.New("stdin is not a terminal; pass --stdin to read token from stdin") } _, _ = fmt.Fprintf(os.Stderr, "%s: ", prompt) content, err := term.ReadPassword(fd) _, _ = fmt.Fprintln(os.Stderr) if err != nil { return "", fmt.Errorf("read token: %w", err) } return strings.TrimSpace(string(content)), nil}
func promptCLIValue(out io.Writer, prompt string, current string) (string, error) { fd := int(os.Stdin.Fd()) //nolint:gosec if !term.IsTerminal(fd) { return current, nil } if current == "" { _, _ = fmt.Fprintf(out, "%s: ", prompt) } else { _, _ = fmt.Fprintf(out, "%s [%s]: ", prompt, current) } scanner := bufio.NewScanner(os.Stdin) if !scanner.Scan() { if err := scanner.Err(); err != nil { return "", err } return current, nil } value := strings.TrimSpace(scanner.Text()) if value == "" { return current, nil } return value, nil}