Files
zot/pkg/cli/client/config.go
T
Andrei Aaron c7ddbe2e36 feat(zli): add config list/show/get/set/reset and isolate deprecated syntax (#4037)
* feat(zli): add config list/show/get/set/reset and isolate deprecated syntax

Introduce first-class subcommands for listing profiles, showing a profile,
getting and setting keys, and resetting optional keys (alongside existing add/remove).
The parent command now resolves ~/.zot via zliUserConfigPath(),
documents that profile names must not clash with subcommand names,
and states that positional/--list/--reset usage is deprecated and will be removed soon.

Legacy behavior is delegated to config_cmd_deprecated.go with stderr warnings for old flags and positional get/set.
Examples and inline help point users at the new commands.
FormatNames/FormatListedVars comments reference config list/show.

Tests are split so config_cmd_test.go exercises the supported subcommands
while config_cmd_deprecated_test.go retains coverage for the deprecated
paths under renamed TestConfigCmdDeprecated* entries.

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>

* test: stabilize retention check tests

See https://github.com/project-zot/zot/actions/runs/25361779632/job/74362802944?pr=4037

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>

---------

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
2026-05-08 20:19:26 +03:00

330 lines
7.8 KiB
Go

//go:build search
package client
import (
"bytes"
"errors"
"fmt"
"os"
"slices"
"strconv"
"strings"
"text/tabwriter"
jsoniter "github.com/json-iterator/go"
zerr "zotregistry.dev/zot/v2/errors"
)
const (
defaultConfigPerms = 0o644
defaultFilePerms = 0o600
nameKey = "_name"
showspinnerConfig = "showspinner"
verifyTLSConfig = "verify-tls"
)
// ZliConfigFile is the on-disk JSON shape for ~/.zot (zli CLI registry profiles).
type ZliConfigFile struct {
Configs []ZliConfig `json:"configs"`
}
// ZliConfig is one named registry profile inside ZliConfigFile.Configs.
type ZliConfig struct {
Name string `json:"_name"` //nolint:tagliatelle // persisted ~/.zot schema uses `_name`
URL string `json:"url"`
ShowSpinner any `json:"showspinner,omitempty"`
VerifyTLS any `json:"verify-tls,omitempty"` //nolint:tagliatelle // hyphenated ~/.zot key
}
// isConfigUnavailable reports errors that mean there is no usable ~/.zot config yet (empty file
// or JSON without a "configs" field). Those cases are treated like an empty config for some commands.
func isConfigUnavailable(err error) bool {
return errors.Is(err, zerr.ErrEmptyJSON) || errors.Is(err, zerr.ErrCliMissingConfigsField)
}
// ReadZliConfigFile reads and validates ~/.zot JSON from path.
//
// If the path does not exist yet, OpenFile(..., O_CREATE) creates an empty file first so later
// ReadFile sees zero bytes and returns [ErrEmptyJSON] ("fresh" CLI state). Adding entries via
// `zli config add` still works without relying on this side effect.
func ReadZliConfigFile(filePath string) (*ZliConfigFile, error) {
file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, defaultConfigPerms)
if err != nil {
return nil, err
}
if err := file.Close(); err != nil {
return nil, err
}
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
if len(bytes.TrimSpace(data)) == 0 {
return nil, zerr.ErrEmptyJSON
}
json := jsoniter.ConfigCompatibleWithStandardLibrary
var jsonMap map[string]any
if unmarshalErr := json.Unmarshal(data, &jsonMap); unmarshalErr != nil {
return nil, fmt.Errorf("%w: %w", zerr.ErrCliBadConfig, unmarshalErr)
}
if _, ok := jsonMap["configs"]; !ok || jsonMap["configs"] == nil {
return nil, fmt.Errorf("%w: %w", zerr.ErrCliBadConfig, zerr.ErrCliMissingConfigsField)
}
configsAny, ok := jsonMap["configs"].([]any)
if !ok {
return nil, fmt.Errorf(
`%w: field "configs" must be a JSON array, got %T`,
zerr.ErrCliBadConfig, jsonMap["configs"])
}
out := &ZliConfigFile{Configs: make([]ZliConfig, 0, len(configsAny))}
for i, v := range configsAny {
configMap, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf(
`%w: configs[%d] must be a JSON object, got %T`,
zerr.ErrCliBadConfig, i, v)
}
c, err := zliConfigFromMap(configMap, i)
if err != nil {
return nil, err
}
out.Configs = append(out.Configs, c)
}
return out, nil
}
func zliConfigFromMap(configMap map[string]any, i int) (ZliConfig, error) {
nameRaw, nameOk := configMap[nameKey]
nameStr, nameIsStr := nameRaw.(string)
if !nameOk || !nameIsStr || strings.TrimSpace(nameStr) == "" {
return ZliConfig{}, fmt.Errorf(
`%w: configs[%d]: "_name" must be a non-empty string`,
zerr.ErrCliBadConfig, i)
}
urlRaw, urlOk := configMap[URLFlag]
urlStr, urlIsStr := urlRaw.(string)
if !urlOk || !urlIsStr || strings.TrimSpace(urlStr) == "" {
return ZliConfig{}, fmt.Errorf(
`%w: configs[%d]: "url" must be a non-empty string`,
zerr.ErrCliBadConfig, i)
}
profile := ZliConfig{
Name: nameStr,
URL: urlStr,
}
if showSpinner, ok := configMap[showspinnerConfig]; ok {
profile.ShowSpinner = showSpinner
}
if verifyTLS, ok := configMap[verifyTLSConfig]; ok {
profile.VerifyTLS = verifyTLS
}
return profile, nil
}
// WriteFile marshals this config to path with standard zli permissions.
func (f *ZliConfigFile) WriteFile(filePath string) error {
json := jsoniter.ConfigCompatibleWithStandardLibrary
cfg := *f
if cfg.Configs == nil {
cfg.Configs = []ZliConfig{}
}
marshalled, err := json.MarshalIndent(&cfg, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filePath, marshalled, defaultFilePerms); err != nil {
return err
}
return nil
}
// Find returns the profile with the given name, applying defaults to the matched entry.
func (f *ZliConfigFile) Find(configName string) (*ZliConfig, error) {
for i := range f.Configs {
if f.Configs[i].Name == configName {
f.Configs[i].ApplyDefaults()
return &f.Configs[i], nil
}
}
return nil, zerr.ErrConfigNotFound
}
// HasEntry reports whether a profile name already exists.
func (f *ZliConfigFile) HasEntry(configName string) bool {
return slices.ContainsFunc(f.Configs, func(c ZliConfig) bool {
return c.Name == configName
})
}
// AddEntry appends a new profile after validating URL and duplicate names.
func (f *ZliConfigFile) AddEntry(configName, urlStr string) error {
if err := validateURL(urlStr); err != nil {
return err
}
if f.HasEntry(configName) {
return zerr.ErrDuplicateConfigName
}
c := ZliConfig{
Name: configName,
URL: urlStr,
}
c.ApplyDefaults()
f.Configs = append(f.Configs, c)
return nil
}
// RemoveEntry removes a profile by name.
func (f *ZliConfigFile) RemoveEntry(configName string) error {
for i, c := range f.Configs {
if c.Name != configName {
continue
}
f.Configs = append(f.Configs[:i], f.Configs[i+1:]...)
return nil
}
return zerr.ErrConfigNotFound
}
// FormatNames renders name and URL columns for `zli config list`.
func (f *ZliConfigFile) FormatNames() (string, error) {
var builder strings.Builder
writer := tabwriter.NewWriter(&builder, 0, 8, 1, '\t', tabwriter.AlignRight) //nolint:mnd
for _, c := range f.Configs {
fmt.Fprintf(writer, "%s\t%s\n", c.Name, c.URL)
}
if err := writer.Flush(); err != nil {
return "", err
}
return builder.String(), nil
}
// ApplyDefaults sets omitted boolean fields to their CLI defaults (mutates receiver).
func (c *ZliConfig) ApplyDefaults() {
if c.ShowSpinner == nil {
c.ShowSpinner = true
}
if c.VerifyTLS == nil {
c.VerifyTLS = true
}
}
// GetVar returns a single setting as text (after defaults apply via Find).
func (c *ZliConfig) GetVar(key string) (string, error) {
switch key {
case nameKey:
return c.Name, nil
case URLFlag:
return c.URL, nil
case showspinnerConfig:
if c.ShowSpinner == nil {
return "", nil
}
return fmt.Sprintf("%v", c.ShowSpinner), nil
case verifyTLSConfig:
if c.VerifyTLS == nil {
return "", nil
}
return fmt.Sprintf("%v", c.VerifyTLS), nil
default:
return "", zerr.ErrIllegalConfigKey
}
}
// SetVar updates url / showspinner / verify-tls (does not persist).
func (c *ZliConfig) SetVar(key, value string) error {
if key == nameKey {
return zerr.ErrIllegalConfigKey
}
if key != URLFlag && key != showspinnerConfig && key != verifyTLSConfig {
return zerr.ErrIllegalConfigKey
}
boolVal, parseErr := strconv.ParseBool(value)
out := any(value)
if parseErr == nil {
out = boolVal
}
switch key {
case URLFlag:
if err := validateURL(value); err != nil {
return err
}
c.URL = value
case showspinnerConfig:
c.ShowSpinner = out
case verifyTLSConfig:
c.VerifyTLS = out
}
return nil
}
// ResetVar clears optional booleans (does not persist).
func (c *ZliConfig) ResetVar(key string) error {
switch key {
case URLFlag, nameKey:
return zerr.ErrCannotResetConfigKey
case showspinnerConfig:
c.ShowSpinner = nil
case verifyTLSConfig:
c.VerifyTLS = nil
default:
return zerr.ErrIllegalConfigKey
}
return nil
}
// FormatListedVars renders lines for `zli config show <name>`.
func (c *ZliConfig) FormatListedVars() string {
var builder strings.Builder
fmt.Fprintf(&builder, "%s = %v\n", URLFlag, c.URL)
fmt.Fprintf(&builder, "%s = %v\n", showspinnerConfig, c.ShowSpinner)
fmt.Fprintf(&builder, "%s = %v\n", verifyTLSConfig, c.VerifyTLS)
return builder.String()
}